Use per-page annotation spacing with inferred margin fallback
This commit is contained in:
1 parent
b069805988
commit
0bb6f7d97c
10 files changed
+170
-39
No files matched your search
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user