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 = [];
}
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 "<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():
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)
@ -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 "<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:
"""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:
<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;">
<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}"
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>
"""
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 "<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):
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"<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)
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 "<div>No images</div>", "", "No images"
return "<div>No images</div>", "", "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 "<div>No images</div>", "", "No images"
return "<div>No images</div>", "", "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 "<div>No images</div>", "", "No images"
return "<div>No images</div>", "", "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 "<div>No images</div>", "", "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 "<div>No images</div>", "", "No images"
return "<div>No images</div>", "", "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 "<div>No model loaded</div>", "", "❌ No model loaded"
return "<div>No model loaded</div>", "", "Image: -/-", "❌ No model loaded"
img, filename = self.get_current_image()
if not img:
return "<div>No images</div>", "", "No images"
return "<div>No images</div>", "", "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 "<div>No images</div>", "", "No images"
return "<div>No images</div>", "", "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
)