Use per-page annotation spacing with inferred margin fallback

This commit is contained in:
sebastien committed 2026-11-03 12:02:06 +01:00
1 parent b069805988
commit 0bb6f7d97c
10 files changed
+170 -39

No files matched your search

+19 -6
View File
@@ -61,13 +61,26 @@ def move_answer_pdf(source, destination):
metadata_path(source).unlink(missing_ok=True)
def added_margin(margin_mm):
"""Interpolate the configured (millimetres, pixels) points, clamping at ends."""
if margin_mm is None:
return configuration.ANNOTATION_MARGIN_LEFT
points = configuration.ANNOTATION_MARGIN_POINTS
if (not points or any(x < 0 or type(y) is not int or y < 0 for x, y in points)
or any(a[0] >= b[0] for a, b in zip(points, points[1:]))):
raise ValueError('Annotation margin points must have increasing mm and non-negative integer pixels')
if margin_mm <= points[0][0]:
return points[0][1]
for (x0, y0), (x1, y1) in zip(points, points[1:]):
if margin_mm <= x1:
return round(y0 + (y1 - y0) * (margin_mm - x0) / (x1 - x0))
return points[-1][1]
def layout_for(pdf):
low = any(part.get('margin_status') == 'low' for part in answer_parts(pdf))
margin = (configuration.ANNOTATION_LOW_MARGIN_LEFT if low
else configuration.ANNOTATION_MARGIN_LEFT)
if type(margin) is not int or margin < 0:
raise ValueError('Annotation left margins must be non-negative integers')
return {'version': 1, 'margin_left': margin, 'narrow_left_pad_limit': 300}
margins = [added_margin(part.get('margin_mm')) for part in answer_parts(pdf)]
return {'version': 2, 'margin_left': max(margins, default=added_margin(None)),
'page_margins': margins, 'narrow_left_pad_limit': 0}
def legacy_layout():
+38 -8
View File
@@ -63,9 +63,13 @@ def make_base_image(pdf_path):
base_img = Image.new("RGBA", (max_w, total_h), "white")
current_y = 0
page_regions = []
for page in pages:
base_img.paste(page.convert("RGBA"), ((max_w - page.width) // 2, current_y))
x = (max_w - page.width) // 2
base_img.paste(page.convert("RGBA"), (x, current_y))
page_regions.append({'x': x, 'y': current_y, 'width': page.width, 'height': page.height})
current_y += page.height
base_img.info['page_regions'] = page_regions
return (base_img, total_h, max_w)
def normalize_mathtext(text):
@@ -352,6 +356,10 @@ def compose_label_image(base_img, label, result, hmin,
layout = dict(layout if layout is not None else layout_for(source_pdf))
margin_left = layout['margin_left']
page_regions = base_img.info.get('page_regions', [
{'x': 0, 'y': 0, 'width': base_img.width, 'height': base_img.height}])
page_margins = layout.get('page_margins', [])
per_page = layout.get('version', 1) >= 2
left_pad = 0
if base_img.width < TARGET_MIN_WIDTH:
total_missing = TARGET_MIN_WIDTH - base_img.width
@@ -411,7 +419,11 @@ def compose_label_image(base_img, label, result, hmin,
total_height = base_img.height + header_height
# Create Canvas
final_img = Image.new("RGB", (base_img.width + margin_left, total_height), "white")
final_width = (max(TARGET_MIN_WIDTH + margin_left,
max(region['width'] + (page_margins[i] if i < len(page_margins) else margin_left)
for i, region in enumerate(page_regions)))
if per_page else base_img.width + margin_left)
final_img = Image.new("RGB", (final_width, total_height), "white")
# Draw Headers
current_y = 0
@@ -434,7 +446,13 @@ def compose_label_image(base_img, label, result, hmin,
# Paste Base Image
image_offset_y = current_y
final_img.paste(base_img, (margin_left, image_offset_y))
if per_page:
for i, region in enumerate(page_regions):
x, y, w, h = (region[key] for key in ('x', 'y', 'width', 'height'))
margin = page_margins[i] if i < len(page_margins) else margin_left
final_img.paste(base_img.crop((x, y, x + w, y + h)), (margin, image_offset_y + y))
else:
final_img.paste(base_img, (margin_left, image_offset_y))
# 2. Draw Local Annotations
draw = ImageDraw.Draw(final_img, "RGBA") # Refresh draw object
@@ -451,12 +469,24 @@ def compose_label_image(base_img, label, result, hmin,
# Draw Rectangle (if not suppressed)
if "norectangle" not in fb:
draw.rectangle([target_xmin, target_ymin, target_xmax, target_ymax], outline="red", width=3)
boxes = [[target_xmin, target_ymin, target_xmax, target_ymax]]
if per_page:
boxes = []
for i, region in enumerate(page_regions):
top = max(ymin - hmin, region['y'])
bottom = min(ymax - hmin, region['y'] + region['height'])
if bottom <= top:
continue
margin = page_margins[i] if i < len(page_margins) else margin_left
shift = margin - region['x']
boxes.append([xmin + shift, top + image_offset_y,
xmax + shift, bottom + image_offset_y])
for box in boxes:
draw.rectangle(box, outline="red", width=3)
if draw_callback:
callback_items.append(("local_rect",
{"box": [target_xmin, target_ymin, target_xmax, target_ymax]},
{"data": fb, "index": idx}))
if draw_callback:
callback_items.append(("local_rect", {"box": box},
{"data": fb, "index": idx}))
# Render Text
txt_img = render_fn(fb['text'], width_px=ANNOT_WIDTH,
+5
View File
@@ -428,6 +428,11 @@ class ImageReviewer:
for group in analysis['groups'])
margin_info = (f"Auto margin: {offsets} mm from left"
+ (' | Check uncertain pages' if needs_review else '') + '\n')
for page in analysis.get('pages', []):
if page.get('status') == 'unknown':
value = page.get('effective_edge_mm')
fallback = f" (copy average: {value:.1f} mm)" if value is not None else ''
margin_info += f"Page {page['page']}: unknown margin{fallback}\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(
+5 -3
View File
@@ -23,6 +23,7 @@ from copienator import (
)
from copienator.filesystem import staged_directory
from copienator.annotation_layout import fingerprint, metadata_path, save_parts
from copienator.left_margin import margins_with_fallback
SQUARE = 1000 // 38
ANSWER_TOP_PADDING_POINTS = 4 * 72 / 25.4
@@ -133,7 +134,7 @@ def _render_split_outputs(
provenance: defaultdict[str, list[dict]] = defaultdict(list)
schema = read_json(input_pdf.parent.parent / 'Cutleft' / f'{input_pdf.stem}_schema.json', default={})
analysis = schema.get('auto_margin') or {}
measurements = ({item['page']: item for item in analysis.get('pages', [])}
measurements = ({item['page']: item for item in margins_with_fallback(analysis.get('pages', []))}
if schema.get('source_sha256') == fingerprint(input_pdf) else {})
with tempfile.TemporaryDirectory(prefix="copienator-split-") as temp_directory:
temporary = Path(temp_directory)
@@ -206,13 +207,14 @@ def _render_split_outputs(
part_path,
)
parts_by_label[clean_label].append(part_path)
edge = measurements.get(page_number + 1, {}).get('edge_mm')
measurement = measurements.get(page_number + 1, {})
edge = measurement.get('effective_edge_mm')
remaining = None if edge is None else max(0, edge - clip.x0 * 25.4 / 72)
provenance[clean_label].append({
'source': input_pdf.name, 'source_sha256': schema.get('source_sha256'),
'page': page_number + 1, 'crop_points': list(clip),
'margin_mm': remaining,
'margin_status': ('unknown' if remaining is None else
'margin_status': ('unknown' if measurement.get('edge_mm') is None else
'low' if remaining < 20 else 'normal'),
})
+1 -1
View File
@@ -40,7 +40,7 @@ THINKING_LEVEL_FLASH = "medium"
THINKING_LEVEL_PRO = "high"
ALWAYS_CROP = False
ANNOTATION_MARGIN_LEFT = 300
ANNOTATION_LOW_MARGIN_LEFT = 400
ANNOTATION_MARGIN_POINTS = [(10, 450), (20, 400), (35, 300)]
ANNOTATION_MAX_HEIGHT_PX = 33000
ANNOTATION_MAX_PIXELS = 67_108_864
SHOW_PERSONAL_STEPS = False
+21 -3
View File
@@ -27,6 +27,15 @@ STANDARD_LINE_HEIGHT_MM = 8
MAX_EXCEPTION_LINES = 4
def margins_with_fallback(pages: list[dict]) -> list[dict]:
"""Keep measurements intact; unknown pages borrow the copy's measured mean."""
known = [page['edge_mm'] for page in pages if page.get('edge_mm') is not None]
average = sum(known) / len(known) if known else None
return [{**page, 'effective_edge_mm': (
page['edge_mm'] if page.get('edge_mm') is not None else average
)} for page in pages]
def trimmed_left_edge(starts: list[float]) -> float | None:
"""Discard the leftmost 10% of occupied bands; retain the next minimum."""
if not starts:
@@ -142,9 +151,13 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width:
if name_localized else estimate_page(images[index], render_dpi))
return page_estimates[index]
page_estimates = dict(enumerate(margins_with_fallback(
[page_estimate(index) for index in range(len(images))])))
for indices in format_groups(images, reference_width):
estimates = {index: page_estimate(index) for index in indices}
values = [item['edge_mm'] for item in estimates.values() if item['edge_mm'] is not None]
values = [item['effective_edge_mm'] for item in estimates.values()
if item['effective_edge_mm'] is not None]
band_starts = [start for item in estimates.values() for start in item['band_starts_mm']]
status = 'insufficient-ink'
edge = round(float(np.median(values)), 1) if values else None
@@ -178,10 +191,15 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width:
previous_coherent = 0
for index in indices:
estimate = page_estimate(index)
low = estimate['edge_mm'] is not None and estimate['edge_mm'] < MIN_MARGIN_MM
effective_edge = estimate['effective_edge_mm']
low = effective_edge is not None and effective_edge < MIN_MARGIN_MM
tolerated = (status == 'margin' and low and previous_coherent >= 2
and short_margin_exception(estimate, edge))
starts[index] = 0 if low and not tolerated else start
if estimate['edge_mm'] is None and status not in {'narrow-page', 'labels-in-margin'}:
fallback_left = (max(effective_edge / 2, effective_edge - MARGIN_CLEARANCE_MM)
if effective_edge is not None and effective_edge >= MIN_MARGIN_MM else 0)
starts[index] = round(fallback_left * render_dpi / 25.4)
estimate['short_margin_exception'] = tolerated
estimate['decision'] = ('short-exception' if tolerated else
'page-near-edge' if low else status)
@@ -209,7 +227,7 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width:
'status': 'unknown' if edge is None else
('low' if edge < MIN_MARGIN_MM else 'normal')})
return {'version': 13, 'pages': pages, 'starts_px': starts, 'groups': groups,
return {'version': 14, 'pages': pages, 'starts_px': starts, 'groups': groups,
'no_margin_from_page': None,
'discard_left_fraction': DISCARD_LEFT_FRACTION, 'margin_clearance_mm': MARGIN_CLEARANCE_MM,
'cut_fraction_of_margin': .5, 'cut_rule': 'maximum',