import os #!/usr/bin/env -S uv run import click import cv2 import numpy as np from PIL import Image from pdf2image import convert_from_path import img2pdf def ensure_temp_dir(): """Ensure temporary directory exists and return its path.""" temp_dir = "temp_processed_images" os.makedirs(temp_dir, exist_ok=True) return temp_dir def get_temp_files(): """Get list of temporary PNG files in order.""" temp_dir = ensure_temp_dir() files = [f for f in os.listdir(temp_dir) if f.endswith('.png')] files.sort() # Ensure correct page order return [os.path.join(temp_dir, f) for f in files] @click.group() def cli(): """PDF cleaning toolbox for musical scores.""" pass @cli.command() @click.argument('input_pdf', type=click.Path(exists=True)) def extract(input_pdf): """Extract pages from PDF to temporary directory.""" temp_dir = ensure_temp_dir() # Convert PDF to images print(f"Extracting pages from {input_pdf}...") pages = convert_from_path(input_pdf, dpi=400) # Save each page for i, page in enumerate(pages): output_path = os.path.join(temp_dir, f"page_{i:03d}.png") page.save(output_path, "PNG", optimize=False) print(f"Saved page {i+1}/{len(pages)}") print(f"Extracted {len(pages)} pages to {temp_dir}/") @cli.command() def deskew(): """Deskew all pages in temporary directory.""" temp_files = get_temp_files() if not temp_files: print("No pages found in temporary directory. Run 'extract' first.") return for file_path in temp_files: print(f"Deskewing {os.path.basename(file_path)}...") # Read image image = cv2.imread(file_path) # Convert to grayscale gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # Apply threshold to get binary image _, binary = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY) # Create a rectangular kernel that's wider than it is tall # This helps detect horizontal lines kernel_length = np.array(binary).shape[1]//80 horizontal_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (kernel_length, 1)) # Detect horizontal lines horizontal_lines = cv2.erode(binary, horizontal_kernel, iterations=3) horizontal_lines = cv2.dilate(horizontal_lines, horizontal_kernel, iterations=3) # Use probabilistic Hough transform to detect line segments line_segments = cv2.HoughLinesP( cv2.bitwise_not(horizontal_lines), rho=1, theta=np.pi/180, threshold=100, minLineLength=binary.shape[1]//4, # Lines must be at least 1/4 of image width maxLineGap=20 ) if line_segments is not None and len(line_segments) > 0: # Calculate angles of detected line segments angles = [] for line in line_segments: x1, y1, x2, y2 = line[0] if x2 - x1 == 0: # Avoid division by zero continue angle = np.degrees(np.arctan2(y2 - y1, x2 - x1)) # Only consider angles that are close to horizontal if abs(angle) < 20: angles.append(angle) if angles: # Use median angle to avoid outliers median_angle = np.median(angles) # Only rotate if the angle is significant but not too large if 0.5 < abs(median_angle) < 20: height, width = image.shape[:2] center = (width/2, height/2) rotation_matrix = cv2.getRotationMatrix2D(center, median_angle, 1.0) rotated = cv2.warpAffine(image, rotation_matrix, (width, height), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) # Save rotated image cv2.imwrite(file_path, rotated) print(f" Rotated by {median_angle:.2f} degrees") else: print(" No significant rotation needed") else: print(" No valid horizontal lines found") else: print(" No line segments detected") @cli.command() @click.argument('output_pdf', type=click.Path()) def finalize(output_pdf): """Combine processed pages into final PDF and clean up.""" 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 print(f"Combining {len(temp_files)} pages into {output_pdf}...") # Convert to PDF with open(output_pdf, "wb") as f: f.write(img2pdf.convert(temp_files)) # Clean up temporary files print("Cleaning up temporary files...") for file_path in temp_files: os.remove(file_path) os.rmdir(temp_dir) print("PDF created successfully and temporary files removed!") if __name__ == '__main__': cli()