diff --git a/annotating.py b/annotating.py index f2b5788..88a0106 100644 --- a/annotating.py +++ b/annotating.py @@ -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") diff --git a/annotating_with_checks.py b/annotating_with_checks.py index 0ad8c77..78ec7d4 100644 --- a/annotating_with_checks.py +++ b/annotating_with_checks.py @@ -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__": diff --git a/reading_annotations.py b/reading_annotations.py index 7fd7cc9..1fd8225 100644 --- a/reading_annotations.py +++ b/reading_annotations.py @@ -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,7 +291,8 @@ def apply_actions_and_regenerate(root_dir, data, student_id, actions, notes_laye label_img = new_l draw = ImageDraw.Draw(label_img, "RGBA") - label_img.paste(txt, (10, int(py)), mask=txt) + if not "to_delete" in fb: + label_img.paste(txt, (10, int(py)), mask=txt) last_bot = py + txt.height final_concats.append(label_img)