trying to improve annotation
This commit is contained in:
@ -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,16 +237,23 @@ 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;
|
||||||
syncHidden();
|
const cx = createStart.x;
|
||||||
redraw();
|
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;
|
isDragging = 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());
|
||||||
setTimeout(() => scan(), 0);
|
scan();
|
||||||
setTimeout(() => scan(), 250);
|
setTimeout(() => scan(), 0);
|
||||||
setTimeout(() => scan(), 1000);
|
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
|
# 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."""
|
||||||
@ -597,6 +613,12 @@ class AnnotationApp:
|
|||||||
def get_current_dir_info(self) -> str:
|
def get_current_dir_info(self) -> str:
|
||||||
"""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."""
|
||||||
@ -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;'>"
|
||||||
return "\n".join(lines)
|
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)."""
|
"""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]:
|
def delete_last_box(self) -> tuple[str, 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 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."""
|
"""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]:
|
def auto_label_current(self, threshold: float = 0.5) -> tuple[str, 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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user