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
+167 -36

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():
+35 -5
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,6 +446,12 @@ def compose_label_image(base_img, label, result, hmin,
# Paste Base Image
image_offset_y = current_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
@@ -451,11 +469,23 @@ 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]},
callback_items.append(("local_rect", {"box": box},
{"data": fb, "index": idx}))
# Render Text
+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',
+3 -1
View File
@@ -8,7 +8,9 @@ IMPORT_DIR = Path("Import")
# Espace ajouté à gauche des réponses annotées, en pixels à 200 dpi.
ANNOTATION_MARGIN_LEFT = 300
ANNOTATION_LOW_MARGIN_LEFT = 400
# (Marge mesurée en mm, espace ajouté en pixels à 200 dpi).
# Interpolation linéaire entre les points ; valeurs constantes aux extrémités.
ANNOTATION_MARGIN_POINTS = [(10, 450), (20, 400), (35, 300)]
# Hauteur maximale des documents Bnot et des groupes BGnot, en pixels.
# Les blocs d'élèves trop grands sont séparés entre deux réponses.
+14 -6
View File
@@ -10,13 +10,18 @@ extension **`.jpg`**, not `.jpeg`.
```python
ANNOTATION_MARGIN_LEFT = 300
ANNOTATION_LOW_MARGIN_LEFT = 400
ANNOTATION_MARGIN_POINTS = [(10, 450), (20, 400), (35, 300)]
```
These are added pixels at the 200 dpi annotation rendering scale. An answer
uses the larger margin if any of its source fragments has a measured left
margin below 20 mm (half the standard 40 mm). Unknown or insufficiently written
pages retain the normal spacing. The 600 px comment width is unchanged, so
The reference points are (measured margin in mm, added pixels at 200 dpi).
Each page fragment gets its own spacing: 450 px at or below 10 mm, 400 px at
20 mm, and 300 px at or above 35 mm, with linear interpolation between points.
Unknown pages retain their unknown status but use the arithmetic mean of the
copy's measured page margins, including during label-strip positioning.
Inferred values never contribute to that mean. If no page has a measurement,
`ANNOTATION_MARGIN_LEFT` supplies the default 300 px.
The old `ANNOTATION_LOW_MARGIN_LEFT` setting is replaced by the reference points.
The 600 px comment width is unchanged, so
comments can still overlap handwriting on pages with very little whitespace.
Automatic `crop-labels` saves per-page measurements in the Cutleft schema.
@@ -33,7 +38,10 @@ save their rendering geometry and reuse it when read back, even after settings
change. Older batches without saved geometry keep their original 300 px offset.
Do not overwrite a batch that still contains handwritten corrections to import.
All fragments of one answer share one left offset. BGnot pads narrower answer
Each fragment is positioned independently, with its annotation rectangles shifted
by the same amount. Rectangles crossing page boundaries are split at those boundaries.
Narrow pages receive any additional width on the right, so left padding does not
stack with the configured allowance. BGnot pads narrower answer
images with white without resizing handwriting; fitting the wider document to
screen or paper may reduce the displayed scale, and return-PDF page breaks can
change. The extra space is also used by Anot and Bnot.
+52 -8
View File
@@ -9,7 +9,7 @@ from PIL import Image
from copienator import atomic_write_json, configuration, read_json
from copienator.annotation_layout import (
answer_parts, copy_answer_pdf, fingerprint, layout_for, legacy_layout,
move_answer_pdf, save_parts,
move_answer_pdf, save_parts, added_margin,
)
from copienator.commands import annotating, annotating_by_label, splitting_int
from copienator.commands.annotating_with_checks import CheckboxRenderer
@@ -42,7 +42,8 @@ class AnnotationLayoutTests(unittest.TestCase):
with patch('copienator.left_margin.estimate_page', side_effect=estimate):
result = analyse_margins(images, 200, 600)
self.assertEqual([p['status'] for p in result['pages']],
['low', 'low', 'normal', 'unknown', 'low', 'normal'])
['low', 'low', 'normal', 'unknown', 'low', 'low'])
self.assertAlmostEqual(result['pages'][3]['effective_edge_mm'], 19.98)
def test_split_records_pages_and_rejects_stale_measurements(self):
schema_path = self.root / 'Cutleft' / 'Copie01_schema.json'
@@ -55,7 +56,7 @@ class AnnotationLayoutTests(unittest.TestCase):
splitting_int._render_split_outputs(self.pdf, [('Ex 1', 0, 0, 10, 0, 10)], staging)
answer = staging / 'Ex 1.pdf'
self.assertEqual([p['page'] for p in answer_parts(answer)], [1, 2])
self.assertEqual(layout_for(answer)['margin_left'], 400)
self.assertEqual(layout_for(answer)['page_margins'], [300, 450])
atomic_write_json(schema_path, {'source_sha256': 'stale', 'auto_margin': {
'pages': [{'page': 1, 'edge_mm': 10}]}})
splitting_int._render_split_outputs(self.pdf, [('Ex 1', 0, 0, 10, 0, 10)], staging)
@@ -63,8 +64,9 @@ class AnnotationLayoutTests(unittest.TestCase):
def test_configurable_margin_unknown_fallback_and_stale_sidecar(self):
self.assertEqual(layout_for(self.pdf)['margin_left'], 300)
save_parts(self.pdf, [{'margin_status': 'normal'}, {'margin_status': 'low'}])
with patch.object(configuration, 'ANNOTATION_LOW_MARGIN_LEFT', 450):
save_parts(self.pdf, [{'margin_status': 'normal', 'margin_mm': 40},
{'margin_status': 'low', 'margin_mm': 20}])
with patch.object(configuration, 'ANNOTATION_MARGIN_POINTS', [(10, 500), (20, 450), (35, 300)]):
self.assertEqual(layout_for(self.pdf)['margin_left'], 450)
self.pdf.write_bytes(self.pdf.read_bytes() + b'\n')
self.assertEqual(layout_for(self.pdf)['margin_left'], 300)
@@ -79,11 +81,12 @@ class AnnotationLayoutTests(unittest.TestCase):
# 20% of a 600-point page removes 42.3 mm from the left.
splitting_int._render_split_outputs(self.pdf, [('|Ex 1', 0, 0, 10, 100, 110)], staging)
answer = staging / 'Ex 1.pdf'
self.assertEqual(layout_for(answer)['margin_left'], 400)
self.assertEqual(layout_for(answer)['margin_left'], 450)
self.assertEqual(answer_parts(answer)[0]['margin_mm'], 0)
def test_copy_move_and_vertical_cut_preserve_page_classification(self):
save_parts(self.pdf, [{'margin_status': 'normal'}, {'margin_status': 'low'}])
save_parts(self.pdf, [{'margin_status': 'normal', 'margin_mm': 40},
{'margin_status': 'low', 'margin_mm': 20}])
copied = self.root / 'copied.pdf'
moved = self.root / 'moved.pdf'
copy_answer_pdf(self.pdf, copied)
@@ -105,6 +108,47 @@ class AnnotationLayoutTests(unittest.TestCase):
box = next(call.args[2]['box'] for call in callback.call_args_list if call.args[0] == 'local_rect')
return image, header, box
def test_interpolation_and_clamping(self):
for mm, pixels in [(None, 300), (0, 450), (10, 450), (15, 425),
(20, 400), (27.5, 350), (35, 300), (80, 300)]:
with self.subTest(mm=mm):
self.assertEqual(added_margin(mm), pixels)
def test_unknown_measurement_inherits_average_during_answer_extraction(self):
atomic_write_json(self.root / 'Cutleft' / 'Copie01_schema.json', {
'source_sha256': fingerprint(self.pdf),
'auto_margin': {'pages': [{'page': 1, 'edge_mm': 15}, {'page': 2, 'edge_mm': None}]},
})
staging = self.root / 'inferred'
staging.mkdir()
splitting_int._render_split_outputs(self.pdf, [('Ex 1', 0, 0, 10, 0, 10)], staging)
answer = staging / 'Ex 1.pdf'
self.assertEqual(answer_parts(answer)[1]['margin_status'], 'unknown')
self.assertEqual(answer_parts(answer)[1]['margin_mm'], 15)
self.assertEqual(layout_for(answer)['page_margins'], [425, 425])
def test_per_page_offsets_narrow_pages_and_saved_layout(self):
save_parts(self.pdf, [{'margin_mm': 10}, {'margin_mm': 35}])
pages = [Image.new('RGB', (1000, 100), 'blue'), Image.new('RGB', (800, 100), 'green')]
with patch.object(annotating, 'convert_from_path', return_value=pages):
base, _, _ = annotating.make_base_image(self.pdf)
result = {'score': 2, 'feedback': [{'text': 'Across pages', 'box_2d': [80, 150, 120, 200]}]}
callback = Mock()
with patch.object(annotating, 'render_score_text', return_value=Image.new('RGB', (200, 40))):
def render(**kwargs):
return annotating.compose_label_image(
base, 'Ex 1', result, 0, render_fn=lambda *a, **k: Image.new('RGBA', (10, 10)),
**kwargs)
image, header = render(source_pdf=self.pdf, draw_callback=callback)
boxes = [call.args[2]['box'] for call in callback.call_args_list if call.args[0] == 'local_rect']
self.assertEqual(boxes, [[600, 120, 650, 140], [350, 140, 400, 160]])
for x, y, color in [(450, header + 20, (0, 0, 255)), (300, header + 150, (0, 128, 0))]:
self.assertEqual(image.getpixel((x, y)), color)
self.assertEqual(image.getpixel((x - 1, y)), (255, 255, 255))
with patch.object(configuration, 'ANNOTATION_MARGIN_POINTS', [(10, 900)]):
restored, _ = render(layout=image.info['annotation_layout'])
self.assertEqual(restored.tobytes(), image.tobytes())
def test_wider_layout_translates_copy_and_feedback_without_scaling(self):
normal, height, box = self.render(legacy_layout())
low, low_height, low_box = self.render({**legacy_layout(), 'margin_left': 400})
@@ -145,7 +189,7 @@ class AnnotationLayoutTests(unittest.TestCase):
self.assertEqual(meta['width'], low.width)
with Image.open(output / 'Reference.jpg') as reference:
self.assertGreater(min(reference.getpixel((low.width - 10, 80))), 245)
with patch.object(reader, 'detect_checks_and_notes', return_value=([], Image.new('RGBA', (low.width, low.height * 2)))), patch.object(configuration, 'ANNOTATION_LOW_MARGIN_LEFT', 500):
with patch.object(reader, 'detect_checks_and_notes', return_value=([], Image.new('RGBA', (low.width, low.height * 2)))), patch.object(configuration, 'ANNOTATION_MARGIN_POINTS', [(10, 500)]):
_, notes = reader._scan_annotation_directory(output)
self.assertEqual(notes['02']['Ex 1']['layout']['margin_left'], 400)
rerender, _, _ = self.render(notes['02']['Ex 1']['layout'])
+12 -3
View File
@@ -324,9 +324,18 @@ class LeftMarginTests(unittest.TestCase):
self.assertNotIn('first_page_body_start_px', result)
def test_sparse_groups_stay_at_left(self):
for edges in ([None] * 4, [40, None, None, None]):
with self.subTest(edges=edges):
self.assertEqual(self.analyse(edges)['starts_px'], [0] * 4)
result = self.analyse([None] * 4)
self.assertEqual(result['starts_px'], [0] * 4)
self.assertTrue(all(page['effective_edge_mm'] is None for page in result['pages']))
def test_unknown_pages_borrow_only_measured_copy_average(self):
result = self.analyse([40, 50, None, None])
self.assertEqual([page['effective_edge_mm'] for page in result['pages']], [40, 50, 45, 45])
self.assertEqual(result['pages'][2]['status'], 'unknown')
self.assertIsNone(result['pages'][2]['edge_mm'])
self.assertEqual(result['starts_px'][2:], [round(25 * 200 / 25.4)] * 2)
sparse = self.analyse([40, None, None, None])
self.assertEqual(sparse['starts_px'], [round(20 * 200 / 25.4)] * 4)
def test_narrow_subpages_stay_at_left_even_with_indented_text(self):
result = self.analyse([45, 45, 70, 72], widths=[1654, 1654, 800, 800])