Better (?) default left margin

This commit is contained in:
sebastien committed 2026-10-12 18:52:05 +02:00
1 parent b6c2a634ca
commit 247b124754
7 files changed
+421 -9

No files matched your search

+46
View File
@@ -26,6 +26,7 @@ from copienator import (
from copienator.filesystem import staged_files
from copienator.copy_errors import clear_copy_error, mark_copy_error, marked_copy_paths
from copienator.configuration import SHOW_PERSONAL_STEPS
from copienator.left_margin import analyse_margins
DELIMITER_WIDTH = 5
DELIMITER_COLOR = (0, 0, 0)
@@ -34,6 +35,7 @@ CROP_SHIFT_STEP = 50
CROP_WIDTH_STEP = 50
RENDER_DPI = 200
NARROW_PAGE_RATIO = 0.8
AUTO_STRIP_WIDTH_FACTOR = 1.10
pdf_cache_lock = threading.Lock()
# The requested spelling is kept as the public switch for this alternate layout.
@@ -116,6 +118,9 @@ def process_single_pdf(
shift_offset: int = 0,
max_per_file: int = 5,
width_offset: int = 0,
*,
automatic_margin: bool = True,
margin_analysis: dict | None = None,
) -> tuple[Image.Image, list[Image.Image], dict[str, object]] | None:
"""Convert one PDF into a preview, full-resolution splits and metadata."""
try:
@@ -124,7 +129,10 @@ def process_single_pdf(
if not images:
return None
reference_width = normal_page_width(pdf_path, images)
if automatic_margin and max_per_file != 1 and margin_analysis is None:
margin_analysis = analyse_margins(images, RENDER_DPI, reference_width)
full_width_pages = []
page_crops = []
for page_index, image in enumerate(images):
width, height = image.size
if max_per_file == 1:
@@ -140,8 +148,17 @@ def process_single_pdf(
width,
width // 3 + 100 + shift_offset + max(0, width_offset),
)
if automatic_margin and max_per_file != 1:
left = min(width - 1, max(0, margin_analysis['starts_px'][page_index] + shift_offset))
strip_width = round(
((reference_width if width < NARROW_PAGE_RATIO * reference_width else width) // 3)
* AUTO_STRIP_WIDTH_FACTOR
)
right = min(width, left + strip_width + max(0, width_offset))
if right > left:
cropped_images.append(image.crop((left, 0, right, height)))
page_crops.append({'page': page_index + 1, 'left_px': left, 'right_px': right,
'width_px': width, 'height_px': height})
if left == 0 and right == width:
full_width_pages.append(page_index + 1)
if not cropped_images:
@@ -165,6 +182,8 @@ def process_single_pdf(
"number_of_files": len(split_images),
"columns_per_file": distribution,
"full_width_pages": full_width_pages,
"page_crops": page_crops,
"auto_margin": margin_analysis if automatic_margin and max_per_file != 1 else None,
}
return preview, split_images, schema
except Exception as exc: # noqa: BLE001 - interactive item failure
@@ -208,6 +227,7 @@ class ImageReviewer:
files: list[Path],
output_dir: Path,
default_max_per_file: int = 5,
automatic_margin: bool = True,
) -> None:
self.files = files
self.output_dir = output_dir
@@ -216,6 +236,8 @@ class ImageReviewer:
self.had_errors = False
self.stop_prefetch = threading.Event()
self.current_result = None
self.automatic_margin = automatic_margin
self.margin_analysis = None
self.index = 0
self.current_shift = 0
self.current_width_offset = 0
@@ -283,6 +305,7 @@ class ImageReviewer:
self.current_shift = 0
self.current_width_offset = 0
self.current_result = None
self.margin_analysis = None
self.trigger_processing(self.files[self.index], self.current_shift)
def trigger_processing(self, pdf_path: Path, shift: int) -> None:
@@ -299,6 +322,8 @@ class ImageReviewer:
shift,
self.current_max_per_file,
self.current_width_offset,
automatic_margin=self.automatic_margin,
margin_analysis=self.margin_analysis,
)
)
@@ -325,6 +350,8 @@ class ImageReviewer:
) -> None:
self.current_preview = result[0]
self.current_result = result
if result[2].get('auto_margin') is not None:
self.margin_analysis = result[2]['auto_margin']
self.update_display(pdf_path.name, result[2])
def update_display(self, filename: str, schema: dict[str, object]) -> None:
@@ -338,6 +365,17 @@ class ImageReviewer:
if personnal_binding
else ""
)
analysis = schema.get('auto_margin')
margin_info = ''
if analysis:
offsets = sorted({round(crop['left_px'] * 25.4 / RENDER_DPI, 1)
for crop in schema['page_crops']})
needs_review = any(group['status'] in {'inconsistent', 'uncertain', 'insufficient-ink'}
for group in analysis['groups'])
margin_info = (f"Auto margin: {offsets} mm from left"
+ (' | Check uncertain pages' if needs_review else '') + '\n')
if analysis.get('no_margin_from_page') is not None:
margin_info += f"No margin from page {analysis['no_margin_from_page']} onwards\n"
self.label_info.configure(
text=(
f"[{self.index + 1}/{len(self.files)}] {filename} | "
@@ -345,6 +383,7 @@ class ImageReviewer:
f"Extra width: {self.current_width_offset}px\n"
f"Files: {schema['number_of_files']} | "
f"Cols: {schema['columns_per_file']}\n"
f"{margin_info}"
"Arrows: left/right move by 50px | up/down widen/narrow by 50px\n"
"Enter: Save and next | !: flag error and skip | 1: use full pages"
f"{personal_instructions}"
@@ -428,6 +467,7 @@ def run(
fullpage: bool = False,
no_visual_check: bool = False,
marked: bool = False,
automatic_margin: bool = True,
) -> ExitCode:
files = marked_copy_paths(workspace) if marked else _selected_files(workspace, target)
if not files:
@@ -463,6 +503,7 @@ def run(
files,
workspace.cutleft_dir,
default_max_per_file=1 if fullpage else 5,
automatic_margin=automatic_margin,
)
if not reviewer.completed:
return ExitCode.INTERRUPTED
@@ -472,6 +513,10 @@ def run(
def build_parser() -> argparse.ArgumentParser:
parser = target_parser("Interactively crop the label margin from PDF copies")
parser.add_argument("--marked", action="store_true", help="Review flagged copies and clear only margin-cropping flags after saving with Enter")
parser.add_argument(
'--manual-margin', action='store_true',
help='Use the previous fixed margin instead of automatically estimating body-text positions',
)
parser.add_argument(
"--fullpage",
action="store_true",
@@ -496,6 +541,7 @@ def main(argv: Sequence[str] | None = None) -> int:
fullpage=args.fullpage,
no_visual_check=args.no_visual_check,
marked=args.marked,
automatic_margin=not args.manual_margin,
)
return execute(parser, argv, handle)
+151
View File
@@ -0,0 +1,151 @@
"""Conservative body-text margin suggestions for the cutleft reviewer."""
from __future__ import annotations
import cv2
import numpy as np
from PIL import Image
from copienator.ink_detection import detect_bounds
ANALYSIS_DPI = 100
WIDTH_TOLERANCE = .08
# Copie01's name/class block reaches about 27% of the full page height.
# Start below it with clearance, including on later pages with repeated names.
BODY_TOP = .30
BODY_BOTTOM = .82
DISCARD_LEFT_FRACTION = .10
STANDARD_MARGIN_MM = 40
MIN_MARGIN_MM = STANDARD_MARGIN_MM / 2
def trimmed_left_edge(starts: list[float]) -> float | None:
"""Discard the leftmost 10% of occupied bands; retain the next minimum."""
if not starts:
return None
ordered = sorted(starts)
return round(ordered[int(len(ordered) * DISCARD_LEFT_FRACTION)], 1)
def handwriting_mask(rgb: np.ndarray) -> np.ndarray:
"""Intersect rognage's foreground with a directional ruling filter."""
px = ANALYSIS_DPI / 25.4
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
darkness = 255 - rgb.min(axis=2)
length = round(5.2 * px) | 1
horizontal = cv2.morphologyEx(darkness, cv2.MORPH_OPEN, np.ones((1, length), np.uint8))
vertical = cv2.morphologyEx(darkness, cv2.MORPH_OPEN, np.ones((length, 1), np.uint8))
residual = cv2.subtract(darkness, np.maximum(horizontal, vertical))
binary = ((residual > 32) & (gray < 200)).astype(np.uint8)
count, labels, stats, _ = cv2.connectedComponentsWithStats(binary, 8)
keep = np.zeros(count, bool)
for index, (_x, _y, width, height, area) in enumerate(stats[1:], 1):
keep[index] = (area >= max(5, .35 * px * px) and height >= .5 * px
and width >= .25 * px and height <= 6 * width and width <= 15 * height)
diagnostics = {}
detect_bounds(rgb, ANALYSIS_DPI, diagnostics=diagnostics)
return keep[labels] & diagnostics['masks']['foreground']
def body_edge(mask: np.ndarray) -> dict:
"""Estimate a left edge from occupied 4 mm bands within 30–82% height."""
height = mask.shape[0]
band_height = max(1, round(4 * ANALYSIS_DPI / 25.4))
bottom = round(BODY_BOTTOM * height)
starts = []
for y in range(round(BODY_TOP * height), bottom, band_height):
band = mask[y:min(y + band_height, bottom)]
xs = np.flatnonzero(band.sum(axis=0) >= 2)
if band.sum() >= 40 and len(xs) >= 12:
starts.append(float(xs[0]) * 25.4 / ANALYSIS_DPI)
return {
'edge_mm': trimmed_left_edge(starts) if len(starts) >= 6 else None,
'bands': len(starts),
'band_starts_mm': starts,
}
def estimate_page(image: Image.Image, render_dpi: int) -> dict:
scale = ANALYSIS_DPI / render_dpi
small = image.convert('RGB').resize(
(max(1, round(image.width * scale)), max(1, round(image.height * scale))),
Image.Resampling.LANCZOS,
)
return body_edge(handwriting_mask(np.asarray(small)))
def format_groups(images: list[Image.Image], reference_width: float) -> list[list[int]]:
"""Group similar widths; keep secondary narrow subpages in separate groups."""
groups: list[list[int]] = []
for index, image in enumerate(images):
narrow = image.width < .8 * reference_width
for group in groups:
width = float(np.median([images[i].width for i in group]))
if (images[group[0]].width < .8 * reference_width) == narrow and abs(image.width - width) <= width * WIDTH_TOLERANCE:
group.append(index)
break
else:
groups.append([index])
return groups
def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width: float) -> dict:
"""Pool occupied bands per format, trim outliers, and crop at half the edge."""
starts = [0] * len(images)
groups = []
page_estimates = {}
def page_estimate(index):
if index not in page_estimates:
page_estimates[index] = estimate_page(images[index], render_dpi)
return page_estimates[index]
for indices in format_groups(images, reference_width):
# Spread samples through the copy rather than relying on the header page.
sample = sorted({indices[0], indices[min(1, len(indices) - 1)],
indices[len(indices) // 2], indices[-1]})
estimates = {index: page_estimate(index) for index in sample}
values = [item['edge_mm'] for item in estimates.values() if item['edge_mm'] is not None]
# Trim across the copy's format group, not separately on each page:
# one marginal comment must not veto the other pages' shared margin.
band_starts = [start for item in estimates.values() for start in item['band_starts_mm']]
status = 'insufficient-ink'
edge = trimmed_left_edge(band_starts)
left_mm = 0.0
if len(values) >= min(2, len(indices)):
status = 'near-edge' if edge < MIN_MARGIN_MM else 'margin'
left_mm = 0.0 if edge < MIN_MARGIN_MM else edge / 2
# Secondary subpages can start in the middle of an answer. Preserve
# their left edge even when the only visible writing is indented.
if images[indices[0]].width < .8 * reference_width:
status = 'narrow-page'
left_mm = 0.0
start = round(left_mm * render_dpi / 25.4)
for index in indices:
starts[index] = start
groups.append({
'pages': [index + 1 for index in indices],
'status': status,
'edge_mm': edge,
'left_mm': round(left_mm, 1),
'bands': len(band_starts),
'discarded_bands': int(len(band_starts) * DISCARD_LEFT_FRACTION),
'samples': [{'page': index + 1, **item} for index, item in estimates.items()],
})
# Inspect actual neighbours, including pages outside the format samples.
# Blank/uncertain pages do not establish or extend a no-margin run.
no_margin_run = 0
no_margin_from_page = None
for index in range(len(images)):
edge = page_estimate(index)['edge_mm']
no_margin_run = no_margin_run + 1 if edge is not None and edge < MIN_MARGIN_MM else 0
if no_margin_run >= 2:
run_start = index - 1
no_margin_from_page = run_start + 1
starts[run_start:] = [0] * (len(images) - run_start)
break
return {'version': 5, 'starts_px': starts, 'groups': groups,
'no_margin_from_page': no_margin_from_page,
'discard_left_fraction': DISCARD_LEFT_FRACTION, 'cut_fraction_of_margin': .5,
'standard_margin_mm': STANDARD_MARGIN_MM, 'minimum_margin_mm': MIN_MARGIN_MM,
'body_height_fraction': [BODY_TOP, BODY_BOTTOM]}