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
)