Working state, mostly…

master
Sébastien Miquel 2026-02-09 22:45:04 +01:00
parent c94c773c10
commit 7656ca652f
3 changed files with 132 additions and 116 deletions

View File

@ -199,6 +199,75 @@ 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
ymin, xmin, ymax, xmax = box[0], box[1], box[2], box[3]
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
draw.rectangle([target_xmin, target_ymin, target_xmax, target_ymax], outline="red", width=3)
# 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
)
# Calculate placement
txt_h = txt_img.height
center_y = (target_ymin + target_ymax) / 2
paste_y = center_y - (txt_h / 2)
paste_y = max(paste_y, image_offset_y)
# Prevent overlap with previous text
if paste_y < last_text_bottom:
paste_y = last_text_bottom + 5 # Move down + padding
# 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")
# 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")
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 process_correction(root_dir, data, all_labels, overwrite=False):
for student_id, labels in data.items():
@ -234,19 +303,7 @@ def process_correction(root_dir, data, all_labels, overwrite=False):
# 2. Convert PDF to Image
try:
pages = convert_from_path(pdf_full_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
(base_img, total_h, max_w) = annotating.make_base_image(pdf_full_path)
except Exception as e:
print(f"Error converting {pdf_full_path}: {e}")
continue
@ -305,56 +362,7 @@ def process_correction(root_dir, data, all_labels, overwrite=False):
last_text_bottom = 0
for fb in local_fb:
# raw_pos = fb.get('pos')
box = fb.get('box_2d')
if not box or len(box) < 4:
continue
ymin, xmin, ymax, xmax = box[0], box[1], box[2], box[3]
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
draw.rectangle([target_xmin, target_ymin, target_xmax, target_ymax], outline="red", width=3)
# 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
)
# Calculate placement
txt_h = txt_img.height
center_y = (target_ymin + target_ymax) / 2
paste_y = center_y - (txt_h / 2)
paste_y = max(paste_y, image_offset_y)
# Prevent overlap with previous text
if paste_y < last_text_bottom:
paste_y = last_text_bottom + 5 # Move down + padding
# 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")
# Paste in the left margin
final_img.paste(txt_img, (10, int(paste_y)), mask=txt_img)
last_text_bottom = paste_y + txt_h
(draw, final_img, last_text_bottom) = add_local_fb(fb, draw, final_img, hmin, image_offset_y, last_text_bottom)
# 7. Save Image
save_path = os.path.join(output_dir, f"{label}.jpg")

View File

@ -15,7 +15,7 @@ from annotating import MARGIN_LEFT, ANNOT_WIDTH
# Global lock for Matplotlib/Latex rendering to prevent race conditions
LATEX_LOCK = threading.Lock()
DPI = 100
BOX_SIZE = 30
SCORE_BOX_SIZE = 30
SCORES = [x * 0.5 for x in range(9)] # 0.0 to 4.0
@ -40,21 +40,6 @@ def safe_render_latex(text, **kwargs):
with LATEX_LOCK:
return annotating.render_latex_text(text, **kwargs)
def create_base_image(pdf_path):
"""Converts PDF pages to a single vertically stacked image."""
try:
pages = annotating.convert_from_path(pdf_path)
total_h = sum(page.height for page in pages)
max_w = max(page.width for page in pages)
base_img = Image.new("RGBA", (max_w, total_h), "white")
cy = 0
for page in pages:
base_img.paste(page.convert("RGBA"), (0, cy))
cy += page.height
return base_img
except Exception:
return None
def render_header(label, score, feedbacks, base_width):
"""Generates the score line and global feedback elements."""
elements = []
@ -101,7 +86,7 @@ def process_label(root_dir, student_id, label, content):
if not os.path.exists(pdf_path):
return None, []
base_img = create_base_image(pdf_path)
base_img, total_h, max_w = annotating.make_base_image(pdf_path)
if not base_img:
return None, []
@ -156,11 +141,12 @@ def process_label(root_dir, student_id, label, content):
rect_cb_box = draw_checkbox(draw, target_xmax - BOX_SIZE, target_ymin, BOX_SIZE)
checkbox_map.append({
"type": "del_local", "label": label, "index": i,
"type": "del_local_rect", "label": label, "index": i,
"text_preview": fb['text'][:20], "final_box": rect_cb_box
})
txt_img_raw = safe_render_latex(fb['text'], width_px=ANNOT_WIDTH, bg_color=(255, 200, 200, 180), max_lines=3)
txt_img_raw = safe_render_latex(fb['text'], width_px=ANNOT_WIDTH,
bg_color=(255, 200, 200, 180), max_lines=3)
container_h = max(txt_img_raw.height, BOX_SIZE)
txt_img = Image.new("RGBA", (ANNOT_WIDTH, container_h), (255, 255, 255, 0))
txt_img.paste(txt_img_raw, (0, 0))
@ -170,7 +156,8 @@ def process_label(root_dir, student_id, label, content):
center_y = (target_ymin + target_ymax) / 2
paste_y = max(center_y - (txt_img.height / 2), image_offset_y)
if paste_y < last_text_bottom: paste_y = last_text_bottom + 5
if paste_y < last_text_bottom:
paste_y = last_text_bottom + 5
req_h = int(paste_y + txt_img.height + 20)
if req_h > final_img.height:
@ -238,7 +225,7 @@ def process_student(args):
json.dump(final_json_map, f, indent=2)
concat_img.save(os.path.join(output_dir, "Reference.png"))
concat_img.save(os.path.join(output_dir, "Concat.pdf"), "PDF", resolution=100.0)
concat_img.save(os.path.join(output_dir, "Concat.pdf"), "PDF", resolution=DPI)
if __name__ == "__main__":

View File

@ -8,6 +8,8 @@ Image.MAX_IMAGE_PIXELS = None
from pdf2image import convert_from_path
import annotating # Reuse rendering logic
DPI = 100
def detect_checks_and_notes(output_dir):
"""
Returns:
@ -33,11 +35,11 @@ def detect_checks_and_notes(output_dir):
# Warning: If the PDF is huge, pdf2image might split pages or OOM.
# Assuming user didn't change page dimensions/order.
try:
user_pages = convert_from_path(pdf_path)
user_pages = convert_from_path(pdf_path, dpi=DPI)
except Exception as e:
print(f"Error reading PDF: {e}")
return [], None
print("Debug : user_pages", len(user_pages))
# print("Debug : user_pages", len(user_pages))
# Concatenate PDF pages back to one image if user saved as multiple pages
# (Xournal++ might preserve the long format or split it)
total_h = sum(p.height for p in user_pages)
@ -133,6 +135,12 @@ def detect_checks_and_notes(output_dir):
from PIL import ImageDraw
import re
def natural_key(text):
return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', str(text))]
from annotating import MARGIN_LEFT, ANNOT_WIDTH
def apply_actions_and_regenerate(root_dir, data, student_id, actions, notes_layer):
"""
Modifies data based on actions, calls annotating.process_correction logic,
@ -148,10 +156,12 @@ def apply_actions_and_regenerate(root_dir, data, student_id, actions, notes_laye
actions_by_label = {}
for a in actions:
l = a['label']
if l not in actions_by_label: actions_by_label[l] = []
if l not in actions_by_label:
actions_by_label[l] = []
actions_by_label[l].append(a)
for label, acts in actions_by_label.items():
for label, acts in sorted(actions_by_label.items(), key=lambda x: natural_key(x[0])):
# print(label)
if label not in labels: continue
content = labels[label]
@ -162,12 +172,13 @@ def apply_actions_and_regenerate(root_dir, data, student_id, actions, notes_laye
global_fb_indices = [i for i, f in enumerate(feedbacks) if not f.get('box_2d')]
local_fb_indices = [i for i, f in enumerate(feedbacks) if f.get('box_2d')]
# Sort local by Y to match generation order in annotating.py
local_fb_sorted_map = sorted(local_fb_indices, key=lambda i: feedbacks[i]['box_2d'][0])
local_fb_sorted_map = sorted(local_fb_indices,
key=lambda i: feedbacks[i]['box_2d'][0])
items_to_remove = set()
for act in acts:
if act['type'] == 'set_score':
if act['type'] == 'score':
result['score'] = act['value']
print(f" > Updated score for {label} to {act['value']}")
@ -176,57 +187,53 @@ def apply_actions_and_regenerate(root_dir, data, student_id, actions, notes_laye
# We need to find the actual index in the main list
if act['index'] < len(global_fb_indices):
real_idx = global_fb_indices[act['index']]
items_to_remove.add(real_idx)
feedbacks[real_idx]["to_delete"] = None
print(f" > Deleted global feedback in {label}")
elif act['type'] == 'del_local':
# act['index'] is index in sorted local list
if act['index'] < len(local_fb_sorted_map):
real_idx = local_fb_sorted_map[act['index']]
items_to_remove.add(real_idx)
feedbacks[real_idx]["to_delete"] = None
print(f" > Deleted local feedback in {label}")
elif act['type'] == 'del_local_rect':
# act['index'] is index in sorted local list
if act['index'] < len(local_fb_sorted_map):
real_idx = local_fb_sorted_map[act['index']]
feedbacks[real_idx]["norectangle"] = None
print(f" > Deleted rect of local feedback in {label}")
# Remove feedbacks (in reverse to preserve indices)
for idx in sorted(list(items_to_remove), reverse=True):
del feedbacks[idx]
# for idx in sorted(list(items_to_remove), reverse=True):
# del feedbacks[idx]
# 2. Regenerate Clean Image
# We use a temporary modified dictionary
temp_data = {student_id: labels}
# Run the original process (but we need to intercept it to not save, or just let it save)
# annotating.process_correction saves to "Anot_CopieID".
# We want "Bnot_CopieID" (updated).
# Hijack the output dir in logic or copy code?
# Easiest: Let's create a temporary helper or modify annotating logic slightly?
# The prompt implies we use `annotating.py` logic.
# Let's call `annotating.process_correction` but point it to a temp root or modify path?
# No, `process_correction` takes `root_dir` and writes to `Anot_...`.
# Let's just implement the rendering loop here to be safe and clean,
# overlaying the notes at the end.
output_dir = os.path.join(root_dir, "Bnot", f"Copie{student_id}")
# Don't delete output_dir, we need it.
# ... (Reuse rendering logic from annotating.py exactly) ...
# See below for condensed integration
final_concats = []
for label, content in labels.items():
for label, content in sorted(labels.items(), key=lambda x: natural_key(x[0])):
# ... [PDF to Image Conversion] ...
copie_folder = f"Copie{student_id}"
pdf_path = os.path.join(root_dir, copie_folder, f"{label}.pdf")
if not os.path.exists(pdf_path): continue
pages = annotating.convert_from_path(pdf_path)
base_img = Image.new("RGBA", (max(p.width for p in pages), sum(p.height for p in pages)), "white")
y=0
for p in pages: base_img.paste(p.convert("RGBA"), (0,y)); y+=p.height
(base_img, total_h, max_w) = annotating.make_base_image(pdf_path)
# ... [Draw Header/Margin (Clean)] ...
margin_left = 200
margin_left = MARGIN_LEFT
result = content['result']
coordinates = content.get('coordinates', (0,0))
hmin = coordinates[0]
@ -234,7 +241,7 @@ def apply_actions_and_regenerate(root_dir, data, student_id, actions, notes_laye
score_text = f"{label} ; Note : {result.get('score', 0)}"
if result.get('error') and result.get('error') != "null": score_text += f" | Error: {result.get('error')}"
header_imgs = [annotating.render_latex_text(score_text, base_img.width, fontsize=18)]
header_imgs = [(annotating.render_latex_text(score_text, base_img.width, fontsize=18), True)]
feedbacks = result.get('feedback', [])
# Separate again (now cleaned)
@ -242,13 +249,21 @@ def apply_actions_and_regenerate(root_dir, data, student_id, actions, notes_laye
local_fb = [f for f in feedbacks if f.get('box_2d')]
local_fb.sort(key=lambda x: x['box_2d'][0])
for fb in global_fb: header_imgs.append(annotating.render_latex_text(fb['text'], base_img.width))
for fb in global_fb:
render = annotating.render_latex_text(fb['text'], base_img.width)
header_imgs.append((render, "to_delete" not in fb))
total_h = base_img.height + sum(i.height for i in header_imgs)
total_h = base_img.height + sum(i.height for (i,_) in header_imgs)
label_img = Image.new("RGB", (base_img.width + margin_left, total_h), "white")
cy = 0
for i in header_imgs: label_img.paste(i, (0, cy)); cy+=i.height
for (i, keep) in header_imgs:
if keep:
label_img.paste(i, (0, cy))
else:
blank = Image.new("RGB", (i.width, i.height), "white")
label_img.paste(blank, (0, cy))
cy+=i.height
img_offset_y = cy
label_img.paste(base_img, (margin_left, img_offset_y))
@ -256,14 +271,19 @@ def apply_actions_and_regenerate(root_dir, data, student_id, actions, notes_laye
last_bot = 0
for fb in local_fb:
box = fb['box_2d']
ymin, xmin, ymax, xmax = box
t_ymin = (ymin - hmin) + img_offset_y
t_ymax = (ymax - hmin) + img_offset_y
draw.rectangle([xmin+margin_left, t_ymin, xmax+margin_left, t_ymax], outline="red", width=3)
if "norectangle" not in fb:
draw.rectangle([xmin+margin_left, t_ymin, xmax+margin_left, t_ymax],
outline="red", width=3)
txt = annotating.render_latex_text(fb['text'], 500, (255,200,200,180), max_lines=3)
txt = annotating.render_latex_text(fb['text'], ANNOT_WIDTH,
(255,200,200,180), max_lines=3)
py = max((t_ymin+t_ymax)/2 - txt.height/2, img_offset_y)
if py < last_bot: py = last_bot + 5
if py < last_bot:
py = last_bot + 5
if py + txt.height + 20 > label_img.height:
new_l = Image.new("RGB", (label_img.width, int(py+txt.height+20)), "white")
@ -271,6 +291,7 @@ def apply_actions_and_regenerate(root_dir, data, student_id, actions, notes_laye
label_img = new_l
draw = ImageDraw.Draw(label_img, "RGBA")
if not "to_delete" in fb:
label_img.paste(txt, (10, int(py)), mask=txt)
last_bot = py + txt.height