diff --git a/annotating.py b/annotating.py index 88a0106..4a4ecfc 100644 --- a/annotating.py +++ b/annotating.py @@ -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__": diff --git a/annotating_with_checks.py b/annotating_with_checks.py index 2fa7e0f..10835ae 100644 --- a/annotating_with_checks.py +++ b/annotating_with_checks.py @@ -37,145 +37,62 @@ def draw_checkbox(draw, x, y, size=BOX_SIZE, label=None, fill="white"): return [x, y, x + size, y + size] -def safe_render_latex(text, **kwargs): +def safe_render_latex(*args, **kwargs): """Thread-safe wrapper for latex rendering.""" with LATEX_LOCK: - return annotating.render_latex_text(text, **kwargs) + return annotating.render_latex_text(*args, **kwargs) -def render_header(label, score, feedbacks, base_width): - """Generates the score line and global feedback elements.""" - elements = [] +class CheckboxRenderer: + def __init__(self, label_name): + self.label = label_name + self.checkboxes = [] # List of {type, box, etc.} - # Score Line - score_text_img = safe_render_latex(f"{label} ; Note : {score}", width_px=base_width // 2, fontsize=18) - score_line_h = max(score_text_img.height, SCORE_BOX_SIZE + 10) - score_line_img = Image.new("RGBA", (base_width, score_line_h), (255, 255, 255, 0)) - score_line_img.paste(score_text_img, (0, 0)) + def callback(self, kind, draw, pos, meta): + """ + Called by compose_label_image during rendering. + pos contains {x, y, w, h} or {box}. + meta contains {data, index, etc.} + """ + if kind == "header_item": + # meta['data'] is either result object (for score) or feedback object + if meta.get("type") == "score": + # Draw score boxes + start_x = pos['w'] + 20 + for val in SCORES: + box = draw_checkbox(draw, start_x, pos['y'] + 5, BOX_SIZE, str(val)) + self.checkboxes.append({ + "type": "score", "label": self.label, "value": val, + "rel_box": box # Will be adjusted for global Y later + }) + start_x += BOX_SIZE + 60 + elif meta.get("type") == "global_fb": + # Draw delete box for global feedback + bx = pos['w'] - BOX_SIZE - 5 + by = pos['y'] + 5 + box = draw_checkbox(draw, bx, by, BOX_SIZE) + self.checkboxes.append({ + "type": "del_global", "label": self.label, "index": meta["index"], + "rel_box": box, "text_preview": meta["data"]["text"][:20] + }) - draw_score = ImageDraw.Draw(score_line_img) - start_x = score_text_img.width + 20 - local_boxes = [] + elif kind == "local_rect": + # Delete rect checkbox + b = pos['box'] # [xmin, ymin, xmax, ymax] + box = draw_checkbox(draw, b[2] - BOX_SIZE, b[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] + }) - for val in SCORES: - box = draw_checkbox(draw_score, start_x, 5, SCORE_BOX_SIZE, str(val)) - local_boxes.append({ - "type": "score", "label": label, "value": val, - "rel_box": box, "elem_y": 0 - }) - start_x += SCORE_BOX_SIZE + 60 - elements.append((score_line_img, local_boxes)) - - # Global Feedback - for i, fb in enumerate(feedbacks): - fb_img = safe_render_latex(fb['text'], width_px=base_width) - draw_fb = ImageDraw.Draw(fb_img) - bx = fb_img.width - BOX_SIZE - 5 - by = 5 - box = draw_checkbox(draw_fb, bx, by, BOX_SIZE) - - elements.append((fb_img, [{ - "type": "del_global", "label": label, "index": i, - "text_preview": fb['text'][:20], "rel_box": box, "elem_y": 0 - }])) - - return elements - -def process_label(root_dir, student_id, label, content): - """Processes a single label (PDF) -> Annotated Image + Checkboxes.""" - 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): - return None, [] - base_img, total_h, max_w = annotating.make_base_image(pdf_path) - if not base_img: - return None, [] - - # Extract Data - coordinates = content.get('coordinates', (0, 0)) - hmin = coordinates[0] - result = content.get('result', {}) - score = result.get('score', 0) - feedbacks = result.get('feedback', []) - - 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]) - - checkbox_map = [] - - # 1. Render Header - header_elements = render_header(label, score, global_fb, base_img.width) - header_height = sum(x[0].height for x in header_elements) - - # 2. Assemble Base + Header - total_height = base_img.height + header_height - final_img = Image.new("RGB", (base_img.width + MARGIN_LEFT, total_height), "white") - - current_y = 0 - for img, boxes in header_elements: - final_img.paste(img, (0, current_y)) - for b in boxes: - b['final_box'] = [b['rel_box'][0], b['rel_box'][1] + current_y, - b['rel_box'][2], b['rel_box'][3] + current_y] - checkbox_map.append(b) - current_y += img.height - - image_offset_y = current_y - final_img.paste(base_img, (MARGIN_LEFT, image_offset_y)) - - # 3. Draw Local Annotations - draw = ImageDraw.Draw(final_img, "RGBA") - last_text_bottom = 0 - - for i, fb in enumerate(local_fb): - box = fb.get('box_2d') - if not box: continue - - 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([target_xmin, target_ymin, target_xmax, target_ymax], outline="red", width=3) - - rect_cb_box = draw_checkbox(draw, target_xmax - BOX_SIZE, target_ymin, BOX_SIZE) - checkbox_map.append({ - "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) - 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)) - - d_txt = ImageDraw.Draw(txt_img) - draw_checkbox(d_txt, ANNOT_WIDTH - BOX_SIZE, 0, BOX_SIZE) - - 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 - - req_h = int(paste_y + txt_img.height + 20) - if req_h > final_img.height: - new_final = Image.new("RGB", (final_img.width, req_h), "white") - new_final.paste(final_img, (0,0)) - final_img = new_final - draw = ImageDraw.Draw(final_img, "RGBA") - - final_img.paste(txt_img, (10, int(paste_y)), mask=txt_img) - checkbox_map.append({ - "type": "del_local", "label": label, "index": i, - "text_preview": fb['text'][:20], - "final_box": [10 + ANNOT_WIDTH - BOX_SIZE, int(paste_y), 10 + ANNOT_WIDTH, int(paste_y) + BOX_SIZE] - }) - last_text_bottom = paste_y + txt_img.height - - return final_img, checkbox_map + elif kind == "local_text": + # Delete whole local feedback checkbox + bx = pos['x'] + pos['w'] - BOX_SIZE + by = pos['y'] + box = draw_checkbox(draw, bx, by, BOX_SIZE) + self.checkboxes.append({ + "type": "del_local", "label": self.label, "index": meta["index"], + "final_box": box, "text_preview": meta["data"]["text"][:20] + }) import re def natural_key(text): @@ -192,19 +109,31 @@ def process_student(args): os.makedirs(output_dir) label_images = [] - student_checkboxes = [] - processed_labels_order = [] + all_checkboxes = [] + sorted_labels = sorted(labels.items(), key=lambda x: natural_key(x[0])) - for label, content in sorted(labels.items(), key=lambda x: natural_key(x[0])): - img, boxes = process_label(root_dir, student_id, label, content) - if img: - label_images.append(img) - student_checkboxes.append(boxes) - processed_labels_order.append(label) + for label, content in sorted_labels: + pdf_path = content['pdf_path'] + if not os.path.exists(pdf_path): continue - if not label_images: - return + base_img, _, _ = annotating.make_base_image(pdf_path) + # Initialize the hook + cb_renderer = CheckboxRenderer(label) + + # Render using the shared engine + final_img = annotating.compose_label_image( + base_img, label, content['result'], content['coordinates'][0], + render_fn=safe_render_latex, + draw_callback=cb_renderer.callback + ) + + label_images.append(final_img) + all_checkboxes.append(cb_renderer.checkboxes) + + if not label_images: return + + # Concatenate max_w = max(i.width for i in label_images) total_h = sum(i.height for i in label_images) concat_img = Image.new("RGB", (max_w, total_h), "white") @@ -212,11 +141,14 @@ def process_student(args): final_json_map = [] current_y = 0 - for label_name, img, boxes in zip(processed_labels_order, label_images, student_checkboxes): + for img, boxes in zip(label_images, all_checkboxes): concat_img.paste(img, (0, current_y)) + # Adjust coordinates for concatenated image for item in boxes: - b = item['final_box'] + # item might have 'rel_box' (header) or 'final_box' (local) + # Both were relative to the label image. We just add current_y. + b = item.get('final_box') or item.get('rel_box') item['global_box'] = [b[0], b[1] + current_y, b[2], b[3] + current_y] final_json_map.append(item) @@ -254,7 +186,6 @@ def process_student(args): # concat_img.save(os.path.join(output_dir, "Concat.pdf"), "PDF", resolution=72.0) # concat_img.save(os.path.join(output_dir, "Reference.jpg")) - if __name__ == "__main__": if len(sys.argv) < 2: print("Usage: python annotating_with_checks.py