trying to improve annotation

This commit is contained in:
2025-12-23 17:38:43 -07:00
parent 50b2bf0305
commit 550f61b1a8

View File

@ -80,9 +80,13 @@ CANVAS_GLOBAL_JS = r"""
boxes = []; 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 = () => { const syncHidden = () => {
if (!hiddenInput) return; if (!hiddenInput) return;
if (!('value' in hiddenInput)) return;
hiddenInput.value = JSON.stringify(boxes); hiddenInput.value = JSON.stringify(boxes);
hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
}; };
@ -233,17 +237,24 @@ CANVAS_GLOBAL_JS = r"""
const y1 = Math.min(createStart.y, dragStart.y); const y1 = Math.min(createStart.y, dragStart.y);
const x2 = Math.max(createStart.x, dragStart.x); const x2 = Math.max(createStart.x, dragStart.x);
const y2 = Math.max(createStart.y, dragStart.y); const y2 = Math.max(createStart.y, dragStart.y);
if (x2 - x1 > 10 && y2 - y1 > 10) { const w = x2 - x1;
boxes.push({ const h = y2 - y1;
bbox: [x1, y1, x2, y2], if (w > 10 && h > 10) {
label: 'knot', boxes.push({ bbox: [x1, y1, x2, y2], label: 'knot', confidence: 1.0, source: 'manual' });
confidence: 1.0, } else {
source: 'manual', // 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(); syncHidden();
redraw(); redraw();
} }
}
isDragging = false; isDragging = false;
creatingBox = false; creatingBox = false;
@ -264,9 +275,13 @@ CANVAS_GLOBAL_JS = r"""
const obs = new MutationObserver(() => scan()); const obs = new MutationObserver(() => scan());
obs.observe(document.documentElement, { childList: true, subtree: true }); obs.observe(document.documentElement, { childList: true, subtree: true });
window.addEventListener('load', () => scan()); window.addEventListener('load', () => scan());
scan();
setTimeout(() => scan(), 0); setTimeout(() => scan(), 0);
setTimeout(() => scan(), 100);
setTimeout(() => scan(), 250); setTimeout(() => scan(), 250);
setTimeout(() => scan(), 500);
setTimeout(() => scan(), 1000); setTimeout(() => scan(), 1000);
setTimeout(() => scan(), 2000);
})(); })();
""" """
@ -536,7 +551,7 @@ class AnnotationApp:
# Format as readable list # Format as readable list
lines = ["📂 Available Models:"] lines = ["📂 Available Models:"]
for i, model in enumerate(available_models, 1): 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") lines.append("\n💡 Use the Model Selector dropdown above to quickly switch models")
return "\n".join(lines) return "\n".join(lines)
@ -562,14 +577,14 @@ class AnnotationApp:
return f"❌ Model '{model_display}' not found" 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.""" """Load a new images directory from the GUI."""
path = Path(images_dir) path = Path(images_dir)
if not path.exists(): if not path.exists():
return "<div>Directory not found</div>", "", f"❌ Directory not found: {images_dir}" return "<div>Directory not found</div>", "", "Image: -/-", f"❌ Directory not found: {images_dir}"
if not path.is_dir(): if not path.is_dir():
return "<div>Not a directory</div>", "", f"❌ Not a directory: {images_dir}" return "<div>Not a directory</div>", "", "Image: -/-", f"❌ Not a directory: {images_dir}"
result = self._load_images(path) result = self._load_images(path)
@ -578,11 +593,12 @@ class AnnotationApp:
img, filename = self.get_current_image() img, filename = self.get_current_image()
boxes = self.annotations.get(filename, []) boxes = self.annotations.get(filename, [])
img_html = self.generate_interactive_canvas(boxes) img_html = self.generate_interactive_canvas(boxes)
boxes_text = self._format_boxes_text(boxes) boxes_html = self._format_boxes_html(boxes)
info = f"{result}\nImage 1/{len(self.image_paths)}: {filename}" image_label = self._current_image_label(filename)
return img_html, boxes_text, info status = result
return img_html, boxes_html, image_label, status
else: else:
return "<div>No images found</div>", "", f"{result}\n⚠️ No .jpg or .png images found in directory" return "<div>No images found</div>", "", "Image: -/-", f"{result}\n⚠️ No .jpg or .png images found in directory"
def get_current_model_info(self) -> str: def get_current_model_info(self) -> str:
"""Get info about currently loaded model.""" """Get info about currently loaded model."""
@ -598,6 +614,12 @@ class AnnotationApp:
"""Get info about current images directory.""" """Get info about current images directory."""
return f"📁 {self.images_dir} ({len(self.image_paths)} images)" 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]: def get_current_image(self) -> tuple[Image.Image, str]:
"""Get current image and filename.""" """Get current image and filename."""
if not self.image_paths: if not self.image_paths:
@ -671,30 +693,64 @@ class AnnotationApp:
<textarea id="annotation-initial-boxes" style="display:none;">{boxes_escaped}</textarea> <textarea id="annotation-initial-boxes" style="display:none;">{boxes_escaped}</textarea>
<div data-annotation-root="1" style="position: relative; width: {display_width}px; height: {display_height}px;"> <div data-annotation-root="1" style="position: relative; width: {display_width}px; height: {display_height}px;">
<img id="annotation-img" src="data:image/png;base64,{img_base64}" <img id="annotation-img" src="data:image/png;base64,{img_base64}"
style="position:absolute; left:0; top:0; width:{display_width}px; height:{display_height}px;" /> style="position:absolute; left:0; top:0; width:{display_width}px; height:{display_height}px; z-index: 1;" />
<canvas id="annotation-canvas" width="{display_width}" height="{display_height}" <canvas id="annotation-canvas" width="{display_width}" height="{display_height}"
style="position:absolute; left:0; top:0; width:{display_width}px; height:{display_height}px; cursor: crosshair; background: transparent;"></canvas> 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;"></canvas>
</div> </div>
</div> </div>
""" """
return html_out return html_out
def _format_boxes_text(self, boxes: list[dict]) -> str: def _format_boxes_html(self, boxes: list[dict]) -> str:
"""Format boxes for display.""" """Format boxes as HTML list with delete buttons."""
if not boxes: if not boxes:
return "No annotations" return "<div style='color: #999; font-style: italic;'>No annotations</div>"
lines = [] lines = ["<div style='font-family: monospace; font-size: 13px;'>"]
for i, box in enumerate(boxes): for i, box in enumerate(boxes):
x1, y1, x2, y2 = box["bbox"] x1, y1, x2, y2 = box["bbox"]
conf = box.get("confidence", 1.0) conf = box.get("confidence", 1.0)
source = box.get("source", "manual") source = box.get("source", "manual")
lines.append(f"{i}: [{x1:.0f}, {y1:.0f}, {x2:.0f}, {y2:.0f}] conf={conf:.2f} ({source})") lines.append(
f"<div style='margin: 4px 0; padding: 4px; background: #f5f5f5; border-radius: 3px; display: flex; justify-content: space-between; align-items: center;'>"
f"<span style='flex: 1;'>{i}: [{x1:.0f},{y1:.0f},{x2:.0f},{y2:.0f}] {conf:.2f} ({source})</span>"
f"<button onclick='window.deleteBox({i})' style='background: #ff4444; color: white; border: none; padding: 2px 8px; border-radius: 3px; cursor: pointer; margin-left: 8px;'>✕</button>"
f"</div>"
)
lines.append("</div>")
return "".join(lines)
return "\n".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).""" """Load image (current/next/prev)."""
if direction == "next": if direction == "next":
self.current_idx = min(self.current_idx + 1, len(self.image_paths) - 1) 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() img, filename = self.get_current_image()
if not img: if not img:
return "<div>No images</div>", "", "No images" return "<div>No images</div>", "", "Image: -/-", "No images"
# Load existing annotations # Load existing annotations
boxes = self.annotations.get(filename, []) boxes = self.annotations.get(filename, [])
img_html = self.generate_interactive_canvas(boxes) img_html = self.generate_interactive_canvas(boxes)
boxes_text = self._format_boxes_text(boxes) boxes_html = self._format_boxes_html(boxes)
info = f"Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}" image_label = self._current_image_label(filename)
return img_html, boxes_text, info 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.""" """Manually add a bounding box."""
img, filename = self.get_current_image() img, filename = self.get_current_image()
if not img: if not img:
return "<div>No images</div>", "", "No images" return "<div>No images</div>", "", "Image: -/-", "No images"
# Add box # Add box
box = { box = {
@ -735,16 +791,14 @@ class AnnotationApp:
# Redraw # Redraw
boxes = self.annotations[filename] boxes = self.annotations[filename]
img_html = self.generate_interactive_canvas(boxes) img_html = self.generate_interactive_canvas(boxes)
boxes_text = self._format_boxes_text(boxes) boxes_html = self._format_boxes_html(boxes)
info = f"✓ Added box: {len(boxes)} total | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}" return img_html, boxes_html, self._current_image_label(filename), f"✓ Added box: {len(boxes)} total"
return img_html, boxes_text, info def delete_last_box(self) -> tuple[str, str, str, str]:
def delete_last_box(self) -> tuple[str, str, str]:
"""Delete the last box from current image.""" """Delete the last box from current image."""
img, filename = self.get_current_image() img, filename = self.get_current_image()
if not img: if not img:
return "<div>No images</div>", "", "No images" return "<div>No images</div>", "", "Image: -/-", "No images"
if filename in self.annotations and self.annotations[filename]: if filename in self.annotations and self.annotations[filename]:
self.annotations[filename].pop() self.annotations[filename].pop()
@ -753,35 +807,50 @@ class AnnotationApp:
# Redraw # Redraw
boxes = self.annotations.get(filename, []) boxes = self.annotations.get(filename, [])
img_html = self.generate_interactive_canvas(boxes) img_html = self.generate_interactive_canvas(boxes)
boxes_text = self._format_boxes_text(boxes) boxes_html = self._format_boxes_html(boxes)
info = f"✓ Deleted last box: {len(boxes)} remaining | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}" return img_html, boxes_html, self._current_image_label(filename), f"✓ Deleted last box: {len(boxes)} remaining"
return img_html, boxes_text, info 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 "<div>No images</div>", "", "Image: -/-", "No images"
def clear_boxes(self) -> tuple[str, str, str]: 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.""" """Clear all boxes from current image."""
img, filename = self.get_current_image() img, filename = self.get_current_image()
if not img: if not img:
return "<div>No images</div>", "", "No images" return "<div>No images</div>", "", "Image: -/-", "No images"
self.annotations[filename] = [] self.annotations[filename] = []
self._save_annotations() self._save_annotations()
boxes = [] boxes = []
img_html = self.generate_interactive_canvas(boxes) img_html = self.generate_interactive_canvas(boxes)
boxes_text = "No annotations" boxes_html = self._format_boxes_html(boxes)
info = f"✓ Cleared all boxes | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}" return img_html, boxes_html, self._current_image_label(filename), "✓ Cleared all boxes"
return img_html, boxes_text, info def auto_label_current(self, threshold: float = 0.5) -> tuple[str, str, str, str]:
def auto_label_current(self, threshold: float = 0.5) -> tuple[str, str, str]:
"""Auto-label current image using loaded model.""" """Auto-label current image using loaded model."""
if not self.model: if not self.model:
return "<div>No model loaded</div>", "", "❌ No model loaded" return "<div>No model loaded</div>", "", "Image: -/-", "❌ No model loaded"
img, filename = self.get_current_image() img, filename = self.get_current_image()
if not img: if not img:
return "<div>No images</div>", "", "No images" return "<div>No images</div>", "", "Image: -/-", "No images"
try: try:
# Run inference based on model type # Run inference based on model type
@ -828,40 +897,37 @@ class AnnotationApp:
# Redraw # Redraw
img_html = self.generate_interactive_canvas(self.annotations[filename]) img_html = self.generate_interactive_canvas(self.annotations[filename])
boxes_text = self._format_boxes_text(self.annotations[filename]) boxes_html = self._format_boxes_html(self.annotations[filename])
info = f"🤖 Auto-labeled: {len(boxes)} detections | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}" return img_html, boxes_html, self._current_image_label(filename), f"🤖 Auto-labeled: {len(boxes)} detections"
return img_html, boxes_text, info
except Exception as e: except Exception as e:
boxes = self.annotations.get(filename, []) boxes = self.annotations.get(filename, [])
img_html = self.generate_interactive_canvas(boxes) 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]: def save_canvas_changes(self, boxes_json: str) -> tuple[str, str, str, str]:
"""Save changes made in the interactive canvas.""" """Auto-save changes made in the canvas."""
img, filename = self.get_current_image() img, filename = self.get_current_image()
if not img: if not img:
return "<div>No images</div>", "", "No images" return "<div>No images</div>", "", "Image: -/-", "No images"
try: try:
# Parse the boxes from JSON
if boxes_json: if boxes_json:
boxes = json.loads(boxes_json) boxes = json.loads(boxes_json)
self.annotations[filename] = boxes self.annotations[filename] = boxes
self._save_annotations() self._save_annotations()
info = f"✓ Saved {len(boxes)} boxes | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}" status = ""
else: else:
boxes = self.annotations.get(filename, []) 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: except json.JSONDecodeError:
boxes = self.annotations.get(filename, []) 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) img_html = self.generate_interactive_canvas(boxes)
boxes_text = self._format_boxes_text(boxes) boxes_html = self._format_boxes_html(boxes)
return img_html, boxes_text, info return img_html, boxes_html, self._current_image_label(filename), status
def _save_annotations(self): def _save_annotations(self):
"""Save annotations to JSON file.""" """Save annotations to JSON file."""
@ -1089,7 +1155,7 @@ class AnnotationApp:
self.training_process.wait() self.training_process.wait()
if self.training_process.returncode == 0: if self.training_process.returncode == 0:
self.training_status = " Training completed successfully!" self.training_status = "[OK] Training completed successfully!"
# Reload model with new weights # Reload model with new weights
if framework == "RF-DETR": if framework == "RF-DETR":
# RF-DETR uses checkpoint_best_total.pth # RF-DETR uses checkpoint_best_total.pth
@ -1229,7 +1295,7 @@ class AnnotationApp:
return ( return (
f"{model_type.upper()} exported to ONNX!\n" f"{model_type.upper()} exported to ONNX!\n"
f"📁 Output: {output_path}\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"Docs: https://docs.luxonis.com/software-v3/ai-inference/conversion/\n"
f"💡 Offline conversion: Use Luxonis ModelConverter with Docker\n" f"💡 Offline conversion: Use Luxonis ModelConverter with Docker\n"
f"⚠️ OpenVINO export not available: {str(e)}" 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: with gr.Blocks(title="Knot Annotation Tool") as demo:
gr.Markdown(""" gr.Markdown("""
# 🪵 Wood Knot Annotation Tool # Wood Knot Annotation Tool
**Label Train Auto-Label Repeat** **Label -> Train -> Auto-Label -> Repeat**
- Manually annotate images or use **Auto-Label** with your trained model - Manually annotate images or use **Auto-Label** with your trained model
- Export and prepare dataset for training - Export and prepare dataset for training
@ -1256,7 +1322,7 @@ def create_ui(app: AnnotationApp) -> gr.Blocks:
""") """)
# Settings section at the top # Settings section at the top
with gr.Accordion("⚙️ Settings", open=False): with gr.Accordion("Settings", open=False):
with gr.Row(): with gr.Row():
with gr.Column(): with gr.Column():
images_dir_input = gr.Textbox( images_dir_input = gr.Textbox(
@ -1307,7 +1373,6 @@ def create_ui(app: AnnotationApp) -> gr.Blocks:
prev_btn = gr.Button("⬅️ Previous") prev_btn = gr.Button("⬅️ Previous")
next_btn = gr.Button("Next ➡️") next_btn = gr.Button("Next ➡️")
auto_label_btn = gr.Button("🤖 Auto-Label", variant="primary") auto_label_btn = gr.Button("🤖 Auto-Label", variant="primary")
save_canvas_btn = gr.Button("💾 Save Canvas Changes")
# Hidden textbox to store canvas boxes data # Hidden textbox to store canvas boxes data
canvas_boxes_data = gr.Textbox(visible=False, elem_id="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") threshold_slider = gr.Slider(0.1, 0.9, DEFAULT_DETECTION_THRESHOLD, label="Detection Threshold")
with gr.Column(scale=1): with gr.Column(scale=1):
image_index_text = gr.Textbox(label="Image", lines=1, interactive=False)
info_text = gr.Textbox(label="Status", lines=2) 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") gr.Markdown("### Manual Annotation")
with gr.Row(): with gr.Row():
@ -1336,11 +1403,11 @@ def create_ui(app: AnnotationApp) -> gr.Blocks:
label="Export Path", label="Export Path",
value="annotations_coco.json" 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) export_result = gr.Textbox(label="Export Result", lines=1)
# Training tab # Training tab
with gr.Tab("🎯 Training"): with gr.Tab("Training"):
gr.Markdown(""" gr.Markdown("""
### Train Object Detection Model ### Train Object Detection Model
@ -1350,7 +1417,7 @@ def create_ui(app: AnnotationApp) -> gr.Blocks:
- **YOLOv6** (MIT): Fast, proven on OAK cameras - **YOLOv6** (MIT): Fast, proven on OAK cameras
- **YOLOX** (MIT): Similar to YOLOv6, slight differences - **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:** **Steps:**
1. Annotate at least 50-100 images in the Annotation tab 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( load_images_btn.click(
app.load_new_images_dir, app.load_new_images_dir,
inputs=[images_dir_input], inputs=[images_dir_input],
outputs=[image_display, boxes_text, info_text] outputs=[image_display, boxes_html, image_index_text, info_text]
).then( ).then(
lambda: (app.get_current_dir_info(), app.get_current_model_info()), lambda: (app.get_current_dir_info(), app.get_current_model_info()),
outputs=[dir_info, model_info] outputs=[dir_info, model_info]
@ -1526,47 +1593,48 @@ def create_ui(app: AnnotationApp) -> gr.Blocks:
prev_btn.click( prev_btn.click(
lambda: app.load_image("prev"), 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( next_btn.click(
lambda: app.load_image("next"), 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( auto_label_btn.click(
lambda t: app.auto_label_current(t), lambda t: app.auto_label_current(t),
inputs=[threshold_slider], 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, app.save_canvas_changes,
inputs=[canvas_boxes_data], inputs=[canvas_boxes_data],
outputs=[image_display, boxes_text, info_text], outputs=[image_display, boxes_html, image_index_text, info_text]
js="""() => { )
const hiddenInput = document.getElementById('canvas-boxes-data');
if (hiddenInput) { # Delete box handler (called from HTML button clicks via JS)
return hiddenInput.value; delete_box_index.change(
} lambda idx: app.delete_box_by_index(int(idx)) if idx >= 0 else (None, None, None, None),
return ''; inputs=[delete_box_index],
}""" outputs=[image_display, boxes_html, image_index_text, info_text]
) )
add_box_btn.click( add_box_btn.click(
app.add_box_manual, app.add_box_manual,
inputs=[x1_input, y1_input, x2_input, y2_input], 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( delete_btn.click(
app.delete_last_box, app.delete_last_box,
outputs=[image_display, boxes_text, info_text] outputs=[image_display, boxes_html, image_index_text, info_text]
) )
clear_btn.click( clear_btn.click(
app.clear_boxes, app.clear_boxes,
outputs=[image_display, boxes_text, info_text] outputs=[image_display, boxes_html, image_index_text, info_text]
) )
export_btn.click( export_btn.click(
@ -1614,7 +1682,7 @@ def create_ui(app: AnnotationApp) -> gr.Blocks:
) )
# Load first image on start # 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 return demo
@ -1674,10 +1742,24 @@ def main():
print(f"💡 You can change images directory and model weights from the Settings panel") print(f"💡 You can change images directory and model weights from the Settings panel")
print(f"{'='*60}\n") 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( demo.launch(
server_name="0.0.0.0", server_name="0.0.0.0",
server_port=args.port, server_port=args.port,
js=CANVAS_GLOBAL_JS, js=combined_js,
share=False share=False
) )