Restructuration de l'application
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Executable Copienator commands.
|
||||
|
||||
Command modules expose a ``main(argv=None)`` entry point and may also expose
|
||||
their processing functions for reuse and tests.
|
||||
"""
|
||||
@@ -0,0 +1,128 @@
|
||||
import argparse
|
||||
import math
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from copienator.configuration import FINAL_SCORE_FONT_PATH, FINAL_SCORE_ODS_PATH, FINAL_SCORE_OUTPUT_DIR
|
||||
|
||||
# Configuration constants
|
||||
ODS_PATH = Path(FINAL_SCORE_ODS_PATH).expanduser()
|
||||
OUTPUT_DIR = Path(FINAL_SCORE_OUTPUT_DIR).expanduser()
|
||||
|
||||
|
||||
def score_font(size):
|
||||
candidates = [FINAL_SCORE_FONT_PATH, "DejaVuSans-Bold.ttf", "arial.ttf"]
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
return ImageFont.truetype(str(candidate), size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
def get_rounded_score(score):
|
||||
"""Round score to one decimal place below (floor)."""
|
||||
try:
|
||||
val = float(score)
|
||||
return math.floor(val * 10) / 10
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def process_images(base_dir, output_dir):
|
||||
# 1. Load Data
|
||||
try:
|
||||
# header=None assumes the file starts directly with data.
|
||||
# If row 0 is a header, change to header=0
|
||||
df = pd.read_excel(ODS_PATH, engine="odf", header=None)
|
||||
# Create a lookup dictionary: {Name: Score}
|
||||
score_db = dict(zip(df[0], df[1]))
|
||||
except Exception as e:
|
||||
print(f"CRITICAL ERROR: Could not read ODS file.\n{e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 2. Prepare Output Directory
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 3. Iterate Files
|
||||
# Structure: Dir/A Rendre/{name}/{name}.jpg
|
||||
search_path = base_dir / "A Rendre"
|
||||
|
||||
if not search_path.exists():
|
||||
print(f"Error: Directory '{search_path}' not found.")
|
||||
sys.exit(1)
|
||||
|
||||
for img_path in sorted(search_path.glob("*/*.jpg")):
|
||||
student_name = img_path.stem # Filename without extension
|
||||
|
||||
# 4. Find Score
|
||||
if student_name not in score_db:
|
||||
print(f"Error: Student '{student_name}' not found in ODS file.")
|
||||
continue
|
||||
|
||||
raw_score = score_db[student_name]
|
||||
score = get_rounded_score(raw_score)
|
||||
|
||||
if score is None:
|
||||
print(f"Error: Invalid score '{raw_score}' for '{student_name}'.")
|
||||
continue
|
||||
|
||||
# 5. Process Image
|
||||
try:
|
||||
with Image.open(img_path) as img:
|
||||
img = img.convert("RGB")
|
||||
draw = ImageDraw.Draw(img)
|
||||
width, height = img.size
|
||||
|
||||
# Dynamic font size (15% of image height)
|
||||
font_size = int(width * 0.08)
|
||||
|
||||
font = score_font(font_size)
|
||||
|
||||
text = str(score)
|
||||
|
||||
# Calculate text size and position (Top Right)
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
text_h = bbox[3] - bbox[1]
|
||||
|
||||
# 30px padding
|
||||
x = width - text_w - 30
|
||||
y = 30
|
||||
|
||||
# Draw Text (Red)
|
||||
draw.text((x, y), text, fill=(255, 0, 0), font=font)
|
||||
|
||||
# Save
|
||||
save_path = output_dir / f"{student_name}.jpg"
|
||||
img.save(save_path)
|
||||
print(f"Processed: {student_name} -> {score}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing image for '{student_name}': {e}")
|
||||
|
||||
for pdf_path in sorted(search_path.glob("*/*.pdf")):
|
||||
student_name = pdf_path.stem # Filename without extension
|
||||
save_path = output_dir / f"{student_name}.pdf"
|
||||
|
||||
shutil.copy(str(pdf_path), str(save_path))
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="Stamp scores on exam copies.")
|
||||
parser.add_argument("dir", type=Path, help="Root directory containing 'A Rendre' folder")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
base_dir = args.dir.expanduser().resolve()
|
||||
output_dir = OUTPUT_DIR / base_dir.name
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
process_images(base_dir, output_dir)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,546 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib
|
||||
import matplotlib.colors as mcolors
|
||||
import PIL.ImageOps
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
from matplotlib import pyplot as plt
|
||||
from pdf2image import convert_from_path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from copienator import utils
|
||||
from copienator.configuration import LATEX_ANOT_AFTER, LATEX_ANOT_BEFORE
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.annotation_data import load_annotation_data
|
||||
from copienator.filesystem import staged_directory
|
||||
from copienator.utils import natural_key
|
||||
|
||||
MARGIN_LEFT = 300
|
||||
ANNOT_WIDTH = 600
|
||||
|
||||
|
||||
|
||||
def make_dictionary(root_dir, refaire=False, refaire_list=None):
|
||||
"""Compatibility wrapper used by the annotation-reading scripts."""
|
||||
workspace = EvaluationWorkspace(Path(root_dir))
|
||||
loaded = load_annotation_data(
|
||||
workspace,
|
||||
refaire_list=(refaire_list or []) if refaire else None,
|
||||
)
|
||||
return loaded.data
|
||||
|
||||
|
||||
def make_base_image(pdf_path):
|
||||
pages = convert_from_path(pdf_path)
|
||||
|
||||
# Calculate total dimensions
|
||||
total_h = sum(page.height for page in pages)
|
||||
max_w = max(page.width for page in pages)
|
||||
|
||||
# Create concatenated base image
|
||||
base_img = Image.new("RGBA", (max_w, total_h), "white")
|
||||
|
||||
current_y = 0
|
||||
for page in pages:
|
||||
base_img.paste(page.convert("RGBA"), (0, current_y))
|
||||
current_y += page.height
|
||||
return (base_img, total_h, max_w)
|
||||
|
||||
def normalize_mathtext(text):
|
||||
"""
|
||||
Replaces LaTeX shortcuts not supported by Matplotlib's mathtext parser.
|
||||
e.g. \\le -> \\leq, \\ge -> \\geq
|
||||
Using lookahead (?![a-zA-Z]) prevents replacing \\left with \\leqft.
|
||||
"""
|
||||
text = re.sub(r'\\le(?![a-zA-Z])', r'\\leq', text)
|
||||
text = re.sub(r'\\ge(?![a-zA-Z])', r'\\geq', text)
|
||||
text = re.sub(r'\\implies', r'\\Rightarrow', text)
|
||||
# Sometimes, Gemini escapes too much ? Not sure
|
||||
text = text.replace("\\\\", "\\")
|
||||
text = text.replace("\\llbracket", "[\\![")
|
||||
text = text.replace("\\rrbracket", "]\\!]")
|
||||
text = text.replace("\\R", "\\mathbb{R}")
|
||||
text = text.replace("\\N", "\\mathbb{N}")
|
||||
text = text.replace("\\Z", "\\mathbb{Z}")
|
||||
text = text.replace("\\C", "\\mathbb{C}")
|
||||
text = text.replace("\\Q", "\\mathbb{Q}")
|
||||
# Sometimes, Gemini doesn't escape enough. In the json, you should have \\f
|
||||
text = text.replace('\f', r'\f')
|
||||
text = re.sub('\u0010', "", text)
|
||||
return text
|
||||
|
||||
def wrap_latex_text(text, width_chars):
|
||||
"""
|
||||
Wraps text but keeps LaTeX math blocks ($...$) intact.
|
||||
"""
|
||||
# 1. Split text into chunks of: text, math, text, math...
|
||||
# The regex looks for $...$ (non-greedy).
|
||||
parts = re.split(r'(\$[^\$]+\$)', text)
|
||||
|
||||
# 2. Tokenize: Break plain text by spaces, keep math blocks whole.
|
||||
tokens = []
|
||||
for part in parts:
|
||||
if part.startswith('$') and part.endswith('$'):
|
||||
tokens.append(part) # Keep math block distinct
|
||||
else:
|
||||
tokens.extend(part.split()) # Split normal text by whitespace
|
||||
|
||||
# 3. Reconstruct lines using textwrap logic
|
||||
lines = []
|
||||
current_line = []
|
||||
current_length = 0
|
||||
|
||||
for token in tokens:
|
||||
# +1 for the space we will add
|
||||
token_len = len(token)
|
||||
|
||||
if current_length + token_len + 1 > width_chars:
|
||||
lines.append(" ".join(current_line))
|
||||
current_line = [token]
|
||||
current_length = token_len
|
||||
else:
|
||||
current_line.append(token)
|
||||
current_length += token_len + 1
|
||||
|
||||
if current_line:
|
||||
lines.append(" ".join(current_line))
|
||||
|
||||
res = "\n".join(lines)
|
||||
return res
|
||||
|
||||
def render_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=None,
|
||||
fontsize=14):
|
||||
# 1. Fix unsupported symbols
|
||||
text = normalize_mathtext(text)
|
||||
|
||||
dpi = 100
|
||||
fig_width = width_px / dpi
|
||||
|
||||
# Estimate characters per line based on width and font size (heuristic)
|
||||
# FontSize 12 approx 0.5 inches wide for ~15 chars usually,
|
||||
# but let's approximate: Width (inches) * ~10 chars/inch for size 12
|
||||
chars_per_line = int(fig_width * 10)
|
||||
|
||||
# Pre-wrap the text respecting LaTeX boundaries
|
||||
wrapped_text = wrap_latex_text(text, chars_per_line)
|
||||
|
||||
# Dynamic height based on actual number of lines
|
||||
num_lines = wrapped_text.count('\n') + 1
|
||||
if max_lines and num_lines > max_lines:
|
||||
# logic to truncate if strictly necessary, or just expand
|
||||
pass
|
||||
|
||||
# 0.3 inches per line buffer
|
||||
fig_height = num_lines * 0.3 + 0.2
|
||||
|
||||
fig = plt.figure(figsize=(fig_width, fig_height), dpi=dpi)
|
||||
|
||||
# NOTE: wrap=False because we did it ourselves
|
||||
plt.text(0.01, 0.95, wrapped_text, fontsize=fontsize,
|
||||
verticalalignment='top', horizontalalignment='left',
|
||||
wrap=False)
|
||||
|
||||
plt.axis('off')
|
||||
|
||||
buf = io.BytesIO()
|
||||
plt.savefig(buf, format='png', bbox_inches='tight', pad_inches=0.1, transparent=True)
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
|
||||
img = Image.open(buf).convert("RGBA")
|
||||
|
||||
# Create background
|
||||
final_img = Image.new("RGBA", img.size, bg_color)
|
||||
final_img.alpha_composite(img)
|
||||
return final_img
|
||||
|
||||
def render_real_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=None, fontsize=19):
|
||||
dpi = 100
|
||||
width_in = width_px / dpi
|
||||
line_spacing = int(fontsize * 1.2)
|
||||
|
||||
# Use the 'standalone' class with 'varwidth' to auto-crop height while restricting width
|
||||
header = LATEX_ANOT_BEFORE.substitute(
|
||||
width_in=width_in, fontsize=fontsize, line_spacing=line_spacing
|
||||
)
|
||||
latex_template = f"{header}{text}{LATEX_ANOT_AFTER}"
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tex_path = os.path.join(temp_dir, 'text.tex')
|
||||
pdf_path = os.path.join(temp_dir, 'text.pdf')
|
||||
|
||||
with open(tex_path, 'w', encoding='utf-8') as f:
|
||||
f.write(latex_template)
|
||||
|
||||
# Compile to PDF
|
||||
subprocess.run(
|
||||
['pdflatex', '-interaction=nonstopmode', 'text.tex'],
|
||||
cwd=temp_dir,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if not os.path.exists(pdf_path):
|
||||
raise RuntimeError("LaTeX compilation failed. Check your LaTeX syntax.")
|
||||
|
||||
# Convert PDF to grayscale (ignoring pdf2image's broken transparency)
|
||||
images = convert_from_path(pdf_path, dpi=dpi)
|
||||
gray_img = images[0].convert("L")
|
||||
|
||||
# 1. Invert grayscale to create an alpha mask (white bg = 0, black text = 255)
|
||||
alpha_mask = PIL.ImageOps.invert(gray_img)
|
||||
|
||||
# 2. Create a transparent image with black text using the mask
|
||||
text_img = Image.new("RGBA", gray_img.size, (0, 0, 0, 255))
|
||||
text_img.putalpha(alpha_mask)
|
||||
|
||||
# 3. Create the requested background and composite the text over it
|
||||
final_img = Image.new("RGBA", text_img.size, bg_color)
|
||||
final_img.alpha_composite(text_img)
|
||||
|
||||
# (Optional) Truncate image height if max_lines is strictly enforced
|
||||
if max_lines:
|
||||
max_height_px = int((fontsize * 1.2 / 72.0) * dpi * max_lines) # Points to pixels
|
||||
if final_img.height > max_height_px:
|
||||
final_img = final_img.crop((0, 0, final_img.width, max_height_px))
|
||||
|
||||
return final_img
|
||||
|
||||
def color(score):
|
||||
t = max(0.0, min(1.0, float(score) / 4.0))
|
||||
t = t*1.5 - 0.25
|
||||
t = max(0.0, min(1.0, t))
|
||||
red = 200 * (1 - t)
|
||||
green = 150 * t
|
||||
return mcolors.to_hex((red/255, green/255, 0))
|
||||
|
||||
def render_score_text(label, score, error, width_px, fontsize=30,
|
||||
bg_color=(255, 255, 255, 255),
|
||||
with_error=True, id=None):
|
||||
|
||||
# 1. Build text segments: (text, color, is_bold)
|
||||
parts = []
|
||||
default_color = (0, 0, 0, 255)
|
||||
|
||||
prefix = f"{id} " if id else ""
|
||||
prefix += f"{label} Note : "
|
||||
parts.append((prefix, default_color, False))
|
||||
|
||||
parts.append((str(score), color(score), True))
|
||||
|
||||
if error and error != "null" and with_error:
|
||||
fontsize=18
|
||||
parts.append((" ", default_color, False))
|
||||
parts.append((str(error), "orange", True))
|
||||
|
||||
# 2. Setup Image
|
||||
height_px = 80 # roughly matches fig_height=0.8 at 100 dpi
|
||||
img = Image.new("RGBA", (int(width_px), height_px), bg_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 3. Load Fonts
|
||||
try:
|
||||
font_regular = ImageFont.truetype("DejaVuSans.ttf", fontsize)
|
||||
font_bold = ImageFont.truetype("DejaVuSans-Bold.ttf", fontsize)
|
||||
except OSError:
|
||||
# Fallback for systems without specific TTFs readily available
|
||||
print("here")
|
||||
try:
|
||||
font_regular = ImageFont.load_default(size=fontsize) # Pillow >= 10.1.0
|
||||
except TypeError:
|
||||
print("there")
|
||||
font_regular = ImageFont.load_default()
|
||||
font_bold = font_regular
|
||||
|
||||
# 4. Draw segments horizontally
|
||||
x, y = int(width_px * 0.125), int(height_px * 0.2)
|
||||
|
||||
for text, text_color, is_bold in parts:
|
||||
f = font_bold if is_bold else font_regular
|
||||
draw.text((x, y), text, fill=text_color, font=f)
|
||||
|
||||
# Advance X position by the width of the drawn text
|
||||
bbox = draw.textbbox((x, y), text, font=f)
|
||||
x = bbox[2]
|
||||
|
||||
return img
|
||||
|
||||
A4_WIDTH_200DPI = 1654
|
||||
TARGET_MIN_WIDTH = int(A4_WIDTH_200DPI * 0.9) # 1406 pixels
|
||||
def compose_label_image(base_img, label, result, hmin,
|
||||
render_fn=render_real_latex_text,
|
||||
draw_callback=None,
|
||||
with_error=True,
|
||||
with_empty=False,
|
||||
more_right=False,
|
||||
with_id=None):
|
||||
"""
|
||||
Composes the final image with annotations.
|
||||
|
||||
Args:
|
||||
base_img: The source PDF converted to image.
|
||||
label: Label name (e.g. "Ex1").
|
||||
result: The JSON result object (score, feedbacks).
|
||||
hmin: Vertical offset coordinate.
|
||||
render_fn: Function to render text to image (allows threading injection).
|
||||
draw_callback: Optional function(type, draw_obj, position_dict, data_dict)
|
||||
called when elements are placed. Used for checkboxes.
|
||||
"""
|
||||
|
||||
left_pad = 0
|
||||
if base_img.width < TARGET_MIN_WIDTH:
|
||||
total_missing = TARGET_MIN_WIDTH - base_img.width
|
||||
left_pad = min(total_missing, MARGIN_LEFT)
|
||||
|
||||
new_base = Image.new("RGB", (TARGET_MIN_WIDTH, base_img.height), "white")
|
||||
new_base.paste(base_img, (left_pad, 0))
|
||||
base_img = new_base
|
||||
|
||||
score = result.get('score', 0)
|
||||
error = result.get('error', "")
|
||||
feedbacks = result.get('feedback', [])
|
||||
|
||||
if error == "empty-answer" and not with_empty:
|
||||
return None, 0
|
||||
|
||||
|
||||
# Filter deleted items (used by reading_annotations.py)
|
||||
feedbacks = [f for f in feedbacks if "to_delete" not in f]
|
||||
|
||||
global_fb = [f for f in feedbacks if not f.get('box_2d')]
|
||||
local_fb = [f for f in feedbacks if f.get('box_2d')]
|
||||
local_fb.sort(key=lambda x: x['box_2d'][0])
|
||||
|
||||
# 1. Prepare Headers
|
||||
header_elements = []
|
||||
|
||||
if more_right:
|
||||
width = base_img.width // 2
|
||||
else:
|
||||
width = base_img.width // 2 - 150
|
||||
img_score = render_score_text(label, score, error, width, with_error=with_error,
|
||||
id=with_id)
|
||||
header_elements.append({"type": "score", "img": img_score, "data": result})
|
||||
|
||||
# Global Feedbacks
|
||||
for idx, fb in enumerate(global_fb):
|
||||
img_fb = render_fn(fb['text'], base_img.width)
|
||||
header_elements.append({"type": "global_fb", "img": img_fb, "data": fb, "index": idx})
|
||||
|
||||
# Calculate Header Height
|
||||
header_height = sum(el["img"].height for el in header_elements)
|
||||
total_height = base_img.height + header_height
|
||||
|
||||
# Create Canvas
|
||||
final_img = Image.new("RGB", (base_img.width + MARGIN_LEFT, total_height), "white")
|
||||
|
||||
# Draw Headers
|
||||
current_y = 0
|
||||
draw = ImageDraw.Draw(final_img, "RGBA")
|
||||
|
||||
for el in header_elements:
|
||||
if el["type"] == "score" and more_right:
|
||||
final_img.paste(el["img"], (150, current_y))
|
||||
else:
|
||||
final_img.paste(el["img"], (0, current_y))
|
||||
|
||||
|
||||
if draw_callback:
|
||||
# Hook for checkboxes
|
||||
draw_callback("header_item", draw,
|
||||
{"x": 0, "y": current_y, "w": el["img"].width, "h": el["img"].height},
|
||||
el)
|
||||
current_y += el["img"].height
|
||||
|
||||
# Paste Base Image
|
||||
image_offset_y = current_y
|
||||
final_img.paste(base_img, (MARGIN_LEFT, image_offset_y))
|
||||
|
||||
# 2. Draw Local Annotations
|
||||
draw = ImageDraw.Draw(final_img, "RGBA") # Refresh draw object
|
||||
last_text_bottom = 0
|
||||
|
||||
for idx, fb in enumerate(local_fb):
|
||||
box = fb.get('box_2d')
|
||||
ymin, xmin, ymax, xmax = box
|
||||
|
||||
target_ymin = (ymin - hmin) + image_offset_y
|
||||
target_ymax = (ymax - hmin) + image_offset_y
|
||||
target_xmin = xmin + MARGIN_LEFT + left_pad
|
||||
target_xmax = xmax + MARGIN_LEFT + left_pad
|
||||
|
||||
# Draw Rectangle (if not suppressed)
|
||||
if "norectangle" not in fb:
|
||||
draw.rectangle([target_xmin, target_ymin, target_xmax, target_ymax], outline="red", width=3)
|
||||
|
||||
if draw_callback:
|
||||
draw_callback("local_rect", draw,
|
||||
{"box": [target_xmin, target_ymin, target_xmax, target_ymax]},
|
||||
{"data": fb, "index": idx})
|
||||
|
||||
# Render Text
|
||||
txt_img = render_fn(fb['text'], width_px=ANNOT_WIDTH,
|
||||
bg_color=(255, 200, 200, 180), max_lines=None)
|
||||
|
||||
# Calculate Position
|
||||
center_y = (target_ymin + target_ymax) / 2
|
||||
paste_y = center_y - (txt_img.height / 2)
|
||||
paste_y = max(paste_y, image_offset_y)
|
||||
|
||||
if paste_y < last_text_bottom:
|
||||
paste_y = last_text_bottom + 5
|
||||
|
||||
# Resize canvas if needed
|
||||
required_height = int(paste_y + txt_img.height + 20)
|
||||
if required_height > final_img.height:
|
||||
new_final = Image.new("RGB", (final_img.width, required_height), "white")
|
||||
new_final.paste(final_img, (0, 0))
|
||||
final_img = new_final
|
||||
draw = ImageDraw.Draw(final_img, "RGBA")
|
||||
|
||||
# Paste Text
|
||||
final_img.paste(txt_img, (10, int(paste_y)), mask=txt_img)
|
||||
|
||||
if draw_callback:
|
||||
draw_callback("local_text", draw,
|
||||
{"x": 10, "y": int(paste_y), "w": txt_img.width, "h": txt_img.height},
|
||||
{"data": fb, "index": idx})
|
||||
|
||||
last_text_bottom = paste_y + txt_img.height
|
||||
|
||||
return final_img, header_height
|
||||
|
||||
def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
|
||||
"""Helper function to process a single student."""
|
||||
|
||||
# Prepare output directory: Dir/Anot_CopieID
|
||||
output_dir = Path(root_dir) / "Anot" / f"Copie{student_id}"
|
||||
|
||||
# Check if already processed (Concat.jpg exists)
|
||||
concat_path = output_dir / "Concat.jpg"
|
||||
if concat_path.exists() and not overwrite:
|
||||
print(f"Skipping Copie {student_id} (Concat.jpg exists)")
|
||||
return "skipped"
|
||||
|
||||
print("Processing :", student_id)
|
||||
problems = False
|
||||
with staged_directory(output_dir) as staging:
|
||||
d_notes = dict.fromkeys(all_labels, "")
|
||||
label_images = []
|
||||
sorted_labels = sorted(labels_data.items(), key=lambda item: natural_key(item[0]))
|
||||
|
||||
for label, content in sorted_labels:
|
||||
pdf_full_path = content.get('pdf_path')
|
||||
if not pdf_full_path or not Path(pdf_full_path).exists():
|
||||
print(f"File not found: {pdf_full_path}")
|
||||
problems = True
|
||||
continue
|
||||
try:
|
||||
base_img, _, _ = make_base_image(pdf_full_path)
|
||||
except Exception as exc: # noqa: BLE001 - PDF/LaTeX backends vary
|
||||
print(f"Error converting {pdf_full_path}: {exc}")
|
||||
problems = True
|
||||
continue
|
||||
|
||||
result = content.get('result', {})
|
||||
coordinates = content.get('coordinates', (0, 0))
|
||||
d_notes[label] = str(result.get('score', 0))
|
||||
final_img, _ = compose_label_image(
|
||||
base_img,
|
||||
label,
|
||||
result,
|
||||
coordinates[0],
|
||||
with_empty=True,
|
||||
render_fn=render_real_latex_text,
|
||||
)
|
||||
final_img.save(staging / f"{label}.jpg")
|
||||
if result.get('error', "") != "empty-answer":
|
||||
label_images.append(final_img)
|
||||
|
||||
atomic_write_json(staging / "score.json", d_notes)
|
||||
if label_images:
|
||||
max_w = max(image.width for image in label_images)
|
||||
total_h = sum(image.height for image in label_images)
|
||||
canvas = Image.new('RGB', (max_w, total_h))
|
||||
current_y = 0
|
||||
for image in label_images:
|
||||
canvas.paste(image, (0, current_y))
|
||||
current_y += image.height
|
||||
canvas.save(staging / "Concat.jpg")
|
||||
elif labels_data:
|
||||
problems = True
|
||||
return "partial" if problems else "success"
|
||||
|
||||
|
||||
def process_correction(root_dir, data, all_labels, overwrite=False):
|
||||
# Ne pas thread cette application
|
||||
# 1. Il faut protéger les appels à matplotlib
|
||||
# 2. tu vas perdre les erreurs
|
||||
statuses = [
|
||||
process_student(student_id, labels, root_dir, all_labels, overwrite)
|
||||
for student_id, labels in sorted(data.items())
|
||||
]
|
||||
return ExitCode.PARTIAL if "partial" in statuses else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = evaluation_parser("Generate simple annotated copies.")
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Replace existing student output directories",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
|
||||
workspace.require_files("labels", "correction.json")
|
||||
workspace.require_directories("Copies", "Par label")
|
||||
labels = utils.read_all_labels(workspace.root)
|
||||
loaded = load_annotation_data(workspace)
|
||||
for warning in loaded.warnings:
|
||||
print(f"Warning: {warning}")
|
||||
if not loaded.data:
|
||||
print("Warning: no annotation data was found.")
|
||||
return ExitCode.PARTIAL
|
||||
result = process_correction(
|
||||
workspace.root,
|
||||
loaded.data,
|
||||
labels,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
if loaded.warnings and result == ExitCode.SUCCESS:
|
||||
return ExitCode.PARTIAL
|
||||
return result
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(
|
||||
parser,
|
||||
argv,
|
||||
lambda args: run(workspace_from_args(args), overwrite=args.overwrite),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,392 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from copienator.commands import annotating
|
||||
from copienator.commands import annotating_with_checks
|
||||
from copienator import utils
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
atomic_write_text,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
read_json,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.annotation_data import load_annotation_data
|
||||
from copienator.filesystem import staged_directory
|
||||
from copienator.utils import natural_key
|
||||
|
||||
MAX_HEIGHT_PX = 25000
|
||||
|
||||
|
||||
def render_item(item):
|
||||
student_id, label, content = item
|
||||
pdf_path = Path(content["pdf_path"])
|
||||
if not pdf_path.exists():
|
||||
print(f"Warning: answer PDF not found: {pdf_path}")
|
||||
return None
|
||||
base_image, _, _ = annotating.make_base_image(pdf_path)
|
||||
checkbox_renderer = annotating_with_checks.CheckboxRenderer(label)
|
||||
final_image, header_height = annotating.compose_label_image(
|
||||
base_image,
|
||||
label,
|
||||
content["result"],
|
||||
content["coordinates"][0],
|
||||
draw_callback=checkbox_renderer.callback,
|
||||
more_right=True,
|
||||
with_id=student_id,
|
||||
)
|
||||
if final_image is None:
|
||||
return None
|
||||
return (
|
||||
student_id,
|
||||
label,
|
||||
final_image,
|
||||
header_height,
|
||||
checkbox_renderer.checkboxes,
|
||||
)
|
||||
|
||||
|
||||
def save_batch(batch, prefix, group_id, output_root: Path) -> None:
|
||||
output_dir = output_root / f"{prefix} G{group_id}"
|
||||
print(f"Generating group PDF: {prefix} G{group_id} ({len(batch)} elements)")
|
||||
max_width = max(item[2].width for item in batch)
|
||||
total_height = sum(item[2].height for item in batch)
|
||||
concatenated = Image.new("RGB", (max_width, total_height), "white")
|
||||
draw = ImageDraw.Draw(concatenated)
|
||||
checkbox_map: list[dict[str, Any]] = []
|
||||
bnote_entries: list[dict[str, Any]] = []
|
||||
current_y = 0
|
||||
previous_student = None
|
||||
|
||||
for student_id, label, image, header_height, checkboxes in batch:
|
||||
concatenated.paste(image, (0, current_y))
|
||||
if student_id != previous_student:
|
||||
draw.rectangle([0, current_y, max_width, current_y + 4], fill="purple")
|
||||
previous_student = student_id
|
||||
bnote_entries.append(
|
||||
{
|
||||
"id": student_id,
|
||||
"label": label,
|
||||
"header_height": header_height,
|
||||
"hmin": current_y,
|
||||
"hmax": current_y + image.height,
|
||||
}
|
||||
)
|
||||
for item in checkboxes:
|
||||
box = item.get("final_box") or item.get("rel_box")
|
||||
item["global_box"] = [
|
||||
box[0],
|
||||
box[1] + current_y,
|
||||
box[2],
|
||||
box[3] + current_y,
|
||||
]
|
||||
item["student_id"] = student_id
|
||||
checkbox_map.append(item)
|
||||
current_y += image.height
|
||||
|
||||
with staged_directory(output_dir) as staging:
|
||||
atomic_write_json(
|
||||
staging / "bnote.json",
|
||||
{"width": max_width, "height": total_height, "images": bnote_entries},
|
||||
)
|
||||
atomic_write_json(staging / "checkboxes.json", checkbox_map)
|
||||
reference = staging / "Reference.jpg"
|
||||
concatenated.save(reference, quality=90)
|
||||
pdf_path = staging / "Concat.pdf"
|
||||
pdf_canvas = canvas.Canvas(str(pdf_path), pagesize=(max_width, total_height))
|
||||
pdf_canvas.drawImage(
|
||||
str(reference),
|
||||
0,
|
||||
0,
|
||||
width=max_width,
|
||||
height=total_height,
|
||||
)
|
||||
pdf_canvas.save()
|
||||
|
||||
|
||||
def _initial_label_groups(labels: list[str]) -> str:
|
||||
groups: dict[str, list[str]] = {}
|
||||
for label in labels:
|
||||
key = label.split(" : ")[0] if " : " in label else label
|
||||
groups.setdefault(key, []).append(label)
|
||||
return "".join(",".join(items) + "\n" for items in groups.values())
|
||||
|
||||
|
||||
def _gemini_label_groups(
|
||||
workspace: EvaluationWorkspace, labels: list[str]
|
||||
) -> list[list[str]] | None:
|
||||
source = workspace.gemini_exam_items_file
|
||||
if not source.is_file():
|
||||
return None
|
||||
|
||||
groups: list[list[str]] = []
|
||||
current: list[str] = []
|
||||
try:
|
||||
source_lines = source.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeError) as exc:
|
||||
print(f"Warning: could not read Gemini question groups from {source}: {exc}")
|
||||
return None
|
||||
for raw_line in source_lines:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line == "---":
|
||||
if current:
|
||||
groups.append(current)
|
||||
current = []
|
||||
continue
|
||||
if " ### " not in line:
|
||||
continue
|
||||
label = line.split(" ### ", 1)[0].strip()
|
||||
if label and label != "CONTEXT":
|
||||
current.append(label)
|
||||
if current:
|
||||
groups.append(current)
|
||||
|
||||
flattened = [label for group in groups for label in group]
|
||||
known = set(labels)
|
||||
if (
|
||||
not flattened
|
||||
or len(flattened) != len(set(flattened))
|
||||
or set(flattened) != known
|
||||
):
|
||||
missing = sorted(known.difference(flattened), key=natural_key)
|
||||
unknown = sorted(set(flattened).difference(known), key=natural_key)
|
||||
details = []
|
||||
if missing:
|
||||
details.append("missing: " + ", ".join(missing))
|
||||
if unknown:
|
||||
details.append("unknown: " + ", ".join(unknown))
|
||||
if len(flattened) != len(set(flattened)):
|
||||
details.append("duplicate labels")
|
||||
print(
|
||||
f"Warning: ignoring incompatible Gemini question groups in {source}"
|
||||
+ (f" ({'; '.join(details)})" if details else "")
|
||||
)
|
||||
return None
|
||||
return groups
|
||||
|
||||
|
||||
def _serialize_label_groups(groups: list[list[str]]) -> str:
|
||||
return "".join(",".join(group) + "\n" for group in groups)
|
||||
|
||||
|
||||
def _load_label_groups(workspace: EvaluationWorkspace, labels: list[str]) -> list[list[str]]:
|
||||
label_groups = workspace.label_groups_file
|
||||
if not label_groups.exists():
|
||||
gemini_groups = _gemini_label_groups(workspace, labels)
|
||||
if gemini_groups is not None:
|
||||
initial_content = _serialize_label_groups(gemini_groups)
|
||||
source_description = "the groups selected in gemini_for_enonce.py"
|
||||
else:
|
||||
initial_content = _initial_label_groups(labels)
|
||||
source_description = "the label-prefix fallback"
|
||||
atomic_write_text(label_groups, initial_content)
|
||||
print(
|
||||
f"Created {label_groups} from {source_description}; "
|
||||
"review the groups before continuing."
|
||||
)
|
||||
utils.edit_file_and_enter(label_groups)
|
||||
known_labels = set(labels)
|
||||
groups: list[list[str]] = []
|
||||
unknown: set[str] = set()
|
||||
for line in label_groups.read_text(encoding="utf-8").splitlines():
|
||||
group = [label.strip() for label in line.split(",") if label.strip()]
|
||||
unknown.update(label for label in group if label not in known_labels)
|
||||
if group:
|
||||
groups.append(group)
|
||||
if unknown:
|
||||
raise CliError(
|
||||
"label_groups contains unknown labels: " + ", ".join(sorted(unknown))
|
||||
)
|
||||
return groups
|
||||
|
||||
|
||||
def _unique_prefixes(groups: list[list[str]]) -> list[tuple[str, list[str]]]:
|
||||
used: set[str] = set()
|
||||
result: list[tuple[str, list[str]]] = []
|
||||
previous: str | None = None
|
||||
for labels in groups:
|
||||
safe_labels = [label.replace(":", "").strip() for label in labels]
|
||||
base = os.path.commonprefix(safe_labels).strip() or "Group"
|
||||
if base and previous is not None and natural_key(base) < natural_key(previous):
|
||||
candidate = f"{safe_labels[0]}+"
|
||||
if natural_key(candidate) > natural_key(previous):
|
||||
base = candidate
|
||||
prefix = base.removesuffix("i")
|
||||
counter = 2
|
||||
while prefix in used:
|
||||
prefix = f"{base}-{counter}"
|
||||
counter += 1
|
||||
if counter == 2 and previous and previous in prefix:
|
||||
prefix = f"{previous}-{counter}"
|
||||
elif counter == 2:
|
||||
previous = prefix
|
||||
used.add(prefix)
|
||||
result.append((prefix, labels))
|
||||
return result
|
||||
|
||||
|
||||
def _existing_group_state(
|
||||
output_root: Path,
|
||||
prefix: str,
|
||||
) -> tuple[set[tuple[str, str]], int]:
|
||||
existing_items: set[tuple[str, str]] = set()
|
||||
maximum_group = 0
|
||||
if not output_root.is_dir():
|
||||
return existing_items, maximum_group
|
||||
for directory in output_root.iterdir():
|
||||
if not directory.is_dir() or not directory.name.startswith(f"{prefix} G"):
|
||||
continue
|
||||
try:
|
||||
maximum_group = max(maximum_group, int(directory.name.split(" G")[-1]))
|
||||
except ValueError:
|
||||
continue
|
||||
metadata = directory / "bnote.json"
|
||||
if not metadata.exists():
|
||||
continue
|
||||
loaded = read_json(metadata)
|
||||
if not isinstance(loaded, dict):
|
||||
raise TypeError(f"Expected a JSON object in {metadata}")
|
||||
for image in loaded.get("images", []):
|
||||
existing_items.add((str(image["id"]), str(image["label"])))
|
||||
return existing_items, maximum_group
|
||||
|
||||
|
||||
def split_batches(rendered):
|
||||
def split(maximum_height: float):
|
||||
batches = []
|
||||
current = []
|
||||
current_height = 0
|
||||
previous_student = None
|
||||
for item in rendered:
|
||||
student_id = item[0]
|
||||
image_height = item[2].height
|
||||
if (
|
||||
current
|
||||
and current_height + image_height > maximum_height
|
||||
and student_id != previous_student
|
||||
):
|
||||
batches.append(current)
|
||||
current = []
|
||||
current_height = 0
|
||||
current.append(item)
|
||||
current_height += image_height
|
||||
previous_student = student_id
|
||||
if current:
|
||||
batches.append(current)
|
||||
return batches
|
||||
|
||||
strict = split(MAX_HEIGHT_PX)
|
||||
relaxed = split(1.1 * MAX_HEIGHT_PX)
|
||||
return relaxed if len(relaxed) < len(strict) else strict
|
||||
|
||||
|
||||
def _generate_groups(
|
||||
output_root: Path,
|
||||
data,
|
||||
groups: list[list[str]],
|
||||
*,
|
||||
resume: bool,
|
||||
) -> tuple[int, bool]:
|
||||
generated = 0
|
||||
problems = False
|
||||
for prefix, labels in _unique_prefixes(groups):
|
||||
existing_items, maximum_group = (
|
||||
_existing_group_state(output_root, prefix) if resume else (set(), 0)
|
||||
)
|
||||
items = [
|
||||
(student_id, label, student_labels[label])
|
||||
for student_id, student_labels in data.items()
|
||||
for label in labels
|
||||
if label in student_labels and (student_id, label) not in existing_items
|
||||
]
|
||||
if not items:
|
||||
continue
|
||||
items.sort(key=lambda item: (natural_key(item[0]), natural_key(item[1])))
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
||||
rendered = list(executor.map(render_item, items))
|
||||
if any(item is None for item in rendered):
|
||||
problems = True
|
||||
successful = [item for item in rendered if item is not None]
|
||||
for index, batch in enumerate(split_batches(successful), start=1):
|
||||
save_batch(batch, prefix, maximum_group + index, output_root)
|
||||
generated += 1
|
||||
return generated, problems
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, *, overwrite: bool = False) -> ExitCode:
|
||||
workspace.require_files("labels", "correction.json")
|
||||
workspace.require_directories("Copies", "Par label")
|
||||
labels = utils.read_all_labels(workspace.root)
|
||||
groups = _load_label_groups(workspace, labels)
|
||||
loaded = load_annotation_data(workspace)
|
||||
for warning in loaded.warnings:
|
||||
print(f"Warning: {warning}")
|
||||
if not loaded.data:
|
||||
print("Warning: no annotation data was found.")
|
||||
return ExitCode.PARTIAL
|
||||
|
||||
output_root = workspace.annotation_dir("grouped")
|
||||
if overwrite:
|
||||
class IncompleteGroupedOutput(Exception):
|
||||
pass
|
||||
|
||||
try:
|
||||
with staged_directory(output_root) as staging:
|
||||
generated, problems = _generate_groups(
|
||||
staging,
|
||||
loaded.data,
|
||||
groups,
|
||||
resume=False,
|
||||
)
|
||||
if generated == 0 or problems or loaded.warnings:
|
||||
raise IncompleteGroupedOutput
|
||||
except IncompleteGroupedOutput:
|
||||
print("Warning: grouped overwrite was incomplete; previous BGnot was preserved.")
|
||||
return ExitCode.PARTIAL
|
||||
else:
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
generated, problems = _generate_groups(
|
||||
output_root,
|
||||
loaded.data,
|
||||
groups,
|
||||
resume=True,
|
||||
)
|
||||
if problems:
|
||||
return ExitCode.PARTIAL
|
||||
if generated == 0:
|
||||
print("No new grouped annotations were required.")
|
||||
return ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = evaluation_parser("Generate annotated PDFs grouped by labels.")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Replace BGnot safely")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(
|
||||
parser,
|
||||
argv,
|
||||
lambda args: run(workspace_from_args(args), overwrite=args.overwrite),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,331 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
|
||||
from PIL import Image, ImageFont
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from copienator.commands import annotating
|
||||
from copienator import utils
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
execute,
|
||||
read_json,
|
||||
target_parser,
|
||||
workspace_from_target,
|
||||
)
|
||||
from copienator.annotation_data import load_annotation_data
|
||||
from copienator.filesystem import staged_directory
|
||||
from copienator.utils import natural_key
|
||||
|
||||
BOX_SIZE = 30
|
||||
SCORE_BOX_SIZE = 40
|
||||
SCORES = [value * 0.5 for value in range(10)]
|
||||
EXPECTED_OUTPUTS = ("bnote.json", "checkboxes.json", "Reference.jpg", "Concat.pdf")
|
||||
|
||||
try:
|
||||
CHECKBOX_FONT = ImageFont.truetype("DejaVuSans.ttf", 20)
|
||||
except OSError:
|
||||
try:
|
||||
CHECKBOX_FONT = ImageFont.truetype("arial.ttf", 20)
|
||||
except OSError:
|
||||
CHECKBOX_FONT = ImageFont.load_default()
|
||||
|
||||
|
||||
def draw_checkbox(draw, x, y, size=BOX_SIZE, label=None, fill="white"):
|
||||
if label:
|
||||
draw.text((x - BOX_SIZE - 5, y + 2), str(label), fill="black", font=CHECKBOX_FONT)
|
||||
draw.rectangle([x, y, x + size, y + size], fill=fill, outline="black", width=2)
|
||||
return [x, y, x + size, y + size]
|
||||
|
||||
|
||||
class CheckboxRenderer:
|
||||
def __init__(self, label_name):
|
||||
self.label = label_name
|
||||
self.checkboxes = []
|
||||
|
||||
def callback(self, kind, draw, pos, meta):
|
||||
if kind == "header_item":
|
||||
if meta.get("type") == "score":
|
||||
start_x = pos["w"] + 20
|
||||
for value in SCORES:
|
||||
box = draw_checkbox(
|
||||
draw,
|
||||
start_x,
|
||||
pos["y"] + 25,
|
||||
SCORE_BOX_SIZE,
|
||||
str(value),
|
||||
)
|
||||
self.checkboxes.append(
|
||||
{
|
||||
"type": "score",
|
||||
"label": self.label,
|
||||
"value": value,
|
||||
"rel_box": box,
|
||||
}
|
||||
)
|
||||
start_x += SCORE_BOX_SIZE + 45
|
||||
start_x += SCORE_BOX_SIZE + 60
|
||||
box = draw_checkbox(
|
||||
draw,
|
||||
start_x,
|
||||
pos["y"] + 25,
|
||||
SCORE_BOX_SIZE,
|
||||
"clr",
|
||||
)
|
||||
self.checkboxes.append(
|
||||
{"type": "clear_all", "label": self.label, "rel_box": box}
|
||||
)
|
||||
elif meta.get("type") == "global_fb":
|
||||
box = draw_checkbox(
|
||||
draw,
|
||||
pos["w"] - BOX_SIZE - 5,
|
||||
pos["y"] + 5,
|
||||
BOX_SIZE,
|
||||
)
|
||||
self.checkboxes.append(
|
||||
{
|
||||
"type": "del_global",
|
||||
"label": self.label,
|
||||
"index": meta["index"],
|
||||
"rel_box": box,
|
||||
"text_preview": meta["data"]["text"][:20],
|
||||
}
|
||||
)
|
||||
elif kind == "local_rect":
|
||||
rectangle = pos["box"]
|
||||
box = draw_checkbox(
|
||||
draw,
|
||||
rectangle[2] - BOX_SIZE,
|
||||
rectangle[1],
|
||||
BOX_SIZE,
|
||||
)
|
||||
self.checkboxes.append(
|
||||
{
|
||||
"type": "del_local_rect",
|
||||
"label": self.label,
|
||||
"index": meta["index"],
|
||||
"final_box": box,
|
||||
"text_preview": meta["data"]["text"][:20],
|
||||
}
|
||||
)
|
||||
elif kind == "local_text":
|
||||
box = draw_checkbox(
|
||||
draw,
|
||||
pos["x"] + pos["w"] - BOX_SIZE,
|
||||
pos["y"],
|
||||
BOX_SIZE,
|
||||
)
|
||||
self.checkboxes.append(
|
||||
{
|
||||
"type": "del_local",
|
||||
"label": self.label,
|
||||
"index": meta["index"],
|
||||
"final_box": box,
|
||||
"text_preview": meta["data"]["text"][:20],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _output_complete(output_dir: Path) -> bool:
|
||||
return all((output_dir / name).is_file() for name in EXPECTED_OUTPUTS)
|
||||
|
||||
|
||||
def _render_student(
|
||||
workspace: EvaluationWorkspace,
|
||||
student_id: str,
|
||||
labels: dict[str, dict[str, Any]],
|
||||
*,
|
||||
overwrite: bool,
|
||||
output_mode: str,
|
||||
) -> str:
|
||||
output_dir = workspace.annotation_dir(output_mode) / f"Copie{student_id}"
|
||||
if _output_complete(output_dir) and not overwrite:
|
||||
print(f"Skipping {student_id}: output is complete.")
|
||||
return "skipped"
|
||||
|
||||
print(f"Generating checkable PDF for: {student_id}")
|
||||
label_images: list[Image.Image] = []
|
||||
checkbox_groups: list[list[dict[str, Any]]] = []
|
||||
bnote_entries: list[dict[str, Any]] = []
|
||||
problems = False
|
||||
|
||||
for label, content in sorted(labels.items(), key=lambda item: natural_key(item[0])):
|
||||
pdf_path = Path(content["pdf_path"])
|
||||
if not pdf_path.exists():
|
||||
print(f"Warning: answer PDF not found: {pdf_path}")
|
||||
problems = True
|
||||
continue
|
||||
base_image, _, _ = annotating.make_base_image(pdf_path)
|
||||
checkbox_renderer = CheckboxRenderer(label)
|
||||
final_image, header_height = annotating.compose_label_image(
|
||||
base_image,
|
||||
label,
|
||||
content["result"],
|
||||
content["coordinates"][0],
|
||||
draw_callback=checkbox_renderer.callback,
|
||||
)
|
||||
if final_image is None:
|
||||
continue
|
||||
label_images.append(final_image)
|
||||
checkbox_groups.append(checkbox_renderer.checkboxes)
|
||||
bnote_entries.append(
|
||||
{
|
||||
"id": student_id,
|
||||
"label": label,
|
||||
"header_height": header_height,
|
||||
"img_h": final_image.height,
|
||||
}
|
||||
)
|
||||
|
||||
if not label_images:
|
||||
print(f"Warning: no annotations could be rendered for Copie{student_id}")
|
||||
return "partial"
|
||||
|
||||
max_width = max(image.width for image in label_images)
|
||||
total_height = sum(image.height for image in label_images)
|
||||
concatenated = Image.new("RGB", (max_width, total_height), "white")
|
||||
checkbox_map: list[dict[str, Any]] = []
|
||||
current_y = 0
|
||||
for index, (image, checkboxes) in enumerate(
|
||||
zip(label_images, checkbox_groups, strict=True)
|
||||
):
|
||||
concatenated.paste(image, (0, current_y))
|
||||
bnote_entries[index]["hmin"] = current_y
|
||||
bnote_entries[index]["hmax"] = current_y + image.height
|
||||
del bnote_entries[index]["img_h"]
|
||||
for item in checkboxes:
|
||||
box = item.get("final_box") or item.get("rel_box")
|
||||
item["global_box"] = [
|
||||
box[0],
|
||||
box[1] + current_y,
|
||||
box[2],
|
||||
box[3] + current_y,
|
||||
]
|
||||
checkbox_map.append(item)
|
||||
current_y += image.height
|
||||
|
||||
with staged_directory(output_dir) as staging:
|
||||
atomic_write_json(
|
||||
staging / "bnote.json",
|
||||
{"width": max_width, "height": total_height, "images": bnote_entries},
|
||||
)
|
||||
atomic_write_json(staging / "checkboxes.json", checkbox_map)
|
||||
reference = staging / "Reference.jpg"
|
||||
concatenated.save(reference, quality=90)
|
||||
pdf_path = staging / "Concat.pdf"
|
||||
pdf_canvas = canvas.Canvas(str(pdf_path), pagesize=(max_width, total_height))
|
||||
pdf_canvas.drawImage(
|
||||
str(reference),
|
||||
0,
|
||||
0,
|
||||
width=max_width,
|
||||
height=total_height,
|
||||
)
|
||||
pdf_canvas.save()
|
||||
return "partial" if problems else "success"
|
||||
|
||||
|
||||
def _copy_id_from_target(workspace: EvaluationWorkspace, target: Path) -> str | None:
|
||||
if target == workspace.root:
|
||||
return None
|
||||
match = re.search(r"Copie(\d+)", target.name)
|
||||
if match is None:
|
||||
raise CliError(f"Could not extract a copy id from target: {target}")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _load_refaire(workspace: EvaluationWorkspace):
|
||||
workspace.require_files("refaire.json")
|
||||
loaded = read_json(workspace.refaire_file)
|
||||
if not isinstance(loaded, list):
|
||||
raise CliError("refaire.json must contain a JSON array")
|
||||
return loaded
|
||||
|
||||
|
||||
def run(
|
||||
workspace: EvaluationWorkspace,
|
||||
target: Path,
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
refaire: bool = False,
|
||||
) -> ExitCode:
|
||||
workspace.require_files("labels", "correction.json")
|
||||
workspace.require_directories("Copies", "Par label")
|
||||
utils.read_all_labels(workspace.root)
|
||||
copy_id = _copy_id_from_target(workspace, target)
|
||||
refaire_list = _load_refaire(workspace) if refaire else None
|
||||
loaded = load_annotation_data(
|
||||
workspace,
|
||||
refaire_list=refaire_list,
|
||||
copy_id=None if refaire else copy_id,
|
||||
)
|
||||
for warning in loaded.warnings:
|
||||
print(f"Warning: {warning}")
|
||||
if not loaded.data:
|
||||
print("Warning: no annotation data was found.")
|
||||
return ExitCode.PARTIAL
|
||||
|
||||
output_mode = "refaire" if refaire else "checks"
|
||||
tasks = sorted(loaded.data.items(), key=lambda item: natural_key(item[0]))
|
||||
statuses: list[str] = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [
|
||||
executor.submit(
|
||||
_render_student,
|
||||
workspace,
|
||||
student_id,
|
||||
labels,
|
||||
overwrite=overwrite,
|
||||
output_mode=output_mode,
|
||||
)
|
||||
for student_id, labels in tasks
|
||||
]
|
||||
for future in futures:
|
||||
statuses.append(future.result())
|
||||
if loaded.warnings or "partial" in statuses:
|
||||
return ExitCode.PARTIAL
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = target_parser("Generate annotated PDFs with checkboxes.")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Replace existing outputs")
|
||||
parser.add_argument(
|
||||
"--refaire",
|
||||
action="store_true",
|
||||
help="Process only entries from refaire.json",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
def handler(args: argparse.Namespace) -> ExitCode:
|
||||
workspace, target = workspace_from_target(args)
|
||||
return run(
|
||||
workspace,
|
||||
target,
|
||||
overwrite=args.overwrite,
|
||||
refaire=args.refaire,
|
||||
)
|
||||
|
||||
return execute(parser, argv, handler)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from google import genai
|
||||
|
||||
from copienator import configuration as config
|
||||
from copienator import (
|
||||
CliError,
|
||||
ExitCode,
|
||||
atomic_write_bytes,
|
||||
execute,
|
||||
standard_parser,
|
||||
)
|
||||
|
||||
|
||||
def _client():
|
||||
if not config.API_KEY:
|
||||
raise CliError("GEMINI_API_KEY is not configured")
|
||||
return genai.Client(api_key=config.API_KEY)
|
||||
|
||||
|
||||
def list_jobs(*, client=None) -> ExitCode:
|
||||
client = client or _client()
|
||||
print("Fetching recent batch jobs...")
|
||||
jobs = list(client.batches.list())
|
||||
for job in jobs:
|
||||
state = job.state.name if hasattr(job.state, "name") else job.state
|
||||
print(f"{job.name}: {state}")
|
||||
if getattr(job, "display_name", None):
|
||||
print(f" Display name: {job.display_name}")
|
||||
if state == "JOB_STATE_FAILED" and getattr(job, "error", None):
|
||||
print(f" Error: {job.error}")
|
||||
destination = getattr(job, "dest", None)
|
||||
if state == "JOB_STATE_SUCCEEDED" and getattr(
|
||||
destination, "file_name", None
|
||||
):
|
||||
print(f" Output file: {destination.file_name}")
|
||||
if not jobs:
|
||||
print("No batch jobs found.")
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
|
||||
def download_job(
|
||||
job_name: str,
|
||||
*,
|
||||
output: Path | None = None,
|
||||
client=None,
|
||||
) -> ExitCode:
|
||||
client = client or _client()
|
||||
job = client.batches.get(name=job_name)
|
||||
state = job.state.name if hasattr(job.state, "name") else job.state
|
||||
print(f"State: {state}")
|
||||
if state != "JOB_STATE_SUCCEEDED":
|
||||
if state == "JOB_STATE_FAILED" and getattr(job, "error", None):
|
||||
print(f"Error: {job.error}")
|
||||
return ExitCode.PARTIAL
|
||||
destination = getattr(job, "dest", None)
|
||||
file_name = getattr(destination, "file_name", None)
|
||||
if not file_name:
|
||||
print("Job succeeded but no output file was found.")
|
||||
return ExitCode.PARTIAL
|
||||
payload = client.files.download(file=file_name)
|
||||
output_path = output or Path(f"results_{job_name.replace('/', '_')}.jsonl")
|
||||
atomic_write_bytes(output_path, payload)
|
||||
print(f"Saved batch results to {output_path}")
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = standard_parser("List or download Gemini correction batch jobs")
|
||||
parser.add_argument("--download", metavar="JOB_NAME")
|
||||
parser.add_argument("--output", type=Path, help="Downloaded JSONL destination")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
if args.output is not None and not args.download:
|
||||
raise CliError("--output requires --download", ExitCode.INVALID_ARGUMENTS)
|
||||
if args.download:
|
||||
return download_job(args.download, output=args.output)
|
||||
return list_jobs()
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
execute,
|
||||
standard_parser,
|
||||
workspace_from_args,
|
||||
)
|
||||
|
||||
|
||||
def copy_pdfs(directory: Path) -> list[Path]:
|
||||
directory = directory.expanduser().resolve()
|
||||
if not directory.is_dir():
|
||||
raise NotADirectoryError(f"Dossier introuvable : {directory}")
|
||||
return sorted(
|
||||
(
|
||||
path
|
||||
for path in directory.glob("*.pdf")
|
||||
if path.name.casefold() not in {"enonce.pdf", "énoncé.pdf"}
|
||||
),
|
||||
key=lambda path: path.name.casefold(),
|
||||
)
|
||||
|
||||
|
||||
def rotate_pdf(path: Path) -> None:
|
||||
temporary = path.with_name(f".{path.stem}.rotate-{uuid.uuid4().hex}.pdf")
|
||||
try:
|
||||
with path.open("rb") as source, temporary.open("wb") as destination:
|
||||
reader = PdfReader(source)
|
||||
writer = PdfWriter()
|
||||
for page in reader.pages:
|
||||
writer.add_page(page.rotate(180))
|
||||
if reader.metadata:
|
||||
metadata = {
|
||||
str(key): str(value)
|
||||
for key, value in reader.metadata.items()
|
||||
if value is not None
|
||||
}
|
||||
writer.add_metadata(metadata)
|
||||
writer.write(destination)
|
||||
temporary.replace(path)
|
||||
finally:
|
||||
if temporary.exists():
|
||||
temporary.unlink()
|
||||
|
||||
|
||||
def rotate_all(directory: Path) -> int:
|
||||
files = copy_pdfs(directory)
|
||||
for path in files:
|
||||
rotate_pdf(path)
|
||||
print(f"Rotated: {path.name}")
|
||||
if not files:
|
||||
print("No PDF copies found.")
|
||||
return len(files)
|
||||
|
||||
|
||||
def rename_all(directory: Path) -> list[tuple[Path, Path]]:
|
||||
directory = directory.expanduser().resolve()
|
||||
files = copy_pdfs(directory)
|
||||
width = max(2, len(str(len(files))))
|
||||
plan = [
|
||||
(source, directory / f"Copie{index:0{width}d}.pdf")
|
||||
for index, source in enumerate(files, start=1)
|
||||
]
|
||||
staged: list[tuple[Path, Path, Path]] = []
|
||||
|
||||
try:
|
||||
for source, destination in plan:
|
||||
temporary = directory / f".copienator-rename-{uuid.uuid4().hex}.pdf"
|
||||
source.replace(temporary)
|
||||
staged.append((source, temporary, destination))
|
||||
|
||||
completed: list[tuple[Path, Path, Path]] = []
|
||||
try:
|
||||
for source, temporary, destination in staged:
|
||||
temporary.replace(destination)
|
||||
completed.append((source, temporary, destination))
|
||||
print(f"Renamed: {source.name} -> {destination.name}")
|
||||
except OSError:
|
||||
for _source, temporary, destination in completed:
|
||||
if destination.exists():
|
||||
destination.replace(temporary)
|
||||
raise
|
||||
except OSError:
|
||||
for source, temporary, _destination in staged:
|
||||
if temporary.exists():
|
||||
temporary.replace(source)
|
||||
raise
|
||||
|
||||
if not plan:
|
||||
print("No PDF copies found.")
|
||||
return [(source, destination) for source, _temporary, destination in staged]
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = standard_parser("Prepare scanned PDF copies.")
|
||||
subparsers = parser.add_subparsers(dest="operation", required=True)
|
||||
for operation in ("rotate", "rename"):
|
||||
subparser = subparsers.add_parser(operation)
|
||||
subparser.add_argument("evaluation", type=Path, help="Evaluation directory")
|
||||
subparser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Show a traceback when an unexpected error occurs",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, *, operation: str) -> ExitCode:
|
||||
if operation == "rotate":
|
||||
rotate_all(workspace.root)
|
||||
else:
|
||||
rename_all(workspace.root)
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(
|
||||
parser,
|
||||
argv,
|
||||
lambda args: run(workspace_from_args(args), operation=args.operation),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from google import genai
|
||||
|
||||
from copienator import configuration as config
|
||||
from copienator.commands import grouping
|
||||
from copienator import prompting
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
atomic_write_text,
|
||||
execute,
|
||||
read_json,
|
||||
target_parser,
|
||||
workspace_from_target,
|
||||
)
|
||||
from copienator.utils import enonce_total, read_all_labels
|
||||
|
||||
NB_THREADS = 12
|
||||
|
||||
# PROXY_URL = "http://192.168.241.1:3128"
|
||||
MODEL_ID_pro = config.MODEL_PRO_ID
|
||||
MODEL_ID_flash = config.MODEL_FLASH_ID
|
||||
api_key = config.API_KEY
|
||||
|
||||
# Runtime globals retained while the processing helpers are migrated incrementally.
|
||||
INPUT_DIR = Path()
|
||||
COPIES_DIR = Path()
|
||||
GROUPS_DIR = Path()
|
||||
output_path = Path()
|
||||
progress_path = Path()
|
||||
tasks: list[tuple] = []
|
||||
tasks_to_process: list[tuple] = []
|
||||
results: dict = {}
|
||||
completed_tasks: list = []
|
||||
errors_summary: list = []
|
||||
overwrite = False
|
||||
limit = None
|
||||
client = None
|
||||
start_time = 0.0
|
||||
|
||||
# --- Thread-safe Logging ---
|
||||
log_lock = threading.Lock()
|
||||
thread_logs = {}
|
||||
|
||||
def tprint(*args, **kwargs):
|
||||
"""Buffer messages per thread to group them."""
|
||||
tid = threading.current_thread().name
|
||||
msg = " ".join(map(str, args))
|
||||
|
||||
with log_lock:
|
||||
if tid not in thread_logs:
|
||||
thread_logs[tid] = []
|
||||
thread_logs[tid].append(msg)
|
||||
|
||||
# Optional: Keep printing to console but prefix with thread name
|
||||
print(f"[{tid}] {msg}", **kwargs)
|
||||
|
||||
def flush_thread_log(tid=None):
|
||||
"""Append a thread's buffered messages to the log file contiguously."""
|
||||
tid = tid or threading.current_thread().name
|
||||
with log_lock:
|
||||
if thread_logs.get(tid):
|
||||
with open(INPUT_DIR / "correction_log", "a", encoding="utf-8") as f:
|
||||
f.write(f"--- Task Log [{tid}] ---\n")
|
||||
f.write("\n".join(thread_logs[tid]) + "\n\n")
|
||||
thread_logs[tid].clear()
|
||||
|
||||
# --- Lock for thread-safe file writing ---
|
||||
io_lock = threading.Lock()
|
||||
pro_lock = threading.Lock()
|
||||
pro_count = 0
|
||||
flash_count = 0
|
||||
pro_quota_exhausted = False
|
||||
|
||||
|
||||
def discover_tasks(
|
||||
workspace: EvaluationWorkspace,
|
||||
targets: list[Path],
|
||||
) -> tuple[list[tuple[str, str]], list[str]]:
|
||||
workspace.require_directories("Copies", "Par label")
|
||||
discovered: list[tuple[str, str]] = []
|
||||
warnings: list[str] = []
|
||||
for target in targets:
|
||||
if target.is_file():
|
||||
if target.suffix.casefold() != ".jpg":
|
||||
raise CliError(
|
||||
f"Correction target is not a group JPG: {target}",
|
||||
ExitCode.INVALID_ARGUMENTS,
|
||||
)
|
||||
try:
|
||||
target.relative_to(workspace.groups_dir)
|
||||
except ValueError as exc:
|
||||
raise CliError(
|
||||
f"Group image is not inside {workspace.groups_dir}: {target}",
|
||||
ExitCode.INVALID_ARGUMENTS,
|
||||
) from exc
|
||||
discovered.append((str(target), target.parent.name))
|
||||
continue
|
||||
|
||||
group_directories = sorted(
|
||||
(path for path in workspace.groups_dir.iterdir() if path.is_dir()),
|
||||
key=lambda path: path.name.casefold(),
|
||||
)
|
||||
for group_directory in group_directories:
|
||||
images = sorted(
|
||||
group_directory.glob("*.jpg"), key=lambda path: path.name.casefold()
|
||||
)
|
||||
discovered.extend(
|
||||
(str(image), group_directory.name) for image in images
|
||||
)
|
||||
if not group_directories:
|
||||
warnings.append(f"No label groups found in {workspace.groups_dir}")
|
||||
return list(dict.fromkeys(discovered)), warnings
|
||||
|
||||
|
||||
def configure_runtime(
|
||||
workspace: EvaluationWorkspace,
|
||||
discovered_tasks: list[tuple[str, str]],
|
||||
args: argparse.Namespace,
|
||||
*,
|
||||
api_client=None,
|
||||
) -> None:
|
||||
global INPUT_DIR, COPIES_DIR, GROUPS_DIR, output_path, progress_path
|
||||
global tasks, tasks_to_process, results, completed_tasks, errors_summary
|
||||
global overwrite, limit, client, start_time
|
||||
global pro_count, flash_count, pro_quota_exhausted
|
||||
|
||||
INPUT_DIR = workspace.root
|
||||
COPIES_DIR = workspace.copies_dir
|
||||
GROUPS_DIR = workspace.groups_dir
|
||||
output_path = workspace.correction_file
|
||||
progress_path = workspace.correction_progress_file
|
||||
tasks = list(discovered_tasks)
|
||||
overwrite = bool(args.overwrite)
|
||||
limit = args.limit
|
||||
start_time = time.time()
|
||||
errors_summary = []
|
||||
completed_tasks = []
|
||||
results = {label: [] for _file, label in tasks}
|
||||
thread_logs.clear()
|
||||
pro_count = 0
|
||||
flash_count = 0
|
||||
pro_quota_exhausted = False
|
||||
|
||||
if not overwrite:
|
||||
if progress_path.is_file():
|
||||
loaded_progress = read_json(progress_path)
|
||||
if not isinstance(loaded_progress, list):
|
||||
raise TypeError("correction_progress.json must contain a JSON array")
|
||||
completed_tasks = loaded_progress
|
||||
if output_path.is_file():
|
||||
loaded_results = read_json(output_path)
|
||||
if not isinstance(loaded_results, dict):
|
||||
raise TypeError("correction.json must contain a JSON object")
|
||||
results = loaded_results
|
||||
|
||||
completed_set = {(str(file_path), label) for file_path, label in completed_tasks}
|
||||
tasks_to_process = [
|
||||
task for task in tasks if (str(task[0]), task[1]) not in completed_set
|
||||
]
|
||||
client = api_client
|
||||
|
||||
|
||||
def reset_workspace(workspace: EvaluationWorkspace) -> None:
|
||||
"""Apply the explicitly requested correction reset."""
|
||||
print("--- Running Reset ---")
|
||||
for path in (workspace.correction_file, workspace.correction_progress_file):
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
print(f"Deleted: {path}")
|
||||
if workspace.copies_dir.is_dir():
|
||||
for copy_directory in workspace.copies_dir.iterdir():
|
||||
if not copy_directory.is_dir():
|
||||
continue
|
||||
for old_pdf in copy_directory.glob("*_old.pdf"):
|
||||
original = old_pdf.with_name(old_pdf.name.replace("_old.pdf", ".pdf"))
|
||||
if original.exists():
|
||||
original.unlink()
|
||||
old_pdf.replace(original)
|
||||
print(f"Moved: {copy_directory.name}/{old_pdf.name} -> {original.name}")
|
||||
for new_pdf in copy_directory.glob("*_new.pdf"):
|
||||
new_pdf.unlink()
|
||||
print(f"Deleted: {copy_directory.name}/{new_pdf.name}")
|
||||
print(
|
||||
"Reset almost complete. Manually remove groups associated with deleted "
|
||||
"_new PDFs from 'Par label'."
|
||||
)
|
||||
|
||||
def call_gemini_with_retries(model_id, contents, config,
|
||||
fallback_model_id=MODEL_ID_flash):
|
||||
"""Handles requests to Gemini with a 1min and 5min retry mechanism, and quota fallback."""
|
||||
global pro_quota_exhausted
|
||||
delays = [60, 300]
|
||||
|
||||
for attempt in range(3):
|
||||
# Switch to fallback immediately if quota was exhausted by another thread
|
||||
if model_id == MODEL_ID_pro and pro_quota_exhausted and fallback_model_id:
|
||||
model_id = fallback_model_id
|
||||
|
||||
try:
|
||||
full_response_text = ""
|
||||
for chunk in client.models.generate_content_stream(
|
||||
model=model_id,
|
||||
contents=contents,
|
||||
config=config,
|
||||
):
|
||||
if chunk.text:
|
||||
full_response_text += chunk.text
|
||||
return full_response_text
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
is_quota_error = "429" in error_msg or "quota" in error_msg or "exhausted" in error_msg
|
||||
is_minute_limit = "minute" in error_msg or "rpm" in error_msg or "tpm" in error_msg
|
||||
|
||||
if is_minute_limit:
|
||||
import re
|
||||
# Extract wait time if present, else use default delay
|
||||
retry_match = re.search(r"retry in ([\d.]+)s", error_msg)
|
||||
wait_time = float(retry_match.group(1)) + 1.0 if retry_match else delays[attempt]
|
||||
|
||||
tprint(f"\tGemini Pro minute limit hit. Waiting {wait_time:.1f}s...")
|
||||
time.sleep(wait_time)
|
||||
continue # Retry same model
|
||||
|
||||
# Immediately fallback to Flash without waiting if it's a Pro quota error
|
||||
if is_quota_error and model_id == MODEL_ID_pro and fallback_model_id:
|
||||
tprint(f"\tGemini Pro quota hit ({e}). \n\n\tFalling back to Flash permanently...")
|
||||
model_id = fallback_model_id
|
||||
pro_quota_exhausted = True
|
||||
continue # Retry immediately with Flash
|
||||
|
||||
if attempt < 2:
|
||||
tprint(f"\tGemini API failure: {e}. Retrying in {delays[attempt]} seconds...")
|
||||
time.sleep(delays[attempt])
|
||||
else:
|
||||
tprint(f"\tGemini API failure: {e}. Maximum retries reached.")
|
||||
raise
|
||||
|
||||
def correct_boxes_with_gemini(pid, label, pdf_path, original_feedbacks,
|
||||
yming, ymaxg, width_r, total_height):
|
||||
"""Requests corrected bounding boxes from Gemini Flash on the single image."""
|
||||
# pdf_path = COPIES_DIR / f"Copie{pid}" / f"{label}.pdf"
|
||||
|
||||
contents, config = prompting.request_for_box_correction(pdf_path, original_feedbacks)
|
||||
response_text = call_gemini_with_retries(MODEL_ID_flash, contents, config)
|
||||
corrected_feedbacks = json.loads(response_text)
|
||||
|
||||
global_feedbacks = [f for f in original_feedbacks if not f["box_2d"]]
|
||||
|
||||
# Map the coordinates back from the single image to the group canvas
|
||||
for f in corrected_feedbacks:
|
||||
b = f.get("box_2d")
|
||||
if b:
|
||||
ymin_s, xmin_s, ymax_s, xmax_s = b
|
||||
|
||||
# Y mapping: Add the group Y-offset (yming), then normalize to total_height
|
||||
single_h = ymaxg - yming
|
||||
new_ymin = int((yming + (ymin_s * single_h / 1000.0)) * 1000.0 / total_height)
|
||||
new_ymax = int((yming + (ymax_s * single_h / 1000.0)) * 1000.0 / total_height)
|
||||
|
||||
# X mapping: Multiply by the width ratio of this sub-image vs the group image
|
||||
new_xmin = int(xmin_s * width_r)
|
||||
new_xmax = int(xmax_s * width_r)
|
||||
|
||||
f["box_2d"] = [new_ymin, new_xmin, new_ymax, new_xmax]
|
||||
|
||||
return global_feedbacks + corrected_feedbacks
|
||||
|
||||
def get_next_group_idx(label):
|
||||
"""Finds the next available Group index for a given label."""
|
||||
target_folder = GROUPS_DIR / label
|
||||
target_folder.mkdir(exist_ok=True)
|
||||
existing = list(target_folder.glob("Group_*.jpg"))
|
||||
if not existing: return 0
|
||||
return max([int(f.stem.split("_")[1]) for f in existing])
|
||||
|
||||
def handle_label_errors(pid, label, res, pdf_path):
|
||||
"""Handles Gemini labeling errors, moves/copies files, and returns new tasks."""
|
||||
new_tasks = []
|
||||
error_type = res.get("error")
|
||||
|
||||
all_labels = read_all_labels(INPUT_DIR)
|
||||
labels_txt = (INPUT_DIR / "labels").read_text(encoding="utf-8", errors="replace")
|
||||
enonce = enonce_total(INPUT_DIR)
|
||||
|
||||
if error_type == "wrong-label":
|
||||
tprint(f"\tHandling wrong-label for {pid} {label}")
|
||||
contents, config = prompting.request_for_wrong_label(pdf_path, label, enonce, labels_txt)
|
||||
new_label = call_gemini_with_retries(MODEL_ID_flash, contents, config).strip().strip('"\'')
|
||||
if new_label not in all_labels:
|
||||
tprint(f"\t\tCopie{pid} returned an incorrect label {new_label} from an initial wrong label {label}. Ignoring")
|
||||
res["error"] = "wrg-lbl:cldtfix"
|
||||
return []
|
||||
if new_label == label:
|
||||
res["error"] = ""
|
||||
return []
|
||||
|
||||
base_new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{new_label}.pdf"
|
||||
new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{new_label}_new.pdf"
|
||||
|
||||
if base_new_pdf_path.exists() or new_pdf_path.exists():
|
||||
tprint(f"""\t\tCopie{pid} tried to move wrong {label} to {new_label},
|
||||
but it already exists. Delaying.""")
|
||||
# res["error"] = f"wrg-lbl:{new_label}?exists"
|
||||
res["error"] = f"wrg-lbl:{new_label}?"
|
||||
res.setdefault("delayed", []).append(["wrong-label", new_label])
|
||||
else:
|
||||
res["error"] = f"wrg-lbl-moved-to:{new_label}"
|
||||
tprint(f"\t\tCopie{pid} : moving wrong {label} to {new_label}.")
|
||||
|
||||
# Copie vers _new, puis renommage de l'original vers _old
|
||||
shutil.copy(str(pdf_path), str(new_pdf_path))
|
||||
old_pdf_path = pdf_path.with_name(f"{label}_old.pdf")
|
||||
if pdf_path != old_pdf_path:
|
||||
shutil.move(str(pdf_path), str(old_pdf_path))
|
||||
|
||||
idx = get_next_group_idx(new_label)
|
||||
height = grouping.get_pdf_height(str(new_pdf_path))
|
||||
grouping.create_jpg(new_label, idx, [(pid, str(new_pdf_path), height)], GROUPS_DIR)
|
||||
tprint(f"\t\tMaking {new_label} group {idx+1}")
|
||||
new_tasks.append((str(GROUPS_DIR / new_label / f"Group_{idx+1}.jpg"),
|
||||
new_label, False))
|
||||
|
||||
elif error_type == "additional-answer":
|
||||
contents, config = prompting.request_for_additional_answer(pdf_path, label, enonce, labels_txt)
|
||||
tprint(f"\tHandling additional-answer for {pid} {label}")
|
||||
try:
|
||||
add_labels = json.loads(call_gemini_with_retries(MODEL_ID_flash, contents, config))
|
||||
except Exception: # noqa: BLE001 - invalid auxiliary model response
|
||||
add_labels = []
|
||||
|
||||
keep_error = False
|
||||
error = "al:"
|
||||
for add_label in add_labels:
|
||||
if add_label == label:
|
||||
continue
|
||||
if add_label not in all_labels:
|
||||
tprint(f"\t\t Inexistent label ({add_label}) from additional-answer processing {pid} {label}. Ignoring")
|
||||
error += f"{add_label}??"
|
||||
keep_error = True
|
||||
continue
|
||||
|
||||
base_add_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{add_label}.pdf"
|
||||
add_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{add_label}_new.pdf"
|
||||
|
||||
if not base_add_pdf_path.exists() and not add_pdf_path.exists():
|
||||
shutil.copy(str(pdf_path), str(add_pdf_path))
|
||||
tprint(f"\t\tCopying Copie{pid} : {label} -> {add_label}")
|
||||
idx = get_next_group_idx(add_label)
|
||||
tprint(f"\t\tMaking {add_label} group {idx+1}")
|
||||
height = grouping.get_pdf_height(str(add_pdf_path))
|
||||
grouping.create_jpg(add_label, idx, [(pid, str(add_pdf_path), height)], GROUPS_DIR)
|
||||
new_tasks.append((str(GROUPS_DIR / add_label / f"Group_{idx+1}.jpg"),
|
||||
add_label, False))
|
||||
error += f"(->){add_label}"
|
||||
keep_error = True
|
||||
else:
|
||||
keep_error = True
|
||||
error += f"(->){add_label}?"
|
||||
res.setdefault("delayed", []).append(["add-label", add_label])
|
||||
tprint(f"\t\tAlready present (not copied) Copie{pid} : {label} -> {add_label}. Delaying.")
|
||||
if not keep_error:
|
||||
res["error"] = ""
|
||||
else:
|
||||
res["error"] = error
|
||||
|
||||
return new_tasks
|
||||
|
||||
def process_single_task(task_tuple, precomputed_response=None):
|
||||
try:
|
||||
global pro_count, flash_count
|
||||
file_path = task_tuple[0]
|
||||
label = task_tuple[1]
|
||||
can_spawn_tasks = task_tuple[2] if len(task_tuple) > 2 else True
|
||||
|
||||
group_name = os.path.splitext(file_path)[0]
|
||||
json_path = group_name + '.json'
|
||||
new_tasks = []
|
||||
|
||||
group_data = read_json(json_path)
|
||||
|
||||
n = len(group_data)
|
||||
d_data = {l[0]: (l[1], l[2], l[3]) for l in group_data}
|
||||
total_height = group_data[-1][2]
|
||||
use_flash = n >= 4 or total_height <= 500
|
||||
|
||||
# Only apply limits and counts if we are making a live call
|
||||
if precomputed_response is None:
|
||||
if not use_flash:
|
||||
with pro_lock:
|
||||
if pro_quota_exhausted:
|
||||
use_flash = True
|
||||
elif limit is None or pro_count < limit:
|
||||
pro_count += 1
|
||||
else:
|
||||
use_flash = True
|
||||
|
||||
if use_flash:
|
||||
with pro_lock:
|
||||
flash_count += 1
|
||||
|
||||
try:
|
||||
contents, config = prompting.generate_request(INPUT_DIR, file_path, label)
|
||||
model_to_use = MODEL_ID_flash if use_flash else MODEL_ID_pro
|
||||
|
||||
if precomputed_response:
|
||||
tprint(f"Using batched response for: {label} {group_name}")
|
||||
full_response_text = precomputed_response
|
||||
else:
|
||||
tprint(f"Asking Gemini {'Flash' if use_flash else 'Pro '}: {label} {group_name}")
|
||||
full_response_text = call_gemini_with_retries(model_to_use, contents, config)
|
||||
|
||||
json_data = json.loads(full_response_text)
|
||||
|
||||
# Ensure consistency of answer placements
|
||||
for p in json_data:
|
||||
pid = p["id"]
|
||||
res = p["result"]
|
||||
yming, ymaxg, width_r = d_data[pid]
|
||||
|
||||
pdf_path = COPIES_DIR / f"Copie{pid}" / f"{label}.pdf"
|
||||
current_suffix = ""
|
||||
|
||||
# Détection du vrai fichier s'il a un suffixe
|
||||
if not pdf_path.exists():
|
||||
if pdf_path.with_name(f"{label}_new.pdf").exists():
|
||||
pdf_path = pdf_path.with_name(f"{label}_new.pdf")
|
||||
current_suffix = "_new"
|
||||
# Quand est-ce que ce chemin est utilisé ? Jamais ?
|
||||
elif pdf_path.with_name(f"{label}_old.pdf").exists():
|
||||
pdf_path = pdf_path.with_name(f"{label}_old.pdf")
|
||||
current_suffix = "_old"
|
||||
|
||||
# 1. Gestion de empty-answer
|
||||
if res.get("error") == "empty-answer":
|
||||
old_path = pdf_path.with_name(f"{label}_old.pdf")
|
||||
if pdf_path.exists() and pdf_path != old_path:
|
||||
shutil.move(str(pdf_path), str(old_path))
|
||||
pdf_path = old_path
|
||||
current_suffix = "_old"
|
||||
|
||||
if (not can_spawn_tasks) and res["error"] == "additional-answer":
|
||||
tprint("\tSwallowing an additional-answer from a subsequent task.")
|
||||
res["error"]= ""
|
||||
if res["error"] != "":
|
||||
tprint("\tError :", res["error"], "for Copie", pid, group_name)
|
||||
|
||||
if can_spawn_tasks and res.get("error") in ["wrong-label", "additional-answer"]:
|
||||
new_tasks.extend(handle_label_errors(pid, label, res, pdf_path))
|
||||
# Si "wrong-label" a déplacé le fichier courant vers _old
|
||||
if res.get("error", "").startswith("wrg-lbl-moved-to:"):
|
||||
current_suffix = "_old"
|
||||
|
||||
# 5. Enregistrer l'information dans correction.json
|
||||
if current_suffix:
|
||||
res["suffix"] = current_suffix
|
||||
|
||||
needs_correction = []
|
||||
for (i,f) in enumerate(res["feedback"]):
|
||||
b = f.get("box_2d")
|
||||
if b:
|
||||
ymin, _xmin, ymax, xmax = b
|
||||
ymin = ymin * total_height // 1000
|
||||
ymax = ymax * total_height // 1000
|
||||
|
||||
if pid not in d_data:
|
||||
tprint("Error : Gemini answered a copie id not present",
|
||||
pid, label, group_name)
|
||||
continue
|
||||
|
||||
if (ymin < yming - 50 or ymax > ymaxg + 50 or xmax / 1000 > width_r):
|
||||
needs_correction.append(i)
|
||||
break
|
||||
if ymin < yming - 5:
|
||||
ymin = yming - 5
|
||||
b[0] = ymin * 1000 // total_height
|
||||
if ymax > ymaxg + 5:
|
||||
ymax = ymaxg + 5
|
||||
b[2] = ymax * 1000 // total_height
|
||||
|
||||
|
||||
if needs_correction:
|
||||
tprint(f"\tBox anomalies detected for Copie {pid} {group_name}. \n\tRequesting isolated correction from Gemini Flash...")
|
||||
try:
|
||||
# Pensez à passer pdf_path à la fonction modifiée !
|
||||
res["feedback"] = correct_boxes_with_gemini(
|
||||
pid, label, pdf_path, res["feedback"],
|
||||
yming, ymaxg, width_r, total_height)
|
||||
except Exception as e: # noqa: BLE001 - correction fallback
|
||||
tprint(f"\tCorrection failed for Copie {pid}, {group_name} : {e}\n\tRemoving the boxes")
|
||||
# Fallback if the second request fails entirely
|
||||
for (i, f) in enumerate(res["feedback"]):
|
||||
if i in needs_correction:
|
||||
f["box_2d"] = None
|
||||
|
||||
# --- Use Lock for writing shared data ---
|
||||
with io_lock:
|
||||
if label not in results:
|
||||
results[label] = []
|
||||
results[label].append(json_data)
|
||||
|
||||
atomic_write_json(output_path, results)
|
||||
|
||||
# To track progress
|
||||
completed_tasks.append((file_path, label))
|
||||
atomic_write_json(progress_path, completed_tasks)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
tprint(f"Error decoding JSON for {file_path}", file=sys.stderr)
|
||||
with io_lock:
|
||||
errors_summary.append(("Error decoding JSON response", file_path))
|
||||
except Exception as e: # noqa: BLE001 - per-task processing boundary
|
||||
error_msg = f"Exception processing {file_path}: {e}"
|
||||
print(error_msg, file=sys.stderr)
|
||||
with io_lock:
|
||||
errors_summary.append((error_msg, file_path))
|
||||
return new_tasks
|
||||
finally:
|
||||
flush_thread_log()
|
||||
|
||||
def resolve_delayed_moves():
|
||||
"""Scans the current results to find delayed moves and executes them if space was freed."""
|
||||
new_tasks = []
|
||||
with io_lock:
|
||||
for label, batches in results.items():
|
||||
for batch in batches:
|
||||
for p in batch:
|
||||
res = p.get("result", {})
|
||||
delayed_list = res.get("delayed", [])
|
||||
if not delayed_list:
|
||||
continue
|
||||
|
||||
pid = p["id"]
|
||||
pdf_path = COPIES_DIR / f"Copie{pid}" / f"{label}.pdf"
|
||||
|
||||
if not pdf_path.exists():
|
||||
if pdf_path.with_name(f"{label}_new.pdf").exists():
|
||||
pdf_path = pdf_path.with_name(f"{label}_new.pdf")
|
||||
elif pdf_path.with_name(f"{label}_old.pdf").exists():
|
||||
pdf_path = pdf_path.with_name(f"{label}_old.pdf")
|
||||
|
||||
remaining_delayed = []
|
||||
resolved_any = False
|
||||
|
||||
for delay_type, target_label in delayed_list:
|
||||
# 1. Résolution de wrong-label
|
||||
if delay_type == "wrong-label":
|
||||
base_new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{target_label}.pdf"
|
||||
new_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{target_label}_new.pdf"
|
||||
|
||||
if not base_new_pdf_path.exists() and not new_pdf_path.exists():
|
||||
tprint(f"Resolving delayed move: Copie{pid} {label} -> {target_label}")
|
||||
res["error"] = f"wrg-lbl-moved-to:{target_label}"
|
||||
res["suffix"] = "_old" # Fixed typo: was suffixe
|
||||
resolved_any = True
|
||||
|
||||
shutil.copy(str(pdf_path), str(new_pdf_path))
|
||||
old_pdf_path = pdf_path.with_name(f"{label}_old.pdf")
|
||||
if pdf_path != old_pdf_path:
|
||||
shutil.move(str(pdf_path), str(old_pdf_path))
|
||||
|
||||
idx = get_next_group_idx(target_label)
|
||||
height = grouping.get_pdf_height(str(new_pdf_path))
|
||||
grouping.create_jpg(target_label, idx, [(pid, str(new_pdf_path), height)], GROUPS_DIR)
|
||||
new_tasks.append((str(GROUPS_DIR / target_label / f"Group_{idx+1}.jpg"), target_label, False))
|
||||
else:
|
||||
remaining_delayed.append([delay_type, target_label])
|
||||
|
||||
# 2. Résolution de additional-answer
|
||||
elif delay_type == "add-label":
|
||||
base_add_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{target_label}.pdf"
|
||||
add_pdf_path = COPIES_DIR / f"Copie{pid}" / f"{target_label}_new.pdf"
|
||||
|
||||
if not base_add_pdf_path.exists() and not add_pdf_path.exists():
|
||||
tprint(f"Resolving delayed additional-answer: Copie{pid} {label} -> {target_label}")
|
||||
res["error"] = res["error"].replace(f"(xx){target_label}", f"(->){target_label}")
|
||||
resolved_any = True
|
||||
|
||||
shutil.copy(str(pdf_path), str(add_pdf_path))
|
||||
idx = get_next_group_idx(target_label)
|
||||
height = grouping.get_pdf_height(str(add_pdf_path))
|
||||
grouping.create_jpg(target_label, idx, [(pid, str(add_pdf_path), height)], GROUPS_DIR)
|
||||
new_tasks.append((str(GROUPS_DIR / target_label / f"Group_{idx+1}.jpg"), target_label, False))
|
||||
else:
|
||||
remaining_delayed.append([delay_type, target_label])
|
||||
|
||||
if resolved_any:
|
||||
if remaining_delayed:
|
||||
res["delayed"] = remaining_delayed
|
||||
else:
|
||||
del res["delayed"]
|
||||
|
||||
if new_tasks:
|
||||
atomic_write_json(output_path, results)
|
||||
|
||||
return new_tasks
|
||||
|
||||
def run_configured(args: argparse.Namespace) -> ExitCode:
|
||||
global client, tasks_to_process
|
||||
if client is None:
|
||||
client = genai.Client(api_key=api_key)
|
||||
if args.refaire:
|
||||
refaire_path = INPUT_DIR / "refaire.json"
|
||||
overwritten_path = INPUT_DIR / "overwritten_correction.json"
|
||||
|
||||
if refaire_path.exists():
|
||||
refaire_list = read_json(refaire_path)
|
||||
|
||||
overwritten_data = []
|
||||
if overwritten_path.exists():
|
||||
overwritten_data = read_json(overwritten_path)
|
||||
|
||||
dirty_results = False
|
||||
|
||||
for copie_name, labels in refaire_list:
|
||||
pid = copie_name.replace("Copie", "")
|
||||
copie_dir = COPIES_DIR / copie_name
|
||||
|
||||
# If list is empty, redo all labels available for this Copie
|
||||
if not labels:
|
||||
labels_set = set()
|
||||
for p in copie_dir.glob("*.pdf"):
|
||||
if p.name.endswith("_old.pdf"):
|
||||
continue # Strictly ignore old files
|
||||
elif p.name.endswith("_new.pdf"):
|
||||
labels_set.add(p.stem[:-4]) # Strip '_new' to get base label
|
||||
else:
|
||||
labels_set.add(p.stem)
|
||||
labels = list(labels_set)
|
||||
|
||||
for label in labels:
|
||||
# 1. Extract and backup old corrections
|
||||
if label in results:
|
||||
for batch in results[label]:
|
||||
to_remove = None
|
||||
for item in batch:
|
||||
if item.get("id") == pid:
|
||||
to_remove = item
|
||||
break
|
||||
if to_remove:
|
||||
batch.remove(to_remove)
|
||||
overwritten_data.append({
|
||||
"pid": pid,
|
||||
"label": label,
|
||||
"data": to_remove,
|
||||
"timestamp": time.time()
|
||||
})
|
||||
dirty_results = True
|
||||
# Clean up empty batches
|
||||
results[label] = [b for b in results[label] if b]
|
||||
|
||||
# 2. Make new group and add to tasks
|
||||
pdf_path = copie_dir / f"{label}.pdf"
|
||||
is_new = False
|
||||
|
||||
if (
|
||||
not pdf_path.exists()
|
||||
and (copie_dir / f"{label}_new.pdf").exists()
|
||||
):
|
||||
pdf_path = copie_dir / f"{label}_new.pdf"
|
||||
is_new = True
|
||||
# elif (copie_dir / f"{label}_old.pdf").exists():
|
||||
# pdf_path = copie_dir / f"{label}_old.pdf"
|
||||
|
||||
if pdf_path.exists():
|
||||
idx = get_next_group_idx(label)
|
||||
height = grouping.get_pdf_height(str(pdf_path))
|
||||
grouping.create_jpg(label, idx, [(pid, str(pdf_path), height)], GROUPS_DIR)
|
||||
new_group_path = str(GROUPS_DIR / label / f"Group_{idx+1}.jpg")
|
||||
tasks_to_process.append((new_group_path, label, not is_new))
|
||||
|
||||
if dirty_results:
|
||||
atomic_write_json(output_path, results)
|
||||
atomic_write_json(overwritten_path, overwritten_data)
|
||||
else:
|
||||
print(f"Warning: --refaire flag used, but {refaire_path} not found.", file=sys.stderr)
|
||||
|
||||
|
||||
if args.batch or args.batch_from:
|
||||
all_labels = read_all_labels(INPUT_DIR)
|
||||
batch_tasks = []
|
||||
if args.batch_from:
|
||||
for label in all_labels:
|
||||
if label.startswith(args.batch_from):
|
||||
args.batch_from = label
|
||||
input(f"About to batch from: {args.batch_from}. Press Enter to confirm...")
|
||||
break
|
||||
if args.batch_from not in all_labels:
|
||||
raise CliError(
|
||||
f"Label '{args.batch_from}' not found. Available labels: "
|
||||
f"{all_labels}",
|
||||
ExitCode.INVALID_ARGUMENTS,
|
||||
)
|
||||
|
||||
target_idx = all_labels.index(args.batch_from)
|
||||
live_tasks = []
|
||||
|
||||
for task in tasks_to_process:
|
||||
lbl = task[1]
|
||||
# Any label found sequentially equal or after `args.batch_from` gets batched
|
||||
if lbl in all_labels and all_labels.index(lbl) >= target_idx:
|
||||
batch_tasks.append(task)
|
||||
else:
|
||||
live_tasks.append(task)
|
||||
|
||||
tasks_to_process = live_tasks # Keep live tasks to be run right after
|
||||
else:
|
||||
batch_tasks = tasks_to_process
|
||||
tasks_to_process = [] # Run nothing live if just `--batch`
|
||||
|
||||
if batch_tasks:
|
||||
batch_flash_file = INPUT_DIR / "batch_requests_flash.jsonl"
|
||||
batch_pro_file = INPUT_DIR / "batch_requests_pro.jsonl"
|
||||
|
||||
count_flash = 0
|
||||
count_pro = 0
|
||||
flash_lines = []
|
||||
pro_lines = []
|
||||
for task in batch_tasks:
|
||||
file_path, label = task[0], task[1]
|
||||
json_path = Path(file_path).with_suffix(".json")
|
||||
group_data = read_json(json_path)
|
||||
use_flash = len(group_data) >= 4 or group_data[-1][2] <= 500
|
||||
b64_img = base64.b64encode(Path(file_path).read_bytes()).decode(
|
||||
"utf-8"
|
||||
)
|
||||
request = {
|
||||
"key": file_path,
|
||||
"request": {
|
||||
"contents": [
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"inlineData": {
|
||||
"mimeType": "image/jpeg",
|
||||
"data": b64_img,
|
||||
}
|
||||
},
|
||||
{"text": prompting.make_prompt(INPUT_DIR, label)},
|
||||
],
|
||||
}
|
||||
],
|
||||
"generation_config": {
|
||||
"temperature": 1.0,
|
||||
"topP": 0.95,
|
||||
"maxOutputTokens": 65535,
|
||||
"responseMimeType": "application/json",
|
||||
"responseSchema": prompting.UNROLLED_SCHEMA,
|
||||
},
|
||||
},
|
||||
}
|
||||
line = json.dumps(request)
|
||||
if use_flash:
|
||||
flash_lines.append(line)
|
||||
count_flash += 1
|
||||
else:
|
||||
pro_lines.append(line)
|
||||
count_pro += 1
|
||||
atomic_write_text(
|
||||
batch_flash_file,
|
||||
"\n".join(flash_lines) + ("\n" if flash_lines else ""),
|
||||
)
|
||||
atomic_write_text(
|
||||
batch_pro_file,
|
||||
"\n".join(pro_lines) + ("\n" if pro_lines else ""),
|
||||
)
|
||||
|
||||
print("Batch generation complete.")
|
||||
print(f" - {count_flash} requests saved to {batch_flash_file} (for {MODEL_ID_flash})")
|
||||
print(f" - {count_pro} requests saved to {batch_pro_file} (for {MODEL_ID_pro})")
|
||||
print("Upload these files via the File API and create two separate batch jobs.")
|
||||
|
||||
# If there's no live tasks to do, and we aren't doing a batched ingestion, exit right away
|
||||
if not tasks_to_process and not args.deal_with_batched:
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
batched_responses = {}
|
||||
if args.deal_with_batched:
|
||||
batch_results_path = INPUT_DIR / "batched_correction_result.jsonl"
|
||||
if batch_results_path.exists():
|
||||
print(f"Loading batch results from {batch_results_path}...")
|
||||
with open(batch_results_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if not line.strip(): continue
|
||||
data = json.loads(line)
|
||||
task_id = data.get("key") # Corresponds to the key sent in the request
|
||||
|
||||
if "response" in data:
|
||||
try:
|
||||
# Extract the JSON response text per standard Batch API schema
|
||||
resp_text = data["response"]["candidates"][0]["content"]["parts"][0]["text"]
|
||||
batched_responses[task_id] = resp_text
|
||||
except (KeyError, IndexError) as e:
|
||||
print(f"Warning: Could not parse response for {task_id}: {e}", file=sys.stderr)
|
||||
elif "error" in data:
|
||||
print(f"Batch API Error for {task_id}: {data['error']}", file=sys.stderr)
|
||||
else:
|
||||
print(f"Warning: Batch results file {batch_results_path} not found.", file=sys.stderr)
|
||||
|
||||
made_progress = True
|
||||
while tasks_to_process or made_progress:
|
||||
if tasks_to_process:
|
||||
print(f"Starting processing on {len(tasks_to_process)} tasks with {NB_THREADS} threads...")
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=NB_THREADS) as executor:
|
||||
futures = {}
|
||||
for task in tasks_to_process:
|
||||
file_path = task[0]
|
||||
precomp = batched_responses.get(file_path)
|
||||
futures[executor.submit(process_single_task, task, precomp)] = task
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
new_generated_tasks = future.result()
|
||||
if new_generated_tasks:
|
||||
for new_task in new_generated_tasks:
|
||||
futures[executor.submit(process_single_task, new_task)] = new_task
|
||||
except Exception as e: # noqa: BLE001 - future boundary
|
||||
print(f"Exception during task execution: {e}", file=sys.stderr)
|
||||
failed_task = futures[future]
|
||||
with io_lock:
|
||||
errors_summary.append((str(e), failed_task[0]))
|
||||
|
||||
tasks_to_process = [] # Vider la liste une fois traitée
|
||||
|
||||
# Après avoir traité toutes les tâches actuelles (live ou batched),
|
||||
# on tente de débloquer les mouvements qui étaient en attente
|
||||
delayed_tasks = resolve_delayed_moves()
|
||||
if delayed_tasks:
|
||||
print(f"Resolved {len(delayed_tasks)} delayed moves! Running executor for new tasks...")
|
||||
tasks_to_process.extend(delayed_tasks)
|
||||
made_progress = True
|
||||
else:
|
||||
made_progress = False
|
||||
|
||||
# Check for remaining unresolved delayed tasks
|
||||
unresolved_delayed = []
|
||||
with io_lock:
|
||||
for label, batches in results.items():
|
||||
for batch in batches:
|
||||
for p in batch:
|
||||
res = p.get("result", {})
|
||||
delayed = res.get("delayed", [])
|
||||
pid = p["id"]
|
||||
|
||||
for delay_type, target_label in delayed:
|
||||
if delay_type == "wrong-label":
|
||||
unresolved_delayed.append(f"Copie{pid} {label} x> {target_label}|")
|
||||
elif delay_type == "add-label":
|
||||
unresolved_delayed.append(f"Copie{pid} {label} -> {target_label}|")
|
||||
|
||||
if unresolved_delayed:
|
||||
manual_path = INPUT_DIR / "manual_resolutions.txt"
|
||||
atomic_write_text(
|
||||
manual_path,
|
||||
"### Use -> x>, -x, ss, sx, xx, xs\n"
|
||||
+ "\n".join(unresolved_delayed)
|
||||
+ "\n",
|
||||
)
|
||||
print(f"\n[!] Unresolved delayed tasks found! Wrote to {manual_path}.")
|
||||
print(" Please edit it manually, then run `python -m copienator resolve-manual <InputDir>`")
|
||||
|
||||
end_time = time.time()
|
||||
print("Time elapsed : ", end_time - start_time)
|
||||
print("Requests to pro / flash : ", pro_count, flash_count)
|
||||
if errors_summary:
|
||||
print("\n--- Summary of Exceptions (You can use several images on one instance) ---", file=sys.stderr)
|
||||
for (err, file) in errors_summary:
|
||||
print(err, file=sys.stderr)
|
||||
escaped_path = shlex.quote(str(file))
|
||||
print(f"Run : python -m copienator correct {escaped_path}")
|
||||
return ExitCode.PARTIAL if errors_summary else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def run(
|
||||
workspace: EvaluationWorkspace,
|
||||
targets: list[Path],
|
||||
args: argparse.Namespace,
|
||||
*,
|
||||
api_client=None,
|
||||
) -> ExitCode:
|
||||
if args.reset:
|
||||
workspace.require_directories("Copies")
|
||||
reset_workspace(workspace)
|
||||
return ExitCode.SUCCESS
|
||||
workspace.require_directories("Copies", "Par label")
|
||||
workspace.require_files("labels")
|
||||
if args.refaire:
|
||||
workspace.require_files("refaire.json")
|
||||
discovered, warnings = discover_tasks(workspace, targets)
|
||||
for warning in warnings:
|
||||
print(f"Warning: {warning}")
|
||||
configure_runtime(workspace, discovered, args, api_client=api_client)
|
||||
if not discovered and not args.refaire:
|
||||
return ExitCode.PARTIAL
|
||||
try:
|
||||
status = run_configured(args)
|
||||
finally:
|
||||
for thread_id in list(thread_logs):
|
||||
flush_thread_log(thread_id)
|
||||
if warnings and status == ExitCode.SUCCESS:
|
||||
return ExitCode.PARTIAL
|
||||
return status
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = target_parser("Correct grouped answers with Gemini")
|
||||
parser.add_argument(
|
||||
"additional_targets",
|
||||
nargs="*",
|
||||
type=Path,
|
||||
help="Additional group JPG files from the same evaluation",
|
||||
)
|
||||
parser.add_argument("--overwrite", action="store_true", help="Redo requests")
|
||||
parser.add_argument("--limit", type=int, help="Maximum Gemini Pro calls")
|
||||
parser.add_argument(
|
||||
"--refaire",
|
||||
action="store_true",
|
||||
help="Redo copies and labels listed in refaire.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch",
|
||||
action="store_true",
|
||||
help="Generate Gemini batch request JSONL files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-from",
|
||||
metavar="LABEL",
|
||||
help="Process earlier labels live and batch from LABEL onward",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deal-with-batched",
|
||||
action="store_true",
|
||||
help="Consume batched_correction_result.jsonl",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reset",
|
||||
action="store_true",
|
||||
help="Delete correction state, restore _old PDFs, and delete _new PDFs",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
workspace, target = workspace_from_target(args)
|
||||
targets = [target]
|
||||
for additional in args.additional_targets:
|
||||
resolved = additional.expanduser().resolve()
|
||||
if not resolved.exists():
|
||||
raise CliError(
|
||||
f"Target does not exist: {resolved}",
|
||||
ExitCode.INVALID_WORKSPACE,
|
||||
)
|
||||
if EvaluationWorkspace.discover(resolved).root != workspace.root:
|
||||
raise CliError(
|
||||
"All targets must belong to the same evaluation",
|
||||
ExitCode.INVALID_ARGUMENTS,
|
||||
)
|
||||
targets.append(resolved)
|
||||
return run(workspace, targets, args)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,344 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
from collections.abc import Sequence
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from queue import Empty, Queue
|
||||
from threading import Thread
|
||||
|
||||
from pdf2image import convert_from_path
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
execute,
|
||||
target_parser,
|
||||
workspace_from_target,
|
||||
)
|
||||
from copienator.filesystem import staged_files
|
||||
|
||||
DELIMITER_WIDTH = 5
|
||||
DELIMITER_COLOR = (0, 0, 0)
|
||||
OUTPUT_SIZE = (1800, 1000)
|
||||
pdf_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def distribute_pages(total_pages: int, max_per_file: int = 5) -> list[int]:
|
||||
"""Distribute pages into balanced chunks no larger than max_per_file."""
|
||||
if total_pages == 0:
|
||||
return []
|
||||
number_of_files = (total_pages + max_per_file - 1) // max_per_file
|
||||
base_count, remainder = divmod(total_pages, number_of_files)
|
||||
return [
|
||||
base_count + (1 if index < remainder else 0)
|
||||
for index in range(number_of_files)
|
||||
]
|
||||
|
||||
|
||||
def stitch_images(image_list: list[Image.Image]) -> Image.Image | None:
|
||||
if not image_list:
|
||||
return None
|
||||
total_width = sum(image.width for image in image_list)
|
||||
total_width += (len(image_list) - 1) * DELIMITER_WIDTH
|
||||
max_height = max(image.height for image in image_list)
|
||||
combined = Image.new("RGB", (total_width, max_height), color="white")
|
||||
x_offset = 0
|
||||
for index, image in enumerate(image_list):
|
||||
combined.paste(image, (x_offset, 0))
|
||||
x_offset += image.width
|
||||
if index < len(image_list) - 1:
|
||||
delimiter = Image.new(
|
||||
"RGB", (DELIMITER_WIDTH, max_height), color=DELIMITER_COLOR
|
||||
)
|
||||
combined.paste(delimiter, (x_offset, 0))
|
||||
x_offset += DELIMITER_WIDTH
|
||||
return combined
|
||||
|
||||
|
||||
@lru_cache(maxsize=3)
|
||||
def _get_pdf_pages_cached(pdf_path: Path) -> list[Image.Image]:
|
||||
return convert_from_path(pdf_path)
|
||||
|
||||
|
||||
def get_pdf_pages(pdf_path: Path) -> list[Image.Image]:
|
||||
"""Thread-safe wrapper around the small PDF conversion cache."""
|
||||
with pdf_cache_lock:
|
||||
return _get_pdf_pages_cached(pdf_path)
|
||||
|
||||
|
||||
def process_single_pdf(
|
||||
pdf_path: Path,
|
||||
shift_offset: int = 0,
|
||||
max_per_file: int = 5,
|
||||
) -> tuple[Image.Image, list[Image.Image], dict[str, object]] | None:
|
||||
"""Convert one PDF into a preview, full-resolution splits and metadata."""
|
||||
try:
|
||||
cropped_images = []
|
||||
for image in get_pdf_pages(pdf_path):
|
||||
width, height = image.size
|
||||
if max_per_file == 1:
|
||||
left, right = 0, width
|
||||
else:
|
||||
left = max(0, 100 + shift_offset)
|
||||
right = min(width, width // 3 + 100 + shift_offset)
|
||||
if right > left:
|
||||
cropped_images.append(image.crop((left, 0, right, height)))
|
||||
if not cropped_images:
|
||||
return None
|
||||
|
||||
distribution = distribute_pages(len(cropped_images), max_per_file)
|
||||
split_images = []
|
||||
current_index = 0
|
||||
for count in distribution:
|
||||
stitched = stitch_images(cropped_images[current_index : current_index + count])
|
||||
if stitched is not None:
|
||||
split_images.append(stitched)
|
||||
current_index += count
|
||||
full_stitch = stitch_images(cropped_images)
|
||||
if full_stitch is None:
|
||||
return None
|
||||
preview = full_stitch.resize(OUTPUT_SIZE, Image.Resampling.BILINEAR)
|
||||
schema: dict[str, object] = {
|
||||
"original_filename": pdf_path.name,
|
||||
"total_pages": len(cropped_images),
|
||||
"number_of_files": len(split_images),
|
||||
"columns_per_file": distribution,
|
||||
}
|
||||
return preview, split_images, schema
|
||||
except Exception as exc: # noqa: BLE001 - interactive item failure
|
||||
print(f"Error processing {pdf_path.name}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def _previous_cutleft_outputs(output_dir: Path, base_name: str) -> set[str]:
|
||||
if not output_dir.is_dir():
|
||||
return set()
|
||||
result = {f"{base_name}_schema.json"}
|
||||
for path in output_dir.glob(f"{base_name}_*.jpg"):
|
||||
suffix = path.stem.removeprefix(f"{base_name}_")
|
||||
if suffix.isdigit():
|
||||
result.add(path.name)
|
||||
return result
|
||||
|
||||
|
||||
def save_results(
|
||||
result: tuple[Image.Image, list[Image.Image], dict[str, object]],
|
||||
pdf_path: Path,
|
||||
output_dir: Path,
|
||||
) -> None:
|
||||
"""Atomically replace every Cutleft output associated with one copy."""
|
||||
_, splits, schema = result
|
||||
base_name = pdf_path.stem
|
||||
previous = _previous_cutleft_outputs(output_dir, base_name)
|
||||
with staged_files(output_dir, remove=previous) as staging:
|
||||
for index, image in enumerate(splits, start=1):
|
||||
filename = f"{base_name}_{index:02d}.jpg"
|
||||
image.save(staging / filename, "JPEG", quality=95)
|
||||
atomic_write_json(staging / f"{base_name}_schema.json", schema)
|
||||
for index in range(1, len(splits) + 1):
|
||||
print(f"Saved: {base_name}_{index:02d}.jpg")
|
||||
print(f"Saved schema: {base_name}_schema.json")
|
||||
|
||||
|
||||
class ImageReviewer:
|
||||
def __init__(
|
||||
self,
|
||||
files: list[Path],
|
||||
output_dir: Path,
|
||||
default_max_per_file: int = 5,
|
||||
) -> None:
|
||||
self.files = files
|
||||
self.output_dir = output_dir
|
||||
self.index = 0
|
||||
self.current_shift = 0
|
||||
self.default_max_per_file = default_max_per_file
|
||||
self.current_max_per_file = default_max_per_file
|
||||
self.current_preview: Image.Image | None = None
|
||||
self.is_processing = False
|
||||
self.manual_queue: Queue[
|
||||
tuple[Image.Image, list[Image.Image], dict[str, object]] | None
|
||||
] = Queue()
|
||||
|
||||
self.root = tk.Tk()
|
||||
self.root.title("PDF Cropper")
|
||||
self.root.geometry("+100+100")
|
||||
self.label_img = tk.Label(self.root)
|
||||
self.label_img.pack()
|
||||
self.label_info = tk.Label(self.root, text="", font=("Arial", 12, "bold"))
|
||||
self.label_info.pack(pady=5)
|
||||
self.root.bind("<Return>", self.on_next)
|
||||
self.root.bind("n", lambda _event: self.on_shift(50))
|
||||
self.root.bind("N", lambda _event: self.on_shift(100))
|
||||
self.root.bind("t", lambda _event: self.on_shift(-50))
|
||||
self.root.bind("1", lambda _event: self.on_set_max_pages(1))
|
||||
|
||||
Thread(target=self.prefetch_worker, daemon=True).start()
|
||||
self.load_current_image()
|
||||
self.root.lift()
|
||||
self.root.focus_force()
|
||||
self.root.mainloop()
|
||||
|
||||
def on_set_max_pages(self, count: int) -> None:
|
||||
if self.is_processing:
|
||||
return
|
||||
self.current_max_per_file = count
|
||||
print(f"Setting max pages per file: {count}")
|
||||
self.trigger_processing(self.files[self.index], self.current_shift)
|
||||
|
||||
def prefetch_worker(self) -> None:
|
||||
processed_index = -1
|
||||
while True:
|
||||
target = self.index + 1
|
||||
if target < len(self.files) and target != processed_index:
|
||||
get_pdf_pages(self.files[target])
|
||||
processed_index = target
|
||||
time.sleep(0.05)
|
||||
|
||||
def load_current_image(self) -> None:
|
||||
if self.index >= len(self.files):
|
||||
print("All files processed.")
|
||||
self.root.destroy()
|
||||
return
|
||||
self.is_processing = False
|
||||
self.current_shift = 0
|
||||
self.trigger_processing(self.files[self.index], self.current_shift)
|
||||
|
||||
def trigger_processing(self, pdf_path: Path, shift: int) -> None:
|
||||
self.is_processing = True
|
||||
self.label_info.configure(
|
||||
text=f"Processing {pdf_path.name} (Shift {shift})... Please wait.",
|
||||
fg="red",
|
||||
)
|
||||
|
||||
def worker() -> None:
|
||||
self.manual_queue.put(
|
||||
process_single_pdf(pdf_path, shift, self.current_max_per_file)
|
||||
)
|
||||
|
||||
Thread(target=worker, daemon=True).start()
|
||||
self.check_manual_queue(pdf_path)
|
||||
|
||||
def check_manual_queue(self, pdf_path: Path) -> None:
|
||||
try:
|
||||
result = self.manual_queue.get_nowait()
|
||||
if result is None:
|
||||
print(f"Failed to process {pdf_path.name}, skipping.")
|
||||
self.index += 1
|
||||
self.load_current_image()
|
||||
else:
|
||||
self.handle_processing_result(result, pdf_path)
|
||||
self.is_processing = False
|
||||
except Empty:
|
||||
self.root.after(100, lambda: self.check_manual_queue(pdf_path))
|
||||
|
||||
def handle_processing_result(
|
||||
self,
|
||||
result: tuple[Image.Image, list[Image.Image], dict[str, object]],
|
||||
pdf_path: Path,
|
||||
) -> None:
|
||||
self.current_preview = result[0]
|
||||
save_results(result, pdf_path, self.output_dir)
|
||||
self.update_display(pdf_path.name, result[2])
|
||||
|
||||
def update_display(self, filename: str, schema: dict[str, object]) -> None:
|
||||
if self.current_preview is None:
|
||||
return
|
||||
tk_image = ImageTk.PhotoImage(self.current_preview)
|
||||
self.label_img.configure(image=tk_image)
|
||||
self.label_img.image = tk_image
|
||||
self.label_info.configure(
|
||||
text=(
|
||||
f"[{self.index + 1}/{len(self.files)}] {filename} | "
|
||||
f"Shift: {self.current_shift}px\nFiles: {schema['number_of_files']} | "
|
||||
f"Cols: {schema['columns_per_file']}\n"
|
||||
"Enter: Next | n: +50 | N: +100 | t: -50 | "
|
||||
"1: use single column"
|
||||
),
|
||||
fg="black",
|
||||
)
|
||||
|
||||
def on_shift(self, amount: int) -> None:
|
||||
if self.is_processing:
|
||||
return
|
||||
self.current_shift += amount
|
||||
print(f"Applying shift: {self.current_shift}")
|
||||
self.trigger_processing(self.files[self.index], self.current_shift)
|
||||
|
||||
def on_next(self, _event: object) -> None:
|
||||
if self.is_processing:
|
||||
return
|
||||
self.index += 1
|
||||
self.current_shift = 0
|
||||
self.current_max_per_file = self.default_max_per_file
|
||||
self.load_current_image()
|
||||
|
||||
|
||||
def _selected_files(
|
||||
workspace: EvaluationWorkspace,
|
||||
target: Path,
|
||||
) -> list[Path]:
|
||||
workspace.require_directories("Copies")
|
||||
if target.is_file():
|
||||
if target.suffix.casefold() != ".pdf":
|
||||
raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS)
|
||||
return [target]
|
||||
return sorted(
|
||||
(
|
||||
path
|
||||
for path in workspace.copies_dir.glob("*.pdf")
|
||||
if "nonc" not in path.name.casefold()
|
||||
),
|
||||
key=lambda path: path.name.casefold(),
|
||||
)
|
||||
|
||||
|
||||
def run(
|
||||
workspace: EvaluationWorkspace,
|
||||
target: Path,
|
||||
*,
|
||||
fullpage: bool = False,
|
||||
) -> ExitCode:
|
||||
files = _selected_files(workspace, target)
|
||||
if not files:
|
||||
print("No PDF files found.")
|
||||
return ExitCode.SUCCESS
|
||||
workspace.cutleft_dir.mkdir(parents=True, exist_ok=True)
|
||||
_get_pdf_pages_cached.cache_clear()
|
||||
ImageReviewer(
|
||||
files,
|
||||
workspace.cutleft_dir,
|
||||
default_max_per_file=1 if fullpage else 5,
|
||||
)
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = target_parser("Interactively crop the label margin from PDF copies")
|
||||
parser.add_argument(
|
||||
"--fullpage",
|
||||
action="store_true",
|
||||
help="Use each complete page instead of cropping the label margin",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
workspace, target = workspace_from_target(args)
|
||||
return run(workspace, target, fullpage=args.fullpage)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
from collections.abc import Sequence
|
||||
from uuid import uuid4
|
||||
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.platform import WindowsLabelError, validate_windows_labels
|
||||
from copienator.utils import compile_to_pdf
|
||||
|
||||
|
||||
def fetch_and_save_sub_text(ex_id, indices, label, text_path):
|
||||
"""Fetches text for a specific sub-question and saves it to Text/{label}.tex"""
|
||||
qinds = ",".join(map(str, indices))
|
||||
if qinds:
|
||||
url = f"http://localhost:8080/exercices/exo_q_text/{ex_id}/{qinds}"
|
||||
else:
|
||||
url = f"http://localhost:8080/exercices/exo_q_text/{ex_id}"
|
||||
try:
|
||||
with urllib.request.urlopen(url) as response:
|
||||
content = response.read().decode('utf-8')
|
||||
content = replace_dots(content.strip("\n"))
|
||||
with open(os.path.join(text_path, f"{label}.tex"), 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
# Compile PDF
|
||||
pdf_file = os.path.join(text_path, f"{label}.pdf")
|
||||
compile_to_pdf(content, pdf_file)
|
||||
except Exception as e:
|
||||
print(f"Error fetching sub-text from {url}: {e}")
|
||||
raise
|
||||
|
||||
def fetch_and_save_sub_sol(ex_id, indices, label, sol_path):
|
||||
"""Fetches text for a specific sub-question and saves it to Text/{label}.tex"""
|
||||
qinds = ",".join(map(str, indices))
|
||||
if qinds:
|
||||
url = f"http://localhost:8080/exercices/exo_q_sol/{ex_id}/{qinds}"
|
||||
else:
|
||||
url = f"http://localhost:8080/exercices/exo_q_sol/{ex_id}"
|
||||
try:
|
||||
with urllib.request.urlopen(url) as response:
|
||||
content = response.read().decode('utf-8')
|
||||
content = replace_dots(content.strip("\n"))
|
||||
with open(os.path.join(sol_path, f"{label}.tex"), 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
# Compile PDF
|
||||
pdf_file = os.path.join(sol_path, f"{label}.pdf")
|
||||
compile_to_pdf(content, pdf_file)
|
||||
except Exception as e:
|
||||
print(f"Error fetching sub-text from {url}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
ROMANS_CAP = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"]
|
||||
ROMANS_LOW = ["", "i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x"]
|
||||
|
||||
def replace_dots(text):
|
||||
# (?m) enables multiline mode so ^ matches start of each line
|
||||
return re.sub(r"(?m)^(\s*.)\.", r"\1)", text)
|
||||
|
||||
def replace_problem_labels(text):
|
||||
"""Replaces labels according to spaces depth when problem=True."""
|
||||
def repl(m):
|
||||
spaces = m.group(1)
|
||||
label = m.group(2)
|
||||
n = len(spaces)
|
||||
try:
|
||||
if n == 1 and label.isdigit(): # 1 space: 1) -> I)
|
||||
return f"{spaces}{ROMANS_CAP[int(label)]})"
|
||||
elif n == 4 and label.isalpha(): # 4 spaces: a) -> 1)
|
||||
return f"{spaces}{ord(label.lower()) - 96})"
|
||||
elif n == 7 and label.isdigit(): # 7 spaces: 1) -> a)
|
||||
return f"{spaces}{chr(96 + int(label))})"
|
||||
elif n == 10 and label.isdigit(): # 10 spaces: 1) -> i)
|
||||
return f"{spaces}{ROMANS_LOW[int(label)]})"
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
return m.group(0)
|
||||
|
||||
# Matches start of line, spaces, alphanumeric label, and closing parenthesis
|
||||
return re.sub(r"(?m)^([ \t]+)([a-zA-Z0-9]+)\)", repl, text)
|
||||
|
||||
def format_indices(indices, problem=False):
|
||||
if not indices: return ""
|
||||
if not problem:
|
||||
res = f"{indices[0]})"
|
||||
if len(indices) > 1: res += f"{chr(96 + indices[1])})"
|
||||
if len(indices) > 2: res += f"{ROMANS_LOW[indices[2]]})"
|
||||
return res
|
||||
else:
|
||||
res = ""
|
||||
if len(indices) > 0: res += f"{ROMANS_CAP[indices[0]]})"
|
||||
if len(indices) > 1: res += f"{indices[1]})"
|
||||
if len(indices) > 2: res += f"{chr(96 + indices[2])})"
|
||||
if len(indices) > 3: res += f"{ROMANS_LOW[indices[3]]})"
|
||||
return res
|
||||
|
||||
|
||||
def save_split_content(text, path, base_fname, problem):
|
||||
# Always save the main aggregated file
|
||||
with open(os.path.join(path, base_fname), 'w', encoding='utf-8') as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
pattern = re.compile(r"(?m)^([ \t]+)([a-zA-Z0-9]+)\)")
|
||||
all_matches = list(pattern.finditer(text))
|
||||
|
||||
target_spaces = 4 if problem else 1
|
||||
splits = [m for m in all_matches if len(m.group(1)) == target_spaces]
|
||||
|
||||
for i, match in enumerate(splits):
|
||||
start_idx = match.start()
|
||||
end_idx = splits[i+1].start() if i + 1 < len(splits) else len(text)
|
||||
chunk = text[start_idx:end_idx].strip("\n")
|
||||
|
||||
label = match.group(2) + ")"
|
||||
|
||||
if problem:
|
||||
# Find the most recent 1-space match before this 4-space match
|
||||
sec_match = next((m for m in reversed(all_matches)
|
||||
if len(m.group(1)) == 1 and m.start() < match.start()), None)
|
||||
if sec_match:
|
||||
label = f"{sec_match.group(2)}){label}"
|
||||
|
||||
sub_fname = f"{base_fname} : {label}"
|
||||
|
||||
with open(os.path.join(path, sub_fname), 'w', encoding='utf-8') as f:
|
||||
f.write(chunk)
|
||||
|
||||
|
||||
def process_directory(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
directory = str(workspace.root)
|
||||
# Find the first .tex file in the directory
|
||||
tex_files = glob.glob(os.path.join(directory, "*.tex"))
|
||||
if not tex_files:
|
||||
print(f"No .tex file found in {directory}. Looking in /Staging/Interro/")
|
||||
int_name = directory.removesuffix("/")
|
||||
tex_path = os.path.join(os.path.expanduser("~"), "Prépa/Staging/Interro", f"{int_name}.tex")
|
||||
if os.path.exists(tex_path):
|
||||
tex_file = tex_path
|
||||
else:
|
||||
raise CliError(
|
||||
f"No .tex input found in {workspace.root}",
|
||||
ExitCode.INVALID_WORKSPACE,
|
||||
)
|
||||
else:
|
||||
tex_file = tex_files[0]
|
||||
|
||||
# Prepare output directories
|
||||
paths = {
|
||||
'Text': os.path.join(directory, "Text"),
|
||||
'Text2': os.path.join(directory, "Text2"),
|
||||
'Sol': os.path.join(directory, "Sol"),
|
||||
'Sol2': os.path.join(directory, "Sol2"),
|
||||
'Persp': os.path.join(directory, "Persp")
|
||||
}
|
||||
for p in paths.values():
|
||||
os.makedirs(p, exist_ok=True)
|
||||
|
||||
labels_file = workspace.labels_file
|
||||
labels_staging = labels_file.with_name(f".{labels_file.name}.{uuid4().hex}.tmp")
|
||||
current_ex_num = 1
|
||||
had_errors = False
|
||||
|
||||
# Read entirely to allow chunking
|
||||
with open(tex_file, 'r', encoding='utf-8') as f_in:
|
||||
content = f_in.read()
|
||||
|
||||
# Split by the specific SHEETINFO tag
|
||||
blocks = content.split("%%SHEETINFO :")
|
||||
if len(blocks) == 1:
|
||||
print(f"No SHEETINFO blocks found in {tex_file}")
|
||||
return ExitCode.PARTIAL
|
||||
|
||||
with open(labels_staging, 'w', encoding='utf-8') as f_labels:
|
||||
# Skip blocks[0] (content before first SHEETINFO)
|
||||
for block in blocks[1:]:
|
||||
parts_line = block.split("\n", 1)
|
||||
json_str = parts_line[0].strip()
|
||||
block_content = parts_line[1] if len(parts_line) > 1 else ""
|
||||
|
||||
# Check if text until next SHEETINFO block contains \Roman
|
||||
problem = r"\Roman" in block_content
|
||||
|
||||
if not json_str: continue
|
||||
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
block_labels = []
|
||||
# Construct 'ids' parameter
|
||||
ex_id = str(data['id'])
|
||||
selection = data.get('select')
|
||||
|
||||
if selection is not None:
|
||||
sel_s = [i+1 for i in selection]
|
||||
ids = f"{ex_id}.{','.join(map(str, sel_s))}"
|
||||
else:
|
||||
ids = ex_id
|
||||
|
||||
|
||||
# 2. Handle Labels
|
||||
indexes = data.get('indexes', [])
|
||||
if not indexes:
|
||||
label = f"Ex {current_ex_num}"
|
||||
validate_windows_labels([label])
|
||||
block_labels.append(label)
|
||||
fetch_and_save_sub_text(ids, [], label, paths['Text2'])
|
||||
fetch_and_save_sub_sol(ids, [], label, paths['Sol2'])
|
||||
else:
|
||||
for item in indexes:
|
||||
suffix = format_indices(item['indices'], problem)
|
||||
label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "")
|
||||
validate_windows_labels([label])
|
||||
block_labels.append(label)
|
||||
fetch_and_save_sub_text(ids, item['indices'], label, paths['Text2'])
|
||||
fetch_and_save_sub_sol(ids, item['indices'], label, paths['Sol2'])
|
||||
|
||||
|
||||
# Construct URL (append pb=true if \Roman matched)
|
||||
url = f"http://localhost:8080/exercices/emacs/{ids}?pretty=true&all=true&persp=true"
|
||||
# if problem:
|
||||
# url += "&pb=true"
|
||||
|
||||
# Perform GET request
|
||||
with urllib.request.urlopen(url) as response:
|
||||
res_content = response.read().decode('utf-8')
|
||||
|
||||
# 4. Split and Save content
|
||||
parts = res_content.split('###')
|
||||
|
||||
# Ensure we have at least 3 parts
|
||||
while len(parts) < 3:
|
||||
parts.append("")
|
||||
|
||||
t_text = replace_dots(parts[0].strip("\n"))
|
||||
s_text = replace_dots(parts[1].strip("\n"))
|
||||
p_text = replace_dots(parts[2].strip("\n"))
|
||||
|
||||
# Apply hierarchy depth replace if problem context
|
||||
if problem:
|
||||
t_text = replace_problem_labels(t_text)
|
||||
s_text = replace_problem_labels(s_text)
|
||||
p_text = replace_problem_labels(p_text)
|
||||
|
||||
base_filename = f"Ex {current_ex_num}"
|
||||
|
||||
if problem:
|
||||
save_split_content(t_text, paths['Text'], base_filename, False)
|
||||
else:
|
||||
with open(os.path.join(paths['Text'], base_filename), 'w', encoding='utf-8') as f:
|
||||
f.write(t_text)
|
||||
|
||||
|
||||
save_split_content(s_text, paths['Sol'], base_filename, problem)
|
||||
save_split_content(p_text, paths['Persp'], base_filename, problem)
|
||||
|
||||
for label in block_labels:
|
||||
f_labels.write(f"{label}\n")
|
||||
current_ex_num += 1
|
||||
|
||||
except WindowsLabelError:
|
||||
labels_staging.unlink(missing_ok=True)
|
||||
raise
|
||||
except json.JSONDecodeError:
|
||||
print(f"Error decoding JSON in block: {json_str}")
|
||||
had_errors = True
|
||||
except Exception as e: # noqa: BLE001 - one malformed exercise is partial
|
||||
print(f"Error processing block {ex_id if 'ex_id' in locals() else 'unknown'}: {e}")
|
||||
had_errors = True
|
||||
|
||||
labels_staging.replace(labels_file)
|
||||
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
return evaluation_parser("Generate statement metadata from SHEETINFO blocks")
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(parser, argv, lambda args: process_directory(workspace_from_args(args)))
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import argparse
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from copienator.configuration import EXPORT_DIR
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.platform import replace_with_link_or_copy
|
||||
|
||||
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
|
||||
|
||||
|
||||
def export_directory(
|
||||
workspace: EvaluationWorkspace,
|
||||
source_dir_name: str,
|
||||
) -> ExitCode:
|
||||
workspace.require_directories(source_dir_name)
|
||||
source_dir = workspace.root / source_dir_name
|
||||
sync_dir = Path(EXPORT_DIR).expanduser() / workspace.name
|
||||
sync_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
subdirs = [directory for directory in source_dir.iterdir() if directory.is_dir()]
|
||||
if source_dir_name == "BGnot" and subdirs:
|
||||
all_start_with_copie = all(directory.name.startswith("Copie") for directory in subdirs)
|
||||
if not all_start_with_copie:
|
||||
subdirs = [directory for directory in subdirs if not directory.name.startswith("Copie")]
|
||||
|
||||
missing_outputs = 0
|
||||
for subdir in subdirs:
|
||||
concat_file = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in (subdir / "Concat.pdf", subdir / "Concat.jpg")
|
||||
if candidate.is_file()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if concat_file is None:
|
||||
print(
|
||||
f"Warning: no Concat.pdf or Concat.jpg found in {subdir}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
missing_outputs += 1
|
||||
continue
|
||||
destination = sync_dir / f"{subdir.name}{concat_file.suffix.lower()}"
|
||||
method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink")
|
||||
print(f"Exported: {destination} ({method})")
|
||||
return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = evaluation_parser("Export annotated PDFs to the tablet directory.")
|
||||
parser.add_argument(
|
||||
"annotation_dir",
|
||||
nargs="?",
|
||||
choices=ANNOTATION_DIRECTORIES,
|
||||
default="BGnot",
|
||||
help="Annotation directory to export (default: BGnot)",
|
||||
)
|
||||
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
|
||||
return parser
|
||||
|
||||
|
||||
def run(
|
||||
workspace: EvaluationWorkspace,
|
||||
*,
|
||||
annotation_dir: str = "BGnot",
|
||||
refaire: bool = False,
|
||||
) -> ExitCode:
|
||||
return export_directory(workspace, "BRnot" if refaire else annotation_dir)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(
|
||||
parser,
|
||||
argv,
|
||||
lambda args: run(
|
||||
workspace_from_args(args),
|
||||
annotation_dir=args.annotation_dir,
|
||||
refaire=args.refaire,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections.abc import Sequence
|
||||
|
||||
from google import genai
|
||||
|
||||
from copienator import configuration as config
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_bytes,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
read_json,
|
||||
workspace_from_args,
|
||||
)
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, *, client=None) -> ExitCode:
|
||||
if client is None:
|
||||
if not config.API_KEY:
|
||||
raise CliError("GEMINI_API_KEY is not configured")
|
||||
client = genai.Client(api_key=config.API_KEY)
|
||||
matching = []
|
||||
if workspace.batch_jobs_file.is_file():
|
||||
manifest = read_json(workspace.batch_jobs_file)
|
||||
jobs = manifest.get("jobs") if isinstance(manifest, dict) else None
|
||||
if not isinstance(jobs, dict):
|
||||
raise CliError(f"Invalid batch manifest: {workspace.batch_jobs_file}")
|
||||
matching = [
|
||||
client.batches.get(name=entry["name"])
|
||||
for entry in jobs.values()
|
||||
if isinstance(entry, dict) and isinstance(entry.get("name"), str)
|
||||
]
|
||||
else:
|
||||
matching = [
|
||||
job
|
||||
for job in client.batches.list()
|
||||
if workspace.name in str(getattr(job, "display_name", ""))
|
||||
]
|
||||
if not matching:
|
||||
raise CliError(
|
||||
f"No batch jobs found for evaluation {workspace.name!r}"
|
||||
)
|
||||
for job in matching:
|
||||
state = job.state.name if hasattr(job.state, "name") else job.state
|
||||
print(f"{job.display_name}: {state}")
|
||||
if state != "JOB_STATE_SUCCEEDED":
|
||||
print("Not all matching jobs have succeeded yet.")
|
||||
return ExitCode.PARTIAL
|
||||
|
||||
chunks = []
|
||||
incomplete = False
|
||||
for job in matching:
|
||||
destination = getattr(job, "dest", None)
|
||||
file_name = getattr(destination, "file_name", None)
|
||||
if not file_name:
|
||||
print(f"Warning: {job.display_name} has no output file.")
|
||||
incomplete = True
|
||||
continue
|
||||
payload = client.files.download(file=file_name)
|
||||
chunks.append(payload.rstrip(b"\n"))
|
||||
if not chunks:
|
||||
return ExitCode.PARTIAL
|
||||
output_path = workspace.batched_correction_result_file
|
||||
atomic_write_bytes(output_path, b"\n".join(chunks) + b"\n")
|
||||
print(f"Saved combined batch results to {output_path}")
|
||||
return ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
return evaluation_parser("Download and combine correction batch results")
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,830 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from copienator import configuration as config
|
||||
from copienator import utils
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_text,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.platform import validate_windows_labels
|
||||
from copienator.utils import compile_to_pdf
|
||||
|
||||
|
||||
def get_lcp(s1: str, s2: str) -> str:
|
||||
i = 0
|
||||
while i < len(s1) and i < len(s2) and s1[i] == s2[i]:
|
||||
i += 1
|
||||
lcp = s1[:i]
|
||||
if ')' in s1 or ')' in s2:
|
||||
last_paren = lcp.rfind(')')
|
||||
if last_paren != -1:
|
||||
return lcp[:last_paren + 1]
|
||||
return lcp
|
||||
|
||||
|
||||
MODEL_ID = config.MODEL_LITE_ID
|
||||
api_key = config.API_KEY
|
||||
|
||||
# --- Modèles pour la Requête 1 ---
|
||||
class QuestionOnlyItem(BaseModel):
|
||||
label: str = Field(description="The unique label of the question (e.g., '1.a', 'Exercice 1')")
|
||||
question_content: str = Field(description="The source text of the question, strictly extracted from the enonce file, EXCLUDING the label itself.")
|
||||
|
||||
class ExamQuestions(BaseModel):
|
||||
questions: list[QuestionOnlyItem]
|
||||
|
||||
# --- Modèles pour la Requête 2 ---
|
||||
class SolutionOnlyItem(BaseModel):
|
||||
label: str = Field(description="The exact unique label of the question provided in the input.")
|
||||
solution_content: str = Field(description="The source text of the solution, strictly extracted from the correction file.")
|
||||
|
||||
class ExamSolutions(BaseModel):
|
||||
solutions: list[SolutionOnlyItem]
|
||||
|
||||
# --- Modèles pour la Requête 3 ---
|
||||
class ExtractedContext(BaseModel):
|
||||
target_question_label: str = Field(description="The exact label of the FIRST question that comes immediately AFTER this information in the exam.")
|
||||
last_question_label: str = Field(description="The exact label of the LAST question that uses or relies on this information.")
|
||||
context_content: str = Field(description="The source text of the definitions, notations, or hypotheses, extracted from the enonce.")
|
||||
|
||||
class ExamContext(BaseModel):
|
||||
contexts: list[ExtractedContext]
|
||||
|
||||
# --- Modèles pour la Requête 4 (Barèmes) ---
|
||||
class RubricItem(BaseModel):
|
||||
label: str = Field(description="The exact label of the question.")
|
||||
rubric_content: str = Field(description="Le barème détaillé en français.")
|
||||
|
||||
class GroupRubrics(BaseModel):
|
||||
rubrics: list[RubricItem]
|
||||
|
||||
PROMPT_4 = """Je te fournis les questions, le contexte éventuel, et les corrections pour un groupe de questions d'un examen.
|
||||
Ta tâche :
|
||||
Établir un barème de correction détaillé en français pour CHAQUE question.
|
||||
Chaque question DOIT être notée sur exactement 4 points. Propose une répartition logique de ces points.
|
||||
Par exemple :
|
||||
- Au moins 2 points si le résultat est correct.
|
||||
- Mettre la moitié des points si le raisonnement est correct mais pas le résultat.
|
||||
- Retirer 1.5 points si les hypothèses d'un théorème ou d'une question précédente ne sont pas vérifiées.
|
||||
|
||||
Renvoie le résultat sous forme de liste JSON correspondant aux labels des questions fournies.
|
||||
"""
|
||||
|
||||
# --- Modèle fusionné (pour le reste du script) ---
|
||||
class QuestionItem(BaseModel):
|
||||
label: str
|
||||
question_content: str
|
||||
solution_content: str
|
||||
|
||||
class ContextItem(BaseModel):
|
||||
target_question_label: str
|
||||
last_question_label: str
|
||||
content: str # Juste une string encapsulée pour le différencier facilement
|
||||
|
||||
class ExamExtraction(BaseModel):
|
||||
items: list[QuestionItem | ContextItem] # Liste mixte
|
||||
|
||||
class GroupedExamExtraction(BaseModel):
|
||||
groups: list[list[QuestionItem | ContextItem]]
|
||||
|
||||
PROMPT_1 = """I am providing:
|
||||
1. A PDF of an exam (`enonce.pdf`)
|
||||
2. The source code of the exam questions (`enonce` file)
|
||||
|
||||
Your task:
|
||||
1. Identify all distinct question labels using the PDF document.
|
||||
These labels should be unique : use `Ex 1 : 1)a)` or `I)1)b)`.
|
||||
2. For each label, extract its exact corresponding question text
|
||||
from the `enonce` source file. Do not include the label itself
|
||||
in this extracted text (nor LaTeX like `item` nor org-mode list
|
||||
labelling like `2.`).
|
||||
Return the result as a JSON list in the exact reading order of the document.
|
||||
"""
|
||||
|
||||
PROMPT_2 = """I am providing:
|
||||
1. A JSON list of question labels and their texts extracted from an exam.
|
||||
2. The source code of the exam solutions (`correction` file).
|
||||
|
||||
Your task:
|
||||
For each question label provided in the JSON, extract its exact corresponding solution textual
|
||||
content from the `correction` source file. Return the result as a JSON list in the exact same order.
|
||||
"""
|
||||
|
||||
PROMPT_3 = """I am providing:
|
||||
1. A JSON list of question labels and their texts extracted from an exam.
|
||||
2. The source code of the exam questions (`enonce` file).
|
||||
|
||||
Your task:
|
||||
Extract important information necessary to understand the questions (e.g., definitions of objects, global notations, hypotheses, context) that are NOT part of the question texts themselves. Often, this information can be in a previous \\item that is not itself a question, but contains the question items.
|
||||
|
||||
For example, given LaTeX code like
|
||||
|
||||
\\item Let N, M be two commutating matrices
|
||||
\\begin{itemize}
|
||||
\\item Prove that N, M have a common eigenvector
|
||||
\\item Prove that N, M are co-trigonalizable.
|
||||
\\end{itemize}
|
||||
|
||||
the `Let N, M be two commutating matrices` part is not a question itself, and is important information to understand the next two questions.
|
||||
|
||||
For each extracted piece of information, identify:
|
||||
1. The label of the FIRST question that comes immediately AFTER this information in the exam.
|
||||
2. The label of the LAST question that uses or relies on this information.
|
||||
Return the result as a JSON list.
|
||||
"""
|
||||
|
||||
def find_file(folder: Path, base_name: str) -> Path | None:
|
||||
for ext in [".org", ".tex"]:
|
||||
path = folder / f"{base_name}{ext}"
|
||||
if path.is_file():
|
||||
return path
|
||||
return None
|
||||
|
||||
def process_exam(
|
||||
workspace: EvaluationWorkspace,
|
||||
restart: bool = False,
|
||||
*,
|
||||
api_client=None,
|
||||
) -> ExitCode:
|
||||
folder = workspace.root
|
||||
|
||||
cache_dir = folder / "Cache"
|
||||
tmp_dir = folder / "Tmp"
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
tmp_dir.mkdir(exist_ok=True)
|
||||
|
||||
cache_q_file = cache_dir / "gemini_questions.json"
|
||||
cache_s_file = cache_dir / "gemini_solutions.json"
|
||||
cache_c_file = cache_dir / "gemini_context.json"
|
||||
|
||||
# 1. Resolve files
|
||||
pdf_path = folder / "enonce.pdf"
|
||||
enonce_path = find_file(folder, "enonce")
|
||||
correction_path = find_file(folder, "correction")
|
||||
|
||||
missing = []
|
||||
if not pdf_path.is_file(): missing.append("enonce.pdf")
|
||||
if not enonce_path: missing.append("enonce.org or enonce.tex")
|
||||
if not correction_path: missing.append("correction.org or correction.tex")
|
||||
|
||||
if missing:
|
||||
raise CliError(
|
||||
f"Missing files in {folder}: {', '.join(missing)}",
|
||||
ExitCode.INVALID_WORKSPACE,
|
||||
)
|
||||
|
||||
print("Reading files...")
|
||||
pdf_bytes = pdf_path.read_bytes()
|
||||
enonce_text = enonce_path.read_text(encoding="utf-8")
|
||||
correction_text = correction_path.read_text(encoding="utf-8")
|
||||
|
||||
if api_client is None:
|
||||
if not api_key:
|
||||
raise CliError("GEMINI_API_KEY is not configured")
|
||||
api_client = genai.Client(api_key=api_key)
|
||||
client = api_client
|
||||
|
||||
# ==========================================
|
||||
# REQUÊTE 1 : Extraction des Énoncés
|
||||
# ==========================================
|
||||
contents_1 = [
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(text=PROMPT_1),
|
||||
types.Part.from_bytes(data=pdf_bytes, mime_type="application/pdf"),
|
||||
types.Part.from_text(text=f"--- ENONCE SOURCE ({enonce_path.name}) ---\n{enonce_text}"),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
config_1 = types.GenerateContentConfig(
|
||||
temperature=0.1,
|
||||
response_mime_type="application/json",
|
||||
response_json_schema=ExamQuestions.model_json_schema(),
|
||||
)
|
||||
|
||||
if cache_q_file.is_file() and not restart:
|
||||
print("Loading cached questions from Cache/gemini_questions.json...")
|
||||
response_q_text = cache_q_file.read_text(encoding="utf-8")
|
||||
else:
|
||||
print("Sending request 1 (Questions) to Gemini...")
|
||||
response_q = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=contents_1,
|
||||
config=config_1
|
||||
)
|
||||
response_q_text = response_q.text
|
||||
print("Saving questions to cache...")
|
||||
atomic_write_text(cache_q_file, response_q_text)
|
||||
|
||||
questions_data = ExamQuestions.model_validate_json(response_q_text)
|
||||
|
||||
# ==========================================
|
||||
# REQUÊTE 2 : Extraction des Corrections
|
||||
# ==========================================
|
||||
extracted_questions_json = questions_data.model_dump_json(indent=2)
|
||||
|
||||
contents_2 = [
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(text=PROMPT_2),
|
||||
types.Part.from_text(text=f"--- EXTRACTED QUESTIONS ---\n{extracted_questions_json}"),
|
||||
types.Part.from_text(text=f"--- CORRECTION SOURCE ({correction_path.name}) ---\n{correction_text}"),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
config_2 = types.GenerateContentConfig(
|
||||
temperature=0.1,
|
||||
response_mime_type="application/json",
|
||||
response_json_schema=ExamSolutions.model_json_schema(),
|
||||
)
|
||||
|
||||
if cache_s_file.is_file() and not restart:
|
||||
print("Loading cached solutions from Cache/gemini_solutions.json...")
|
||||
response_s_text = cache_s_file.read_text(encoding="utf-8")
|
||||
else:
|
||||
print("Sending request 2 (Solutions) to Gemini...")
|
||||
response_s = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=contents_2,
|
||||
config=config_2
|
||||
)
|
||||
response_s_text = response_s.text
|
||||
print("Saving solutions to cache...")
|
||||
atomic_write_text(cache_s_file, response_s_text)
|
||||
|
||||
solutions_data = ExamSolutions.model_validate_json(response_s_text)
|
||||
|
||||
# ==========================================
|
||||
# REQUÊTE 3 : Extraction du Contexte (Notations, etc.)
|
||||
# ==========================================
|
||||
contents_3 = [
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(text=PROMPT_3),
|
||||
types.Part.from_text(text=f"--- EXTRACTED QUESTIONS ---\n{extracted_questions_json}"),
|
||||
types.Part.from_text(text=f"--- ENONCE SOURCE ({enonce_path.name}) ---\n{enonce_text}"),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
config_3 = types.GenerateContentConfig(
|
||||
temperature=0.1,
|
||||
response_mime_type="application/json",
|
||||
response_json_schema=ExamContext.model_json_schema(),
|
||||
)
|
||||
|
||||
if cache_c_file.is_file() and not restart:
|
||||
print("Loading cached context from Cache/gemini_context.json...")
|
||||
response_c_text = cache_c_file.read_text(encoding="utf-8")
|
||||
else:
|
||||
print("Sending request 3 (Context) to Gemini...")
|
||||
response_c = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=contents_3,
|
||||
config=config_3
|
||||
)
|
||||
response_c_text = response_c.text
|
||||
print("Saving context to cache...")
|
||||
atomic_write_text(cache_c_file, response_c_text)
|
||||
|
||||
context_data = ExamContext.model_validate_json(response_c_text)
|
||||
|
||||
# ==========================================
|
||||
# FUSION des trois résultats
|
||||
# ==========================================
|
||||
sol_map = {s.label: s.solution_content for s in solutions_data.solutions}
|
||||
|
||||
|
||||
# Map labels to their index to validate ordering
|
||||
label_to_idx = {q.label: i for i, q in enumerate(questions_data.questions)}
|
||||
|
||||
for c in context_data.contexts:
|
||||
first_idx = label_to_idx.get(c.target_question_label, -1)
|
||||
last_idx = label_to_idx.get(c.last_question_label, -1)
|
||||
|
||||
# Enforce LAST is after (or equal to) FIRST
|
||||
if first_idx != -1 and last_idx != -1 and last_idx < first_idx:
|
||||
print(f"Warning: LAST question ({c.last_question_label}) is before FIRST ({c.target_question_label}). Fixing.")
|
||||
c.last_question_label = c.target_question_label
|
||||
elif last_idx == -1: # Fallback if invalid
|
||||
c.last_question_label = c.target_question_label
|
||||
|
||||
# Grouper les contextes par label cible
|
||||
ctx_map = {}
|
||||
for c in context_data.contexts:
|
||||
if c.target_question_label in ctx_map:
|
||||
ctx_map[c.target_question_label]['content'] += "\n\n" + c.context_content
|
||||
# Keep the furthest LAST question label
|
||||
curr_last = ctx_map[c.target_question_label]['last']
|
||||
if label_to_idx.get(c.last_question_label, -1) > label_to_idx.get(curr_last, -1):
|
||||
ctx_map[c.target_question_label]['last'] = c.last_question_label
|
||||
else:
|
||||
ctx_map[c.target_question_label] = {
|
||||
'content': c.context_content,
|
||||
'last': c.last_question_label
|
||||
}
|
||||
|
||||
merged_items = []
|
||||
for q in questions_data.questions:
|
||||
if q.label in ctx_map:
|
||||
merged_items.append(ContextItem(
|
||||
target_question_label=q.label,
|
||||
last_question_label=ctx_map[q.label]['last'],
|
||||
content=ctx_map[q.label]['content']
|
||||
))
|
||||
|
||||
sol_content = sol_map.get(q.label, "")
|
||||
merged_items.append(QuestionItem(
|
||||
label=q.label,
|
||||
question_content=q.question_content,
|
||||
solution_content=sol_content
|
||||
))
|
||||
|
||||
extracted_data = ExamExtraction(items=merged_items)
|
||||
|
||||
# ==========================================
|
||||
# INITIAL GROUPING COMPUTATION
|
||||
# ==========================================
|
||||
items_file = tmp_dir / "exam_items.txt"
|
||||
full_items_file = tmp_dir / "exam_items_full.txt"
|
||||
trunc_map = {}
|
||||
|
||||
# --- INITIAL GROUPING COMPUTATION ---
|
||||
# 1. Normalize labels first
|
||||
for item in extracted_data.items:
|
||||
if isinstance(item, QuestionItem):
|
||||
item.label = item.label.replace("Exercice", "Ex").replace(".", ")")
|
||||
|
||||
# 2. Extract questions and compute grouping indices
|
||||
questions_only = [item for item in extracted_data.items if isinstance(item, QuestionItem)]
|
||||
# questions_only = [item for item in extracted_data.items if isinstance(item, QuestionItem)]
|
||||
|
||||
q_group_indices = []
|
||||
if questions_only:
|
||||
n = len(questions_only)
|
||||
if n == 1:
|
||||
q_group_indices = [[0]]
|
||||
else:
|
||||
adj_lcp = [get_lcp(questions_only[i].label, questions_only[i+1].label) for i in range(n - 1)]
|
||||
|
||||
current_g = [0]
|
||||
for i in range(n - 1):
|
||||
p = adj_lcp[i]
|
||||
prev_p = adj_lcp[i - 1] if i > 0 else ""
|
||||
next_p = adj_lcp[i + 1] if i < n - 2 else ""
|
||||
|
||||
# Group i and i+1 together if p is non-empty and at least as specific as adjacent LCPs
|
||||
if p and len(p) >= len(prev_p) and len(p) >= len(next_p):
|
||||
current_g.append(i + 1)
|
||||
else:
|
||||
q_group_indices.append(current_g)
|
||||
current_g = [i + 1]
|
||||
q_group_indices.append(current_g)
|
||||
|
||||
|
||||
# Build list of unique ContextItems from extracted data
|
||||
all_contexts = [item for item in extracted_data.items if isinstance(item, ContextItem)]
|
||||
|
||||
initial_groups = []
|
||||
for g_indices in q_group_indices:
|
||||
group_items = []
|
||||
first_q_idx = g_indices[0]
|
||||
|
||||
for q_idx in g_indices:
|
||||
q_item = questions_only[q_idx]
|
||||
|
||||
# 1. Collect contexts targeting this specific question
|
||||
# 2. Or contexts carried over from an earlier group (only added at the start of the group)
|
||||
for ctx in all_contexts:
|
||||
target_idx = label_to_idx.get(ctx.target_question_label, -1)
|
||||
last_idx = label_to_idx.get(ctx.last_question_label, -1)
|
||||
|
||||
if target_idx != -1 and last_idx != -1:
|
||||
is_exact_target = (target_idx == q_idx)
|
||||
is_carried_over = (q_idx == first_q_idx and target_idx < first_q_idx and last_idx >= first_q_idx)
|
||||
|
||||
if is_exact_target or is_carried_over:
|
||||
group_items.append(ContextItem(
|
||||
target_question_label=ctx.target_question_label,
|
||||
last_question_label=ctx.last_question_label,
|
||||
content=ctx.content
|
||||
))
|
||||
|
||||
group_items.append(q_item)
|
||||
|
||||
initial_groups.append(group_items)
|
||||
|
||||
# ---- Transform labels, and check uniqueness
|
||||
|
||||
seen_labels = set()
|
||||
label_updates = {}
|
||||
for group in initial_groups:
|
||||
for item in group:
|
||||
if isinstance(item, QuestionItem):
|
||||
orig_label = item.label
|
||||
|
||||
# 2. Ensure uniqueness (prefix with XX)
|
||||
while item.label in seen_labels:
|
||||
item.label = f"XX{item.label}"
|
||||
seen_labels.add(item.label)
|
||||
label_updates[orig_label] = item.label
|
||||
|
||||
# Sync the modified labels to ContextItem
|
||||
for group in initial_groups:
|
||||
for item in group:
|
||||
if isinstance(item, ContextItem):
|
||||
item.target_question_label = label_updates.get(item.target_question_label, item.target_question_label)
|
||||
item.last_question_label = label_updates.get(item.last_question_label, item.last_question_label)
|
||||
|
||||
# --- WRITE TEXT FILES ---
|
||||
print(f"Writing items files to {items_file.name} and {full_items_file.name}...")
|
||||
|
||||
with open(items_file, "w", encoding="utf-8") as f, \
|
||||
open(full_items_file, "w", encoding="utf-8") as f_full:
|
||||
|
||||
header = "# Edit labels. Modify groups (---). Ensure label uniqueness (XX). Duplicate CONTEXT.\n\n"
|
||||
f.write(header)
|
||||
f_full.write(header)
|
||||
|
||||
for g_idx, group in enumerate(initial_groups):
|
||||
if g_idx > 0:
|
||||
f.write("\n---\n\n")
|
||||
f_full.write("\n---\n\n")
|
||||
|
||||
for item in group:
|
||||
if isinstance(item, QuestionItem):
|
||||
safe_content = item.question_content.replace('\n', ' \\n ')
|
||||
f_full.write(f"{item.label} ### {safe_content}\n")
|
||||
|
||||
if len(safe_content) > 65:
|
||||
trunc_content = safe_content[:64] + "…"
|
||||
trunc_map[trunc_content] = safe_content
|
||||
else:
|
||||
trunc_content = safe_content
|
||||
|
||||
f.write(f"{item.label} ### {trunc_content}\n")
|
||||
|
||||
elif isinstance(item, ContextItem):
|
||||
safe_content = item.content.replace('\n', ' \\n ')
|
||||
f.write(f"CONTEXT ### {safe_content}\n")
|
||||
f_full.write(f"CONTEXT ### {safe_content}\n")
|
||||
|
||||
# --- OPEN EDITOR AND PARSE ---
|
||||
while True:
|
||||
print("Opening items file for editing...")
|
||||
utils.edit_file_and_enter(items_file)
|
||||
|
||||
print(f"Parsing edited items from {items_file.name}...")
|
||||
with open(items_file, "r", encoding="utf-8") as f:
|
||||
edited_lines = [line.strip() for line in f if line.strip() and not line.startswith("#")]
|
||||
|
||||
# 1. Validation for XX labels
|
||||
has_xx = False
|
||||
for line in edited_lines:
|
||||
if " ### " in line:
|
||||
lbl = line.split(" ### ", 1)[0].strip()
|
||||
if lbl.startswith("XX"):
|
||||
has_xx = True
|
||||
break
|
||||
|
||||
if has_xx:
|
||||
print("\n!!! ERROR: Some labels still start with 'XX'. Please remove the 'XX' prefixes to ensure unique, valid labels.")
|
||||
input("Press ENTER to return to the editor...")
|
||||
continue
|
||||
|
||||
# Map original contexts by normalized content
|
||||
orig_contexts = {c.context_content.strip(): c for c in context_data.contexts}
|
||||
|
||||
# 2. Actual Parsing
|
||||
grouped_items = []
|
||||
all_new_q_labels = []
|
||||
|
||||
# Pass 1: Read all edited lines and collect question labels in sequence
|
||||
for line in edited_lines:
|
||||
if line == "---" or " ### " not in line:
|
||||
continue
|
||||
lbl, _content_raw = line.split(" ### ", 1)
|
||||
lbl = lbl.strip()
|
||||
if lbl != "CONTEXT":
|
||||
all_new_q_labels.append(lbl)
|
||||
|
||||
# Mapping from original question index to new label
|
||||
idx_to_new_label = {i: all_new_q_labels[i] for i in range(min(len(questions_only), len(all_new_q_labels)))}
|
||||
|
||||
orig_q_idx = 0
|
||||
current_group = []
|
||||
|
||||
for line in edited_lines:
|
||||
if line == "---":
|
||||
if current_group:
|
||||
grouped_items.append(current_group)
|
||||
current_group = []
|
||||
continue
|
||||
|
||||
if " ### " not in line:
|
||||
continue
|
||||
|
||||
new_label, edited_content_raw = line.split(" ### ", 1)
|
||||
new_label = new_label.strip()
|
||||
|
||||
if "…" in edited_content_raw and edited_content_raw in trunc_map:
|
||||
edited_content_raw = trunc_map[edited_content_raw]
|
||||
|
||||
edited_content = edited_content_raw.replace(' \\n ', '\n')
|
||||
|
||||
if new_label == "CONTEXT":
|
||||
current_group.append(('CONTEXT', edited_content))
|
||||
else:
|
||||
sol_content = questions_only[orig_q_idx].solution_content if orig_q_idx < len(questions_only) else ""
|
||||
current_group.append(QuestionItem(
|
||||
label=new_label,
|
||||
question_content=edited_content,
|
||||
solution_content=sol_content
|
||||
))
|
||||
orig_q_idx += 1
|
||||
|
||||
if current_group:
|
||||
grouped_items.append(current_group)
|
||||
|
||||
# Pass 2: Resolve ContextItem target/last labels per group
|
||||
final_grouped_items = []
|
||||
for group in grouped_items:
|
||||
final_group = []
|
||||
q_in_group = [item for item in group if isinstance(item, QuestionItem)]
|
||||
g_first_label = q_in_group[0].label if q_in_group else ""
|
||||
g_last_label = q_in_group[-1].label if q_in_group else ""
|
||||
|
||||
for i, item in enumerate(group):
|
||||
if isinstance(item, tuple) and item[0] == 'CONTEXT':
|
||||
c_text = item[1]
|
||||
norm_text = c_text.strip()
|
||||
|
||||
# Find next question label in group following this context
|
||||
next_q_label = g_first_label
|
||||
for successor in group[i+1:]:
|
||||
if isinstance(successor, QuestionItem):
|
||||
next_q_label = successor.label
|
||||
break
|
||||
|
||||
if norm_text in orig_contexts:
|
||||
orig_c = orig_contexts[norm_text]
|
||||
orig_target_idx = label_to_idx.get(orig_c.target_question_label, -1)
|
||||
orig_last_idx = label_to_idx.get(orig_c.last_question_label, -1)
|
||||
|
||||
mapped_target = idx_to_new_label.get(orig_target_idx, next_q_label)
|
||||
mapped_last = idx_to_new_label.get(orig_last_idx, g_last_label)
|
||||
|
||||
# Check if context's last question is BEFORE the first question of this group
|
||||
first_q_idx_in_exam = all_new_q_labels.index(g_first_label) if g_first_label in all_new_q_labels else -1
|
||||
last_q_idx_in_exam = all_new_q_labels.index(mapped_last) if mapped_last in all_new_q_labels else -1
|
||||
|
||||
if last_q_idx_in_exam != -1 and first_q_idx_in_exam != -1 and last_q_idx_in_exam < first_q_idx_in_exam:
|
||||
mapped_last = g_last_label
|
||||
|
||||
final_group.append(ContextItem(
|
||||
target_question_label=mapped_target,
|
||||
last_question_label=mapped_last,
|
||||
content=c_text
|
||||
))
|
||||
else:
|
||||
# New context created by user
|
||||
final_group.append(ContextItem(
|
||||
target_question_label=next_q_label,
|
||||
last_question_label=g_last_label,
|
||||
content=c_text
|
||||
))
|
||||
else:
|
||||
final_group.append(item)
|
||||
|
||||
final_grouped_items.append(final_group)
|
||||
|
||||
grouped_items = final_grouped_items
|
||||
break
|
||||
|
||||
labels_list = [item.label for group in grouped_items for item in group if isinstance(item, QuestionItem)]
|
||||
validate_windows_labels(labels_list)
|
||||
|
||||
# Save labels and proceed
|
||||
grouped_extraction = GroupedExamExtraction(groups=grouped_items)
|
||||
|
||||
# 2. Setup output directories
|
||||
text_dir = folder / "Text"
|
||||
sol_dir = folder / "Sol"
|
||||
text2_dir = folder / "Text2"
|
||||
sol2_dir = folder / "Sol2"
|
||||
persp_dir = folder / "Persp"
|
||||
dirs = [text_dir, sol_dir, text2_dir, sol2_dir, persp_dir]
|
||||
|
||||
import shutil
|
||||
# Ask only if any directory already exists
|
||||
if any(d.exists() for d in dirs):
|
||||
answer = input(
|
||||
"Output directories already exist. Delete their contents? [y/N] "
|
||||
).strip().lower()
|
||||
|
||||
if answer not in ("y", "yes"):
|
||||
raise CliError("Output replacement aborted", ExitCode.INVALID_ARGUMENTS)
|
||||
# Empty each directory
|
||||
for d in dirs:
|
||||
if d.exists():
|
||||
shutil.rmtree(d)
|
||||
d.mkdir(parents=True)
|
||||
else:
|
||||
# Create them if they don't exist
|
||||
for d in dirs:
|
||||
d.mkdir(parents=True)
|
||||
|
||||
text_dir.mkdir(exist_ok=True)
|
||||
sol_dir.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
print("Writing grouped question and solution files...")
|
||||
processing_errors = []
|
||||
|
||||
for group in grouped_extraction.groups:
|
||||
q_items = [item for item in group if isinstance(item, QuestionItem)]
|
||||
labels = [q.label for q in q_items]
|
||||
|
||||
if not labels:
|
||||
continue # Skip if a group has no questions (only contexts)
|
||||
|
||||
# ==========================================
|
||||
# REQUÊTE 4 : Génération du Barème pour le groupe
|
||||
# ==========================================
|
||||
group_text_parts = []
|
||||
for item in group:
|
||||
if isinstance(item, QuestionItem):
|
||||
group_text_parts.append(f"Question [{item.label}]:\n{item.question_content}\nCorrection [{item.label}]:\n{item.solution_content}")
|
||||
elif isinstance(item, ContextItem):
|
||||
group_text_parts.append(f"Contexte (Cible: {item.target_question_label}):\n{item.content}")
|
||||
|
||||
group_context_text = "\n\n---\n\n".join(group_text_parts)
|
||||
|
||||
contents_4 = [
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_text(text=PROMPT_4),
|
||||
types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
config_4 = types.GenerateContentConfig(
|
||||
temperature=0.2,
|
||||
response_mime_type="application/json",
|
||||
response_json_schema=GroupRubrics.model_json_schema(),
|
||||
)
|
||||
|
||||
print(f"Generating rubric (Persp) for group: {', '.join(labels)}...")
|
||||
try:
|
||||
response_r = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=contents_4,
|
||||
config=config_4
|
||||
)
|
||||
rubrics_data = GroupRubrics.model_validate_json(response_r.text)
|
||||
rubrics_map = {r.label: r.rubric_content for r in rubrics_data.rubrics}
|
||||
except Exception as e: # noqa: BLE001 - remote API boundary
|
||||
print(f"Error generating rubric for group {labels[0]}: {e}")
|
||||
processing_errors.append(str(e))
|
||||
rubrics_map = {}
|
||||
|
||||
# 1. Compute the common prefix for the group
|
||||
prefix = labels[0]
|
||||
for lbl in labels[1:]:
|
||||
prefix = get_lcp(prefix, lbl)
|
||||
|
||||
# 2. Format the Text filename: prefix [label1, label2]
|
||||
labels_str = ",".join([label[len(prefix):] for label in labels])
|
||||
group_filename = f"{prefix}[{labels_str}]"
|
||||
safe_group_filename = group_filename.replace("/", "_")
|
||||
|
||||
text_content_lines = []
|
||||
|
||||
# 3. Process each item in the group
|
||||
for item in group:
|
||||
if isinstance(item, QuestionItem):
|
||||
# 1. Prepare tabulated content:
|
||||
# Start with a tab, then replace every newline+whitespace with newline+tab
|
||||
raw_content = item.question_content.strip()
|
||||
tabulated = "\t" + re.sub(r'\n\s*', '\n\t', raw_content)
|
||||
|
||||
# 2. Build Text entry
|
||||
text_content_lines.append(f"{item.label} :")
|
||||
text_content_lines.append(tabulated)
|
||||
|
||||
# Write individual Sol file (remains unchanged)
|
||||
safe_label = item.label.replace("/", "_")
|
||||
with open(sol_dir / safe_label, "w", encoding="utf-8") as f_sol:
|
||||
f_sol.write(f"{item.label}\n{item.solution_content}")
|
||||
|
||||
with open(text2_dir / f"{safe_label}.tex", "w", encoding="utf-8") as f_t2:
|
||||
f_t2.write(f"\\textbf{{{item.label}}} {item.question_content}")
|
||||
|
||||
with open(sol2_dir / f"{safe_label}.tex", "w", encoding="utf-8") as f_s2:
|
||||
f_s2.write(f"\\textbf{{{item.label}}} {item.solution_content}")
|
||||
|
||||
# --- Écriture du Barème (Persp) ---
|
||||
rubric_text = rubrics_map.get(item.label, "")
|
||||
with open(persp_dir / safe_label, "w", encoding="utf-8") as f_persp:
|
||||
f_persp.write(f"{item.label}\n{rubric_text}")
|
||||
|
||||
elif isinstance(item, ContextItem):
|
||||
raw_ctx = item.content.strip()
|
||||
tabulated_ctx = "\t" + re.sub(r'\n\s*', '\n\t', raw_ctx)
|
||||
text_content_lines.append("CONTEXT :")
|
||||
text_content_lines.append(tabulated_ctx)
|
||||
|
||||
# --- Save context to Text2 (Concatenating if exists) ---
|
||||
safe_first = item.target_question_label.replace("/", "_")
|
||||
safe_last = item.last_question_label.replace("/", "_")
|
||||
ctxt_filename = f"CTXT {safe_first} -> {safe_last}.tex"
|
||||
ctxt_path = text2_dir / ctxt_filename
|
||||
|
||||
# If file exists, prepend some spacing before appending
|
||||
prefix = "\n\n" if ctxt_path.exists() else ""
|
||||
|
||||
with open(ctxt_path, "a", encoding="utf-8") as f_c2:
|
||||
f_c2.write(prefix + item.content)
|
||||
|
||||
# 4. Write the grouped Text file
|
||||
with open(text_dir / safe_group_filename, "w", encoding="utf-8") as f_text:
|
||||
f_text.write("\n".join(text_content_lines))
|
||||
|
||||
print(f"Success! Processed {len(grouped_extraction.groups)} groups.")
|
||||
# ==========================================
|
||||
# PDF COMPILATION (4 Threads)
|
||||
# ==========================================
|
||||
all_tex_files = list(text2_dir.glob("*.tex")) + list(sol2_dir.glob("*.tex"))
|
||||
|
||||
def compile_worker(tex_file: Path) -> str | None:
|
||||
"""Helper to read content and call the utility function."""
|
||||
try:
|
||||
content = tex_file.read_text(encoding="utf-8")
|
||||
pdf_path = tex_file.with_suffix(".pdf")
|
||||
compile_to_pdf(content, pdf_path)
|
||||
except Exception as e: # noqa: BLE001 - compiler worker boundary
|
||||
return f"Error compiling {tex_file.name}: {e}"
|
||||
return None
|
||||
|
||||
print(f"Compiling {len(all_tex_files)} files to PDF using 4 threads...")
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
compile_errors = [
|
||||
error for error in executor.map(compile_worker, all_tex_files) if error
|
||||
]
|
||||
for error in compile_errors:
|
||||
print(error)
|
||||
processing_errors.extend(compile_errors)
|
||||
atomic_write_text(
|
||||
workspace.labels_file,
|
||||
"".join(f"{label}\n" for label in labels_list),
|
||||
)
|
||||
|
||||
return ExitCode.PARTIAL if processing_errors else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = evaluation_parser("Extract exam and solution code via Gemini")
|
||||
parser.add_argument(
|
||||
"--restart",
|
||||
action="store_true",
|
||||
help="Ignore cached Gemini extraction responses",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(
|
||||
parser,
|
||||
argv,
|
||||
lambda args: process_exam(
|
||||
workspace_from_args(args),
|
||||
restart=args.restart,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,450 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import time
|
||||
import typing
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from copienator import configuration as config
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
execute,
|
||||
read_json,
|
||||
target_parser,
|
||||
workspace_from_target,
|
||||
)
|
||||
from copienator.utils import natural_key, read_all_labels
|
||||
|
||||
MODEL_ID = config.MODEL_FOR_LABEL_ID
|
||||
api_key = config.API_KEY
|
||||
|
||||
my_prompt = """I'm giving you an image of the left columns of a written exam.
|
||||
Students answer several exercises, which can have several questions.
|
||||
|
||||
The image consists of several columns, separated by vertical black
|
||||
lines. The image should be read top to bottom and then left to right,
|
||||
meaning first column, then second column, etc.
|
||||
|
||||
In their sheet, students delimit exercises and questions using
|
||||
delimiters such as `Ex 1`, or `Exercice 1`, and `1)` or `a)`. You need
|
||||
to give me the bounding boxes of each delimiter.
|
||||
|
||||
When giving the bounding box of the first question of an exercise, the
|
||||
box should be large enough to contain both the exercice label
|
||||
(`Exercice i`) and the question label (`1)`) parts. If they are
|
||||
horizontally far apart (example : if the `1)` is to the left and the
|
||||
`Exercice i` is either to the right, or in the middle) then give only
|
||||
the bounding box of the question label `1)` part. You should still
|
||||
label it as `Exercice i : 1)` though.
|
||||
|
||||
You also need to give me the student name. It should appear on the top
|
||||
left of the image. Disregard any mention of `MPSI 3`, it is their
|
||||
class. A list of possible student names will be given below.
|
||||
|
||||
You will answer with a JSON object, containing a `name` field with the
|
||||
name, and a `list` field, with the list of the bounding boxes and
|
||||
their labels. The box_2d should be [ymin, xmin, ymax, xmax] normalized
|
||||
to 0-1000.
|
||||
|
||||
Here is an example :
|
||||
{\"name\" : \"John Doe\", \"list\" : [{\"box_2d\": (10, 20, 30, 40), \"label\" : \"Ex 1 : 1)\"}]}
|
||||
|
||||
Do not provide a box_2d for the name. Only for the labels. Order the
|
||||
box_2d by their position in the page, column by column : first column
|
||||
(top to bottom), then second column, etc.
|
||||
|
||||
You may find the same label present several times, as a student either
|
||||
recall the current label on a new page, or adds content to its answer
|
||||
later on. Give the position of each instance of each label.
|
||||
|
||||
For this exam you should look for the labels given below, separated by
|
||||
newlines. A student need not have answered every question, so some may
|
||||
be missing.
|
||||
|
||||
##labels##
|
||||
|
||||
##wrong_labels##
|
||||
|
||||
Here's a list of the names of the students, pick the one that matches
|
||||
the best or `\"Unknown\"` if you cannot read the name
|
||||
|
||||
##names##"""
|
||||
my_prompt2 = """I'm giving you an image of the left columns of a written exam.
|
||||
Students answer several exercises, which can have several questions.
|
||||
|
||||
The image consists of several columns, separated by vertical black
|
||||
lines. The image should be read top to bottom and then left to right,
|
||||
meaning first column, then second column, etc.
|
||||
|
||||
In their sheet, students delimit exercises and questions using
|
||||
delimiters such as `Ex 1`, or `Exercice 1`, and `1)` or `a)`. You need
|
||||
to give me the bounding boxes of each delimiter.
|
||||
|
||||
When giving the bounding box of the first question of an exercise, the
|
||||
box should be large enough to contain both the exercice label
|
||||
(`Exercice i`) and the question label (`1)`) parts.
|
||||
|
||||
You also need to give me the student name. It should appear on the top
|
||||
left of the image. Disregard any mention of `MPSI 3`, it is their
|
||||
class. A list of possible student names will be given below.
|
||||
|
||||
You will answer with a JSON object, containing a `name` field with the
|
||||
name, and a `list` field, with the list of the bounding boxes and
|
||||
their labels. The box_2d should be [ymin, xmin, ymax, xmax] normalized
|
||||
to 0-1000.
|
||||
|
||||
Here is an example :
|
||||
{\"name\" : \"John Doe\", \"list\" : [{\"box_2d\": (10, 20, 30, 40), \"label\" : \"Ex 1 : 1)\"}]}
|
||||
|
||||
Do not provide a box_2d for the name. Only for the labels.
|
||||
|
||||
You may find the same label present several times, as a student either
|
||||
recall the current label on a new page, or adds content to its answer
|
||||
later on. Give the position of each instance of each label.
|
||||
|
||||
This image is one part of a sequence (e.g., part 2 of 3) for a single
|
||||
student. Here is the list of labels found in the *previous* parts of
|
||||
this copy:
|
||||
|
||||
[
|
||||
##prev_context##
|
||||
]
|
||||
|
||||
If the first column starts with a number like =3)= or =c)=, look at
|
||||
the labels in the list above. If the last relevant label was =Ex 4 :
|
||||
2)=, you should label the new box =Ex 4 : 3)=.
|
||||
|
||||
For this exam you should look for the labels given below, separated by
|
||||
newlines. A student need not have answered every question, so some may
|
||||
be missing.
|
||||
|
||||
##labels##
|
||||
|
||||
##wrong_labels##
|
||||
|
||||
Since this copy isn't the first part of a sequence, simply set the
|
||||
name to `\"Continued\"`."""
|
||||
|
||||
class BoxItem(BaseModel):
|
||||
box_2d: list[int] = Field(description="Bounding box coordinates (e.g., [ymin, xmin, ymax, xmax])")
|
||||
label: str = Field(description="The label associated with the specific box")
|
||||
|
||||
class AnnotationData(BaseModel):
|
||||
name: str = Field(description="The name identifier")
|
||||
list: typing.List[BoxItem] = Field( # noqa: UP006 - field name shadows list
|
||||
description="List of bounding box items"
|
||||
)
|
||||
|
||||
|
||||
def generate_request(file, labels, names, context_labels, wrong_labels):
|
||||
"""Generates request for Gemini with context."""
|
||||
|
||||
image_path = Path(file)
|
||||
|
||||
# Format context list as a string
|
||||
context_str = ", ".join([f'"{l}"' for l in context_labels]) if context_labels else "No previous context"
|
||||
|
||||
if context_labels == []:
|
||||
text = my_prompt.replace("##labels##", labels)\
|
||||
.replace("##names##", names)
|
||||
else:
|
||||
text = my_prompt2.replace("##labels##", labels)\
|
||||
.replace("##prev_context##", context_str)
|
||||
if wrong_labels:
|
||||
text= text.replace("##wrong_labels##\n\n", f"On a previous request, you answered with the following wrong labels : {wrong_labels}. These are wrong, since they do not exactly match any of the labels in the previous list.")
|
||||
else:
|
||||
text = text.replace("##wrong_labels##\n\n", "")
|
||||
|
||||
|
||||
contents = [
|
||||
types.Content(
|
||||
role="user",
|
||||
parts=[
|
||||
types.Part.from_bytes(
|
||||
data=image_path.read_bytes(),
|
||||
mime_type="image/jpeg"
|
||||
),
|
||||
types.Part.from_text(text=text),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
generate_content_config = types.GenerateContentConfig(
|
||||
temperature=1.0,
|
||||
top_p=0.95,
|
||||
seed=0,
|
||||
max_output_tokens=65535,
|
||||
response_mime_type= "application/json",
|
||||
response_json_schema= AnnotationData.model_json_schema(),
|
||||
)
|
||||
return (contents, generate_content_config)
|
||||
|
||||
TARGET_INTERVAL = 3.5
|
||||
Sleep = Callable[[float], None]
|
||||
|
||||
|
||||
def selected_images(
|
||||
workspace: EvaluationWorkspace,
|
||||
targets: list[Path],
|
||||
) -> tuple[list[Path], list[str]]:
|
||||
"""Resolve evaluation, copy-PDF, or Cutleft-image targets."""
|
||||
workspace.require_directories("Copies", "Cutleft")
|
||||
images: list[Path] = []
|
||||
warnings: list[str] = []
|
||||
for target in targets:
|
||||
if target.is_dir():
|
||||
copy_pdfs = sorted(
|
||||
workspace.copies_dir.glob("Copie*.pdf"), key=natural_key
|
||||
)
|
||||
if not copy_pdfs:
|
||||
warnings.append(f"No Copie*.pdf files found in {workspace.copies_dir}")
|
||||
stems = [path.stem for path in copy_pdfs]
|
||||
elif target.suffix.casefold() in {".jpg", ".jpeg"}:
|
||||
if target.parent != workspace.cutleft_dir:
|
||||
raise CliError(
|
||||
f"Image target is not in {workspace.cutleft_dir}: {target}",
|
||||
ExitCode.INVALID_ARGUMENTS,
|
||||
)
|
||||
images.append(target)
|
||||
continue
|
||||
elif target.suffix.casefold() == ".pdf":
|
||||
stems = [target.stem]
|
||||
else:
|
||||
raise CliError(
|
||||
f"Unsupported target for label detection: {target}",
|
||||
ExitCode.INVALID_ARGUMENTS,
|
||||
)
|
||||
for stem in stems:
|
||||
found = sorted(
|
||||
workspace.cutleft_dir.glob(f"{stem}_*.jpg"), key=natural_key
|
||||
)
|
||||
if found:
|
||||
images.extend(found)
|
||||
else:
|
||||
warnings.append(
|
||||
f"No Cutleft image variants found for {stem} in "
|
||||
f"{workspace.cutleft_dir}"
|
||||
)
|
||||
return list(dict.fromkeys(images)), warnings
|
||||
|
||||
|
||||
def group_images(image_files: list[Path]) -> dict[str, list[Path]]:
|
||||
groups: defaultdict[str, list[Path]] = defaultdict(list)
|
||||
for image in image_files:
|
||||
match = re.match(r"(.+)_(\d+)$", image.stem)
|
||||
groups[match.group(1) if match else image.stem].append(image)
|
||||
for files in groups.values():
|
||||
files.sort(key=natural_key)
|
||||
return dict(groups)
|
||||
|
||||
|
||||
def _existing_context(output_json: Path) -> list[str]:
|
||||
try:
|
||||
loaded = read_json(output_json)
|
||||
if not isinstance(loaded, dict):
|
||||
return []
|
||||
return [
|
||||
str(item["label"])
|
||||
for item in loaded.get("list", [])
|
||||
if isinstance(item, dict) and "label" in item
|
||||
]
|
||||
except (OSError, TypeError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def process_copy_group(
|
||||
workspace: EvaluationWorkspace,
|
||||
group_key: str,
|
||||
files: list[Path],
|
||||
*,
|
||||
client,
|
||||
labels_text: str,
|
||||
names_text: str,
|
||||
valid_labels: set[str],
|
||||
valid_names: set[str],
|
||||
overwrite: bool,
|
||||
sleep: Sleep = time.sleep,
|
||||
target_interval: float = TARGET_INTERVAL,
|
||||
) -> int:
|
||||
"""Process one student's image parts sequentially to preserve context."""
|
||||
accumulated_labels: list[str] = []
|
||||
generated = 0
|
||||
for image_file in files:
|
||||
started = time.monotonic()
|
||||
output_json = workspace.copies_dir / f"{image_file.stem}.json"
|
||||
if output_json.exists() and not overwrite:
|
||||
print(f"[{group_key}] Skipping {image_file.name}, output exists.")
|
||||
accumulated_labels.extend(_existing_context(output_json))
|
||||
continue
|
||||
|
||||
print(
|
||||
f"[{group_key}] Processing {image_file.name} with "
|
||||
f"{len(accumulated_labels)} accumulated labels..."
|
||||
)
|
||||
attempt = 0
|
||||
wrong_labels: list[str] = []
|
||||
while True:
|
||||
if attempt > 0:
|
||||
sleep(10 * attempt)
|
||||
try:
|
||||
contents, request_config = generate_request(
|
||||
image_file,
|
||||
labels_text,
|
||||
names_text,
|
||||
accumulated_labels,
|
||||
wrong_labels,
|
||||
)
|
||||
response = client.models.generate_content(
|
||||
model=MODEL_ID,
|
||||
contents=contents,
|
||||
config=request_config,
|
||||
)
|
||||
annotation = AnnotationData.model_validate_json(response.text)
|
||||
unknown = [
|
||||
item.label
|
||||
for item in annotation.list
|
||||
if item.label not in valid_labels
|
||||
]
|
||||
if unknown:
|
||||
print(
|
||||
f"Error: {image_file.name} contained unknown labels: "
|
||||
f"{unknown}"
|
||||
)
|
||||
wrong_labels.extend(unknown)
|
||||
attempt += 1
|
||||
continue
|
||||
if annotation.name not in valid_names:
|
||||
print(
|
||||
f"Error: {image_file.name} returned unknown name: "
|
||||
f"{annotation.name}"
|
||||
)
|
||||
if attempt == 0:
|
||||
attempt += 1
|
||||
continue
|
||||
annotation.name = "Unknown"
|
||||
|
||||
atomic_write_json(output_json, annotation.model_dump())
|
||||
accumulated_labels.extend(box.label for box in annotation.list)
|
||||
generated += 1
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - remote API retry boundary
|
||||
print(
|
||||
f"Error processing {image_file.name}: {exc}\n"
|
||||
"\tIt will be retried."
|
||||
)
|
||||
attempt += 1
|
||||
sleep(max(0.0, target_interval - (time.monotonic() - started)))
|
||||
return generated
|
||||
|
||||
|
||||
def run(
|
||||
workspace: EvaluationWorkspace,
|
||||
targets: list[Path],
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
client=None,
|
||||
sleep: Sleep = time.sleep,
|
||||
max_workers: int = 12,
|
||||
) -> ExitCode:
|
||||
workspace.require_files("labels")
|
||||
images, warnings = selected_images(workspace, targets)
|
||||
for warning in warnings:
|
||||
print(f"Warning: {warning}")
|
||||
if not images:
|
||||
return ExitCode.PARTIAL
|
||||
|
||||
all_labels = read_all_labels(workspace.root)
|
||||
labels_text = "\n".join(all_labels) + "\n"
|
||||
names_path = workspace.names_file()
|
||||
if not names_path.is_file():
|
||||
raise CliError(f"Names file not found: {names_path}", ExitCode.INVALID_WORKSPACE)
|
||||
names_text = names_path.read_text(encoding="utf-8")
|
||||
valid_names = {
|
||||
line.strip() for line in names_text.splitlines() if line.strip()
|
||||
} | {"Unknown", "Continued"}
|
||||
if client is None:
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
groups = group_images(images)
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = [
|
||||
executor.submit(
|
||||
process_copy_group,
|
||||
workspace,
|
||||
group_key,
|
||||
files,
|
||||
client=client,
|
||||
labels_text=labels_text,
|
||||
names_text=names_text,
|
||||
valid_labels=set(all_labels),
|
||||
valid_names=valid_names,
|
||||
overwrite=overwrite,
|
||||
sleep=sleep,
|
||||
)
|
||||
for group_key, files in groups.items()
|
||||
]
|
||||
for future in futures:
|
||||
future.result()
|
||||
return ExitCode.PARTIAL if warnings else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = target_parser("Detect handwritten question labels with Gemini")
|
||||
parser.add_argument(
|
||||
"additional_targets",
|
||||
nargs="*",
|
||||
type=Path,
|
||||
help="Additional copy PDFs or Cutleft images from the same evaluation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Regenerate JSON outputs that already exist",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
workspace, target = workspace_from_target(
|
||||
args, repository=Path(__file__).resolve().parents[2]
|
||||
)
|
||||
targets = [target]
|
||||
for additional in args.additional_targets:
|
||||
resolved = additional.expanduser().resolve()
|
||||
if not resolved.exists():
|
||||
raise CliError(
|
||||
f"Target does not exist: {resolved}",
|
||||
ExitCode.INVALID_WORKSPACE,
|
||||
)
|
||||
additional_workspace = EvaluationWorkspace.discover(
|
||||
resolved, repository=workspace.repository
|
||||
)
|
||||
if additional_workspace.root != workspace.root:
|
||||
raise CliError(
|
||||
"All targets must belong to the same evaluation",
|
||||
ExitCode.INVALID_ARGUMENTS,
|
||||
)
|
||||
targets.append(resolved)
|
||||
return run(workspace, targets, overwrite=args.overwrite)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
read_json,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.platform import replace_with_link_or_copy, safe_filename
|
||||
|
||||
ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = evaluation_parser("Assign student names and prepare the return directory.")
|
||||
parser.add_argument(
|
||||
"annotation_dir",
|
||||
choices=ANNOTATION_CHOICES,
|
||||
help="Annotation directory to use",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _read_expected_names(workspace: EvaluationWorkspace) -> set[str]:
|
||||
names_path = workspace.names_file()
|
||||
if not names_path.exists():
|
||||
print(
|
||||
f"Warning: names file not found in {workspace.root} or the current directory.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return set()
|
||||
return {
|
||||
line.strip()
|
||||
for line in names_path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
|
||||
|
||||
def prepare_named_returns(
|
||||
workspace: EvaluationWorkspace,
|
||||
annotation_dir_name: str,
|
||||
) -> ExitCode:
|
||||
workspace.require_directories("Copies", annotation_dir_name)
|
||||
workspace.return_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
expected_names = _read_expected_names(workspace)
|
||||
copies_map: defaultdict[str, list[str]] = defaultdict(list)
|
||||
pattern = re.compile(r"^Copie(\d+)\.json$")
|
||||
had_errors = False
|
||||
|
||||
for json_path in workspace.copies_dir.iterdir():
|
||||
match = pattern.match(json_path.name)
|
||||
if not match:
|
||||
continue
|
||||
try:
|
||||
data = read_json(json_path)
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError("expected a JSON object")
|
||||
name = str(data.get("name", "Unknown")).strip()
|
||||
copies_map[name].append(match.group(1))
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
print(f"Error processing {json_path}: {exc}", file=sys.stderr)
|
||||
had_errors = True
|
||||
|
||||
assigned_names: set[str] = set()
|
||||
selected_annotations = workspace.root / annotation_dir_name
|
||||
fallback_annotations = workspace.annotation_dir("simple")
|
||||
|
||||
for name, copy_ids in copies_map.items():
|
||||
if name == "Unknown":
|
||||
print(
|
||||
f"Warning: unknown name for copies: {', '.join(copy_ids)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif len(copy_ids) > 1:
|
||||
print(
|
||||
f"Warning: name {name!r} is assigned to multiple copies: "
|
||||
f"{', '.join(copy_ids)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
safe_name = safe_filename(name)
|
||||
for copy_id in copy_ids:
|
||||
selected = selected_annotations / f"Copie{copy_id}"
|
||||
fallback = fallback_annotations / f"Copie{copy_id}"
|
||||
source_folder = None
|
||||
for candidate in (selected, fallback):
|
||||
if (candidate / "Concat.jpg").exists() and (
|
||||
candidate / "score.json"
|
||||
).exists():
|
||||
source_folder = candidate
|
||||
break
|
||||
if source_folder is None:
|
||||
continue
|
||||
|
||||
assigned_names.add(name)
|
||||
destination = workspace.return_dir / f"{safe_name} ({copy_id})"
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
links = (
|
||||
("Concat.jpg", f"{safe_name}.jpg"),
|
||||
("Concat_F.pdf", f"{safe_name}.pdf"),
|
||||
("score.json", "score.json"),
|
||||
)
|
||||
for source_name, destination_name in links:
|
||||
source = source_folder / source_name
|
||||
if not source.exists():
|
||||
continue
|
||||
try:
|
||||
method = replace_with_link_or_copy(
|
||||
source,
|
||||
destination / destination_name,
|
||||
prefer="symlink",
|
||||
)
|
||||
if method == "copy":
|
||||
print(
|
||||
f"Copied {source_name} for {destination.name} "
|
||||
"(links unavailable)"
|
||||
)
|
||||
except OSError as exc:
|
||||
print(
|
||||
f"Error linking {source} for {destination.name}: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
had_errors = True
|
||||
|
||||
unassigned = expected_names - assigned_names
|
||||
if unassigned:
|
||||
print("Names from the list that were not assigned:", file=sys.stderr)
|
||||
for name in sorted(unassigned):
|
||||
print(f" - {name}", file=sys.stderr)
|
||||
|
||||
return ExitCode.PARTIAL if had_errors else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def run(
|
||||
workspace: EvaluationWorkspace,
|
||||
*,
|
||||
annotation_dir: str,
|
||||
) -> ExitCode:
|
||||
return prepare_named_returns(workspace, annotation_dir)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(
|
||||
parser,
|
||||
argv,
|
||||
lambda args: run(
|
||||
workspace_from_args(args, repository=Path.cwd()),
|
||||
annotation_dir=args.annotation_dir,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from pdf2image import convert_from_path, pdfinfo_from_path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
workspace_from_args,
|
||||
)
|
||||
|
||||
# Configuration
|
||||
DPI = 200 # Good balance for readability and size
|
||||
A4_HEIGHT_INCHES = 11.69
|
||||
FULL_PAGE_PX = int(A4_HEIGHT_INCHES * DPI)
|
||||
MAX_GROUP_HEIGHT = 1.5 * FULL_PAGE_PX
|
||||
MAX_GROUP_COUNT = 8
|
||||
SEPARATOR_HEIGHT = 20
|
||||
LABEL_HEIGHT = 50
|
||||
MAX_FILE_SIZE_BYTES = 2.5 * 1024 * 1024 # 2MB
|
||||
|
||||
def get_pdf_height(path):
|
||||
"""Returns total height of all pages in pixels at defined DPI."""
|
||||
try:
|
||||
info = pdfinfo_from_path(path)
|
||||
# Get page count (default to 1)
|
||||
num_pages = int(info["Pages"]) if "Pages" in info else 1
|
||||
|
||||
# 1 pt = 1/72 inch
|
||||
pts_height = float(info['Page size'].split(' ')[2]) if 'Page size' in info else 0
|
||||
|
||||
# Height of one page in pixels
|
||||
single_page_px = int((pts_height / 72.0) * DPI)
|
||||
|
||||
# Return total height
|
||||
return single_page_px * num_pages
|
||||
except Exception as e: # noqa: BLE001 - pdfinfo may raise backend-specific errors
|
||||
print(f"Error reading {path}: {e}")
|
||||
return 0
|
||||
|
||||
def collect_files(root_dir):
|
||||
"""
|
||||
Scans Dir/Copiedd/identifier.pdf
|
||||
Returns dict: {identifier: [(dd, path, height), ...]}
|
||||
"""
|
||||
data = defaultdict(list)
|
||||
|
||||
# Regex to match 'Copie' followed by 2 digits
|
||||
folder_pattern = re.compile(r'Copie(\d{2})')
|
||||
|
||||
for root, dirs, files in os.walk(root_dir):
|
||||
folder_name = os.path.basename(root)
|
||||
match = folder_pattern.match(folder_name)
|
||||
|
||||
if match:
|
||||
dd = match.group(1)
|
||||
for file in files:
|
||||
if file.lower().endswith('.pdf'):
|
||||
identifier = os.path.splitext(file)[0]
|
||||
full_path = os.path.join(root, file)
|
||||
|
||||
# Calculate height (c)
|
||||
height = get_pdf_height(full_path)
|
||||
|
||||
# Store triple (a, b, c)
|
||||
data[identifier].append((dd, full_path, height))
|
||||
return data
|
||||
|
||||
def group_files(file_list):
|
||||
"""
|
||||
Groups files using First Fit Decreasing algorithm to minimize group count.
|
||||
"""
|
||||
# 1. Sort by height DESCENDING. Large items are hardest to fit, handle them first.
|
||||
# (Remove this sort if you must strictly preserve input order logic)
|
||||
sorted_files = sorted(file_list, key=lambda x: x[2], reverse=True)
|
||||
|
||||
# Each group is a dict: {'items': [], 'current_height': 0}
|
||||
groups = []
|
||||
|
||||
for item in sorted_files:
|
||||
_, _, height = item
|
||||
placed = False
|
||||
|
||||
# 2. Try to fit item into an existing group (First Fit)
|
||||
for group in groups:
|
||||
# Check Count Constraint
|
||||
if len(group['items']) >= MAX_GROUP_COUNT:
|
||||
continue
|
||||
|
||||
# Calculate Overhead (only if group is not empty)
|
||||
overhead = (SEPARATOR_HEIGHT + 30) if group['items'] else 0
|
||||
|
||||
# Check Height Constraint
|
||||
if group['current_height'] + height + overhead <= MAX_GROUP_HEIGHT:
|
||||
group['items'].append(item)
|
||||
group['current_height'] += height + overhead
|
||||
placed = True
|
||||
break
|
||||
|
||||
# 3. If it doesn't fit anywhere, create a new group
|
||||
if not placed:
|
||||
groups.append({
|
||||
'items': [item],
|
||||
'current_height': height
|
||||
})
|
||||
|
||||
# Return list of lists (strip the metadata)
|
||||
return [g['items'] for g in groups]
|
||||
|
||||
def stitch_pdf_pages(images_list):
|
||||
"""Vertically concatenates a list of PIL images with no separator."""
|
||||
if not images_list:
|
||||
return None
|
||||
if len(images_list) == 1:
|
||||
return images_list[0]
|
||||
|
||||
max_width = max(img.width for img in images_list)
|
||||
total_height = sum(img.height for img in images_list)
|
||||
|
||||
combined = Image.new('RGB', (max_width, total_height), 'white')
|
||||
|
||||
y_offset = 0
|
||||
for img in images_list:
|
||||
combined.paste(img, (0, y_offset))
|
||||
y_offset += img.height
|
||||
|
||||
return combined
|
||||
|
||||
def create_jpg(identifier, group_index, group, root_dir):
|
||||
images = []
|
||||
metadata = [] # To store (id, h_min, h_max)
|
||||
|
||||
# Render PDFs to images
|
||||
for dd, path, _ in group:
|
||||
try:
|
||||
# Convert pdf to image
|
||||
imgs = convert_from_path(path, dpi=DPI)
|
||||
if imgs:
|
||||
# Concatenate multi-page PDFs into one single image object
|
||||
combined_img = stitch_pdf_pages(imgs)
|
||||
if combined_img:
|
||||
images.append((dd, combined_img))
|
||||
except Exception as e: # noqa: BLE001 - PDF/image backends vary by platform
|
||||
print(f"Failed to convert {path}: {e}")
|
||||
|
||||
if not images:
|
||||
return
|
||||
|
||||
# Calculate total canvas size
|
||||
total_width = max(img.width for _, img in images)
|
||||
total_height = sum(img.height for _, img in images) + ((len(images) - 1) * SEPARATOR_HEIGHT)
|
||||
|
||||
# Add space for text (approx 40px per label)
|
||||
total_height += len(images) * LABEL_HEIGHT
|
||||
|
||||
canvas = Image.new('RGB', (total_width, total_height), 'white')
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
# Try loading a font, fallback to default
|
||||
try:
|
||||
font = ImageFont.truetype("DejaVuSans.ttf", 40)
|
||||
except OSError:
|
||||
print("font not found")
|
||||
font = ImageFont.load_default()
|
||||
|
||||
y_offset = 0
|
||||
|
||||
for i, (dd, img) in enumerate(images):
|
||||
# Draw separator if not first image
|
||||
if i > 0:
|
||||
draw.rectangle([0, y_offset, total_width, y_offset + SEPARATOR_HEIGHT], fill='black')
|
||||
y_offset += SEPARATOR_HEIGHT
|
||||
|
||||
# Draw Text (dd)
|
||||
text = f"ID: {dd}"
|
||||
draw.text((10, y_offset + 5), text, fill='black', font=font)
|
||||
y_offset += LABEL_HEIGHT # Space for text
|
||||
|
||||
# Record Image Coordinates
|
||||
h_min = y_offset
|
||||
h_max = y_offset + img.height
|
||||
# identifier should be a label
|
||||
metadata.append((dd, h_min, h_max, img.width/total_width, identifier))
|
||||
|
||||
# Draw Image
|
||||
x_pos = 0
|
||||
canvas.paste(img, (x_pos, y_offset))
|
||||
y_offset += img.height
|
||||
|
||||
target_folder = os.path.join(root_dir, identifier)
|
||||
os.makedirs(target_folder, exist_ok=True)
|
||||
|
||||
# Save JSON metadata
|
||||
json_filename = f"Group_{group_index+1}.json"
|
||||
json_path = os.path.join(target_folder, json_filename)
|
||||
atomic_write_json(json_path, metadata, indent=None)
|
||||
|
||||
# Save with size constraints
|
||||
output_filename = f"Group_{group_index+1}.jpg"
|
||||
output_path = os.path.join(target_folder, output_filename)
|
||||
|
||||
quality = 90
|
||||
while quality > 10:
|
||||
canvas.save(output_path, "JPEG", quality=quality, optimize=True)
|
||||
if os.path.getsize(output_path) <= MAX_FILE_SIZE_BYTES:
|
||||
if quality < 90:
|
||||
print("quality : ", quality)
|
||||
break
|
||||
quality -= 5
|
||||
|
||||
print(f"Saved {output_path} with {len(group)} ({os.path.getsize(output_path)/1024/1024:.2f} MB)")
|
||||
|
||||
from copienator.utils import natural_key
|
||||
|
||||
|
||||
def process_identifier(identifier, files_info, output_dir):
|
||||
# Clear output directory if it exists
|
||||
target_folder = os.path.join(output_dir, identifier)
|
||||
if os.path.exists(target_folder):
|
||||
shutil.rmtree(target_folder)
|
||||
os.makedirs(target_folder, exist_ok=True)
|
||||
|
||||
# files_info is list of (dd, path, height)
|
||||
file_groups = group_files(files_info)
|
||||
|
||||
for idx, group in enumerate(file_groups):
|
||||
create_jpg(identifier, idx, group, output_dir)
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
return evaluation_parser("Group copy extracts by question label.")
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
workspace.require_directories("Copies")
|
||||
workspace.groups_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print("Scanning files...")
|
||||
data = collect_files(workspace.copies_dir)
|
||||
|
||||
print(f"Found {len(data)} identifiers. Processing...")
|
||||
|
||||
# Sort identifiers naturally
|
||||
sorted_identifiers = sorted(data.keys(), key=natural_key)
|
||||
|
||||
# Process using 8 threads
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
futures = [
|
||||
executor.submit(
|
||||
process_identifier,
|
||||
identifier,
|
||||
data[identifier],
|
||||
workspace.groups_dir,
|
||||
)
|
||||
for identifier in sorted_identifiers
|
||||
]
|
||||
for future in futures:
|
||||
future.result()
|
||||
|
||||
print("Done.")
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from copienator.configuration import IMPORT_DIR
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
workspace_from_args,
|
||||
)
|
||||
|
||||
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
|
||||
|
||||
|
||||
def sync_annotated(
|
||||
workspace: EvaluationWorkspace,
|
||||
*,
|
||||
annotation_dir_name: str,
|
||||
import_dir: Path,
|
||||
) -> ExitCode:
|
||||
workspace.require_directories(annotation_dir_name)
|
||||
annotation_dir = workspace.root / annotation_dir_name
|
||||
annotated_dir = Path(import_dir).expanduser()
|
||||
if not annotated_dir.is_dir():
|
||||
print(f"Error: directory does not exist: {annotated_dir}", file=sys.stderr)
|
||||
return ExitCode.INVALID_WORKSPACE
|
||||
|
||||
missing_targets = 0
|
||||
annotated_files = sorted(
|
||||
(
|
||||
path
|
||||
for path in annotated_dir.iterdir()
|
||||
if path.is_file() and path.suffix.casefold() in {".pdf", ".jpg", ".jpeg"}
|
||||
),
|
||||
key=lambda path: path.name.casefold(),
|
||||
)
|
||||
for annotated_file in annotated_files:
|
||||
target_subdir = annotation_dir / annotated_file.stem
|
||||
|
||||
if not target_subdir.is_dir():
|
||||
print(f"Warning: directory not found: {target_subdir}", file=sys.stderr)
|
||||
missing_targets += 1
|
||||
else:
|
||||
suffix = annotated_file.suffix.lower()
|
||||
dest_file = target_subdir / f"Concat_annotated{suffix}"
|
||||
print(f"Copying {annotated_file} to {dest_file}")
|
||||
shutil.copy2(annotated_file, dest_file)
|
||||
return ExitCode.PARTIAL if missing_targets else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = evaluation_parser("Import handwritten annotations from the tablet directory.")
|
||||
parser.add_argument(
|
||||
"annotation_dir",
|
||||
nargs="?",
|
||||
choices=ANNOTATION_DIRECTORIES,
|
||||
default="BGnot",
|
||||
help="Annotation directory receiving imported PDFs (default: BGnot)",
|
||||
)
|
||||
parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
|
||||
return parser
|
||||
|
||||
|
||||
def run(
|
||||
workspace: EvaluationWorkspace,
|
||||
*,
|
||||
annotation_dir: str = "BGnot",
|
||||
refaire: bool = False,
|
||||
) -> ExitCode:
|
||||
return sync_annotated(
|
||||
workspace,
|
||||
annotation_dir_name="BRnot" if refaire else annotation_dir,
|
||||
import_dir=Path(IMPORT_DIR),
|
||||
)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(
|
||||
parser,
|
||||
argv,
|
||||
lambda args: run(
|
||||
workspace_from_args(args),
|
||||
annotation_dir=args.annotation_dir,
|
||||
refaire=args.refaire,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,87 @@
|
||||
import json
|
||||
import sys
|
||||
import concurrent.futures
|
||||
from pathlib import Path
|
||||
from copienator.commands import correction
|
||||
|
||||
def get_missing_tasks():
|
||||
"""
|
||||
Identifies tasks (groups) where NONE of the student IDs in that group
|
||||
appear in the existing results for that label in correction.json.
|
||||
"""
|
||||
missing = []
|
||||
|
||||
# correction.results is already loaded from correction.json during 'from copienator.commands import correction'
|
||||
# correction.tasks is populated with (filepath, label) during 'from copienator.commands import correction'
|
||||
|
||||
for task in correction.tasks:
|
||||
file_path, label = task
|
||||
# Find the group metadata file (Group_X.json) to know which IDs are inside
|
||||
meta_path = Path(file_path).with_suffix('.json')
|
||||
|
||||
if not meta_path.exists():
|
||||
print("Missing meta_path :", meta_path)
|
||||
continue
|
||||
|
||||
with open(meta_path, 'r', encoding="utf-8") as f:
|
||||
# group_data entries: [pid, ymin, ymax, width_ratio]
|
||||
group_data = json.load(f)
|
||||
|
||||
pids_in_group = [str(item[0]) for item in group_data]
|
||||
|
||||
# Check correction.json results for this specific label
|
||||
label_results = correction.results.get(label, [])
|
||||
|
||||
# Collect all student IDs that have already been processed for this label
|
||||
covered_ids = set()
|
||||
for result_list in label_results:
|
||||
for entry in result_list:
|
||||
covered_ids.add(str(entry.get('id')))
|
||||
|
||||
# Logic: Only process if EVERY ID in this group is missing from correction.json
|
||||
if all(pid not in covered_ids for pid in pids_in_group):
|
||||
missing.append(task)
|
||||
|
||||
return missing
|
||||
|
||||
def main(argv=None):
|
||||
if argv:
|
||||
print("missing-correction does not accept command-line arguments.", file=sys.stderr)
|
||||
return 2
|
||||
missing_tasks = get_missing_tasks()
|
||||
print("\n Total nb of tasks : ", len(correction.tasks))
|
||||
|
||||
if not missing_tasks:
|
||||
print("All groups are already present in correction.json. Nothing to do.")
|
||||
return
|
||||
|
||||
print("\nThe following groups are missing from correction.json:")
|
||||
for path, label in missing_tasks:
|
||||
print(f" - [{label}] {path}")
|
||||
|
||||
confirm = input(f"\nFound {len(missing_tasks)} missing groups. Start processing? (y/N): ")
|
||||
if confirm.lower() != 'y':
|
||||
print("Aborted.")
|
||||
return
|
||||
|
||||
print(f"Processing {len(missing_tasks)} tasks with {correction.NB_THREADS} threads...")
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=correction.NB_THREADS) as executor:
|
||||
# Map tasks to the processing function defined in correction.py
|
||||
futures = {executor.submit(correction.process_single_task, t): t for t in missing_tasks}
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
try:
|
||||
# Handle potential sub-tasks (like label errors) generated during processing
|
||||
new_generated_tasks = future.result()
|
||||
if new_generated_tasks:
|
||||
for nt in new_generated_tasks:
|
||||
executor.submit(correction.process_single_task, nt)
|
||||
except Exception as e:
|
||||
t = futures[future]
|
||||
print(f"Error processing {t[0]}: {e}")
|
||||
|
||||
print("\nProcessing complete.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,661 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import tkinter as tk
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox
|
||||
|
||||
import fitz # PyMuPDF
|
||||
from PIL import Image, ImageDraw, ImageTk
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
from copienator.configuration import PAGE_SPLITTER_KB
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
execute,
|
||||
target_parser,
|
||||
workspace_from_target,
|
||||
)
|
||||
from copienator.platform import launch_pdf_arranger
|
||||
|
||||
# --- Constants ---
|
||||
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
|
||||
CM_TO_POINTS = (1 / 2.54) * 72
|
||||
|
||||
def list_pdf_files(directory: str | Path) -> list[Path]:
|
||||
paths = sorted(Path(directory).glob("*.pdf"), key=lambda path: path.name.casefold())
|
||||
return [path for path in paths if "enonce" not in path.name.casefold()]
|
||||
|
||||
|
||||
def _temporary_sibling(path: Path, purpose: str) -> Path:
|
||||
return path.with_name(f".{path.name}.{purpose}.{uuid.uuid4().hex}.tmp")
|
||||
|
||||
|
||||
def commit_processed_pdf(
|
||||
workspace: EvaluationWorkspace,
|
||||
original_path: Path,
|
||||
generated_path: Path,
|
||||
) -> Path:
|
||||
"""Commit a processed copy and its original backup with rollback."""
|
||||
backup_path = workspace.original_copies_dir / original_path.name
|
||||
output_path = workspace.copies_dir / original_path.name
|
||||
workspace.original_copies_dir.mkdir(parents=True, exist_ok=True)
|
||||
workspace.copies_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
staged_backup = None
|
||||
if original_path.resolve() != backup_path.resolve():
|
||||
staged_backup = _temporary_sibling(backup_path, "new-original")
|
||||
shutil.copy2(original_path, staged_backup)
|
||||
saved_backup = _temporary_sibling(backup_path, "old-original")
|
||||
saved_output = _temporary_sibling(output_path, "old-output")
|
||||
backup_replaced = False
|
||||
output_replaced = False
|
||||
try:
|
||||
if staged_backup is not None:
|
||||
if backup_path.exists():
|
||||
backup_path.replace(saved_backup)
|
||||
staged_backup.replace(backup_path)
|
||||
backup_replaced = True
|
||||
if output_path.exists():
|
||||
output_path.replace(saved_output)
|
||||
generated_path.replace(output_path)
|
||||
output_replaced = True
|
||||
if original_path.resolve() not in {
|
||||
backup_path.resolve(),
|
||||
output_path.resolve(),
|
||||
}:
|
||||
original_path.unlink()
|
||||
except Exception:
|
||||
if output_replaced and output_path.exists():
|
||||
output_path.unlink()
|
||||
if saved_output.exists():
|
||||
saved_output.replace(output_path)
|
||||
if backup_replaced and backup_path.exists():
|
||||
backup_path.unlink()
|
||||
if saved_backup.exists():
|
||||
saved_backup.replace(backup_path)
|
||||
raise
|
||||
finally:
|
||||
for temporary in (staged_backup, saved_backup, saved_output):
|
||||
if temporary is not None and temporary.exists():
|
||||
temporary.unlink()
|
||||
return output_path
|
||||
|
||||
class PDFPreviewer:
|
||||
|
||||
def setup_next_file(self):
|
||||
self.num += 1
|
||||
if len(self.inputs) == 0:
|
||||
return False
|
||||
self.pdf_path = self.inputs.pop()
|
||||
self.file_rotation = 0
|
||||
self.base_name = self.pdf_path.stem
|
||||
self._temporary_directory = tempfile.TemporaryDirectory(
|
||||
prefix=f".{self.base_name}.page-splitter.",
|
||||
dir=self.workspace.root,
|
||||
)
|
||||
working_dir = Path(self._temporary_directory.name)
|
||||
self.split_dir = working_dir / "split"
|
||||
self.reorder_dir = working_dir / "reorder"
|
||||
self.final_file = working_dir / f"{self.base_name}.pdf"
|
||||
|
||||
self.current_page_index = 0
|
||||
self.page_settings = []
|
||||
self.processing = False # Flag to prevent multiple finish calls
|
||||
try:
|
||||
self.doc = fitz.open(self.pdf_path)
|
||||
except (OSError, RuntimeError, ValueError) as e:
|
||||
self.failed = True
|
||||
self._temporary_directory.cleanup()
|
||||
messagebox.showerror("Error", f"Failed to open PDF file: {e}")
|
||||
self.master.destroy()
|
||||
return
|
||||
self.master.title(f"PDF Splitter - {self.pdf_path.name}")
|
||||
return True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
master: tk.Tk,
|
||||
workspace: EvaluationWorkspace,
|
||||
inputs: list[Path],
|
||||
) -> None:
|
||||
"""
|
||||
Initializes the application.
|
||||
|
||||
Args:
|
||||
master (tk.Tk): The root Tkinter window.
|
||||
pdf_path (str): The path to the input PDF file.
|
||||
"""
|
||||
self.workspace = workspace
|
||||
self.inputs = inputs
|
||||
self.output_dir = None
|
||||
self.master = master
|
||||
self.num = 0
|
||||
self.global_rotation = 0 # Rotation appliquée à tous les fichiers
|
||||
self.history = []
|
||||
self.failed = False
|
||||
if not self.setup_next_file():
|
||||
print(f"No PDF files found in {workspace.root}")
|
||||
master.destroy()
|
||||
return
|
||||
|
||||
self._resize_job = None # For debouncing resize events
|
||||
|
||||
self._initialize_current_page_settings()
|
||||
|
||||
# --- UI Setup ---
|
||||
# Set a reasonable initial size for the window
|
||||
self.master.geometry("800x1000")
|
||||
|
||||
def fmt(action):
|
||||
k = PAGE_SPLITTER_KB.get(action, "")
|
||||
return k
|
||||
|
||||
# Dynamic instructions text
|
||||
instructions = (
|
||||
f"{fmt('move_left')} / {fmt('move_right')} : Move line 1cm left/right\n"
|
||||
f"'{fmt('rotate_page')}': Rotate page 180°, '{fmt('rotate_all_pages')}' : rotate all pages, '{fmt('rotate_all_files')}' : rotate all files\n"
|
||||
f"{fmt('keep_left')} {fmt('next_page')} {fmt('discard_page')} {fmt('keep_right')} {fmt('keep_as_is')}: keep left, next page, keep none, keep right, keep as is\n"
|
||||
f"{fmt('send_end')}: send page to end, '{fmt('arranger')}': pdf arranger, '{fmt('restart_file')}': restart file, '{fmt('prev_file')}': previous file\n"
|
||||
)
|
||||
|
||||
self.info_label = tk.Label(master, text=instructions, justify=tk.LEFT)
|
||||
self.info_label.pack(pady=5, side=tk.TOP)
|
||||
|
||||
self.page_label = tk.Label(master, text="", font=("Helvetica", 12))
|
||||
self.page_label.pack(pady=5, side=tk.TOP)
|
||||
|
||||
# Canvas for PDF page preview
|
||||
self.canvas = tk.Canvas(master, bg="gray")
|
||||
self.canvas.pack(fill="both", expand=True)
|
||||
|
||||
# --- Bindings ---
|
||||
action_map = {
|
||||
"move_left": self.move_line_left,
|
||||
"move_right": self.move_line_right,
|
||||
"confirm_next": self.confirm_and_next_page,
|
||||
"rotate_page": self.rotate_page,
|
||||
"rotate_all_pages": self.rotate_all_pages,
|
||||
"rotate_all_files": self.rotate_all_files,
|
||||
"keep_left": self.keep_left,
|
||||
"keep_right": self.keep_right,
|
||||
"keep_as_is": self.keep_as_is,
|
||||
"next_page": self.confirm_and_next_page,
|
||||
"discard_page": self.discard_page,
|
||||
"send_end": self.send_page_end,
|
||||
"restart_file": self.restart_current_file,
|
||||
"arranger": self.start_arranger,
|
||||
"prev_file": self.go_to_previous_file,
|
||||
}
|
||||
|
||||
for action, key in PAGE_SPLITTER_KB.items():
|
||||
if action in action_map:
|
||||
self.master.bind(key, action_map[action])
|
||||
|
||||
# self.master.bind("<Left>", self.move_line_left)
|
||||
# self.master.bind("<Right>", self.move_line_right)
|
||||
# self.master.bind("<Return>", self.confirm_and_next_page)
|
||||
# self.master.bind("c", self.rotate_page)
|
||||
# self.master.bind("C", self.rotate_all_pages)
|
||||
# self.master.bind(",", self.rotate_all_files)
|
||||
# self.master.bind("t", self.keep_left)
|
||||
# self.master.bind("n", self.keep_right)
|
||||
# self.master.bind("m", self.keep_as_is)
|
||||
# self.master.bind("s", self.confirm_and_next_page)
|
||||
# self.master.bind("r", self.discard_page)
|
||||
# self.master.bind("z", self.send_page_end)
|
||||
# self.master.bind("R", self.restart_current_file)
|
||||
# self.master.bind("A", self.start_arranger)
|
||||
# self.master.bind("P", self.go_to_previous_file)
|
||||
|
||||
|
||||
# Bind the resize event on the canvas
|
||||
self.canvas.bind("<Configure>", self.on_resize)
|
||||
|
||||
self.current_zoom = 1.0
|
||||
|
||||
def start_arranger(self):
|
||||
try:
|
||||
launch_pdf_arranger(self.pdf_path)
|
||||
except FileNotFoundError as exc:
|
||||
messagebox.showerror("PDF Arranger", str(exc))
|
||||
|
||||
def on_resize(self, event):
|
||||
"""
|
||||
Handles window resize events by reloading the page.
|
||||
Uses a "debounce" mechanism to avoid excessive redrawing.
|
||||
"""
|
||||
if self._resize_job:
|
||||
self.master.after_cancel(self._resize_job)
|
||||
self._resize_job = self.master.after(250, self.load_page) # Redraw after 250ms of no resizing
|
||||
|
||||
def _initialize_current_page_settings(self):
|
||||
"""Initializes or resets the settings for the current page."""
|
||||
if self.current_page_index < len(self.doc):
|
||||
page = self.doc.load_page(self.current_page_index)
|
||||
self.current_line_x = page.rect.width / 2
|
||||
self.current_rotation = 0
|
||||
|
||||
def load_page(self):
|
||||
"""Loads and displays the current page on the canvas, scaled to fit."""
|
||||
if self.current_page_index >= len(self.doc):
|
||||
if not self.processing:
|
||||
self.processing = True
|
||||
self.finish_and_process()
|
||||
return
|
||||
|
||||
page = self.doc.load_page(self.current_page_index)
|
||||
self.page_label.config(text=f"Page {self.current_page_index + 1} of {len(self.doc)}")
|
||||
|
||||
# --- Calculate Scaling ---
|
||||
canvas_width = self.canvas.winfo_width()
|
||||
canvas_height = self.canvas.winfo_height()
|
||||
|
||||
# Don't try to render if the canvas has no size yet.
|
||||
if canvas_width <= 1 or canvas_height <= 1:
|
||||
return
|
||||
|
||||
page_rect = page.rect
|
||||
zoom_x = canvas_width / page_rect.width
|
||||
zoom_y = canvas_height / page_rect.height
|
||||
# Use 98% of the smallest zoom factor to leave a small margin
|
||||
self.current_zoom = min(zoom_x, zoom_y) * 0.98
|
||||
|
||||
# --- Render Page ---
|
||||
mat = fitz.Matrix(self.current_zoom, self.current_zoom)
|
||||
pix = page.get_pixmap(matrix=mat, alpha=False)
|
||||
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
|
||||
|
||||
|
||||
# Apply rotation if needed *after* drawing the line
|
||||
if (self.current_rotation + self.file_rotation + self.global_rotation) % 360 != 0:
|
||||
img = img.rotate(180, expand=True)
|
||||
|
||||
# --- Draw Line and Rotate ---
|
||||
draw = ImageDraw.Draw(img)
|
||||
# The line position is scaled by the same zoom factor
|
||||
line_x_scaled = self.current_line_x * self.current_zoom
|
||||
draw.line([(line_x_scaled, 0), (line_x_scaled, pix.height)], fill="red", width=3)
|
||||
|
||||
|
||||
# --- Display on Canvas ---
|
||||
self.photo_img = ImageTk.PhotoImage(img)
|
||||
self.canvas.delete("all")
|
||||
# Center the image on the canvas
|
||||
self.canvas.create_image(canvas_width / 2, canvas_height / 2, anchor="center",
|
||||
image=self.photo_img)
|
||||
|
||||
def restart_current_file(self, event=None):
|
||||
"""Restarts the processing of the current file."""
|
||||
# Close the modified in-memory document
|
||||
if hasattr(self, 'doc'):
|
||||
self.doc.close()
|
||||
|
||||
# Re-open the file from disk to reset changes (like moved pages)
|
||||
try:
|
||||
self.doc = fitz.open(self.pdf_path)
|
||||
except (OSError, RuntimeError, ValueError) as e:
|
||||
messagebox.showerror("Error", f"Failed to reopen PDF file: {e}")
|
||||
self.master.destroy()
|
||||
return
|
||||
|
||||
# Reset state variables for the current file
|
||||
self.file_rotation = 0
|
||||
self.current_page_index = 0
|
||||
self.page_settings = []
|
||||
self.processing = False
|
||||
|
||||
# Reload UI
|
||||
self._initialize_current_page_settings()
|
||||
self.load_page()
|
||||
|
||||
|
||||
def move_line_left(self, event=None):
|
||||
"""Moves the split line to the left."""
|
||||
self.current_line_x = max(0, self.current_line_x - CM_TO_POINTS / 2)
|
||||
self.load_page()
|
||||
|
||||
def move_line_right(self, event=None):
|
||||
"""Moves the split line to the right."""
|
||||
page = self.doc.load_page(self.current_page_index)
|
||||
self.current_line_x = min(page.rect.width, self.current_line_x + CM_TO_POINTS / 2)
|
||||
self.load_page()
|
||||
|
||||
def rotate_page(self, event=None):
|
||||
"""Toggles the page rotation between 0 and 180 degrees."""
|
||||
self.current_rotation = 180 if self.current_rotation == 0 else 0
|
||||
self.load_page()
|
||||
|
||||
def rotate_all_pages(self, event=None):
|
||||
"""Toggles the page rotation between 0 and 180 degrees."""
|
||||
self.file_rotation = 180 if self.file_rotation == 0 else 0
|
||||
self.load_page()
|
||||
|
||||
def rotate_all_files(self, event=None):
|
||||
"""Toggles the page rotation between 0 and 180 degrees."""
|
||||
self.global_rotation = 180 if self.global_rotation == 0 else 0
|
||||
self.load_page()
|
||||
|
||||
def keep_left(self, event=None):
|
||||
self.confirm_and_next_page(keep="left")
|
||||
def keep_right(self, event=None):
|
||||
self.confirm_and_next_page(keep="right")
|
||||
def discard_page(self, event=None):
|
||||
self.confirm_and_next_page(keep="none")
|
||||
def keep_as_is(self, event=None):
|
||||
self.confirm_and_next_page(keep="as_is")
|
||||
def send_page_end(self, event=None):
|
||||
# Do nothing if we are already at or past the last page
|
||||
if self.current_page_index >= len(self.doc) - 1:
|
||||
return
|
||||
|
||||
# Move the current page to the end of the document
|
||||
# -1 as the destination puts it after the last page
|
||||
self.doc.move_page(self.current_page_index, -1)
|
||||
|
||||
# Initialize settings for the page that shifted into the current slot
|
||||
self._initialize_current_page_settings()
|
||||
|
||||
# Reload the canvas to show the new page
|
||||
self.load_page()
|
||||
|
||||
def confirm_and_next_page(self, event=None, keep="both"):
|
||||
"""Saves the settings for the current page and moves to the next."""
|
||||
self.page_settings.append({
|
||||
"line_x": self.current_line_x,
|
||||
"rotation": self.current_rotation,
|
||||
"keep": keep
|
||||
})
|
||||
|
||||
self.current_page_index += 1
|
||||
|
||||
if self.current_page_index < len(self.doc):
|
||||
self._initialize_current_page_settings()
|
||||
self.load_page()
|
||||
else:
|
||||
if not self.finish_and_process():
|
||||
return
|
||||
self.history.append(self.pdf_path)
|
||||
if self.setup_next_file():
|
||||
self._initialize_current_page_settings()
|
||||
self.load_page()
|
||||
else:
|
||||
self.master.destroy()
|
||||
|
||||
def finish_and_process(self) -> bool:
|
||||
"""Render and transactionally install the processed PDF."""
|
||||
try:
|
||||
self.split_pdf()
|
||||
self.reorder_pdfs()
|
||||
self.concate_files()
|
||||
commit_processed_pdf(self.workspace, self.pdf_path, self.final_file)
|
||||
except Exception as exc: # noqa: BLE001 - interactive boundary
|
||||
self.failed = True
|
||||
self.processing = False
|
||||
print(f"Failed to process {self.pdf_path}: {exc}")
|
||||
messagebox.showerror("Error", f"Failed to process PDF: {exc}")
|
||||
self._temporary_directory.cleanup()
|
||||
self.master.destroy()
|
||||
return False
|
||||
else:
|
||||
self._temporary_directory.cleanup()
|
||||
return True
|
||||
|
||||
def go_to_previous_file(self, event=None):
|
||||
"""Goes back to the beginning of the previously completed file."""
|
||||
if not self.history:
|
||||
return # Nowhere to go back to
|
||||
|
||||
# Close the currently open document to avoid lock issues
|
||||
if hasattr(self, 'doc'):
|
||||
self.doc.close()
|
||||
if hasattr(self, "_temporary_directory"):
|
||||
self._temporary_directory.cleanup()
|
||||
|
||||
# 1. Push current file back onto the stack so it processes next
|
||||
self.inputs.append(self.pdf_path)
|
||||
|
||||
# 2. Reprocess the previous file from its preserved original backup
|
||||
prev_file = self.history.pop()
|
||||
backup = self.workspace.original_copies_dir / Path(prev_file).name
|
||||
self.inputs.append(backup if backup.is_file() else Path(prev_file))
|
||||
|
||||
# 3. Reload environment (setup_next_file will pop prev_file back off the stack)
|
||||
self.setup_next_file()
|
||||
self._initialize_current_page_settings()
|
||||
self.load_page()
|
||||
|
||||
def split_filename_left(self, i):
|
||||
return os.path.join(self.split_dir, f"{self.base_name}_{i+1}l.pdf")
|
||||
def split_filename_right(self, i):
|
||||
return os.path.join(self.split_dir, f"{self.base_name}_{i+1}r.pdf")
|
||||
def reorder_filename(self, i):
|
||||
return os.path.join(self.reorder_dir, f"{self.base_name}_{i+1}.pdf")
|
||||
|
||||
def clean_up_dir(self, dir, make=True):
|
||||
if make:
|
||||
os.makedirs(dir, exist_ok=True)
|
||||
pdf_files = glob.glob(os.path.join(dir, "*.pdf"))
|
||||
for pdf in pdf_files:
|
||||
try:
|
||||
os.remove(pdf)
|
||||
except OSError as e:
|
||||
print(f"Error deleting {pdf}: {e}")
|
||||
|
||||
def split_pdf(self):
|
||||
"""Splits each page of the PDF according to the saved settings."""
|
||||
print("Starting PDF processing...")
|
||||
self.clean_up_dir(self.split_dir)
|
||||
|
||||
for i, settings in enumerate(self.page_settings):
|
||||
page = self.doc.load_page(i)
|
||||
line_x = settings['line_x']
|
||||
rotation_settings = settings['rotation']
|
||||
keep = settings['keep']
|
||||
rotation = (page.rotation + rotation_settings +
|
||||
self.file_rotation + self.global_rotation) % 360
|
||||
|
||||
if keep == "as_is":
|
||||
doc_full = fitz.open()
|
||||
page_full = doc_full.new_page(width=page.rect.width, height=page.rect.height)
|
||||
page_full.show_pdf_page(page_full.rect, self.doc, i)
|
||||
page_full.set_rotation(rotation)
|
||||
|
||||
output_path_full = self.split_filename_left(i)
|
||||
doc_full.save(output_path_full)
|
||||
doc_full.close()
|
||||
continue # Skip left/right generation
|
||||
|
||||
# --- Create Left Part ---
|
||||
if rotation == 0:
|
||||
rect_left = fitz.Rect(0, 0, line_x, page.rect.height)
|
||||
else:
|
||||
rect_left = fitz.Rect(page.rect.width-line_x, 0, page.rect.width, page.rect.height)
|
||||
|
||||
if (keep == "both" or keep == "left") and line_x > 0:
|
||||
|
||||
doc_left = fitz.open()
|
||||
page_left = doc_left.new_page(width=rect_left.width, height=rect_left.height)
|
||||
page_left.show_pdf_page(page_left.rect, self.doc, i, clip=rect_left)
|
||||
page_left.set_rotation(rotation)
|
||||
|
||||
output_path_left = self.split_filename_left(i)
|
||||
doc_left.save(output_path_left)
|
||||
doc_left.close()
|
||||
|
||||
# --- Create Right Part ---
|
||||
if rotation == 0:
|
||||
rect_right = fitz.Rect(line_x, 0, page.rect.width, page.rect.height)
|
||||
else:
|
||||
rect_right = fitz.Rect(0, 0, page.rect.width-line_x, page.rect.height)
|
||||
if (keep == "both" or keep == "right") and line_x < page.rect.width:
|
||||
doc_right = fitz.open()
|
||||
page_right = doc_right.new_page(width=rect_right.width, height=rect_right.height)
|
||||
page_right.show_pdf_page(page_right.rect, self.doc, i, clip=rect_right)
|
||||
page_right.set_rotation(rotation)
|
||||
output_path_right = self.split_filename_right(i)
|
||||
doc_right.save(output_path_right)
|
||||
doc_right.close()
|
||||
|
||||
self.doc.close()
|
||||
print(f"\nProcessing complete. Files are in '{self.split_dir}' directory.")
|
||||
|
||||
def reorder_pdfs(self):
|
||||
"""Reordonne les pages, si ce sont des copies doubles."""
|
||||
self.clean_up_dir(self.reorder_dir)
|
||||
ps = self.page_settings
|
||||
ri = 0
|
||||
i = 0
|
||||
while i < len(ps):
|
||||
psk = ps[i]['keep']
|
||||
|
||||
# Si c'est une copie double (on s'assure qu'on a bien 2 pages consécutives modifiables)
|
||||
if psk in ["both", "right", "left", "none"] and i < len(ps)-1 and ps[i+1]['keep'] in ["both", "right", "left", "none"]:
|
||||
|
||||
# 1. Page de garde (Extérieur Droit)
|
||||
if ps[i]['keep'] in ["both", "right"]:
|
||||
shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
|
||||
ri += 1
|
||||
|
||||
# 2. Intérieur Gauche
|
||||
if ps[i+1]['keep'] in ["both", "left"]:
|
||||
shutil.copy2(self.split_filename_left(i+1), self.reorder_filename(ri))
|
||||
ri += 1
|
||||
|
||||
# 3. Intérieur Droit
|
||||
if ps[i+1]['keep'] in ["both", "right"]:
|
||||
shutil.copy2(self.split_filename_right(i+1), self.reorder_filename(ri))
|
||||
ri += 1
|
||||
|
||||
# 4. Dos de la copie (Extérieur Gauche)
|
||||
if ps[i]['keep'] in ["both", "left"]:
|
||||
shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
|
||||
ri += 1
|
||||
|
||||
i += 2
|
||||
else:
|
||||
# Si c'est une page simple (ou as_is)
|
||||
if psk in ["left", "both", "as_is"]:
|
||||
shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
|
||||
ri += 1
|
||||
if psk in ["right", "both"]:
|
||||
shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
|
||||
ri += 1
|
||||
i += 1
|
||||
# def reorder_pdfs(self):
|
||||
# """Reordonne les pages, si ce sont des copies doubles."""
|
||||
# self.clean_up_dir(self.reorder_dir)
|
||||
# ps = self.page_settings
|
||||
# ri = 0
|
||||
# i = 0
|
||||
# while i < len(ps):
|
||||
# # Si c'est une copie double
|
||||
# if (ps[i]['keep'] == "both" or ps[i]['keep'] == "right") \
|
||||
# and i < len(ps)-1 and (ps[i+1]['keep'] != "right"):
|
||||
# shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
|
||||
# ri += 1
|
||||
# if ps[i+1]['keep'] != "none":
|
||||
# shutil.copy2(self.split_filename_left(i+1), self.reorder_filename(ri))
|
||||
# ri += 1
|
||||
# if ps[i+1]['keep'] != "left":
|
||||
# shutil.copy2(self.split_filename_right(i+1), self.reorder_filename(ri))
|
||||
# ri += 1
|
||||
# if ps[i]['keep'] == "both":
|
||||
# shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
|
||||
# ri += 1
|
||||
# i += 2
|
||||
# else:
|
||||
# psk = ps[i]['keep']
|
||||
# if psk == "left" or psk == "both" or psk == "as_is":
|
||||
# shutil.copy2(self.split_filename_left(i), self.reorder_filename(ri))
|
||||
# ri += 1
|
||||
# if psk == "right" or psk == "both":
|
||||
# shutil.copy2(self.split_filename_right(i), self.reorder_filename(ri))
|
||||
# ri += 1
|
||||
# i += 1
|
||||
|
||||
def concate_files(self):
|
||||
writer = PdfWriter()
|
||||
|
||||
def natural_key(text):
|
||||
return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', text)]
|
||||
|
||||
pdf_files = sorted(
|
||||
glob.glob(os.path.join(self.reorder_dir, "*.pdf")),
|
||||
key=natural_key
|
||||
)
|
||||
|
||||
for pdf in pdf_files:
|
||||
reader = PdfReader(pdf)
|
||||
for page in reader.pages:
|
||||
writer.add_page(page)
|
||||
|
||||
if self.output_dir != None:
|
||||
os.makedirs(os.path.dirname(self.final_file), exist_ok=True)
|
||||
with open(self.final_file, "wb") as f:
|
||||
writer.write(f)
|
||||
print(f"Created merged PDF: {self.final_file}")
|
||||
|
||||
|
||||
def _selected_inputs(
|
||||
workspace: EvaluationWorkspace,
|
||||
target: Path,
|
||||
) -> list[Path]:
|
||||
if target.is_file():
|
||||
if target.suffix.casefold() != ".pdf":
|
||||
raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS)
|
||||
backup = workspace.original_copies_dir / target.name
|
||||
return [backup if backup.is_file() else target]
|
||||
|
||||
directory = target
|
||||
if target == workspace.copies_dir:
|
||||
candidates = list_pdf_files(workspace.copies_dir)
|
||||
candidates = [
|
||||
(
|
||||
workspace.original_copies_dir / path.name
|
||||
if (workspace.original_copies_dir / path.name).is_file()
|
||||
else path
|
||||
)
|
||||
for path in candidates
|
||||
]
|
||||
else:
|
||||
candidates = list_pdf_files(directory)
|
||||
return list(reversed(candidates))
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
|
||||
inputs = _selected_inputs(workspace, target)
|
||||
if not inputs:
|
||||
print(f"No PDF files found in {target}")
|
||||
return ExitCode.SUCCESS
|
||||
root = tk.Tk()
|
||||
application = PDFPreviewer(root, workspace, inputs)
|
||||
root.mainloop()
|
||||
return ExitCode.FAILURE if application.failed else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
return target_parser("Interactively split and reorder scanned PDF pages")
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
workspace, target = workspace_from_target(args)
|
||||
return run(workspace, target)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,475 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import queue
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageTk
|
||||
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
execute,
|
||||
read_json,
|
||||
target_parser,
|
||||
workspace_from_target,
|
||||
)
|
||||
from copienator.platform import open_path
|
||||
from copienator.utils import natural_key, read_all_labels
|
||||
|
||||
# --- Configuration & Globals ---
|
||||
padding = 60
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype("DejaVuSans.ttf", size=30)
|
||||
except OSError:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# --- Helper Functions (Shared) ---
|
||||
|
||||
def page_number(b, nb_pages):
|
||||
column_width = 1000 // nb_pages
|
||||
center_x = (b[1] + b[3]) // 2
|
||||
return center_x // column_width
|
||||
|
||||
def convert_box2d(b, pn_ori, npn, tot_ori, tot_dest):
|
||||
l = b.copy()
|
||||
l[1] = (l[1] - (1000 // tot_ori) * (pn_ori-1)) * tot_ori // tot_dest\
|
||||
+ (1000 // tot_dest) * (npn - 1)
|
||||
l[3] = (l[3] - (1000 // tot_ori) * (pn_ori-1)) * tot_ori // tot_dest\
|
||||
+ (1000 // tot_dest) * (npn - 1)
|
||||
return l
|
||||
|
||||
def convert_list(l, group_id, json_schema):
|
||||
ll = []
|
||||
nb_pages = json_schema["columns_per_file"][group_id-1]
|
||||
nb_previous_pages = sum([json_schema["columns_per_file"][i] for i in range(group_id-1)])
|
||||
nb_tot_pages = sum([e for e in json_schema["columns_per_file"]])
|
||||
for e in l:
|
||||
ee = e.copy()
|
||||
pn = page_number(e["box_2d"], nb_pages)
|
||||
npn = pn + nb_previous_pages
|
||||
ee["box_2d"] = convert_box2d(ee["box_2d"], pn, npn, nb_pages, nb_tot_pages)
|
||||
ee["part"] = group_id
|
||||
ee["pn"] = npn
|
||||
ll.append(ee)
|
||||
return ll
|
||||
|
||||
|
||||
def normalized_labels(entries):
|
||||
return [
|
||||
str(value["label"]).removeprefix("|").removesuffix("|")
|
||||
for value in entries
|
||||
if str(value["label"]) != "_"
|
||||
]
|
||||
|
||||
def prepare_image(image_path: str, bounding_boxes, all_labels, nb_pages, last_label_index):
|
||||
im = Image.open(image_path)
|
||||
im.load()
|
||||
width, height = im.size
|
||||
new_im = Image.new(im.mode, (width + padding, height), "white")
|
||||
new_im.paste(im, (0, 0))
|
||||
draw = ImageDraw.Draw(new_im)
|
||||
bounding_boxes.sort(key=lambda b: (page_number(b["box_2d"], nb_pages), b["box_2d"][0]))
|
||||
|
||||
for bbox in bounding_boxes:
|
||||
raw_y_min = int(bbox["box_2d"][0] * height / 1000)
|
||||
raw_x_min = int(bbox["box_2d"][1] * width / 1000)
|
||||
raw_y_max = int(bbox["box_2d"][2] * height / 1000)
|
||||
raw_x_max = int(bbox["box_2d"][3] * width / 1000)
|
||||
abs_y_min = max(0, raw_y_min - 10)
|
||||
abs_x_min = max(0, raw_x_min - 10)
|
||||
abs_y_max = min(height, raw_y_max + 10)
|
||||
abs_x_max = min(width, raw_x_max + 10)
|
||||
|
||||
color = "black"
|
||||
label = bbox.get("label")
|
||||
if label and label in all_labels:
|
||||
current_index = all_labels.index(label)
|
||||
if current_index < last_label_index or (last_label_index == -1 and current_index != 0):
|
||||
color = "red"
|
||||
elif current_index > last_label_index + 1:
|
||||
color = "orange"
|
||||
last_label_index = current_index
|
||||
|
||||
draw.rectangle(((abs_x_min, abs_y_min), (abs_x_max, abs_y_max)), outline=color, width=4)
|
||||
if label:
|
||||
if abs_y_min > 80:
|
||||
draw.text((abs_x_min + 8, abs_y_min - 30), label, fill=color, font=font)
|
||||
else:
|
||||
draw.text((abs_x_min + 8, abs_y_max + 6), label, fill=color, font=font)
|
||||
return (new_im, last_label_index)
|
||||
|
||||
# --- Processing Logic (Worker Thread) ---
|
||||
|
||||
def _worker_items(base_dir, files_to_process, all_labels, output_queue):
|
||||
"""
|
||||
Iterates through files, prepares VISUALS only, and puts metadata in queue.
|
||||
Does NOT write final JSON files anymore.
|
||||
"""
|
||||
previous_copie = None
|
||||
last_label_index = None
|
||||
for img_path in files_to_process:
|
||||
json_path = base_dir / "Copies" / f"{img_path.stem}.json"
|
||||
copie_part = int(img_path.stem[-2:])
|
||||
copie = img_path.stem[:-3]
|
||||
if copie != previous_copie:
|
||||
last_label_index = -1
|
||||
previous_copie = copie
|
||||
json_schema_path = base_dir / 'Cutleft' / f"{copie}_schema.json"
|
||||
|
||||
try:
|
||||
json_schema = read_json(json_schema_path)
|
||||
except (OSError, TypeError, ValueError):
|
||||
print("No json_schema : ", json_schema_path)
|
||||
continue
|
||||
|
||||
nb_pages = json_schema["columns_per_file"][copie_part-1]
|
||||
|
||||
if json_path.exists():
|
||||
# Read strictly for visualization purposes
|
||||
bb_list = []
|
||||
json_name = ""
|
||||
try:
|
||||
json_result = read_json(json_path)
|
||||
bb_list = json_result.get("list", [])
|
||||
json_name = json_result.get("name", "")
|
||||
except Exception as e: # noqa: BLE001 - malformed user-editable JSON
|
||||
print(f"Warning: {json_path.name} is malformed! Loading blank. {e}")
|
||||
# We do NOT skip; we continue so the user can fix it in the GUI
|
||||
|
||||
try:
|
||||
print(f"Buffering {img_path.name}...")
|
||||
(pil_image, last_label_index) = \
|
||||
prepare_image(str(img_path), bb_list, all_labels, nb_pages, last_label_index)
|
||||
error_msg = None
|
||||
|
||||
except Exception as e: # noqa: BLE001 - keep the item editable in the GUI
|
||||
print(f"Error processing {img_path.name}: {e}")
|
||||
pil_image = Image.open(str(img_path))
|
||||
error_msg = str(e)
|
||||
|
||||
metadata = {
|
||||
"copie": copie,
|
||||
"part": copie_part,
|
||||
"schema": json_schema,
|
||||
"name": json_name,
|
||||
"error": error_msg
|
||||
}
|
||||
|
||||
output_queue.put((pil_image, json_path, metadata))
|
||||
|
||||
def worker_thread(base_dir, files_to_process, all_labels, output_queue):
|
||||
"""Prepare queue items and always terminate the GUI stream."""
|
||||
failure = None
|
||||
try:
|
||||
_worker_items(base_dir, files_to_process, all_labels, output_queue)
|
||||
except Exception as exc: # noqa: BLE001 - worker boundary
|
||||
failure = str(exc)
|
||||
print(f"Plotting worker failed: {exc}")
|
||||
finally:
|
||||
metadata = {"worker_error": failure} if failure else None
|
||||
output_queue.put((None, None, metadata))
|
||||
|
||||
# --- GUI Logic (Main Thread) ---
|
||||
|
||||
class ImageViewer:
|
||||
def __init__(self, root, workspace, valid_labels, input_queue):
|
||||
self.root = root
|
||||
self.root.resizable(False, False) # If you resize, coordinates will be wrong
|
||||
|
||||
screen_w = root.winfo_screenwidth()
|
||||
screen_h = root.winfo_screenheight()
|
||||
|
||||
x = int(screen_w * 0.1)
|
||||
y = int(screen_h * 0.05)
|
||||
|
||||
root.geometry(f"+{x}+{y}")
|
||||
|
||||
self.workspace = workspace
|
||||
self.base_dir = workspace.root
|
||||
self.valid_labels = valid_labels
|
||||
self.image_queue = input_queue
|
||||
self.root.title("Bounding Box Viewer")
|
||||
self.label = tk.Label(root, text="Waiting for images...")
|
||||
self.label.pack(expand=True, fill="both")
|
||||
|
||||
# Display State
|
||||
self.current_image = None
|
||||
self.current_json_path = None
|
||||
self.current_meta = None # Stores schema/copie info
|
||||
self.is_viewing = False
|
||||
self.scale_factor = 1.0
|
||||
self.orig_size = (1, 1)
|
||||
|
||||
# Data Aggregation State
|
||||
self.active_copie_name = None
|
||||
self.accumulated_results = None # Dict with "name" and "list"
|
||||
|
||||
# To go back
|
||||
self.history = []
|
||||
self.forward_stack = []
|
||||
self.current_pil_image = None
|
||||
self.failed = False
|
||||
|
||||
from config import PLOTTING_KB
|
||||
|
||||
# Bindings
|
||||
self.root.bind(PLOTTING_KB["OK"], self.on_enter)
|
||||
self.root.bind(PLOTTING_KB["previous"], self.on_previous)
|
||||
self.root.bind(PLOTTING_KB["edit"], self.on_edit)
|
||||
self.root.bind(PLOTTING_KB["open pdf"], self.on_open_pdf)
|
||||
self.root.bind(PLOTTING_KB["open original pdf"], self.on_open_ori_pdf)
|
||||
self.root.bind(PLOTTING_KB["open eval"], self.on_open_interro)
|
||||
self.root.bind('<Escape>', lambda _event: self.close())
|
||||
self.root.protocol("WM_DELETE_WINDOW", self.close)
|
||||
self.label.bind('<Button-1>', self.on_click)
|
||||
|
||||
self.poll_queue()
|
||||
|
||||
def poll_queue(self):
|
||||
if not self.is_viewing:
|
||||
try:
|
||||
# pil_image, json_path, metadata = image_queue.get_nowait()
|
||||
if self.forward_stack:
|
||||
pil_image, json_path, metadata = self.forward_stack.pop()
|
||||
else:
|
||||
pil_image, json_path, metadata = self.image_queue.get_nowait()
|
||||
|
||||
# Handle End of Stream
|
||||
if pil_image is None:
|
||||
if metadata and metadata.get("worker_error"):
|
||||
self.failed = True
|
||||
messagebox.showerror(
|
||||
"Processing Error", metadata["worker_error"]
|
||||
)
|
||||
self.save_current_batch() # Save any remaining data
|
||||
print("All images processed.")
|
||||
self.root.quit()
|
||||
return
|
||||
|
||||
# Check if we switched to a new "Copie" group
|
||||
if self.active_copie_name != metadata["copie"]:
|
||||
self.save_current_batch() # Write previous group to disk
|
||||
# Start new batch
|
||||
self.active_copie_name = metadata["copie"]
|
||||
self.accumulated_results = {"name": metadata["name"], "list": []}
|
||||
self.history.clear()
|
||||
|
||||
self.display_image(pil_image, json_path, metadata)
|
||||
except queue.Empty:
|
||||
pass
|
||||
self.root.after(100, self.poll_queue)
|
||||
|
||||
def save_current_batch(self):
|
||||
"""Writes the accumulated data to the main JSON file."""
|
||||
if self.active_copie_name and self.accumulated_results:
|
||||
main_json_path = self.base_dir / "Copies" / f"{self.active_copie_name}.json"
|
||||
print(f"Writing aggregated result to {main_json_path}")
|
||||
atomic_write_json(main_json_path, self.accumulated_results)
|
||||
self.accumulated_results = None
|
||||
|
||||
def close(self):
|
||||
self.root.quit()
|
||||
|
||||
|
||||
def on_previous(self, event):
|
||||
if self.is_viewing and self.history:
|
||||
print("Going back to previous image...")
|
||||
prev_pil, prev_json, prev_meta, num_added = self.history.pop()
|
||||
|
||||
# Undo the accumulation to prevent duplicates when we hit Enter again
|
||||
if self.accumulated_results and num_added > 0:
|
||||
self.accumulated_results["list"] = self.accumulated_results["list"][:-num_added]
|
||||
|
||||
# Push current image to the forward stack so we don't lose it
|
||||
self.forward_stack.append((self.current_pil_image,
|
||||
self.current_json_path, self.current_meta))
|
||||
|
||||
# Display the previous image immediately
|
||||
self.display_image(prev_pil, prev_json, prev_meta)
|
||||
def display_image(self, pil_image, json_path, metadata):
|
||||
self.current_pil_image = pil_image # ADD THIS LINE
|
||||
self.orig_size = pil_image.size
|
||||
self.scale_factor = 1.0
|
||||
screen_h = self.root.winfo_screenheight() - 100
|
||||
if pil_image.height > screen_h:
|
||||
self.scale_factor = screen_h / pil_image.height
|
||||
pil_image = pil_image.resize((int(pil_image.width * self.scale_factor),
|
||||
int(pil_image.height * self.scale_factor)))
|
||||
|
||||
self.tk_image = ImageTk.PhotoImage(pil_image)
|
||||
self.label.config(image=self.tk_image, text=f"Processing: {json_path.name}")
|
||||
self.current_json_path = json_path
|
||||
self.current_meta = metadata
|
||||
self.is_viewing = True
|
||||
self.root.lift()
|
||||
|
||||
if metadata.get("error"):
|
||||
msg = f"Error generating boxes for {json_path.name}:\n\n{metadata['error']}\n\nPlease press 'e' to fix the JSON file, then press Enter to retry."
|
||||
messagebox.showerror("Processing Error", msg)
|
||||
|
||||
def on_enter(self, event):
|
||||
if self.is_viewing:
|
||||
print(f"Committing data for {self.current_json_path.name}...")
|
||||
num_added = 0 # ADD THIS LINE
|
||||
|
||||
try:
|
||||
current_data = read_json(self.current_json_path)
|
||||
|
||||
# Perform the conversion now, post-edit
|
||||
converted_items = convert_list(
|
||||
current_data["list"],
|
||||
self.current_meta["part"],
|
||||
self.current_meta["schema"]
|
||||
)
|
||||
|
||||
labels = normalized_labels(current_data["list"])
|
||||
false_labels = [
|
||||
label for label in labels if label not in self.valid_labels
|
||||
]
|
||||
|
||||
if false_labels:
|
||||
msg = f"Wrong label in {self.current_json_path.name}: {false_labels}\n\n\tPlease press 'e' to fix it, then press Enter again."
|
||||
print(msg)
|
||||
messagebox.showerror("Label Error", msg)
|
||||
return
|
||||
num_added = len(converted_items)
|
||||
|
||||
# Add to accumulator
|
||||
if self.accumulated_results:
|
||||
self.accumulated_results["list"].extend(converted_items)
|
||||
# Update name just in case (though usually consistent per group)
|
||||
if "name" in current_data and current_data["name"] != "Continued":
|
||||
self.accumulated_results["name"] = current_data["name"]
|
||||
|
||||
except Exception as e: # noqa: BLE001 - interactive validation boundary
|
||||
# Warn user and STOP (do not advance to next image)
|
||||
msg = f"Error reading {self.current_json_path.name}:\n\n{e}\n\nPlease press 'e' to fix it, then press Enter again."
|
||||
print(msg)
|
||||
messagebox.showerror("JSON Error", msg)
|
||||
return # Abort advancement
|
||||
|
||||
self.history.append((self.current_pil_image, self.current_json_path,
|
||||
self.current_meta, num_added))
|
||||
|
||||
# Advance UI
|
||||
self.is_viewing = False
|
||||
self.label.config(image="", text="Loading next...")
|
||||
|
||||
def on_open_pdf(self, event):
|
||||
if self.is_viewing and self.current_json_path:
|
||||
a = self.current_json_path.stem.split('_')[0] + ".pdf"
|
||||
pdf_path = self.current_json_path.with_name(a)
|
||||
print(f"Opening {pdf_path}")
|
||||
open_path(pdf_path)
|
||||
|
||||
def on_open_interro(self, event):
|
||||
if self.is_viewing and self.current_json_path:
|
||||
# Check local directory first
|
||||
local_accent = self.base_dir / "énoncé.pdf"
|
||||
local_plain = self.base_dir / "enonce.pdf"
|
||||
|
||||
if local_accent.exists():
|
||||
pdf_path = str(local_accent)
|
||||
elif local_plain.exists():
|
||||
pdf_path = local_plain
|
||||
else:
|
||||
messagebox.showerror(
|
||||
"PDF not found",
|
||||
f"Neither {local_accent.name} nor {local_plain.name} exists in {self.base_dir}.",
|
||||
)
|
||||
return
|
||||
|
||||
print(f"Opening {pdf_path}")
|
||||
open_path(pdf_path)
|
||||
|
||||
def on_open_ori_pdf(self, event):
|
||||
if self.is_viewing and self.current_json_path:
|
||||
new_filename = self.current_json_path.stem.split('_')[0] + ".pdf"
|
||||
pdf_path = self.base_dir / "Copies Originales" / new_filename
|
||||
print(f"Opening {pdf_path}")
|
||||
open_path(pdf_path)
|
||||
|
||||
def on_edit(self, event):
|
||||
if self.is_viewing and self.current_json_path:
|
||||
print(f"Opening {self.current_json_path}")
|
||||
open_path(self.current_json_path)
|
||||
|
||||
def on_click(self, event):
|
||||
if not self.is_viewing: return
|
||||
x = int(event.x / self.scale_factor)
|
||||
y = int(event.y / self.scale_factor)
|
||||
w, h = self.orig_size
|
||||
box = [
|
||||
int(max(0, y - 5) / h * 1000),
|
||||
int(max(0, x - 5) / (w- padding) * 1000),
|
||||
int(min(h, y + 5) / h * 1000),
|
||||
int(min(w, x + 5) / (w - padding) * 1000),
|
||||
]
|
||||
box_str = "{ \"box_2d\": " + str(box) + ", \"label\": \"\" },"
|
||||
print(f"Copied box at ({x},{y}): {box_str}")
|
||||
self.root.clipboard_clear()
|
||||
self.root.clipboard_append(box_str)
|
||||
|
||||
def _selected_images(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
|
||||
workspace.require_directories("Cutleft", "Copies")
|
||||
if target.is_file():
|
||||
stem = target.stem
|
||||
exact = workspace.cutleft_dir / f"{stem}.jpg"
|
||||
if exact.is_file():
|
||||
return [exact]
|
||||
return sorted(
|
||||
workspace.cutleft_dir.glob(f"{stem}_*.jpg"),
|
||||
key=natural_key,
|
||||
)
|
||||
return sorted(workspace.cutleft_dir.glob("*.jpg"), key=natural_key)
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
|
||||
workspace.require_files("labels")
|
||||
all_labels = read_all_labels(workspace.root)
|
||||
files_to_process = _selected_images(workspace, target)
|
||||
if not files_to_process:
|
||||
print(f"No Cutleft images found for {target}")
|
||||
return ExitCode.PARTIAL
|
||||
|
||||
print(
|
||||
"o to open pdf, O original pdf, e to edit part, p to go back, "
|
||||
"i to open the statement, click for coordinates"
|
||||
)
|
||||
input_queue = queue.Queue(maxsize=5)
|
||||
worker = threading.Thread(
|
||||
target=worker_thread,
|
||||
args=(workspace.root, files_to_process, all_labels, input_queue),
|
||||
daemon=True,
|
||||
)
|
||||
worker.start()
|
||||
root = tk.Tk()
|
||||
application = ImageViewer(root, workspace, set(all_labels), input_queue)
|
||||
root.mainloop()
|
||||
return ExitCode.PARTIAL if application.failed else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
return target_parser("Interactively verify detected label coordinates")
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
workspace, target = workspace_from_target(args)
|
||||
return run(workspace, target)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
read_json,
|
||||
workspace_from_args,
|
||||
)
|
||||
|
||||
WORD_LIST_FILE = Path(__file__).resolve().parents[1] / "data" / "liste_francais.txt"
|
||||
ACCENT_PATTERN = re.compile(r"[éèêëàâäîïôöùûüçœÉÈÊËÀÂÄÎÏÔÖÙÛÜÇŒ]")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
return evaluation_parser("Clean encoding and LaTeX issues in correction.json.")
|
||||
|
||||
|
||||
def escape_latex_underscores(text: str) -> str:
|
||||
r"""Escape underscores outside LaTeX math environments."""
|
||||
math_pattern = re.compile(
|
||||
r"(\$\$.*?\$\$|\$.*?\$|\\\(.*?\\\)|\\\[.*?\\\])",
|
||||
re.DOTALL,
|
||||
)
|
||||
parts: list[str] = []
|
||||
last_end = 0
|
||||
for match in math_pattern.finditer(text):
|
||||
start, end = match.span()
|
||||
parts.append(text[last_end:start].replace("_", r"\_"))
|
||||
parts.append(match.group(0))
|
||||
last_end = end
|
||||
parts.append(text[last_end:].replace("_", r"\_"))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def build_lookup_map(word_list_path: Path = WORD_LIST_FILE) -> dict[str, str]:
|
||||
words = word_list_path.read_text(encoding="utf-8").splitlines()
|
||||
lookup: dict[str, str] = {}
|
||||
for word in words:
|
||||
broken_key = ACCENT_PATTERN.sub("\x00", word)
|
||||
if "\x00" in broken_key:
|
||||
lookup[broken_key.lower()] = word
|
||||
return lookup
|
||||
|
||||
|
||||
def fast_fix(text: str, lookup: dict[str, str]) -> str:
|
||||
def replacer(match: re.Match[str]) -> str:
|
||||
broken_word = match.group(0)
|
||||
return lookup.get(broken_word.lower(), broken_word)
|
||||
|
||||
return re.sub(r"[a-zA-Z\x00]+", replacer, text)
|
||||
|
||||
|
||||
def fix_hex_corruption_safe(text: str) -> str:
|
||||
return re.sub(
|
||||
r"\x00([eEfF][0-9a-fA-F])",
|
||||
lambda match: chr(int(match.group(1), 16)),
|
||||
text,
|
||||
)
|
||||
|
||||
|
||||
def some_other_replacements(text: str) -> str:
|
||||
return text.replace("\neq", "\\neq").replace("\not", "\\not")
|
||||
|
||||
|
||||
def clean_string(text: str, lookup: dict[str, str]) -> str:
|
||||
text = fix_hex_corruption_safe(text)
|
||||
text = text.replace("\x19", "\x00")
|
||||
text = text.replace("\x18", "\x00")
|
||||
text = text.replace("\x00\x00", "\x00")
|
||||
text = re.sub(r" \x00{1,2} ", " à ", text)
|
||||
if "\x00" in text:
|
||||
text = fast_fix(text, lookup).replace("\x00", "")
|
||||
return escape_latex_underscores(some_other_replacements(text))
|
||||
|
||||
|
||||
def clean_obj(value: Any, lookup: dict[str, str]) -> Any:
|
||||
if isinstance(value, str):
|
||||
return clean_string(value, lookup)
|
||||
if isinstance(value, list):
|
||||
return [clean_obj(item, lookup) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: item if key == "suffix" else clean_obj(item, lookup)
|
||||
for key, item in value.items()
|
||||
}
|
||||
return value
|
||||
|
||||
|
||||
def run(
|
||||
workspace: EvaluationWorkspace,
|
||||
*,
|
||||
word_list_path: Path = WORD_LIST_FILE,
|
||||
) -> ExitCode:
|
||||
workspace.require_files("correction.json")
|
||||
lookup = build_lookup_map(word_list_path)
|
||||
data = read_json(workspace.correction_file)
|
||||
cleaned = clean_obj(data, lookup)
|
||||
atomic_write_json(workspace.correction_file, cleaned)
|
||||
print(f"Fixed JSON saved to {workspace.correction_file}")
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,287 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from pdf2image import convert_from_path
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFilter
|
||||
|
||||
from copienator.commands import annotating
|
||||
from copienator import utils
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
read_json,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
|
||||
from copienator.annotation_data import AnnotationData, load_annotation_data
|
||||
from copienator.filesystem import staged_files
|
||||
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
def detect_checks_and_notes(
|
||||
output_dir: str | Path,
|
||||
) -> tuple[list[dict[str, Any]], Image.Image | None]:
|
||||
"""Detect checked boxes and extract handwritten notes from an annotated PDF."""
|
||||
directory = Path(output_dir)
|
||||
pdf_path = directory / "Concat_annotated.pdf"
|
||||
reference_path = directory / "Reference.jpg"
|
||||
boxes_path = directory / "checkboxes.json"
|
||||
missing = [
|
||||
path.name
|
||||
for path in (pdf_path, reference_path, boxes_path)
|
||||
if not path.is_file()
|
||||
]
|
||||
if missing:
|
||||
print(f"\tMissing annotation input in {directory}: {', '.join(missing)}")
|
||||
return [], None
|
||||
|
||||
boxes = read_json(boxes_path)
|
||||
if not isinstance(boxes, list):
|
||||
raise TypeError(f"Expected a JSON array in {boxes_path}")
|
||||
with Image.open(reference_path) as opened_reference:
|
||||
reference = opened_reference.convert("RGB").copy()
|
||||
|
||||
try:
|
||||
pages = convert_from_path(pdf_path, dpi=72)
|
||||
except Exception as exc: # noqa: BLE001 - PDF backends expose many errors
|
||||
print(f"Error reading PDF {pdf_path}: {exc}")
|
||||
return [], None
|
||||
if not pages:
|
||||
print(f"Error reading PDF {pdf_path}: no page found")
|
||||
return [], None
|
||||
|
||||
user_image = Image.new("RGB", (pages[0].width, sum(page.height for page in pages)))
|
||||
current_y = 0
|
||||
for page in pages:
|
||||
user_image.paste(page.convert("RGB"), (0, current_y))
|
||||
current_y += page.height
|
||||
if user_image.size != reference.size:
|
||||
print(f" Resizing annotated PDF from {user_image.size} to {reference.size}")
|
||||
user_image = user_image.resize(reference.size, Image.Resampling.LANCZOS)
|
||||
|
||||
difference = np.abs(
|
||||
np.array(reference).astype(int) - np.array(user_image).astype(int)
|
||||
).astype(np.uint8)
|
||||
difference_gray = np.mean(difference, axis=2)
|
||||
keep_mask = Image.new("L", reference.size, 255)
|
||||
mask_draw = ImageDraw.Draw(keep_mask)
|
||||
actions: list[dict[str, Any]] = []
|
||||
|
||||
for raw_box in boxes:
|
||||
if not isinstance(raw_box, dict) or "global_box" not in raw_box:
|
||||
continue
|
||||
x1, y1, x2, y2 = map(int, raw_box["global_box"])
|
||||
x1, y1 = max(0, x1), max(0, y1)
|
||||
x2, y2 = min(reference.width, x2), min(reference.height, y2)
|
||||
region = difference_gray[y1 + 5 : y2 - 5, x1 + 5 : x2 - 5]
|
||||
if region.size == 0:
|
||||
continue
|
||||
density = np.sum(region > 30) / region.size
|
||||
if density > 0.05:
|
||||
actions.append(raw_box)
|
||||
mask_draw.rectangle([x1 - 15, y1 - 15, x2 + 15, y2 + 15], fill=0)
|
||||
else:
|
||||
mask_draw.rectangle([x1 - 2, y1 - 2, x2 + 2, y2 + 2], fill=0)
|
||||
if raw_box.get("type") == "score" and raw_box.get("value") == 0.0:
|
||||
mask_draw.rectangle([0, y1 - 10, reference.width, y2 + 10], fill=0)
|
||||
|
||||
reference_blur = reference.filter(ImageFilter.GaussianBlur(2))
|
||||
user_blur = user_image.filter(ImageFilter.GaussianBlur(2))
|
||||
diff_image = ImageChops.difference(reference_blur, user_blur).convert("L")
|
||||
alpha = np.where(np.array(diff_image) > 50, 255, 0).astype(np.uint8)
|
||||
final_alpha = np.minimum(alpha, np.array(keep_mask))
|
||||
notes = user_image.convert("RGBA")
|
||||
notes.putalpha(Image.fromarray(final_alpha))
|
||||
return actions, notes
|
||||
|
||||
|
||||
def has_significant_notes(note_img: Image.Image | None, threshold: int = 20) -> bool:
|
||||
"""Return whether an RGBA note layer contains enough visible pixels."""
|
||||
if note_img is None or note_img.mode != "RGBA":
|
||||
return False
|
||||
alpha = np.array(note_img)[:, :, 3]
|
||||
return bool(np.sum(alpha > 50) > threshold)
|
||||
|
||||
|
||||
def concatenate(images: list[Image.Image]) -> Image.Image | None:
|
||||
if not images:
|
||||
return None
|
||||
result = Image.new(
|
||||
"RGB",
|
||||
(max(image.width for image in images), sum(image.height for image in images)),
|
||||
"white",
|
||||
)
|
||||
current_y = 0
|
||||
for image in images:
|
||||
result.paste(image, (0, current_y))
|
||||
current_y += image.height
|
||||
return result
|
||||
|
||||
|
||||
def apply_actions_and_regenerate(
|
||||
workspace: EvaluationWorkspace,
|
||||
data: AnnotationData,
|
||||
student_id: str,
|
||||
actions: list[dict[str, Any]],
|
||||
notes_layer: Image.Image | None,
|
||||
all_labels: list[str],
|
||||
*,
|
||||
update_score: bool = False,
|
||||
) -> ExitCode:
|
||||
"""Apply annotations and atomically merge the regenerated student files."""
|
||||
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
|
||||
bnote_path = output_dir / "bnote.json"
|
||||
if not bnote_path.is_file():
|
||||
print(f" Missing {bnote_path}")
|
||||
return ExitCode.PARTIAL
|
||||
bnote_data = read_json(bnote_path)
|
||||
if not isinstance(bnote_data, dict):
|
||||
raise TypeError(f"Expected a JSON object in {bnote_path}")
|
||||
|
||||
labels_data = data[student_id]
|
||||
dirty_labels = apply_checkbox_actions(labels_data, actions, print)
|
||||
if update_score:
|
||||
dirty_labels |= apply_score_overrides(labels_data, output_dir / "score.json", print)
|
||||
|
||||
scores = dict.fromkeys(all_labels, "")
|
||||
dirty_images: dict[str, Image.Image] = {}
|
||||
concatenated: list[Image.Image] = []
|
||||
filtered: list[Image.Image] = []
|
||||
incomplete = False
|
||||
|
||||
for image_info in bnote_data.get("images", []):
|
||||
if not isinstance(image_info, dict):
|
||||
incomplete = True
|
||||
continue
|
||||
label = str(image_info.get("label", ""))
|
||||
if label not in labels_data:
|
||||
incomplete = True
|
||||
continue
|
||||
content = labels_data[label]
|
||||
result = content["result"]
|
||||
scores[label] = str(result.get("score", 0))
|
||||
|
||||
sub_note = None
|
||||
if notes_layer is not None:
|
||||
hmin = int(image_info.get("hmin", 0))
|
||||
hmax = int(image_info.get("hmax", 0))
|
||||
sub_note = notes_layer.crop((0, hmin, notes_layer.width, hmax))
|
||||
has_notes = has_significant_notes(sub_note)
|
||||
|
||||
pdf_path = Path(content["pdf_path"])
|
||||
if not pdf_path.is_file():
|
||||
print(f" Missing answer PDF: {pdf_path}")
|
||||
incomplete = True
|
||||
continue
|
||||
base_image, _, _ = annotating.make_base_image(pdf_path)
|
||||
final_image, new_header_height = annotating.compose_label_image(
|
||||
base_image,
|
||||
label,
|
||||
result,
|
||||
content["coordinates"][0],
|
||||
with_error=False,
|
||||
)
|
||||
if final_image is None:
|
||||
incomplete = True
|
||||
continue
|
||||
|
||||
if has_notes and sub_note is not None:
|
||||
old_header_height = int(image_info.get("header_height", 0))
|
||||
width, height = sub_note.size
|
||||
if old_header_height > 0:
|
||||
header = sub_note.crop((0, 0, width, min(height, old_header_height)))
|
||||
final_image.paste(header, (0, 0), mask=header)
|
||||
if height > old_header_height:
|
||||
body = sub_note.crop((0, old_header_height, width, height))
|
||||
final_image.paste(body, (0, new_header_height), mask=body)
|
||||
|
||||
if label in dirty_labels or has_notes:
|
||||
dirty_images[label] = final_image
|
||||
concatenated.append(final_image)
|
||||
if float(scores[label]) != 4.0 or result.get("feedback", []):
|
||||
filtered.append(final_image)
|
||||
|
||||
concat_image = concatenate(concatenated)
|
||||
filtered_image = concatenate(filtered)
|
||||
with staged_files(output_dir) as staging:
|
||||
for label, image in dirty_images.items():
|
||||
image.save(staging / f"{label}.jpg")
|
||||
atomic_write_json(staging / "score.json", scores)
|
||||
if concat_image is not None:
|
||||
concat_image.save(staging / "Concat.jpg")
|
||||
if filtered_image is not None:
|
||||
filtered_image.save(staging / "Concat_F.jpg")
|
||||
|
||||
print(f" Saved regenerated files in {output_dir}")
|
||||
return ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, *, update_score: bool = False) -> ExitCode:
|
||||
workspace.require_files("labels", "correction.json")
|
||||
workspace.require_directories("Copies", "Par label", "Bnot")
|
||||
all_labels = utils.read_all_labels(workspace.root)
|
||||
loaded = load_annotation_data(workspace)
|
||||
for warning in loaded.warnings:
|
||||
print(f"Warning: {warning}")
|
||||
if not loaded.data:
|
||||
print("No annotation data found.")
|
||||
return ExitCode.PARTIAL
|
||||
|
||||
status = ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
|
||||
for student_id in sorted(loaded.data, key=utils.natural_key):
|
||||
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
|
||||
if not output_dir.is_dir():
|
||||
print(f"Warning: missing annotation directory {output_dir}")
|
||||
status = ExitCode.PARTIAL
|
||||
continue
|
||||
print(f"Processing annotations for: {student_id}")
|
||||
actions, notes = detect_checks_and_notes(output_dir)
|
||||
if notes is None and not actions and not update_score:
|
||||
print(" No readable annotation input found.")
|
||||
status = ExitCode.PARTIAL
|
||||
continue
|
||||
result = apply_actions_and_regenerate(
|
||||
workspace,
|
||||
loaded.data,
|
||||
student_id,
|
||||
actions,
|
||||
notes,
|
||||
all_labels,
|
||||
update_score=update_score,
|
||||
)
|
||||
if result != ExitCode.SUCCESS:
|
||||
status = ExitCode.PARTIAL
|
||||
return status
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = evaluation_parser("Read checked annotations and regenerate copies")
|
||||
parser.add_argument(
|
||||
"--update-score",
|
||||
action="store_true",
|
||||
help="Override generated scores with values from existing score.json files",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
return run(workspace_from_args(args), update_score=args.update_score)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from copienator.commands import annotating
|
||||
from copienator import utils
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
read_json,
|
||||
workspace_from_args,
|
||||
)
|
||||
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
|
||||
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
|
||||
from copienator.filesystem import staged_files
|
||||
from copienator.commands.reading_annotations import (
|
||||
concatenate,
|
||||
detect_checks_and_notes,
|
||||
has_significant_notes,
|
||||
)
|
||||
|
||||
LabelNotes = dict[str, dict[str, Any]]
|
||||
ScanResult = tuple[dict[str, list[dict[str, Any]]], dict[str, LabelNotes]]
|
||||
|
||||
|
||||
def get_extra_pdfs_as_images(
|
||||
root_dir: str | Path,
|
||||
label: str,
|
||||
annotating_module: Any,
|
||||
all_labels: list[str],
|
||||
) -> list[Image.Image]:
|
||||
"""Convert the context, question and solution PDFs associated with a label."""
|
||||
paths = [
|
||||
*utils.pdf_images_of_contexts(root_dir, label, all_labels),
|
||||
utils.pdf_image_of_enonce(root_dir, label),
|
||||
utils.pdf_image_of_solution(root_dir, label),
|
||||
]
|
||||
images = []
|
||||
for path in paths:
|
||||
if path:
|
||||
image, _, _ = annotating_module.make_base_image(path)
|
||||
if image is not None:
|
||||
images.append(image)
|
||||
return images
|
||||
|
||||
|
||||
def save_paginated_pdf(image_groups: list[list[Image.Image]], output_path: Path) -> None:
|
||||
"""Paginate vertically concatenated image groups and save them as a PDF."""
|
||||
non_empty = [group for group in image_groups if group]
|
||||
if not non_empty:
|
||||
return
|
||||
max_width = max(image.width for group in non_empty for image in group)
|
||||
max_page_height = int(max_width * 1.414 * 1.25)
|
||||
border = int((0.2 / 2.54) * 100)
|
||||
left_margin = int((0.3 / 2.54) * 100)
|
||||
vertical_margin = int((0.2 / 2.54) * 100)
|
||||
max_content_height = max_page_height - 2 * vertical_margin
|
||||
|
||||
pages: list[Image.Image] = []
|
||||
page_images: list[Image.Image] = []
|
||||
page_height = 0
|
||||
|
||||
def finish_page() -> None:
|
||||
nonlocal page_images, page_height
|
||||
if not page_images:
|
||||
return
|
||||
page = Image.new(
|
||||
"RGB",
|
||||
(max_width + left_margin, page_height + 2 * vertical_margin),
|
||||
"white",
|
||||
)
|
||||
current_y = vertical_margin
|
||||
for image in page_images:
|
||||
page.paste(image, (left_margin, current_y))
|
||||
current_y += image.height
|
||||
pages.append(page)
|
||||
page_images = []
|
||||
page_height = 0
|
||||
|
||||
for group in non_empty:
|
||||
processed: list[Image.Image] = []
|
||||
for index, image in enumerate(group):
|
||||
if index in (0, 1):
|
||||
image = image.copy()
|
||||
color = "black" if index == 0 else "blue"
|
||||
ImageDraw.Draw(image).rectangle(
|
||||
[0, 0, image.width - 1, image.height - 1],
|
||||
outline=color,
|
||||
width=border,
|
||||
)
|
||||
processed.append(image)
|
||||
group_height = sum(image.height for image in processed)
|
||||
if page_images and page_height + group_height > max_content_height:
|
||||
finish_page()
|
||||
page_images.extend(processed)
|
||||
page_height += group_height
|
||||
finish_page()
|
||||
pages[0].save(
|
||||
output_path,
|
||||
"PDF",
|
||||
resolution=100.0,
|
||||
save_all=True,
|
||||
append_images=pages[1:],
|
||||
)
|
||||
|
||||
|
||||
def _scan_annotation_directory(
|
||||
directory: Path,
|
||||
only_ids: set[str] | None = None,
|
||||
default_student_id: str | None = None,
|
||||
) -> ScanResult:
|
||||
bnote_path = directory / "bnote.json"
|
||||
if not bnote_path.is_file():
|
||||
raise FileNotFoundError(f"Missing {bnote_path}")
|
||||
bnote = read_json(bnote_path)
|
||||
if not isinstance(bnote, dict):
|
||||
raise TypeError(f"Expected a JSON object in {bnote_path}")
|
||||
images = [item for item in bnote.get("images", []) if isinstance(item, dict)]
|
||||
if only_ids and not any(
|
||||
str(item.get("id", default_student_id)) in only_ids for item in images
|
||||
):
|
||||
return {}, {}
|
||||
|
||||
actions, notes_image = detect_checks_and_notes(directory)
|
||||
if notes_image is None:
|
||||
return {}, {}
|
||||
actions_by_student: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
notes_by_student: dict[str, LabelNotes] = defaultdict(dict)
|
||||
for action in actions:
|
||||
raw_student_id = action.get("student_id", default_student_id)
|
||||
if raw_student_id is not None:
|
||||
actions_by_student[str(raw_student_id)].append(action)
|
||||
for image_info in images:
|
||||
student_id = str(image_info.get("id", default_student_id or ""))
|
||||
label = str(image_info.get("label", ""))
|
||||
hmin = int(image_info.get("hmin", 0))
|
||||
hmax = int(image_info.get("hmax", 0))
|
||||
if student_id and label and hmax > hmin:
|
||||
crop = notes_image.crop((0, hmin, notes_image.width, hmax))
|
||||
if has_significant_notes(crop):
|
||||
notes_by_student[student_id][label] = {
|
||||
"img": crop,
|
||||
"old_header_h": int(image_info.get("header_height", 0)),
|
||||
}
|
||||
return dict(actions_by_student), dict(notes_by_student)
|
||||
|
||||
|
||||
def _merge_scan_result(
|
||||
target_actions: dict[str, list[dict[str, Any]]],
|
||||
target_notes: dict[str, LabelNotes],
|
||||
result: ScanResult,
|
||||
) -> None:
|
||||
actions, notes = result
|
||||
for student_id, student_actions in actions.items():
|
||||
target_actions[student_id].extend(student_actions)
|
||||
for student_id, student_notes in notes.items():
|
||||
target_notes[student_id].update(student_notes)
|
||||
|
||||
|
||||
def apply_actions_and_regenerate_grouped(
|
||||
workspace: EvaluationWorkspace,
|
||||
data: AnnotationData,
|
||||
student_id: str,
|
||||
actions: list[dict[str, Any]],
|
||||
label_notes: LabelNotes,
|
||||
all_labels: list[str],
|
||||
*,
|
||||
update_score: bool = False,
|
||||
) -> tuple[ExitCode, str]:
|
||||
"""Apply grouped annotations and atomically merge regenerated student files."""
|
||||
logs = [f"\nProcessing compilation for: Copie{student_id}"]
|
||||
output_dir = workspace.annotation_dir("grouped") / f"Copie{student_id}"
|
||||
labels_data = data.get(student_id, {})
|
||||
dirty_labels = apply_checkbox_actions(labels_data, actions, logs.append)
|
||||
if update_score:
|
||||
dirty_labels |= apply_score_overrides(
|
||||
labels_data, output_dir / "score.json", logs.append
|
||||
)
|
||||
|
||||
scores = dict.fromkeys(all_labels, "")
|
||||
dirty_images: dict[str, Image.Image] = {}
|
||||
concat_images: list[Image.Image] = []
|
||||
filtered_groups: list[list[Image.Image]] = []
|
||||
incomplete = False
|
||||
|
||||
for label, content in sorted(labels_data.items(), key=lambda item: utils.natural_key(item[0])):
|
||||
result = content["result"]
|
||||
scores[label] = str(result.get("score", 0))
|
||||
pdf_path = Path(content["pdf_path"])
|
||||
if not pdf_path.is_file():
|
||||
logs.append(f" Missing answer PDF: {pdf_path}")
|
||||
incomplete = True
|
||||
continue
|
||||
base_image, _, _ = annotating.make_base_image(pdf_path)
|
||||
final_image, new_header_height = annotating.compose_label_image(
|
||||
base_image,
|
||||
label,
|
||||
result,
|
||||
content["coordinates"][0],
|
||||
with_error=False,
|
||||
)
|
||||
if final_image is None:
|
||||
incomplete = True
|
||||
continue
|
||||
|
||||
has_notes = False
|
||||
if label in label_notes:
|
||||
sub_note = label_notes[label]["img"]
|
||||
old_header_height = int(label_notes[label]["old_header_h"])
|
||||
has_notes = has_significant_notes(sub_note)
|
||||
if has_notes:
|
||||
width, height = sub_note.size
|
||||
if old_header_height > 0:
|
||||
header = sub_note.crop((0, 0, width, min(height, old_header_height)))
|
||||
final_image.paste(header, (0, 0), mask=header)
|
||||
if height > old_header_height:
|
||||
body = sub_note.crop((0, old_header_height, width, height))
|
||||
final_image.paste(body, (0, new_header_height), mask=body)
|
||||
|
||||
if label in dirty_labels or has_notes:
|
||||
dirty_images[label] = final_image
|
||||
logs.append(f" Saved dirty image: {label}.jpg")
|
||||
concat_images.append(final_image)
|
||||
|
||||
feedbacks = result.get("feedback", [])
|
||||
perfect = float(scores[label]) >= 4.0 and all(
|
||||
feedback.get("to_delete", False) for feedback in feedbacks
|
||||
)
|
||||
if not perfect or has_notes:
|
||||
extras = get_extra_pdfs_as_images(
|
||||
workspace.root, label, annotating, all_labels
|
||||
)
|
||||
filtered_groups.append([*extras, final_image])
|
||||
|
||||
concat_image = concatenate(concat_images)
|
||||
with staged_files(output_dir) as staging:
|
||||
for label, image in dirty_images.items():
|
||||
image.save(staging / f"{label}.jpg")
|
||||
atomic_write_json(staging / "score.json", scores)
|
||||
if concat_image is not None:
|
||||
concat_image.save(staging / "Concat.jpg")
|
||||
if filtered_groups:
|
||||
save_paginated_pdf(filtered_groups, staging / "Concat_F.pdf")
|
||||
logs.append(f" Saved regenerated files in {output_dir}")
|
||||
status = ExitCode.PARTIAL if incomplete else ExitCode.SUCCESS
|
||||
return status, "\n".join(logs)
|
||||
|
||||
|
||||
def _read_refaire(workspace: EvaluationWorkspace) -> tuple[RefaireList, dict[str, list[str]]]:
|
||||
loaded = read_json(workspace.refaire_file)
|
||||
if not isinstance(loaded, list):
|
||||
raise TypeError("refaire.json must contain a JSON array")
|
||||
entries: RefaireList = []
|
||||
by_student: dict[str, list[str]] = {}
|
||||
for entry in loaded:
|
||||
if not isinstance(entry, list) or len(entry) != 2 or not isinstance(entry[1], list):
|
||||
raise TypeError(f"Malformed refaire entry: {entry!r}")
|
||||
copy_name, labels = entry
|
||||
student_id = str(copy_name).removeprefix("Copie")
|
||||
normalized_labels = [str(label) for label in labels]
|
||||
entries.append([str(copy_name), normalized_labels])
|
||||
by_student[student_id] = normalized_labels
|
||||
return entries, by_student
|
||||
|
||||
|
||||
def run(
|
||||
workspace: EvaluationWorkspace,
|
||||
*,
|
||||
refaire: bool = False,
|
||||
update_score: bool = False,
|
||||
) -> ExitCode:
|
||||
workspace.require_files("labels", "correction.json")
|
||||
workspace.require_directories("Copies", "Par label", "BGnot")
|
||||
refaire_list: RefaireList | None = None
|
||||
refaire_by_student: dict[str, list[str]] = {}
|
||||
if refaire:
|
||||
workspace.require_files("refaire.json")
|
||||
workspace.require_directories("BRnot")
|
||||
refaire_list, refaire_by_student = _read_refaire(workspace)
|
||||
|
||||
all_labels = utils.read_all_labels(workspace.root)
|
||||
loaded = load_annotation_data(workspace, refaire_list=refaire_list)
|
||||
for warning in loaded.warnings:
|
||||
print(f"Warning: {warning}")
|
||||
if not loaded.data:
|
||||
print("No annotation data found.")
|
||||
return ExitCode.PARTIAL
|
||||
|
||||
actions_by_student: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
notes_by_student: dict[str, LabelNotes] = defaultdict(dict)
|
||||
only_ids = set(refaire_by_student) or None
|
||||
group_dirs = [
|
||||
path
|
||||
for path in workspace.annotation_dir("grouped").iterdir()
|
||||
if path.is_dir() and not path.name.startswith("Copie")
|
||||
]
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
|
||||
futures = [
|
||||
executor.submit(_scan_annotation_directory, path, only_ids)
|
||||
for path in group_dirs
|
||||
]
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
_merge_scan_result(actions_by_student, notes_by_student, future.result())
|
||||
|
||||
refaire_incomplete = False
|
||||
if refaire:
|
||||
for student_id, requested_labels in refaire_by_student.items():
|
||||
selected = requested_labels or list(loaded.data.get(student_id, {}))
|
||||
selected_set = set(selected)
|
||||
directory = workspace.annotation_dir("refaire") / f"Copie{student_id}"
|
||||
if not directory.is_dir():
|
||||
print(f"Warning: missing refaire annotation directory {directory}")
|
||||
refaire_incomplete = True
|
||||
continue
|
||||
actions_by_student[student_id] = [
|
||||
action
|
||||
for action in actions_by_student[student_id]
|
||||
if str(action.get("label")) not in selected_set
|
||||
]
|
||||
for label in selected:
|
||||
notes_by_student[student_id].pop(label, None)
|
||||
refaire_actions, refaire_notes = _scan_annotation_directory(
|
||||
directory, default_student_id=student_id
|
||||
)
|
||||
for action in refaire_actions.get(student_id, []):
|
||||
if str(action.get("label")) in selected_set:
|
||||
actions_by_student[student_id].append(action)
|
||||
for label, note in refaire_notes.get(student_id, {}).items():
|
||||
if label in selected_set:
|
||||
notes_by_student[student_id][label] = note
|
||||
|
||||
status = (
|
||||
ExitCode.PARTIAL
|
||||
if loaded.warnings or refaire_incomplete
|
||||
else ExitCode.SUCCESS
|
||||
)
|
||||
student_ids = list(refaire_by_student) if refaire else sorted(loaded.data, key=utils.natural_key)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
|
||||
futures = {
|
||||
executor.submit(
|
||||
apply_actions_and_regenerate_grouped,
|
||||
workspace,
|
||||
loaded.data,
|
||||
student_id,
|
||||
actions_by_student[student_id],
|
||||
notes_by_student[student_id],
|
||||
all_labels,
|
||||
update_score=update_score,
|
||||
): student_id
|
||||
for student_id in student_ids
|
||||
if student_id in loaded.data
|
||||
}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
result, output = future.result()
|
||||
print(output)
|
||||
if result != ExitCode.SUCCESS:
|
||||
status = ExitCode.PARTIAL
|
||||
return status
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = evaluation_parser("Read grouped annotations and regenerate copies")
|
||||
parser.add_argument(
|
||||
"--refaire",
|
||||
action="store_true",
|
||||
help="Use refaire.json and merge annotations from BRnot",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--update-score",
|
||||
action="store_true",
|
||||
help="Override generated scores with values from existing score.json files",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
return run(
|
||||
workspace_from_args(args),
|
||||
refaire=args.refaire,
|
||||
update_score=args.update_score,
|
||||
)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pypdf import PdfWriter
|
||||
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
read_json,
|
||||
workspace_from_args,
|
||||
)
|
||||
|
||||
OPERATORS = ("-x", "->", "x>", "ss", "sx", "xx", "xs")
|
||||
OPERATOR_PATTERN = re.compile(r"\s+(-x|->|x>|ss|sx|xx|xs)\s+")
|
||||
COPY_PATTERN = re.compile(r"Copie(\d+)\s+(.+)")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManualInstruction:
|
||||
copy_id: str
|
||||
old_label: str
|
||||
operator: str
|
||||
new_label: str
|
||||
pipe_first: bool
|
||||
|
||||
@property
|
||||
def should_merge(self) -> bool:
|
||||
return self.operator.endswith(">")
|
||||
|
||||
@property
|
||||
def should_copy(self) -> bool:
|
||||
return not self.should_merge and "s" not in self.operator
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
return evaluation_parser("Apply the instructions from manual_resolutions.txt.")
|
||||
|
||||
|
||||
def parse_instructions(path: Path) -> list[ManualInstruction]:
|
||||
instructions: list[ManualInstruction] = []
|
||||
malformed: list[int] = []
|
||||
for line_number, raw_line in enumerate(
|
||||
path.read_text(encoding="utf-8").splitlines(), start=1
|
||||
):
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("###"):
|
||||
continue
|
||||
operator_match = OPERATOR_PATTERN.search(line)
|
||||
if operator_match is None:
|
||||
malformed.append(line_number)
|
||||
continue
|
||||
left = line[: operator_match.start()].strip()
|
||||
right = line[operator_match.end() :].strip()
|
||||
copy_match = COPY_PATTERN.fullmatch(left)
|
||||
new_label = right.strip("|").strip()
|
||||
if copy_match is None or not new_label:
|
||||
malformed.append(line_number)
|
||||
continue
|
||||
instructions.append(
|
||||
ManualInstruction(
|
||||
copy_id=copy_match.group(1),
|
||||
old_label=copy_match.group(2).strip(),
|
||||
operator=operator_match.group(1),
|
||||
new_label=new_label,
|
||||
pipe_first=right.startswith("|"),
|
||||
)
|
||||
)
|
||||
if malformed:
|
||||
lines = ", ".join(str(number) for number in malformed)
|
||||
raise CliError(f"Malformed manual resolution instruction at line(s): {lines}")
|
||||
return instructions
|
||||
|
||||
|
||||
def set_suffix_and_clean_error(
|
||||
results: dict[str, Any],
|
||||
copy_id: str,
|
||||
label: str,
|
||||
suffix: str | None,
|
||||
new_label_target: str | None = None,
|
||||
) -> None:
|
||||
for batch in results.get(label, []):
|
||||
for item in batch:
|
||||
if item["id"] != copy_id:
|
||||
continue
|
||||
if suffix:
|
||||
item["result"]["suffix"] = suffix
|
||||
error = item["result"].get("error", "")
|
||||
if new_label_target:
|
||||
if f"wrg-lbl:{new_label_target}?delayed" in error:
|
||||
item["result"]["error"] = (
|
||||
f"wrg-lbl-moved-to:{new_label_target}"
|
||||
)
|
||||
if f"(delayed){new_label_target}" in error:
|
||||
item["result"]["error"] = error.replace(
|
||||
f"(delayed){new_label_target}",
|
||||
f"(->){new_label_target}",
|
||||
)
|
||||
|
||||
|
||||
def get_actual_pdf(copies_dir: Path, copy_id: str, label: str) -> Path:
|
||||
base = copies_dir / f"Copie{copy_id}" / f"{label}.pdf"
|
||||
for candidate in (
|
||||
base,
|
||||
base.with_name(f"{label}_new.pdf"),
|
||||
base.with_name(f"{label}_old.pdf"),
|
||||
):
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return base
|
||||
|
||||
|
||||
def safe_strip_suffix(stem: str) -> str:
|
||||
if stem.endswith(("_new", "_old")):
|
||||
return stem[:-4]
|
||||
return stem
|
||||
|
||||
|
||||
def _validate_pdf_inputs(
|
||||
instructions: list[ManualInstruction],
|
||||
initial_paths: dict[tuple[str, str], Path],
|
||||
) -> None:
|
||||
missing: set[Path] = set()
|
||||
for instruction in instructions:
|
||||
source = initial_paths[(instruction.copy_id, instruction.old_label)]
|
||||
destination = initial_paths[(instruction.copy_id, instruction.new_label)]
|
||||
if instruction.should_merge:
|
||||
if not source.exists():
|
||||
missing.add(source)
|
||||
if not destination.exists():
|
||||
missing.add(destination)
|
||||
elif instruction.should_copy and not source.exists():
|
||||
missing.add(source)
|
||||
if missing:
|
||||
rendered = ", ".join(str(path) for path in sorted(missing))
|
||||
raise CliError(f"PDF input(s) required by manual resolutions not found: {rendered}")
|
||||
|
||||
|
||||
def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
workspace.require_files("manual_resolutions.txt", "correction.json")
|
||||
workspace.require_directories("Copies")
|
||||
loaded = read_json(workspace.correction_file)
|
||||
if not isinstance(loaded, dict):
|
||||
raise CliError("correction.json must contain a JSON object")
|
||||
results: dict[str, Any] = loaded
|
||||
instructions = parse_instructions(workspace.manual_resolutions_file)
|
||||
|
||||
initial_paths: dict[tuple[str, str], Path] = {}
|
||||
current_paths: dict[tuple[str, str], Path] = {}
|
||||
for instruction in instructions:
|
||||
for label in (instruction.old_label, instruction.new_label):
|
||||
key = (instruction.copy_id, label)
|
||||
if key not in initial_paths:
|
||||
path = get_actual_pdf(workspace.copies_dir, *key)
|
||||
initial_paths[key] = path
|
||||
current_paths[key] = path
|
||||
_validate_pdf_inputs(instructions, initial_paths)
|
||||
|
||||
files_to_old: set[Path] = set()
|
||||
temp_files: list[Path] = []
|
||||
try:
|
||||
for instruction in instructions:
|
||||
key_old = (instruction.copy_id, instruction.old_label)
|
||||
key_new = (instruction.copy_id, instruction.new_label)
|
||||
source = initial_paths[key_old]
|
||||
destination = current_paths[key_new]
|
||||
temp_output = (
|
||||
workspace.copies_dir
|
||||
/ f"Copie{instruction.copy_id}"
|
||||
/ f"temp_{len(temp_files)}.pdf"
|
||||
)
|
||||
|
||||
if instruction.operator.startswith("x"):
|
||||
files_to_old.add(initial_paths[key_old])
|
||||
if instruction.operator.endswith("x"):
|
||||
files_to_old.add(initial_paths[key_new])
|
||||
|
||||
if instruction.should_merge:
|
||||
writer = PdfWriter()
|
||||
try:
|
||||
if instruction.pipe_first:
|
||||
writer.append(source)
|
||||
writer.append(destination)
|
||||
else:
|
||||
writer.append(destination)
|
||||
writer.append(source)
|
||||
writer.write(temp_output)
|
||||
except Exception:
|
||||
temp_output.unlink(missing_ok=True)
|
||||
raise
|
||||
finally:
|
||||
writer.close()
|
||||
current_paths[key_new] = temp_output
|
||||
temp_files.append(temp_output)
|
||||
files_to_old.add(initial_paths[key_new])
|
||||
elif instruction.should_copy:
|
||||
shutil.copy(source, temp_output)
|
||||
current_paths[key_new] = temp_output
|
||||
temp_files.append(temp_output)
|
||||
except Exception:
|
||||
for temporary in temp_files:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
for pdf in files_to_old:
|
||||
if not pdf.exists():
|
||||
continue
|
||||
copy_id = pdf.parent.name.removeprefix("Copie")
|
||||
label = safe_strip_suffix(pdf.stem)
|
||||
old_name = pdf.with_name(f"{label}_old.pdf")
|
||||
if pdf != old_name:
|
||||
old_name.unlink(missing_ok=True)
|
||||
shutil.move(str(pdf), str(old_name))
|
||||
set_suffix_and_clean_error(results, copy_id, label, "_old")
|
||||
|
||||
for instruction in instructions:
|
||||
set_suffix_and_clean_error(
|
||||
results,
|
||||
instruction.copy_id,
|
||||
instruction.old_label,
|
||||
None,
|
||||
instruction.new_label,
|
||||
)
|
||||
|
||||
refaire_by_copy: dict[str, list[str]] = {}
|
||||
for (copy_id, label), current_path in current_paths.items():
|
||||
if not current_path.name.startswith("temp_"):
|
||||
continue
|
||||
final_name = workspace.copies_dir / f"Copie{copy_id}" / f"{label}_new.pdf"
|
||||
final_name.unlink(missing_ok=True)
|
||||
shutil.move(str(current_path), str(final_name))
|
||||
set_suffix_and_clean_error(results, copy_id, label, "_new")
|
||||
labels = refaire_by_copy.setdefault(f"Copie{copy_id}", [])
|
||||
if label not in labels:
|
||||
labels.append(label)
|
||||
|
||||
used_temps = set(current_paths.values())
|
||||
for temporary in temp_files:
|
||||
if temporary not in used_temps:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
atomic_write_json(workspace.correction_file, results)
|
||||
refaire_tasks = [[copy_name, labels] for copy_name, labels in refaire_by_copy.items()]
|
||||
if refaire_tasks:
|
||||
atomic_write_json(workspace.refaire_file, refaire_tasks)
|
||||
workspace.manual_resolutions_file.unlink()
|
||||
|
||||
print("Manual resolutions successfully applied.")
|
||||
if refaire_tasks:
|
||||
print(
|
||||
f"File {workspace.refaire_file.name} generated. Run "
|
||||
f'`python -m copienator correct "{workspace.command_argument()}" --refaire` '
|
||||
"to process updates."
|
||||
)
|
||||
else:
|
||||
print("No new corrections required.")
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
return resolve_manual(workspace)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,267 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import fitz
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
from copienator import utils
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
execute,
|
||||
read_json,
|
||||
target_parser,
|
||||
workspace_from_target,
|
||||
)
|
||||
from copienator.filesystem import staged_directory
|
||||
|
||||
SQUARE = 1000 // 38
|
||||
Coordinate = tuple[str, int, int, int, int, int]
|
||||
ParsedCoordinate = tuple[str, str, int, int, int, int, int]
|
||||
|
||||
|
||||
def decode_json(pdf_file: str | Path) -> tuple[str, list[Coordinate]]:
|
||||
"""Read verified label coordinates associated with one copy PDF."""
|
||||
pdf_path = Path(pdf_file)
|
||||
loaded = read_json(pdf_path.with_suffix(".json"))
|
||||
if not isinstance(loaded, dict):
|
||||
raise TypeError(f"Expected a JSON object for {pdf_path}")
|
||||
boxes = loaded.get("list")
|
||||
if not isinstance(boxes, list):
|
||||
raise TypeError(f"Expected a list of labels for {pdf_path}")
|
||||
page_count = len(PdfReader(pdf_path).pages)
|
||||
if page_count == 0:
|
||||
raise ValueError(f"PDF contains no pages: {pdf_path}")
|
||||
column_width = 1000 // page_count
|
||||
result: list[Coordinate] = []
|
||||
for entry in boxes:
|
||||
if not isinstance(entry, dict):
|
||||
raise TypeError(f"Malformed label entry for {pdf_path}: {entry!r}")
|
||||
box = entry["box_2d"]
|
||||
label = str(entry["label"])
|
||||
page_number = ((box[1] + box[3]) // 2) // column_width
|
||||
result.append(
|
||||
(label, page_number, box[0] - SQUARE, box[2] - SQUARE, box[1], box[3])
|
||||
)
|
||||
result.sort(key=lambda item: (item[1], item[2]))
|
||||
return str(loaded.get("name", "")), result
|
||||
|
||||
|
||||
def _parse_coordinates(coords_list: list[Coordinate]) -> list[ParsedCoordinate]:
|
||||
parsed: list[ParsedCoordinate] = []
|
||||
for label, page, y0, y1, x0, x1 in coords_list:
|
||||
if label.startswith("|"):
|
||||
kind, clean_label = "L", label[1:]
|
||||
elif label.endswith("|"):
|
||||
kind, clean_label = "R", label[:-1]
|
||||
else:
|
||||
kind, clean_label = "N", label
|
||||
parsed.append((clean_label, kind, page, y0, y1, x0, x1))
|
||||
filtered: list[ParsedCoordinate] = []
|
||||
for item in parsed:
|
||||
if not filtered or item[0] != filtered[-1][0]:
|
||||
filtered.append(item)
|
||||
return filtered
|
||||
|
||||
|
||||
def _save_cropped_page(
|
||||
document: fitz.Document,
|
||||
page_number: int,
|
||||
x0: float,
|
||||
y0: float,
|
||||
x1: float,
|
||||
y1: float,
|
||||
output_path: Path,
|
||||
) -> None:
|
||||
page = document[page_number]
|
||||
rotated_rectangle = page.rect * page.transformation_matrix
|
||||
visual_crop = fitz.Rect(
|
||||
rotated_rectangle.x0 + x0,
|
||||
y0,
|
||||
rotated_rectangle.x0 + x1,
|
||||
y1,
|
||||
)
|
||||
unrotated_clip = visual_crop * page.derotation_matrix
|
||||
cropped = fitz.open()
|
||||
try:
|
||||
target_page = cropped.new_page(width=visual_crop.width, height=visual_crop.height)
|
||||
target_page.show_pdf_page(
|
||||
target_page.rect,
|
||||
document,
|
||||
page_number,
|
||||
rotate=-page.rotation,
|
||||
clip=unrotated_clip,
|
||||
)
|
||||
cropped.save(output_path)
|
||||
finally:
|
||||
cropped.close()
|
||||
|
||||
|
||||
def _render_split_outputs(
|
||||
input_pdf: Path,
|
||||
coords_list: list[Coordinate],
|
||||
staging: Path,
|
||||
) -> set[str]:
|
||||
"""Render every current answer into an otherwise empty staging directory."""
|
||||
document = fitz.open(input_pdf)
|
||||
try:
|
||||
parsed = _parse_coordinates(coords_list)
|
||||
parts_by_label: defaultdict[str, list[Path]] = defaultdict(list)
|
||||
with tempfile.TemporaryDirectory(prefix="copienator-split-") as temp_directory:
|
||||
temporary = Path(temp_directory)
|
||||
for index, item in enumerate(parsed):
|
||||
clean_label, kind, start_page, y_start, _y_end, x0_raw, _x1_raw = item
|
||||
if clean_label == "_":
|
||||
continue
|
||||
if not 0 <= start_page < document.page_count:
|
||||
raise ValueError(
|
||||
f"Invalid page {start_page} for {input_pdf.name}"
|
||||
)
|
||||
end_page = document.page_count - 1
|
||||
end_y = 1000
|
||||
for next_item in parsed[index + 1 :]:
|
||||
_next_label, next_kind, next_page, next_y, *_rest = next_item
|
||||
if (
|
||||
(kind == "L" and next_kind in {"L", "N"})
|
||||
or (kind == "R" and next_kind in {"R", "N"})
|
||||
or kind == "N"
|
||||
):
|
||||
end_page = next_page
|
||||
end_y = min(next_y + int(1.5 * SQUARE), 1000)
|
||||
break
|
||||
|
||||
column_width = 1000 / document.page_count
|
||||
if kind == "L":
|
||||
fraction_x0 = (x0_raw % column_width) / column_width
|
||||
fraction_x1 = 1.0
|
||||
end_y = min(1000, end_y + 40)
|
||||
elif kind == "R":
|
||||
fraction_x0 = 0.0
|
||||
left_labels = [entry for entry in parsed if entry[1] == "L"]
|
||||
if left_labels:
|
||||
closest = min(left_labels, key=lambda entry: abs(entry[3] - y_start))
|
||||
center = (closest[5] + closest[6]) / 2.0
|
||||
fraction_x1 = (center % column_width) / column_width
|
||||
if fraction_x1 <= fraction_x0:
|
||||
fraction_x1 = 1.0
|
||||
else:
|
||||
fraction_x1 = 1.0
|
||||
else:
|
||||
fraction_x0, fraction_x1 = 0.0, 1.0
|
||||
|
||||
for page_number in range(start_page, end_page + 1):
|
||||
page = document[page_number]
|
||||
y0 = (y_start / 1000) * page.rect.height if page_number == start_page else 0
|
||||
y1 = (end_y / 1000) * page.rect.height if page_number == end_page else page.rect.height
|
||||
if y1 <= y0 + 1:
|
||||
continue
|
||||
part_path = temporary / f"part-{index}-{page_number}.pdf"
|
||||
_save_cropped_page(
|
||||
document,
|
||||
page_number,
|
||||
fraction_x0 * page.rect.width,
|
||||
y0,
|
||||
fraction_x1 * page.rect.width,
|
||||
y1,
|
||||
part_path,
|
||||
)
|
||||
parts_by_label[clean_label].append(part_path)
|
||||
|
||||
generated: set[str] = set()
|
||||
for label, parts in parts_by_label.items():
|
||||
filename = f"{label}.pdf"
|
||||
merger = PdfWriter()
|
||||
try:
|
||||
for part in parts:
|
||||
merger.append(part)
|
||||
merger.write(staging / filename)
|
||||
finally:
|
||||
merger.close()
|
||||
generated.add(filename)
|
||||
return generated
|
||||
finally:
|
||||
document.close()
|
||||
|
||||
|
||||
def _preserve_previous_outputs(
|
||||
output_dir: Path,
|
||||
staging: Path,
|
||||
generated_files: set[str],
|
||||
) -> None:
|
||||
if not output_dir.is_dir():
|
||||
return
|
||||
for directory in (path for path in output_dir.iterdir() if path.is_dir()):
|
||||
shutil.copytree(directory, staging / directory.name, dirs_exist_ok=True)
|
||||
missing_dir = staging / "Missing"
|
||||
for item in (path for path in output_dir.iterdir() if path.is_file()):
|
||||
if item.name in generated_files:
|
||||
continue
|
||||
print(f"ALERT: File '{item.name}' not generated. Moving to {missing_dir}")
|
||||
missing_dir.mkdir(exist_ok=True)
|
||||
shutil.copy2(item, missing_dir / item.name)
|
||||
|
||||
|
||||
def split_an_interro(
|
||||
workspace: EvaluationWorkspace,
|
||||
input_pdf: Path,
|
||||
coords_list: list[Coordinate],
|
||||
) -> None:
|
||||
"""Regenerate one copy's answers and preserve obsolete ones under Missing."""
|
||||
output_dir = workspace.copies_dir / input_pdf.stem
|
||||
with staged_directory(output_dir) as staging:
|
||||
generated = _render_split_outputs(input_pdf, coords_list, staging)
|
||||
_preserve_previous_outputs(output_dir, staging, generated)
|
||||
|
||||
|
||||
def _selected_pdfs(workspace: EvaluationWorkspace, target: Path) -> list[Path]:
|
||||
workspace.require_directories("Copies")
|
||||
if target.is_file():
|
||||
if target.suffix.casefold() != ".pdf":
|
||||
raise CliError(f"Target is not a PDF: {target}", ExitCode.INVALID_ARGUMENTS)
|
||||
return [target]
|
||||
return sorted(workspace.copies_dir.glob("*.pdf"), key=lambda path: path.name.casefold())
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, target: Path) -> ExitCode:
|
||||
workspace.require_files("labels")
|
||||
utils.read_all_labels(workspace.root)
|
||||
pdf_files = _selected_pdfs(workspace, target)
|
||||
status = ExitCode.SUCCESS
|
||||
for pdf_path in pdf_files:
|
||||
json_path = pdf_path.with_suffix(".json")
|
||||
if not json_path.is_file():
|
||||
print(f"Warning: No JSON found for {pdf_path.name}")
|
||||
status = ExitCode.PARTIAL
|
||||
continue
|
||||
name, coordinates = decode_json(pdf_path)
|
||||
print(f"Decoded name: {name}")
|
||||
split_an_interro(workspace, pdf_path, coordinates)
|
||||
if not pdf_files:
|
||||
print("No PDF copies found.")
|
||||
return status
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
return target_parser("Split verified PDF copies into answers by label")
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
workspace, target = workspace_from_target(args)
|
||||
return run(workspace, target)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections.abc import Sequence
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
from copienator import configuration as config
|
||||
from copienator import (
|
||||
CliError,
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
atomic_write_json,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
read_json,
|
||||
workspace_from_args,
|
||||
)
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, *, client=None) -> ExitCode:
|
||||
if client is None:
|
||||
if not config.API_KEY:
|
||||
raise CliError("GEMINI_API_KEY is not configured")
|
||||
client = genai.Client(api_key=config.API_KEY)
|
||||
batches = (
|
||||
(
|
||||
"flash",
|
||||
workspace.root / "batch_requests_flash.jsonl",
|
||||
config.MODEL_FLASH_ID,
|
||||
f"flash-correction-{workspace.name}",
|
||||
),
|
||||
(
|
||||
"pro",
|
||||
workspace.root / "batch_requests_pro.jsonl",
|
||||
config.MODEL_PRO_ID,
|
||||
f"pro-correction-{workspace.name}",
|
||||
),
|
||||
)
|
||||
manifest = {
|
||||
"version": 1,
|
||||
"evaluation": workspace.name,
|
||||
"jobs": {},
|
||||
}
|
||||
if workspace.batch_jobs_file.is_file():
|
||||
previous = read_json(workspace.batch_jobs_file)
|
||||
if isinstance(previous, dict) and isinstance(previous.get("jobs"), dict):
|
||||
manifest["jobs"] = previous["jobs"]
|
||||
started = 0
|
||||
for tier, file_path, model_id, display_name in batches:
|
||||
if not file_path.is_file():
|
||||
print(f"Skipping {model_id}: {file_path.name} does not exist.")
|
||||
continue
|
||||
if file_path.stat().st_size == 0:
|
||||
print(f"Skipping {model_id}: {file_path.name} is empty.")
|
||||
continue
|
||||
print(f"Uploading {file_path.name} for model {model_id}...")
|
||||
uploaded = client.files.upload(
|
||||
file=str(file_path),
|
||||
config=types.UploadFileConfig(
|
||||
display_name=f"{display_name}-input",
|
||||
mime_type="jsonl",
|
||||
),
|
||||
)
|
||||
job = client.batches.create(
|
||||
model=model_id,
|
||||
src=uploaded.name,
|
||||
config={"display_name": display_name},
|
||||
)
|
||||
started += 1
|
||||
manifest["jobs"][tier] = {
|
||||
"name": job.name,
|
||||
"display_name": display_name,
|
||||
"model": model_id,
|
||||
"request_file": file_path.name,
|
||||
}
|
||||
atomic_write_json(workspace.batch_jobs_file, manifest)
|
||||
print(f"Started batch job: {job.name}")
|
||||
if not started:
|
||||
print("No non-empty batch request files were found.")
|
||||
return ExitCode.PARTIAL
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
return evaluation_parser("Upload correction JSONL files and start Gemini batches")
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,172 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import ezodf
|
||||
|
||||
from copienator.configuration import CURRENT_SCORE_ODS_PATH
|
||||
from copienator.utils import read_all_labels
|
||||
|
||||
# Configuration
|
||||
ODS_PATH = Path(CURRENT_SCORE_ODS_PATH).expanduser()
|
||||
TARGET_DIR_NAME = "A Rendre"
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="Update ODS with student scores.")
|
||||
parser.add_argument("work_dir", nargs="?", default=os.getcwd(), help="Directory to process")
|
||||
parser.add_argument("--sum", action="store_true", help="Write only the total sum per student")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
work_dir = os.path.abspath(args.work_dir)
|
||||
|
||||
all_labels = read_all_labels(Path(work_dir))
|
||||
|
||||
a_rendre_path = os.path.join(work_dir, TARGET_DIR_NAME)
|
||||
|
||||
if not os.path.isdir(a_rendre_path):
|
||||
print(f"Error: Directory '{TARGET_DIR_NAME}' not found in {work_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.exists(ODS_PATH):
|
||||
print(f"Error: ODS file not found at {ODS_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Opening ODS file: {ODS_PATH}...")
|
||||
try:
|
||||
doc = ezodf.opendoc(str(ODS_PATH))
|
||||
except Exception as e:
|
||||
print(f"Failed to open ODS: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Assuming the data is in the first sheet
|
||||
sheet = doc.sheets[0]
|
||||
|
||||
# Map Student Names to Column Indices
|
||||
# User specified: Names are in the second line (index 1)
|
||||
# User specified: Ignore first 3 columns (0, 1, 2)
|
||||
name_row_index = 1
|
||||
name_to_col = {}
|
||||
|
||||
for col_idx in range(3, sheet.ncols()):
|
||||
cell = sheet[name_row_index, col_idx]
|
||||
if cell.value:
|
||||
# Normalize name: strip spaces
|
||||
name = str(cell.value).strip()
|
||||
name_to_col[name] = col_idx
|
||||
|
||||
print(f"Found {len(name_to_col)} students in ODS.")
|
||||
|
||||
# Iterate over folders in "A Rendre"
|
||||
for item in os.listdir(a_rendre_path):
|
||||
student_dir = os.path.join(a_rendre_path, item)
|
||||
|
||||
# Check if it is a directory and has a name (ignoring the (ID) suffix if present from previous script)
|
||||
# The directory name might be "Name" or "Name (ID)".
|
||||
# The ODS usually contains just "Name".
|
||||
|
||||
if not os.path.isdir(student_dir):
|
||||
continue
|
||||
|
||||
# Extract strict name for ODS matching (remove potential ID suffix if added by previous tool)
|
||||
# Assuming the ODS name matches the folder name prefix
|
||||
# If folder is "Doe John (123)", ODS likely has "Doe John" or "Doe John (123)"?
|
||||
# Based on previous prompt, ODS has "AMELOT Gautier".
|
||||
# We try exact match first, then simplified match.
|
||||
|
||||
json_path = os.path.join(student_dir, "score.json")
|
||||
if not os.path.exists(json_path):
|
||||
continue
|
||||
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
try:
|
||||
scores_data = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
print(f"Error decoding JSON for {item}")
|
||||
continue
|
||||
|
||||
# Determine Column Index
|
||||
col_idx = name_to_col.get(item)
|
||||
|
||||
# If not found exact match, try stripping ID suffix e.g. "Name (123)" -> "Name"
|
||||
if col_idx is None and '(' in item:
|
||||
clean_name = item.rsplit('(', 1)[0].strip()
|
||||
col_idx = name_to_col.get(clean_name)
|
||||
|
||||
if col_idx is None:
|
||||
print(f"Skipping '{item}': Name not found in ODS columns.")
|
||||
continue
|
||||
|
||||
# Sort keys naturally (Ex 2 comes before Ex 10)
|
||||
# sorted_keys = natsorted(scores_data.keys())
|
||||
|
||||
# Start filling from Row 2 (index 2), immediately below the name line
|
||||
start_row = 2
|
||||
|
||||
if args.sum:
|
||||
# Calculate total
|
||||
total = 0.0
|
||||
for val in scores_data.values():
|
||||
try:
|
||||
total += float(val)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
cell = sheet[start_row, col_idx]
|
||||
cell.set_value(total)
|
||||
print(f"Set sum for {item}: {total}")
|
||||
else:
|
||||
for i, key in enumerate(all_labels):
|
||||
row_idx = start_row + i
|
||||
|
||||
# Ensure we don't go out of bounds
|
||||
if row_idx >= sheet.nrows():
|
||||
sheet.append_rows(1)
|
||||
|
||||
if key in scores_data:
|
||||
val_str = str(scores_data[key])
|
||||
else:
|
||||
val_str = ""
|
||||
|
||||
# Logic: if "" -> "NT"
|
||||
new_val = "NT" if val_str == "" else val_str
|
||||
|
||||
cell = sheet[row_idx, col_idx]
|
||||
current_val = cell.value
|
||||
|
||||
# Conflict Detection
|
||||
# Normalize current ODS value to string for comparison
|
||||
# ODS might store 2.0 as float 2.0. JSON has "2.0".
|
||||
is_different = False
|
||||
|
||||
if current_val is not None and current_val != "":
|
||||
# specific check to handle float/string mismatch (2.0 vs "2.0")
|
||||
try:
|
||||
if float(str(current_val)) != float(str(new_val)):
|
||||
is_different = True
|
||||
except ValueError:
|
||||
# If conversion fails (e.g. comparing "NT" to "2.0"), compare strings
|
||||
if str(current_val).strip() != str(new_val).strip():
|
||||
is_different = True
|
||||
|
||||
if is_different:
|
||||
print(f"DEBUG: Conflict for {item} at {key} (Row {row_idx}). "
|
||||
f"Existing: '{current_val}' vs New: '{new_val}'. Overwriting.")
|
||||
|
||||
# Set value
|
||||
# Try to set as float if it looks like a number, otherwise string
|
||||
if new_val == "NT":
|
||||
cell.set_value(new_val)
|
||||
else:
|
||||
try:
|
||||
cell.set_value(float(new_val))
|
||||
except ValueError:
|
||||
cell.set_value(new_val)
|
||||
|
||||
print("Saving ODS file...")
|
||||
doc.save()
|
||||
print("Done.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from copienator import (
|
||||
EvaluationWorkspace,
|
||||
ExitCode,
|
||||
evaluation_parser,
|
||||
execute,
|
||||
read_json,
|
||||
workspace_from_args,
|
||||
)
|
||||
|
||||
COPY_PATTERN = re.compile(r"Copie(\d+)")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
return evaluation_parser("Verify that every answer PDF appears in group metadata.")
|
||||
|
||||
|
||||
def collect_source_pdfs(copies_dir: Path) -> set[tuple[str, str]]:
|
||||
source_pdfs: set[tuple[str, str]] = set()
|
||||
for copy_dir in copies_dir.iterdir():
|
||||
match = COPY_PATTERN.fullmatch(copy_dir.name)
|
||||
if not match or not copy_dir.is_dir():
|
||||
continue
|
||||
copy_id = match.group(1)
|
||||
for pdf_path in copy_dir.glob("*.pdf"):
|
||||
source_pdfs.add((pdf_path.stem, copy_id))
|
||||
return source_pdfs
|
||||
|
||||
|
||||
def collect_grouped_pdfs(groups_dir: Path) -> tuple[set[tuple[str, str]], int]:
|
||||
grouped: set[tuple[str, str]] = set()
|
||||
read_errors = 0
|
||||
for json_path in groups_dir.glob("*/Group_*.json"):
|
||||
try:
|
||||
data = read_json(json_path)
|
||||
if not isinstance(data, list):
|
||||
raise TypeError("expected a JSON array")
|
||||
for entry in data:
|
||||
grouped.add((str(entry[4]), str(entry[0])))
|
||||
except (IndexError, OSError, TypeError, ValueError) as exc:
|
||||
print(f"Error reading {json_path}: {exc}", file=sys.stderr)
|
||||
read_errors += 1
|
||||
return grouped, read_errors
|
||||
|
||||
|
||||
def verify_groups(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
workspace.require_directories("Copies", "Par label")
|
||||
source_pdfs = collect_source_pdfs(workspace.copies_dir)
|
||||
grouped_pdfs, read_errors = collect_grouped_pdfs(workspace.groups_dir)
|
||||
missing = source_pdfs - grouped_pdfs
|
||||
|
||||
if missing:
|
||||
print(f"Verification failed: {len(missing)} files missing from groups:")
|
||||
for label, copy_id in sorted(missing):
|
||||
print(f"Copie{copy_id}/{label}.pdf")
|
||||
return ExitCode.FAILURE
|
||||
if read_errors:
|
||||
print("Verification incomplete because some metadata could not be read.")
|
||||
return ExitCode.PARTIAL
|
||||
print("Verification successful: all files accounted for.")
|
||||
return ExitCode.SUCCESS
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace) -> ExitCode:
|
||||
return verify_groups(workspace)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
return execute(parser, argv, lambda args: run(workspace_from_args(args)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
Reference in New Issue
Block a user