Refactor of all annotating things.

This commit is contained in:
2026-02-15 11:33:58 +01:00
parent d725b9edbc
commit 5c25ed42a3
3 changed files with 237 additions and 394 deletions
+150 -156
View File
@@ -2,6 +2,8 @@ import sys
import os
import json
import glob
from pathlib import Path
import subprocess
from PIL import Image
MARGIN_LEFT = 300
@@ -81,6 +83,22 @@ def make_dictionary(root_dir):
return result_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)
import io
import shutil
from pdf2image import convert_from_path
@@ -111,7 +129,6 @@ def normalize_mathtext(text):
text = re.sub('\u0010', "", text)
return text
import re
def wrap_latex_text(text, width_chars):
"""
Wraps text but keeps LaTeX math blocks ($...$) intact.
@@ -178,8 +195,6 @@ def render_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=N
fig = plt.figure(figsize=(fig_width, fig_height), dpi=dpi)
# print(wrapped_text)
# print("\n\n")
# NOTE: wrap=False because we did it ourselves
plt.text(0.01, 0.95, wrapped_text, fontsize=fontsize,
verticalalignment='top', horizontalalignment='left',
@@ -199,74 +214,129 @@ def render_latex_text(text, width_px, bg_color=(255, 255, 255, 255), max_lines=N
final_img.alpha_composite(img)
return final_img
def add_local_fb(fb, draw, final_img, hmin, image_offset_y, last_text_bottom):
# raw_pos = fb.get('pos')
box = fb.get('box_2d')
if not box or len(box) < 4:
return
def compose_label_image(base_img, label, result, hmin,
render_fn=render_latex_text,
draw_callback=None):
"""
Composes the final image with annotations.
ymin, xmin, ymax, xmax = box[0], box[1], box[2], box[3]
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.
"""
score = result.get('score', 0)
error = result.get('error', "")
feedbacks = result.get('feedback', [])
target_ymin = (ymin - hmin) + image_offset_y
target_ymax = (ymax - hmin) + image_offset_y
target_xmin = xmin + MARGIN_LEFT
target_xmax = xmax + MARGIN_LEFT
# Filter deleted items (used by reading_annotations.py)
feedbacks = [f for f in feedbacks if "to_delete" not in f]
# Draw Rectangle
draw.rectangle([target_xmin, target_ymin, target_xmax, target_ymax], outline="red", width=3)
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])
# Render Text with transparent red background
# (255, 0, 0, 50) is transparent red
txt_img = render_latex_text(
fb['text'],
width_px=ANNOT_WIDTH,
bg_color=(255, 200, 200, 180), # Light Red semi-transparent
max_lines=3
)
# 1. Prepare Headers
header_elements = []
# Calculate placement
txt_h = txt_img.height
center_y = (target_ymin + target_ymax) / 2
paste_y = center_y - (txt_h / 2)
# Score
score_text = f"{label} ; Note : {score}"
if error and error != "null": score_text += f" | Error: {error}"
paste_y = max(paste_y, image_offset_y)
img_score = render_fn(score_text, base_img.width // 2, fontsize=18)
header_elements.append({"type": "score", "img": img_score, "data": result})
# Prevent overlap with previous text
if paste_y < last_text_bottom:
paste_y = last_text_bottom + 5 # Move down + padding
# 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})
# Check for overflow and resize if necessary
required_height = int(paste_y + txt_h + 20) # +20 for bottom padding
if required_height > final_img.height:
# Create a new taller image
new_final = Image.new("RGB", (final_img.width, required_height), "white")
# Paste the current image content onto the new one
new_final.paste(final_img, (0, 0))
final_img = new_final
# Re-initialize the draw object for the new image so subsequent rectangles are drawn correctly
draw = ImageDraw.Draw(final_img, "RGBA")
# 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")
# Paste in the left margin
final_img.paste(txt_img, (10, int(paste_y)), mask=txt_img)
last_text_bottom = paste_y + txt_h
return (draw, final_img, last_text_bottom)
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")
# Draw Headers
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)
draw = ImageDraw.Draw(final_img, "RGBA")
for el in header_elements:
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
target_xmax = xmax + MARGIN_LEFT
# 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=3)
# 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
def natural_key(text):
return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', str(text))]
def process_correction(root_dir, data, all_labels, overwrite=False):
for student_id, labels in data.items():
@@ -274,8 +344,6 @@ def process_correction(root_dir, data, all_labels, overwrite=False):
# Prepare output directory: Dir/Anot_CopieID
output_dir = os.path.join(root_dir, "Anot", f"Copie{student_id}")
# output_dir = os.path.join(root_dir, f"Anot_Copie{student_id}")
# Check if already processed (Concat.jpg exists)
concat_path = os.path.join(output_dir, "Concat.jpg")
if os.path.exists(concat_path) and not overwrite:
@@ -290,8 +358,11 @@ def process_correction(root_dir, data, all_labels, overwrite=False):
os.makedirs(output_dir)
d_notes = dict.fromkeys(all_labels,"")
label_images = []
for label, content in labels.items():
labels = sorted(list(labels.items()), key=natural_key)
for label, content in labels:
# 1. Find PDF path
copie_folder = f"Copie{student_id}"
pdf_rel_path = os.path.join(copie_folder, f"{label}.pdf")
@@ -303,114 +374,37 @@ def process_correction(root_dir, data, all_labels, overwrite=False):
# 2. Convert PDF to Image
try:
(base_img, total_h, max_w) = annotating.make_base_image(pdf_full_path)
(base_img, _, _) = make_base_image(pdf_full_path)
except Exception as e:
print(f"Error converting {pdf_full_path}: {e}")
continue
coordinates = content.get('coordinates', (0, 0)) # (hmin, hmax)
hmin = coordinates[0]
result = content.get('result', {})
coordinates = content.get('coordinates', (0, 0)) # (hmin, hmax)
score = result.get('score', 0)
error = result.get('error', "")
feedbacks = result.get('feedback', [])
# Organize feedbacks
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')]
# Sort local feedback by Y position
local_fb.sort(key=lambda x: x['box_2d'][0])
# --- PREPARE HEADERS ---
header_elements = []
score_text = f"{label} ; Note : {score}"
d_notes[label] = str(score)
if error and error != "null":
score_text += f" | Error: {error}"
# Render Row 1
row1_img = render_latex_text(score_text, base_img.width,fontsize=18)
header_elements.append(row1_img)
# --- OTHER HEADERS
# Render Global Feedbacks (Rows 2+)
for fb in global_fb:
fb_img = render_latex_text(fb['text'], base_img.width)
header_elements.append(fb_img)
# Calculate total new height
header_height = sum(img.height for img 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")
# Paste Headers
current_y = 0
for elem in header_elements:
final_img.paste(elem, (0, current_y))
current_y += elem.height
# Paste Original Image
# Note: current_y is now the offset for the actual image content
image_offset_y = current_y
final_img.paste(base_img, (MARGIN_LEFT, image_offset_y))
# --- DRAW LOCAL ANNOTATIONS ---
draw = ImageDraw.Draw(final_img, "RGBA")
last_text_bottom = 0
for fb in local_fb:
(draw, final_img, last_text_bottom) = add_local_fb(fb, draw, final_img, hmin, image_offset_y, last_text_bottom)
final_img = compose_label_image(base_img, label, result, coordinates[0])
# 7. Save Image
save_path = os.path.join(output_dir, f"{label}.jpg")
final_img.save(save_path)
label_images.append(final_img)
json_path = os.path.join(output_dir, "score.json")
with open(json_path, "w") as f:
# Save scores
with open(os.path.join(output_dir, "score.json"), "w") as f:
json.dump(d_notes, f, indent=4)
concat_display_image(output_dir)
from pathlib import Path
import subprocess
def concat_display_image(subdir):
subdir = Path(subdir)
# Find valid images, excluding previous concatenations
images = sorted([
f for f in subdir.glob("*.jpg")
if f.name != "Concat.jpg"
])
if not images:
return
images.sort(key=lambda f: [int(n) for n in re.findall(r'\d+', str(f))])
# Load images
opened_imgs = [Image.open(img) for img in images]
# Calculate dimensions (max width, sum of heights)
max_w = max(i.width for i in opened_imgs)
total_h = sum(i.height for i in opened_imgs)
# Create canvas and paste vertically
canvas = Image.new('RGB', (max_w, total_h))
current_y = 0
for img in opened_imgs:
canvas.paste(img, (0, current_y))
current_y += img.height
# Save
save_path = subdir / "Concat.jpg"
canvas.save(save_path)
print(f"Saved: {save_path}")
# subprocess.call(('xdg-open', save_path))
# Concatenate
if label_images:
max_w = max(i.width for i in label_images)
total_h = sum(i.height for i in label_images)
canvas = Image.new('RGB', (max_w, total_h))
cy = 0
for img in label_images:
canvas.paste(img, (0, cy))
cy += img.height
canvas.save(concat_path)
import argparse
if __name__ == "__main__":