Compare commits
10
Commits
7ef31b4876
...
4c9cad58ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c9cad58ec | ||
|
|
721bbeb70d | ||
|
|
a78b9c25d1 | ||
|
|
0f105880e6 | ||
|
|
a979712c8c | ||
|
|
75dc1c3e2c | ||
|
|
84e1a6a82e | ||
|
|
682a1f13ee | ||
|
|
6e1d90de2f | ||
|
|
3d143ce5a2 |
@@ -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
@@ -1,11 +1,28 @@
|
|||||||
import os
|
|
||||||
#!/usr/bin/env -S uv run
|
#!/usr/bin/env -S uv run
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
|
||||||
from PIL import Image
|
|
||||||
from pdf2image import convert_from_path
|
|
||||||
import img2pdf
|
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():
|
def ensure_temp_dir():
|
||||||
"""Ensure temporary directory exists and return its path."""
|
"""Ensure temporary directory exists and return its path."""
|
||||||
@@ -37,12 +54,95 @@ def extract(input_pdf):
|
|||||||
|
|
||||||
# Save each page
|
# Save each page
|
||||||
for i, page in enumerate(pages):
|
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")
|
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)
|
page.save(output_path, "PNG", optimize=False)
|
||||||
print(f"Saved page {i+1}/{len(pages)}")
|
print(f"Saved page {i+1}/{len(pages)}")
|
||||||
|
|
||||||
print(f"Extracted {len(pages)} pages to {temp_dir}/")
|
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()
|
@cli.command()
|
||||||
def deskew():
|
def deskew():
|
||||||
"""Deskew all pages in temporary directory."""
|
"""Deskew all pages in temporary directory."""
|
||||||
@@ -117,6 +217,157 @@ def deskew():
|
|||||||
else:
|
else:
|
||||||
print(" No line segments detected")
|
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()
|
@cli.command()
|
||||||
@click.argument('output_pdf', type=click.Path())
|
@click.argument('output_pdf', type=click.Path())
|
||||||
def finalize(output_pdf):
|
def finalize(output_pdf):
|
||||||
@@ -129,9 +380,21 @@ def finalize(output_pdf):
|
|||||||
|
|
||||||
print(f"Combining {len(temp_files)} pages into {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:
|
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
|
# Clean up temporary files
|
||||||
print("Cleaning up temporary files...")
|
print("Cleaning up temporary files...")
|
||||||
|
|||||||
+5
-1
@@ -1,8 +1,12 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "notes-cleaner"
|
name = "notes-cleaner"
|
||||||
version = "0.1.0"
|
version = "0.9rc1"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
license = "MIT"
|
||||||
|
authors = [
|
||||||
|
{ name = "Kessinen" }
|
||||||
|
]
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"click>=8.1.7",
|
"click>=8.1.7",
|
||||||
|
|||||||
+37
@@ -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_'
|
||||||
Reference in New Issue
Block a user