Initial support for user general remarks
This commit is contained in:
1 parent
a38c1ff4b3
commit
2c9dc65640
16 files changed
+606
-20
No files matched your search
@@ -2,6 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from copienator import general_remarks
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
@@ -154,7 +157,7 @@ def edited_bodies(directory: Path, meta: dict, instructions: list[ManualInstruct
|
||||
|
||||
def output_removals(directory: Path) -> tuple[str, ...]:
|
||||
info = read_json(directory / "info.json") if (directory / "info.json").exists() else {}
|
||||
return (STATE_FILE, "Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json",
|
||||
return (general_remarks.IMAGE_FILENAME, STATE_FILE, "Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json",
|
||||
*(f"{label}.jpg" for label, entry in info.items()
|
||||
if Path(label).name == label))
|
||||
|
||||
@@ -181,7 +184,13 @@ def rebuild(root: Path, mode: str, directory: Path, staging: Path, meta: dict,
|
||||
label for label, entry in info.items() if entry["not_empty"]
|
||||
}
|
||||
all_labels = utils.read_all_labels(root)
|
||||
images, groups, checked_images = [], [], []
|
||||
general = general_remarks.existing(directory)
|
||||
images = [general] if general is not None else []
|
||||
groups = [[general]] if general is not None else []
|
||||
checked_images = []
|
||||
if general is not None and directory != staging:
|
||||
shutil.copy2(directory / general_remarks.IMAGE_FILENAME,
|
||||
staging / general_remarks.IMAGE_FILENAME)
|
||||
seen_contexts = set()
|
||||
order = sorted(bodies, key=utils.natural_key)
|
||||
for label in order:
|
||||
@@ -215,7 +224,7 @@ def rebuild(root: Path, mode: str, directory: Path, staging: Path, meta: dict,
|
||||
save_paginated_pdf(groups, staging / "Concat_F.pdf")
|
||||
else:
|
||||
stack([image for group in groups for image in group]).save(staging / "Concat_F.jpg")
|
||||
save_checked_pdf(root, checked_images, all_labels, staging / "Concat_F.pdf")
|
||||
save_checked_pdf(root, checked_images, all_labels, staging / "Concat_F.pdf", general=general)
|
||||
|
||||
|
||||
def stage_baseline(root: Path, mode: str, output: Path, staging: Path,
|
||||
|
||||
@@ -7,6 +7,7 @@ from copienator import atomic_write_json, configuration, read_json
|
||||
from copienator.annotation_documents import copy_documents
|
||||
from copienator.return_answers import load_return_answer_options
|
||||
from copienator.custom_remarks import load_remarks, copy_key
|
||||
from copienator import general_remarks
|
||||
|
||||
STATE_FILE = ".annotation-read-state.json"
|
||||
DOCUMENT_INPUTS = ("bnote.json", "checkboxes.json", "Reference.jpg", "Concat_annotated.pdf")
|
||||
@@ -40,6 +41,7 @@ class AnnotationReadCache:
|
||||
configuration.RETURN_JPEG_ENABLED, configuration.RETURN_PDF_ENABLED,
|
||||
configuration.LATEX_ANOT_BEFORE, configuration.LATEX_ANOT_AFTER)
|
||||
remarks = load_remarks(root)
|
||||
general = general_remarks.load_remarks(root)
|
||||
for copy_id, labels in data.items():
|
||||
directory = root / mode / f"Copie{copy_id}"
|
||||
paths = [directory / "score.json", directory / "bnote.json"]
|
||||
@@ -48,7 +50,7 @@ class AnnotationReadCache:
|
||||
for content in labels.values():
|
||||
pdf = Path(content["pdf_path"])
|
||||
paths.extend((pdf, pdf.with_suffix(".layout.json")))
|
||||
payload = json.dumps((2, common, labels, remarks.get(copy_key(copy_id), {}), _files_digest(paths)),
|
||||
payload = json.dumps((3, common, labels, general.get(copy_key(copy_id), []), remarks.get(copy_key(copy_id), {}), _files_digest(paths)),
|
||||
sort_keys=True, default=str).encode()
|
||||
self.signatures[copy_id] = sha256(payload).hexdigest()
|
||||
|
||||
@@ -69,6 +71,7 @@ class AnnotationReadCache:
|
||||
info = read_json(directory / "info.json")
|
||||
outputs.extend(directory / f"{label}.jpg" for label, entry in info.items()
|
||||
if entry["present"])
|
||||
outputs.extend(directory.glob(general_remarks.IMAGE_FILENAME))
|
||||
outputs.extend(directory.glob("Concat*.*"))
|
||||
outputs.extend(directory.glob(".annotation-base-*.png"))
|
||||
outputs.extend(directory.glob("annotation_baseline.json"))
|
||||
|
||||
@@ -26,6 +26,7 @@ from copienator.annotation_documents import copy_documents
|
||||
from copienator.annotation_layout import legacy_layout
|
||||
from copienator.annotation_read_cache import AnnotationReadCache
|
||||
from copienator.custom_remarks import apply_custom_remarks
|
||||
from copienator import general_remarks
|
||||
from copienator.annotation_modifications import (
|
||||
BASELINE,
|
||||
output_removals,
|
||||
@@ -260,9 +261,16 @@ def apply_actions_and_regenerate(
|
||||
|
||||
if incomplete:
|
||||
return ExitCode.PARTIAL
|
||||
general = general_remarks.render(workspace.root, student_id,
|
||||
max((im.width for im in dirty_images.values()), default=800))
|
||||
if general is not None:
|
||||
concatenated.insert(0, general)
|
||||
filtered.insert(0, general)
|
||||
concat_image = concatenate(concatenated)
|
||||
filtered_image = concatenate(filtered)
|
||||
with staged_files(output_dir, remove=(*output_removals(output_dir), BASELINE)) as staging:
|
||||
if general is not None:
|
||||
general.save(staging / general_remarks.IMAGE_FILENAME)
|
||||
for label, image in dirty_images.items():
|
||||
image.save(staging / f"{label}.jpg")
|
||||
if not preserve_score_file:
|
||||
@@ -277,8 +285,8 @@ def apply_actions_and_regenerate(
|
||||
# Imported here because the grouped reader also uses this module.
|
||||
from copienator.commands.reading_grouped_annotations import save_checked_pdf
|
||||
save_checked_pdf(
|
||||
workspace.root, list(zip(filtered_labels, filtered, strict=True)),
|
||||
all_labels, staging / "Concat_F.pdf",
|
||||
workspace.root, [(label, dirty_images[label]) for label in filtered_labels],
|
||||
all_labels, staging / "Concat_F.pdf", general=general,
|
||||
)
|
||||
stage_baseline(workspace.root, "Bnot", output_dir, staging, dirty_images,
|
||||
header_heights, build_answer_info(scores, labels_data, answer_labels),
|
||||
|
||||
@@ -29,6 +29,7 @@ from copienator.annotation_documents import copy_documents, tablet_documents
|
||||
from copienator.annotation_layout import legacy_layout
|
||||
from copienator.annotation_read_cache import AnnotationReadCache
|
||||
from copienator.custom_remarks import apply_custom_remarks
|
||||
from copienator import general_remarks
|
||||
from copienator.annotation_modifications import (
|
||||
BASELINE,
|
||||
baseline,
|
||||
@@ -150,9 +151,9 @@ def save_paginated_pdf(
|
||||
)
|
||||
|
||||
|
||||
def save_checked_pdf(root, labelled_images, all_labels, output_path):
|
||||
def save_checked_pdf(root, labelled_images, all_labels, output_path, *, general=None):
|
||||
"""Build the checked-mode return PDF with the same supplements as BGnot."""
|
||||
groups = []
|
||||
groups = [[general]] if general is not None else []
|
||||
seen_contexts = set()
|
||||
for label, image in labelled_images:
|
||||
extras = get_extra_pdfs_as_images(
|
||||
@@ -410,10 +411,17 @@ def apply_actions_and_regenerate_grouped(
|
||||
filtered_groups.append([*extras, final_image])
|
||||
touched[label] = annotation_dir == "BGnot"
|
||||
|
||||
general = general_remarks.render(workspace.root, student_id,
|
||||
max((im.width for im in dirty_images.values()), default=800))
|
||||
if general is not None:
|
||||
concat_images.insert(0, general)
|
||||
filtered_groups.insert(0, [general])
|
||||
concat_image = concatenate(concat_images)
|
||||
if incomplete:
|
||||
return ExitCode.PARTIAL, "\n".join(logs)
|
||||
with staged_files(output_dir, remove=(*output_removals(output_dir), BASELINE)) as staging:
|
||||
if general is not None:
|
||||
general.save(staging / general_remarks.IMAGE_FILENAME)
|
||||
if simple_layout is not None:
|
||||
simple_layout["replaced"] = sorted(
|
||||
set(simple_layout["replaced"]) | selected_labels
|
||||
@@ -440,7 +448,7 @@ def apply_actions_and_regenerate_grouped(
|
||||
save_checked_pdf(
|
||||
workspace.root,
|
||||
[(label, dirty_images[label]) for label in filtered_labels],
|
||||
all_labels, staging / "Concat_F.pdf",
|
||||
all_labels, staging / "Concat_F.pdf", general=general,
|
||||
)
|
||||
stage_baseline(workspace.root, annotation_dir, output_dir, staging, dirty_images,
|
||||
header_heights, build_answer_info(scores, labels_data, answer_labels, touched),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Ordered copy-level remarks and their generated LaTeX image."""
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import atomic_update_json, read_json
|
||||
from copienator.custom_remarks import copy_key
|
||||
|
||||
FILENAME = "general_remarks.json"
|
||||
IMAGE_FILENAME = "Remarques_generales.jpg"
|
||||
|
||||
|
||||
def validate(data):
|
||||
if not isinstance(data, dict) or any(
|
||||
not isinstance(key, str) or copy_key(key) != key
|
||||
or not isinstance(remarks, list)
|
||||
or any(not isinstance(text, str) for text in remarks)
|
||||
for key, remarks in data.items()
|
||||
):
|
||||
raise ValueError(f"Format invalide dans {FILENAME}.")
|
||||
|
||||
|
||||
def load_remarks(root: Path) -> dict[str, list[str]]:
|
||||
data = read_json(root / FILENAME, default={})
|
||||
validate(data)
|
||||
return data
|
||||
|
||||
|
||||
def remarks_for_copy(root: Path, copy_id: str) -> list[str]:
|
||||
return load_remarks(root).get(copy_key(copy_id), [])
|
||||
|
||||
|
||||
def set_remarks(root: Path, copy_id: str, remarks: list[str]) -> list[str]:
|
||||
key = copy_key(copy_id)
|
||||
current = [text.strip() for text in remarks if text.strip()]
|
||||
previous = []
|
||||
|
||||
def update(data):
|
||||
nonlocal previous
|
||||
validate(data)
|
||||
previous = data.get(key, [])
|
||||
if current:
|
||||
data[key] = current
|
||||
else:
|
||||
data.pop(key, None)
|
||||
|
||||
atomic_update_json(root / FILENAME, update, default_factory=dict)
|
||||
return previous
|
||||
|
||||
|
||||
def render(root: Path, copy_id: str, width: int) -> Image.Image | None:
|
||||
remarks = remarks_for_copy(root, copy_id)
|
||||
if not remarks:
|
||||
return None
|
||||
from copienator.commands.annotating import render_real_latex_text
|
||||
text = r"\textbf{Remarques générales}\par\medskip " + r"\par\medskip ".join(remarks)
|
||||
content = render_real_latex_text(text, max(100, width - 40)).convert("RGB")
|
||||
image = Image.new("RGB", (max(width, content.width + 40), content.height + 40), "white")
|
||||
image.paste(content, (20, 20))
|
||||
return image
|
||||
|
||||
|
||||
def existing(directory: Path) -> Image.Image | None:
|
||||
path = directory / IMAGE_FILENAME
|
||||
if path.is_file():
|
||||
with Image.open(path) as image:
|
||||
return image.convert("RGB")
|
||||
return None
|
||||
@@ -13,6 +13,10 @@ WIDTH_TOLERANCE = .08
|
||||
# Start below it with clearance, including on later pages with repeated names.
|
||||
BODY_TOP = .30
|
||||
BODY_BOTTOM = .82
|
||||
LABEL_BOTTOM = .92
|
||||
LABEL_LEFT_FRACTION = .05
|
||||
COHERENT_MARGIN_TOLERANCE_MM = 10
|
||||
LABEL_MARGIN_GAP_MM = 10
|
||||
DISCARD_LEFT_FRACTION = .10
|
||||
STANDARD_MARGIN_MM = 40
|
||||
MARGIN_CLEARANCE_MM = 20
|
||||
@@ -47,21 +51,28 @@ def handwriting_mask(rgb: np.ndarray) -> np.ndarray:
|
||||
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]
|
||||
def occupied_band_starts(mask: np.ndarray, bottom_fraction: float) -> list[float]:
|
||||
"""Ignore empty bands and writing starting beyond the page midpoint."""
|
||||
height, width = mask.shape
|
||||
band_height = max(1, round(4 * ANALYSIS_DPI / 25.4))
|
||||
bottom = round(BODY_BOTTOM * height)
|
||||
bottom = round(bottom_fraction * 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:
|
||||
if band.sum() >= 40 and len(xs) >= 12 and xs[0] <= width / 2:
|
||||
starts.append(float(xs[0]) * 25.4 / ANALYSIS_DPI)
|
||||
return starts
|
||||
|
||||
|
||||
def body_edge(mask: np.ndarray) -> dict:
|
||||
"""Keep the body decile; collect a wider 30–92% label-search window."""
|
||||
starts = occupied_band_starts(mask, BODY_BOTTOM)
|
||||
return {
|
||||
'edge_mm': trimmed_left_edge(starts) if len(starts) >= 6 else None,
|
||||
'bands': len(starts),
|
||||
'band_starts_mm': starts,
|
||||
'label_band_starts_mm': occupied_band_starts(mask, LABEL_BOTTOM),
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +101,7 @@ def format_groups(images: list[Image.Image], reference_width: float) -> list[lis
|
||||
|
||||
|
||||
def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width: float) -> dict:
|
||||
"""Pool occupied bands and use the larger half-margin or 20 mm-clearance cut."""
|
||||
"""Estimate body margins, then retain left labels supported across coherent pages."""
|
||||
starts = [0] * len(images)
|
||||
groups = []
|
||||
page_estimates = {}
|
||||
@@ -116,6 +127,24 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width:
|
||||
status = 'near-edge' if edge < MIN_MARGIN_MM else 'margin'
|
||||
left_mm = (0.0 if edge < MIN_MARGIN_MM else
|
||||
max(edge / 2, edge - MARGIN_CLEARANCE_MM))
|
||||
# Use ALL coherent pages, not just the four body-margin samples.
|
||||
# A sparse/low/outlying page cannot supply evidence for this format's
|
||||
# established body margin. Its normal fallback behaviour remains below.
|
||||
coherent = [index for index in indices
|
||||
if edge is not None and page_estimate(index)['edge_mm'] is not None
|
||||
and page_estimate(index)['edge_mm'] >= MIN_MARGIN_MM
|
||||
and abs(page_estimate(index)['edge_mm'] - edge) <= COHERENT_MARGIN_TOLERANCE_MM]
|
||||
label_starts = [start for index in coherent for start in
|
||||
page_estimate(index).get('label_band_starts_mm',
|
||||
page_estimate(index)['band_starts_mm'])]
|
||||
label_edge = (round(sorted(label_starts)[int(len(label_starts) * LABEL_LEFT_FRACTION)], 1)
|
||||
if label_starts else None)
|
||||
labels_in_margin = (status == 'margin' and len(coherent) >= min(2, len(indices))
|
||||
and label_edge is not None and edge - label_edge >= LABEL_MARGIN_GAP_MM)
|
||||
body_left_mm = left_mm
|
||||
if labels_in_margin:
|
||||
status = 'labels-in-margin'
|
||||
left_mm = 0.0
|
||||
# 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:
|
||||
@@ -129,6 +158,11 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width:
|
||||
'status': status,
|
||||
'edge_mm': edge,
|
||||
'left_mm': round(left_mm, 1),
|
||||
'body_left_mm': round(body_left_mm, 1),
|
||||
'labels_in_margin': labels_in_margin,
|
||||
'coherent_pages': [index + 1 for index in coherent],
|
||||
'label_edge_mm': label_edge,
|
||||
'label_bands': len(label_starts),
|
||||
'bands': len(band_starts),
|
||||
'discarded_bands': int(len(band_starts) * DISCARD_LEFT_FRACTION),
|
||||
'samples': [{'page': index + 1, **item} for index, item in estimates.items()],
|
||||
@@ -154,9 +188,14 @@ 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': 8, 'pages': pages, 'starts_px': starts, 'groups': groups,
|
||||
return {'version': 9, 'pages': pages, 'starts_px': starts, 'groups': groups,
|
||||
'no_margin_from_page': no_margin_from_page,
|
||||
'discard_left_fraction': DISCARD_LEFT_FRACTION, 'margin_clearance_mm': MARGIN_CLEARANCE_MM,
|
||||
'cut_fraction_of_margin': .5, 'cut_rule': 'maximum',
|
||||
'standard_margin_mm': STANDARD_MARGIN_MM, 'minimum_margin_mm': MIN_MARGIN_MM,
|
||||
'body_height_fraction': [BODY_TOP, BODY_BOTTOM]}
|
||||
'body_height_fraction': [BODY_TOP, BODY_BOTTOM],
|
||||
'label_height_fraction': [BODY_TOP, LABEL_BOTTOM],
|
||||
'label_left_fraction': LABEL_LEFT_FRACTION,
|
||||
'coherent_margin_tolerance_mm': COHERENT_MARGIN_TOLERANCE_MM,
|
||||
'label_margin_gap_mm': LABEL_MARGIN_GAP_MM,
|
||||
'maximum_band_start_width_fraction': .5}
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
from copienator import general_remarks
|
||||
|
||||
from PIL import Image
|
||||
|
||||
@@ -92,6 +95,9 @@ def publish_answer_returns(
|
||||
|
||||
all_labels = utils.read_all_labels(root)
|
||||
with staged_directory(answers_dir) as staging:
|
||||
general = source / general_remarks.IMAGE_FILENAME
|
||||
if general.is_file():
|
||||
shutil.copy2(general, staging / general.name)
|
||||
for label in sorted(labels, key=utils.natural_key):
|
||||
paths = []
|
||||
if options["context"]:
|
||||
|
||||
Reference in new issue
Block a user