10 Commits
Author SHA1 Message Date
Esa Kataja 4c9cad58ec Bump version to 0.9rc1 2024-12-03 22:58:47 +02:00
Esa Kataja 721bbeb70d Merge branch 'feature/music-score-cleanup-v2' 2024-12-03 22:54:46 +02:00
Esa Kataja a78b9c25d1 Improve PDF generation with proper A4 sizing and borders
- Move PDF border size to settings.py
- Use standard A4 dimensions (210x297mm)
- Fix aspect ratio preservation in layout function
- Use img2pdf's built-in layout function with proper border specification
2024-12-03 22:53:23 +02:00
Esa Kataja 0f105880e6 fix: improve content detection in trim_whitespace
- Add thresholding to better separate content from background
- Invert image so content becomes white for getbbox detection
- Fix issue where white and transparent areas weren't being trimmed
2024-12-03 22:32:19 +02:00
Esa Kataja a979712c8c perf: optimize PNG compression only on final operations
- Add is_final parameter to trim_whitespace and convert_to_monochrome
- Only optimize PNGs on the final operation of each level
- Remove optimization from extract command
- Level 1: optimize in trim_whitespace
- Level 2: optimize in convert_to_monochrome
- Level 3: defer to optipng
2024-12-03 22:23:54 +02:00
Esa Kataja 75dc1c3e2c fix: properly handle transparency in trim_whitespace
Simplify transparency handling by flattening image with white
background for bounds detection while preserving original
transparency in the final crop. This fixes the issue where
transparent areas were being treated as black during trimming.
2024-12-03 22:06:51 +02:00
Esa Kataja 84e1a6a82e refactor: consolidate image trimming and standardize processing
- Replace OpenCV-based trim_whitespace with PIL implementation
- Remove redundant trim_whitespace_pil function
- Add initial trim step to extract command
- Standardize image processing to 2048px wide grayscale using Lanczos
2024-12-03 21:47:03 +02:00
Esa Kataja 682a1f13ee fix: Improve transparency handling and optimization levels
- Handle transparency in whitespace trimming
- Use IntEnum for optimization levels
- Add proper level comparisons
- Treat transparent pixels as white for boundary detection
- Preserve transparency in output
- Fix black artifacts in transparent areas
2024-12-03 21:38:55 +02:00
Esa Kataja 6e1d90de2f feat: Add settings module
- Add configuration for parallel processing
- Add optimization settings (optipng level, trim padding)
- Support environment variable overrides
- Add temporary directory prefix setting
2024-12-03 21:15:34 +02:00
Esa Kataja 3d143ce5a2 feat: Add optimize command
- Add whitespace trimming functionality
- Add optional PNG optimization with optipng
- Add progress tracking and error handling
- Improve imports organization
- Add proper docstrings and return values
2024-12-03 21:10:38 +02:00
4 changed files with 332 additions and 7 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Kessinen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+269 -6
View File
@@ -1,11 +1,28 @@
import os
#!/usr/bin/env -S uv run
import os
import shutil
import subprocess
from pathlib import Path
from typing import List, Tuple
import click
import cv2
import numpy as np
from PIL import Image
from pdf2image import convert_from_path
import img2pdf
import numpy as np
from pdf2image import convert_from_path
import settings
from settings import OptimizationLevel
from PIL import Image
class Settings:
TRIM_PADDING_PIXELS = 20
MONOCHROME_THRESHOLD = 127
OPTIPNG_OPTIMIZATION_LEVEL = 7
PDF_BORDER_SIZE = 50
settings = Settings()
def ensure_temp_dir():
"""Ensure temporary directory exists and return its path."""
@@ -37,12 +54,95 @@ def extract(input_pdf):
# Save each page
for i, page in enumerate(pages):
# Convert to grayscale
page = page.convert('L')
output_path = os.path.join(temp_dir, f"page_{i:03d}.png")
# Save initial version
page.save(output_path, "PNG", optimize=False)
# Trim whitespace
trim_whitespace(output_path)
# Reload the trimmed image
page = Image.open(output_path)
# Calculate new height maintaining aspect ratio
width = 2048
ratio = width / page.width
height = int(page.height * ratio)
# Resize using Lanczos
page = page.resize((width, height), Image.Resampling.LANCZOS)
# Save final version
page.save(output_path, "PNG", optimize=False)
print(f"Saved page {i+1}/{len(pages)}")
print(f"Extracted {len(pages)} pages to {temp_dir}/")
def trim_whitespace(image_path: str, is_final: bool = False) -> bool:
"""Remove white space from around the image.
Handles both RGB and RGBA images, treating transparent pixels as white.
Args:
image_path: Path to the image file
is_final: Whether this is the final operation on the image
Returns:
bool: True if successful, False otherwise
"""
try:
# Open image with PIL
image = Image.open(image_path)
# If image has transparency, flatten it first
if image.mode == 'RGBA':
# Create a white background
background = Image.new('RGB', image.size, 'white')
# Paste using alpha channel as mask
background.paste(image, mask=image.split()[3])
image = background
# Convert to grayscale
image = image.convert('L')
# Threshold to make all light pixels white and everything else black
# This helps with finding content bounds
image = image.point(lambda x: 255 if x > 250 else 0)
# Invert so content is white on black background
image = Image.eval(image, lambda x: 255 - x)
# Get the bounding box of content (now white pixels)
bbox = image.getbbox()
if not bbox:
print(f"Warning: No content found in {image_path}")
return False
# Add padding
padding = settings.TRIM_PADDING_PIXELS
width, height = image.size
x1, y1, x2, y2 = bbox
x1 = max(0, x1 - padding)
y1 = max(0, y1 - padding)
x2 = min(width, x2 + padding)
y2 = min(height, y2 + padding)
# Open original image again and crop it using the bounds
original = Image.open(image_path)
cropped = original.crop((x1, y1, x2, y2))
# Save the cropped image, optimizing only if this is the final operation
cropped.save(image_path, "PNG", optimize=is_final)
return True
except Exception as e:
print(f"Error processing {image_path}: {str(e)}")
return False
@cli.command()
def deskew():
"""Deskew all pages in temporary directory."""
@@ -117,6 +217,157 @@ def deskew():
else:
print(" No line segments detected")
def convert_to_monochrome(image_path: str, is_final: bool = False) -> bool:
"""Convert image to 1-bit monochrome.
Handles RGBA images by converting transparent pixels to white before thresholding.
Args:
image_path: Path to the image file
is_final: Whether this is the final operation on the image
Returns:
bool: True if successful, False otherwise
"""
try:
# Open image with PIL
image = Image.open(image_path)
# Convert to RGBA if not already
if image.mode != 'RGBA':
image = image.convert('RGBA')
# Get the image data as a list of pixels
data = image.getdata()
# Create new image data, replacing transparent pixels with white
new_data = []
for item in data:
# If pixel is transparent (alpha < 128), make it white
if item[3] < 128:
new_data.append((255, 255, 255, 255))
else:
new_data.append(item)
# Create new image with modified data
image.putdata(new_data)
# Convert to grayscale
image = image.convert('L')
# Convert to 1-bit using threshold
image = image.point(lambda x: 255 if x > settings.MONOCHROME_THRESHOLD else 0, '1')
# Save the monochrome image, optimizing only if this is the final operation
image.save(image_path, "PNG", optimize=is_final)
return True
except Exception as e:
print(f"Error converting to monochrome {image_path}: {str(e)}")
return False
def check_optipng_installed() -> bool:
"""Check if optipng is installed."""
try:
result = subprocess.run(['optipng', '-v'], capture_output=True, text=True)
return result.returncode == 0
except FileNotFoundError:
return False
def run_optipng(file_path: str) -> bool:
"""Run optipng on a file with error handling.
Returns:
bool: True if successful, False otherwise
"""
try:
result = subprocess.run(
['optipng', f'-o{settings.OPTIPNG_OPTIMIZATION_LEVEL}', file_path],
capture_output=True,
text=True,
check=True
)
return True
except subprocess.CalledProcessError as e:
print(f"Error optimizing {file_path}: {e.stderr}")
return False
except Exception as e:
print(f"Unexpected error optimizing {file_path}: {str(e)}")
return False
@cli.command()
@click.option('--level', type=click.IntRange(1, 3), default=1,
help='Optimization level: 1=trim, 2=monochrome, 3=full with optipng')
def optimize(level):
"""Optimize images with specified level of processing.
Optimization Levels:
1: Only trim whitespace
2: Level 1 + convert to 1-bit monochrome
3: Level 2 + optipng optimization
"""
temp_dir = ensure_temp_dir()
temp_files = get_temp_files()
if not temp_files:
print("No pages found in temporary directory. Run 'extract' first.")
return
opt_level = OptimizationLevel(level)
total_files = len(temp_files)
successful = {
'trim': 0,
'monochrome': 0,
'optipng': 0
}
print(f"Processing {total_files} images at optimization level {level}...")
# Step 1: Always trim whitespace
print("\nTrimming whitespace from images...")
for i, file_path in enumerate(temp_files, 1):
print(f"[{i}/{total_files}] Processing {os.path.basename(file_path)}...", end='', flush=True)
# Only optimize if this is the final step (level 1)
if trim_whitespace(file_path, is_final=(opt_level == OptimizationLevel.TRIM)):
successful['trim'] += 1
print(" ")
else:
print(" ")
# Step 2: Convert to monochrome if level >= 2
if int(opt_level) >= int(OptimizationLevel.MONOCHROME):
print("\nConverting to monochrome...")
for i, file_path in enumerate(temp_files, 1):
print(f"[{i}/{total_files}] Converting {os.path.basename(file_path)}...", end='', flush=True)
# Only optimize if this is the final step (level 2)
if convert_to_monochrome(file_path, is_final=(opt_level == OptimizationLevel.MONOCHROME)):
successful['monochrome'] += 1
print(" ")
else:
print(" ")
# Step 3: Run optipng if level = 3
if opt_level == OptimizationLevel.FULL:
if check_optipng_installed():
print("\nOptimizing PNG files with optipng...")
for i, file_path in enumerate(temp_files, 1):
print(f"[{i}/{total_files}] Optimizing {os.path.basename(file_path)}...", end='', flush=True)
if run_optipng(file_path):
successful['optipng'] += 1
print(" ")
else:
print(" ")
else:
print("\nNote: optipng not found. Skipping PNG optimization.")
# Print summary
print("\nOptimization complete!")
print(f"Successfully trimmed: {successful['trim']}/{total_files} images")
if int(opt_level) >= int(OptimizationLevel.MONOCHROME):
print(f"Successfully converted to monochrome: {successful['monochrome']}/{total_files} images")
if opt_level == OptimizationLevel.FULL and check_optipng_installed():
print(f"Successfully optimized with optipng: {successful['optipng']}/{total_files} images")
@cli.command()
@click.argument('output_pdf', type=click.Path())
def finalize(output_pdf):
@@ -129,9 +380,21 @@ def finalize(output_pdf):
print(f"Combining {len(temp_files)} pages into {output_pdf}...")
# Convert to PDF
# A4 size in millimeters
A4_WIDTH_MM = 210
A4_HEIGHT_MM = 297
# Convert to PDF with border
with open(output_pdf, "wb") as f:
f.write(img2pdf.convert(temp_files))
f.write(img2pdf.convert(
temp_files,
with_pdfrw=True,
layout_fun=img2pdf.get_layout_fun(
pagesize=(img2pdf.mm_to_pt(A4_WIDTH_MM), img2pdf.mm_to_pt(A4_HEIGHT_MM)),
border=(settings.PDF_BORDER_SIZE,) * 4, # Same border size for all sides (top, right, bottom, left)
fit=img2pdf.FitMode.into
)
))
# Clean up temporary files
print("Cleaning up temporary files...")
+5 -1
View File
@@ -1,8 +1,12 @@
[project]
name = "notes-cleaner"
version = "0.1.0"
version = "0.9rc1"
description = "Add your description here"
readme = "README.md"
license = "MIT"
authors = [
{ name = "Kessinen" }
]
requires-python = ">=3.12"
dependencies = [
"click>=8.1.7",
+37
View File
@@ -0,0 +1,37 @@
"""Configuration settings for the PDF Musical Score Cleaner."""
import os
import multiprocessing
from enum import IntEnum
class OptimizationLevel(IntEnum):
"""Optimization levels for image processing.
Levels:
1: Only trim whitespace
2: Trim + convert to 1-bit monochrome
3: All optimizations + optipng
"""
TRIM = 1 # Only trim whitespace
MONOCHROME = 2 # Trim + convert to 1-bit monochrome
FULL = 3 # All optimizations + optipng
# Number of parallel processes to use for operations that support parallelization
# Defaults to number of CPU cores - 1, but never less than 1
DEFAULT_PARALLEL_PROCESSES = max(1, multiprocessing.cpu_count() - 1)
# Can be overridden by environment variable
PARALLEL_PROCESSES = int(os.getenv('NOTES_CLEANER_PARALLEL_PROCESSES', DEFAULT_PARALLEL_PROCESSES))
# Border size in pixels for the final PDF output
PDF_BORDER_SIZE = 50
# Optimization settings
OPTIPNG_OPTIMIZATION_LEVEL = int(os.getenv('NOTES_CLEANER_OPTIPNG_LEVEL', '7')) # 0-7, higher = better compression but slower
TRIM_PADDING_PIXELS = int(os.getenv('NOTES_CLEANER_TRIM_PADDING', '20')) # Padding around content after trimming
# Image processing settings
MONOCHROME_THRESHOLD = int(os.getenv('NOTES_CLEANER_MONO_THRESHOLD', '200')) # 0-255, higher = more white
# Temporary directory settings
TEMP_DIR_PREFIX = 'notes_cleaner_'