From 550f61b1a815d662b70588841cd4036a5cd6fc35 Mon Sep 17 00:00:00 2001 From: dillonj Date: Tue, 23 Dec 2025 17:38:43 -0700 Subject: [PATCH] trying to improve annotation --- annotation_gui.py | 286 +++++++++++++++++++++++++++++----------------- 1 file changed, 184 insertions(+), 102 deletions(-) diff --git a/annotation_gui.py b/annotation_gui.py index 0a7a820a..590c4aa0 100644 --- a/annotation_gui.py +++ b/annotation_gui.py @@ -80,9 +80,13 @@ CANVAS_GLOBAL_JS = r""" boxes = []; } - const hiddenInput = document.getElementById('canvas-boxes-data'); + const hiddenRoot = document.getElementById('canvas-boxes-data'); + const hiddenInput = hiddenRoot + ? (hiddenRoot.querySelector('textarea, input') || hiddenRoot) + : null; const syncHidden = () => { if (!hiddenInput) return; + if (!('value' in hiddenInput)) return; hiddenInput.value = JSON.stringify(boxes); hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); }; @@ -233,16 +237,23 @@ CANVAS_GLOBAL_JS = r""" const y1 = Math.min(createStart.y, dragStart.y); const x2 = Math.max(createStart.x, dragStart.x); const y2 = Math.max(createStart.y, dragStart.y); - if (x2 - x1 > 10 && y2 - y1 > 10) { - boxes.push({ - bbox: [x1, y1, x2, y2], - label: 'knot', - confidence: 1.0, - source: 'manual', - }); - syncHidden(); - redraw(); + const w = x2 - x1; + const h = y2 - y1; + if (w > 10 && h > 10) { + boxes.push({ bbox: [x1, y1, x2, y2], label: 'knot', confidence: 1.0, source: 'manual' }); + } else { + // Click without drag: create a default-size box around the click. + const size = 120; + const cx = createStart.x; + const cy = createStart.y; + const bx1 = Math.max(0, cx - size / 2); + const by1 = Math.max(0, cy - size / 2); + const bx2 = Math.min(displayWidth, cx + size / 2); + const by2 = Math.min(displayHeight, cy + size / 2); + boxes.push({ bbox: [bx1, by1, bx2, by2], label: 'knot', confidence: 1.0, source: 'manual' }); } + syncHidden(); + redraw(); } isDragging = false; @@ -264,9 +275,13 @@ CANVAS_GLOBAL_JS = r""" const obs = new MutationObserver(() => scan()); obs.observe(document.documentElement, { childList: true, subtree: true }); window.addEventListener('load', () => scan()); - setTimeout(() => scan(), 0); - setTimeout(() => scan(), 250); - setTimeout(() => scan(), 1000); + scan(); + setTimeout(() => scan(), 0); + setTimeout(() => scan(), 100); + setTimeout(() => scan(), 250); + setTimeout(() => scan(), 500); + setTimeout(() => scan(), 1000); + setTimeout(() => scan(), 2000); })(); """ @@ -536,7 +551,7 @@ class AnnotationApp: # Format as readable list lines = ["šŸ“‚ Available Models:"] for i, model in enumerate(available_models, 1): - lines.append(f"{i}. {model['dir']} → {model['path'].name} ({model['type'].upper()})") + lines.append(f"{i}. {model['dir']} -> {model['path'].name} ({model['type'].upper()})") lines.append("\nšŸ’” Use the Model Selector dropdown above to quickly switch models") return "\n".join(lines) @@ -562,14 +577,14 @@ class AnnotationApp: return f"āŒ Model '{model_display}' not found" - def load_new_images_dir(self, images_dir: str) -> tuple[str, str, str]: + def load_new_images_dir(self, images_dir: str) -> tuple[str, str, str, str]: """Load a new images directory from the GUI.""" path = Path(images_dir) if not path.exists(): - return "
Directory not found
", "", f"āŒ Directory not found: {images_dir}" + return "
Directory not found
", "", "Image: -/-", f"āŒ Directory not found: {images_dir}" if not path.is_dir(): - return "
Not a directory
", "", f"āŒ Not a directory: {images_dir}" + return "
Not a directory
", "", "Image: -/-", f"āŒ Not a directory: {images_dir}" result = self._load_images(path) @@ -578,11 +593,12 @@ class AnnotationApp: img, filename = self.get_current_image() boxes = self.annotations.get(filename, []) img_html = self.generate_interactive_canvas(boxes) - boxes_text = self._format_boxes_text(boxes) - info = f"{result}\nImage 1/{len(self.image_paths)}: {filename}" - return img_html, boxes_text, info + boxes_html = self._format_boxes_html(boxes) + image_label = self._current_image_label(filename) + status = result + return img_html, boxes_html, image_label, status else: - return "
No images found
", "", f"{result}\nāš ļø No .jpg or .png images found in directory" + return "
No images found
", "", "Image: -/-", f"{result}\nāš ļø No .jpg or .png images found in directory" def get_current_model_info(self) -> str: """Get info about currently loaded model.""" @@ -597,6 +613,12 @@ class AnnotationApp: def get_current_dir_info(self) -> str: """Get info about current images directory.""" return f"šŸ“ {self.images_dir} ({len(self.image_paths)} images)" + + def _current_image_label(self, filename: str) -> str: + """Stable image index display (kept separate from status messages).""" + if not filename or not self.image_paths: + return "Image: -/-" + return f"Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}" def get_current_image(self) -> tuple[Image.Image, str]: """Get current image and filename.""" @@ -671,30 +693,64 @@ class AnnotationApp:
+ style="position:absolute; left:0; top:0; width:{display_width}px; height:{display_height}px; z-index: 1;" /> + style="position:absolute; left:0; top:0; width:{display_width}px; height:{display_height}px; cursor: crosshair; background: transparent; z-index: 2; pointer-events: auto;">
""" return html_out - def _format_boxes_text(self, boxes: list[dict]) -> str: - """Format boxes for display.""" + def _format_boxes_html(self, boxes: list[dict]) -> str: + """Format boxes as HTML list with delete buttons.""" if not boxes: - return "No annotations" + return "
No annotations
" - lines = [] + lines = ["
"] for i, box in enumerate(boxes): x1, y1, x2, y2 = box["bbox"] conf = box.get("confidence", 1.0) source = box.get("source", "manual") - lines.append(f"{i}: [{x1:.0f}, {y1:.0f}, {x2:.0f}, {y2:.0f}] conf={conf:.2f} ({source})") - - return "\n".join(lines) + lines.append( + f"
" + f"{i}: [{x1:.0f},{y1:.0f},{x2:.0f},{y2:.0f}] {conf:.2f} ({source})" + f"" + f"
" + ) + lines.append("
") + return "".join(lines) + + def _parse_boxes_text(self, text: str) -> list[dict] | None: + """Parse edited JSON from the Annotations textbox.""" + if not text: + return None + try: + data = json.loads(text) + except json.JSONDecodeError: + return None + if not isinstance(data, list): + return None + cleaned: list[dict] = [] + for item in data: + if not isinstance(item, dict) or "bbox" not in item: + continue + bbox = item.get("bbox") + if not (isinstance(bbox, list) and len(bbox) == 4): + continue + try: + x1, y1, x2, y2 = [float(v) for v in bbox] + except Exception: + continue + cleaned.append({ + "bbox": [x1, y1, x2, y2], + "label": item.get("label", "knot"), + "confidence": float(item.get("confidence", 1.0)), + "source": item.get("source", "manual"), + }) + return cleaned - def load_image(self, direction: str = "current") -> tuple[str, str, str]: + def load_image(self, direction: str = "current") -> tuple[str, str, str, str]: """Load image (current/next/prev).""" if direction == "next": self.current_idx = min(self.current_idx + 1, len(self.image_paths) - 1) @@ -703,21 +759,21 @@ class AnnotationApp: img, filename = self.get_current_image() if not img: - return "
No images
", "", "No images" + return "
No images
", "", "Image: -/-", "No images" # Load existing annotations boxes = self.annotations.get(filename, []) img_html = self.generate_interactive_canvas(boxes) - boxes_text = self._format_boxes_text(boxes) - info = f"Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}" - - return img_html, boxes_text, info + boxes_html = self._format_boxes_html(boxes) + image_label = self._current_image_label(filename) + + return img_html, boxes_html, image_label, "" - def add_box_manual(self, x1: int, y1: int, x2: int, y2: int) -> tuple[str, str, str]: + def add_box_manual(self, x1: int, y1: int, x2: int, y2: int) -> tuple[str, str, str, str]: """Manually add a bounding box.""" img, filename = self.get_current_image() if not img: - return "
No images
", "", "No images" + return "
No images
", "", "Image: -/-", "No images" # Add box box = { @@ -735,16 +791,14 @@ class AnnotationApp: # Redraw boxes = self.annotations[filename] img_html = self.generate_interactive_canvas(boxes) - boxes_text = self._format_boxes_text(boxes) - info = f"āœ“ Added box: {len(boxes)} total | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}" - - return img_html, boxes_text, info + boxes_html = self._format_boxes_html(boxes) + return img_html, boxes_html, self._current_image_label(filename), f"āœ“ Added box: {len(boxes)} total" - def delete_last_box(self) -> tuple[str, str, str]: + def delete_last_box(self) -> tuple[str, str, str, str]: """Delete the last box from current image.""" img, filename = self.get_current_image() if not img: - return "
No images
", "", "No images" + return "
No images
", "", "Image: -/-", "No images" if filename in self.annotations and self.annotations[filename]: self.annotations[filename].pop() @@ -753,35 +807,50 @@ class AnnotationApp: # Redraw boxes = self.annotations.get(filename, []) img_html = self.generate_interactive_canvas(boxes) - boxes_text = self._format_boxes_text(boxes) - info = f"āœ“ Deleted last box: {len(boxes)} remaining | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}" - - return img_html, boxes_text, info + boxes_html = self._format_boxes_html(boxes) + return img_html, boxes_html, self._current_image_label(filename), f"āœ“ Deleted last box: {len(boxes)} remaining" - def clear_boxes(self) -> tuple[str, str, str]: + def delete_box_by_index(self, index: int) -> tuple[str, str, str, str]: + """Delete a specific box by index.""" + img, filename = self.get_current_image() + if not img: + return "
No images
", "", "Image: -/-", "No images" + + boxes = self.annotations.get(filename, []) + if 0 <= index < len(boxes): + boxes.pop(index) + self.annotations[filename] = boxes + self._save_annotations() + img_html = self.generate_interactive_canvas(boxes) + boxes_html = self._format_boxes_html(boxes) + return img_html, boxes_html, self._current_image_label(filename), f"āœ“ Deleted box {index}" + else: + img_html = self.generate_interactive_canvas(boxes) + boxes_html = self._format_boxes_html(boxes) + return img_html, boxes_html, self._current_image_label(filename), "āŒ Invalid box index" + + def clear_boxes(self) -> tuple[str, str, str, str]: """Clear all boxes from current image.""" img, filename = self.get_current_image() if not img: - return "
No images
", "", "No images" + return "
No images
", "", "Image: -/-", "No images" self.annotations[filename] = [] self._save_annotations() boxes = [] img_html = self.generate_interactive_canvas(boxes) - boxes_text = "No annotations" - info = f"āœ“ Cleared all boxes | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}" - - return img_html, boxes_text, info + boxes_html = self._format_boxes_html(boxes) + return img_html, boxes_html, self._current_image_label(filename), "āœ“ Cleared all boxes" - def auto_label_current(self, threshold: float = 0.5) -> tuple[str, str, str]: + def auto_label_current(self, threshold: float = 0.5) -> tuple[str, str, str, str]: """Auto-label current image using loaded model.""" if not self.model: - return "
No model loaded
", "", "āŒ No model loaded" + return "
No model loaded
", "", "Image: -/-", "āŒ No model loaded" img, filename = self.get_current_image() if not img: - return "
No images
", "", "No images" + return "
No images
", "", "Image: -/-", "No images" try: # Run inference based on model type @@ -828,40 +897,37 @@ class AnnotationApp: # Redraw img_html = self.generate_interactive_canvas(self.annotations[filename]) - boxes_text = self._format_boxes_text(self.annotations[filename]) - info = f"šŸ¤– Auto-labeled: {len(boxes)} detections | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}" - - return img_html, boxes_text, info + boxes_html = self._format_boxes_html(self.annotations[filename]) + return img_html, boxes_html, self._current_image_label(filename), f"šŸ¤– Auto-labeled: {len(boxes)} detections" except Exception as e: boxes = self.annotations.get(filename, []) img_html = self.generate_interactive_canvas(boxes) - return img_html, self._format_boxes_text(boxes), f"āŒ Auto-label failed: {e}" + return img_html, self._format_boxes_html(boxes), self._current_image_label(filename), f"āŒ Auto-label failed: {e}" - def save_canvas_changes(self, boxes_json: str) -> tuple[str, str, str]: - """Save changes made in the interactive canvas.""" + def save_canvas_changes(self, boxes_json: str) -> tuple[str, str, str, str]: + """Auto-save changes made in the canvas.""" img, filename = self.get_current_image() if not img: - return "
No images
", "", "No images" - + return "
No images
", "", "Image: -/-", "No images" + try: - # Parse the boxes from JSON if boxes_json: boxes = json.loads(boxes_json) self.annotations[filename] = boxes self._save_annotations() - info = f"āœ“ Saved {len(boxes)} boxes | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}" + status = "" else: boxes = self.annotations.get(filename, []) - info = f"āœ“ No changes to save | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}" + status = "" except json.JSONDecodeError: boxes = self.annotations.get(filename, []) - info = f"āŒ Invalid boxes data | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}" - + status = "āŒ Invalid canvas data" + img_html = self.generate_interactive_canvas(boxes) - boxes_text = self._format_boxes_text(boxes) - - return img_html, boxes_text, info + boxes_html = self._format_boxes_html(boxes) + + return img_html, boxes_html, self._current_image_label(filename), status def _save_annotations(self): """Save annotations to JSON file.""" @@ -1089,7 +1155,7 @@ class AnnotationApp: self.training_process.wait() if self.training_process.returncode == 0: - self.training_status = "āœ… Training completed successfully!" + self.training_status = "[OK] Training completed successfully!" # Reload model with new weights if framework == "RF-DETR": # RF-DETR uses checkpoint_best_total.pth @@ -1229,7 +1295,7 @@ class AnnotationApp: return ( f"āœ“ {model_type.upper()} exported to ONNX!\n" f"šŸ“ Output: {output_path}\n" - f"šŸ”— Next: Convert ONNX → RVC using HubAI (online) or ModelConverter (offline).\n" + f"Next: Convert ONNX -> RVC using HubAI (online) or ModelConverter (offline).\n" f"Docs: https://docs.luxonis.com/software-v3/ai-inference/conversion/\n" f"šŸ’” Offline conversion: Use Luxonis ModelConverter with Docker\n" f"āš ļø OpenVINO export not available: {str(e)}" @@ -1245,8 +1311,8 @@ def create_ui(app: AnnotationApp) -> gr.Blocks: with gr.Blocks(title="Knot Annotation Tool") as demo: gr.Markdown(""" - # 🪵 Wood Knot Annotation Tool - **Label → Train → Auto-Label → Repeat** + # Wood Knot Annotation Tool + **Label -> Train -> Auto-Label -> Repeat** - Manually annotate images or use **Auto-Label** with your trained model - Export and prepare dataset for training @@ -1256,7 +1322,7 @@ def create_ui(app: AnnotationApp) -> gr.Blocks: """) # Settings section at the top - with gr.Accordion("āš™ļø Settings", open=False): + with gr.Accordion("Settings", open=False): with gr.Row(): with gr.Column(): images_dir_input = gr.Textbox( @@ -1307,7 +1373,6 @@ def create_ui(app: AnnotationApp) -> gr.Blocks: prev_btn = gr.Button("ā¬…ļø Previous") next_btn = gr.Button("Next āž”ļø") auto_label_btn = gr.Button("šŸ¤– Auto-Label", variant="primary") - save_canvas_btn = gr.Button("šŸ’¾ Save Canvas Changes") # Hidden textbox to store canvas boxes data canvas_boxes_data = gr.Textbox(visible=False, elem_id="canvas-boxes-data") @@ -1316,8 +1381,10 @@ def create_ui(app: AnnotationApp) -> gr.Blocks: threshold_slider = gr.Slider(0.1, 0.9, DEFAULT_DETECTION_THRESHOLD, label="Detection Threshold") with gr.Column(scale=1): + image_index_text = gr.Textbox(label="Image", lines=1, interactive=False) info_text = gr.Textbox(label="Status", lines=2) - boxes_text = gr.Textbox(label="Annotations", lines=10) + boxes_html = gr.HTML(label="Annotations") + delete_box_index = gr.Number(visible=False, value=-1) gr.Markdown("### Manual Annotation") with gr.Row(): @@ -1336,11 +1403,11 @@ def create_ui(app: AnnotationApp) -> gr.Blocks: label="Export Path", value="annotations_coco.json" ) - export_btn = gr.Button("šŸ’¾ Export COCO") + export_btn = gr.Button("Export COCO") export_result = gr.Textbox(label="Export Result", lines=1) # Training tab - with gr.Tab("šŸŽÆ Training"): + with gr.Tab("Training"): gr.Markdown(""" ### Train Object Detection Model @@ -1350,7 +1417,7 @@ def create_ui(app: AnnotationApp) -> gr.Blocks: - **YOLOv6** (MIT): Fast, proven on OAK cameras - **YOLOX** (MIT): Similar to YOLOv6, slight differences - **All MIT/Apache 2.0 licensed - free for commercial use!** āœ… + **All MIT/Apache 2.0 licensed - free for commercial use!** **Steps:** 1. Annotate at least 50-100 images in the Annotation tab @@ -1492,7 +1559,7 @@ def create_ui(app: AnnotationApp) -> gr.Blocks: load_images_btn.click( app.load_new_images_dir, inputs=[images_dir_input], - outputs=[image_display, boxes_text, info_text] + outputs=[image_display, boxes_html, image_index_text, info_text] ).then( lambda: (app.get_current_dir_info(), app.get_current_model_info()), outputs=[dir_info, model_info] @@ -1526,47 +1593,48 @@ def create_ui(app: AnnotationApp) -> gr.Blocks: prev_btn.click( lambda: app.load_image("prev"), - outputs=[image_display, boxes_text, info_text] + outputs=[image_display, boxes_html, image_index_text, info_text] ) next_btn.click( lambda: app.load_image("next"), - outputs=[image_display, boxes_text, info_text] + outputs=[image_display, boxes_html, image_index_text, info_text] ) auto_label_btn.click( lambda t: app.auto_label_current(t), inputs=[threshold_slider], - outputs=[image_display, boxes_text, info_text] + outputs=[image_display, boxes_html, image_index_text, info_text] ) - save_canvas_btn.click( + # Auto-save when canvas changes + canvas_boxes_data.change( app.save_canvas_changes, inputs=[canvas_boxes_data], - outputs=[image_display, boxes_text, info_text], - js="""() => { - const hiddenInput = document.getElementById('canvas-boxes-data'); - if (hiddenInput) { - return hiddenInput.value; - } - return ''; - }""" + outputs=[image_display, boxes_html, image_index_text, info_text] + ) + + # Delete box handler (called from HTML button clicks via JS) + delete_box_index.change( + lambda idx: app.delete_box_by_index(int(idx)) if idx >= 0 else (None, None, None, None), + inputs=[delete_box_index], + outputs=[image_display, boxes_html, image_index_text, info_text] ) add_box_btn.click( app.add_box_manual, inputs=[x1_input, y1_input, x2_input, y2_input], - outputs=[image_display, boxes_text, info_text] + outputs=[image_display, boxes_html, image_index_text, info_text] ) delete_btn.click( app.delete_last_box, - outputs=[image_display, boxes_text, info_text] + outputs=[image_display, boxes_html, image_index_text, info_text] ) clear_btn.click( app.clear_boxes, - outputs=[image_display, boxes_text, info_text] + outputs=[image_display, boxes_html, image_index_text, info_text] ) export_btn.click( @@ -1614,7 +1682,7 @@ def create_ui(app: AnnotationApp) -> gr.Blocks: ) # Load first image on start - demo.load(on_load, outputs=[image_display, boxes_text, info_text]) + demo.load(on_load, outputs=[image_display, boxes_html, image_index_text, info_text]) return demo @@ -1674,10 +1742,24 @@ def main(): print(f"šŸ’” You can change images directory and model weights from the Settings panel") print(f"{'='*60}\n") + # Combine canvas JS with delete button handler + combined_js = CANVAS_GLOBAL_JS + r""" +// Wire delete buttons to hidden number input +window.deleteBox = function(index) { + const hiddenInput = document.querySelector('input[type=number][style*=display], input[type=number].\\!hidden'); + if (hiddenInput) { + hiddenInput.value = index; + hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); + // Reset after triggering + setTimeout(() => { hiddenInput.value = -1; }, 100); + } +}; +""" + demo.launch( server_name="0.0.0.0", server_port=args.port, - js=CANVAS_GLOBAL_JS, + js=combined_js, share=False )