2025-12-22 14:11:39 -07:00
|
|
|
|
"""
|
|
|
|
|
|
Simple customizable annotation GUI with auto-labeling support.
|
|
|
|
|
|
|
|
|
|
|
|
Built with Gradio - easy to modify and extend.
|
|
|
|
|
|
Run: python annotation_gui.py
|
|
|
|
|
|
|
|
|
|
|
|
To set default paths, edit config.py
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
2025-12-23 16:55:59 -07:00
|
|
|
|
import html
|
2025-12-22 14:11:39 -07:00
|
|
|
|
import json
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
import threading
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
import gradio as gr
|
|
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
|
|
|
|
|
|
|
|
# Try to load config, use fallbacks if not available
|
|
|
|
|
|
try:
|
|
|
|
|
|
from config import (
|
|
|
|
|
|
DEFAULT_IMAGES_DIR, DEFAULT_MODEL_WEIGHTS, DEFAULT_PORT,
|
|
|
|
|
|
DEFAULT_DETECTION_THRESHOLD, DEFAULT_TRAIN_EPOCHS,
|
|
|
|
|
|
DEFAULT_BATCH_SIZE, DEFAULT_LEARNING_RATE, DEFAULT_MODEL_SIZE
|
|
|
|
|
|
)
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
DEFAULT_IMAGES_DIR = None
|
|
|
|
|
|
DEFAULT_MODEL_WEIGHTS = None
|
|
|
|
|
|
DEFAULT_PORT = 7860
|
|
|
|
|
|
DEFAULT_DETECTION_THRESHOLD = 0.5
|
|
|
|
|
|
DEFAULT_TRAIN_EPOCHS = 20
|
|
|
|
|
|
DEFAULT_BATCH_SIZE = 4
|
|
|
|
|
|
DEFAULT_LEARNING_RATE = 1e-4
|
|
|
|
|
|
DEFAULT_MODEL_SIZE = "small"
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
# Gradio 6 sanitizes <script> tags inside gr.HTML content. Also, js_on_load only
|
|
|
|
|
|
# runs when the component is first mounted, not when its HTML updates. We
|
|
|
|
|
|
# therefore install a global initializer (via demo.launch(js=...)) and have
|
|
|
|
|
|
# js_on_load call into it when available.
|
2025-12-23 16:55:59 -07:00
|
|
|
|
CANVAS_JS_ON_LOAD = r"""
|
|
|
|
|
|
(() => {
|
2025-12-23 17:04:59 -07:00
|
|
|
|
if (window.__initAnnotationCanvas) {
|
|
|
|
|
|
window.__initAnnotationCanvas(element);
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
|
|
|
|
|
"""
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
CANVAS_GLOBAL_JS = r"""
|
|
|
|
|
|
(() => {
|
|
|
|
|
|
function init(root) {
|
|
|
|
|
|
if (!root) return;
|
|
|
|
|
|
const canvas = root.querySelector('#annotation-canvas');
|
|
|
|
|
|
const imgEl = root.querySelector('#annotation-img');
|
|
|
|
|
|
const initialBoxesEl = root.querySelector('#annotation-initial-boxes');
|
|
|
|
|
|
if (!canvas || !imgEl || !initialBoxesEl) return;
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
// Ensure we don't double-bind if Gradio reuses the DOM node.
|
|
|
|
|
|
if (canvas.dataset.bound === '1') {
|
|
|
|
|
|
// Still redraw in case boxes were updated.
|
|
|
|
|
|
if (canvas.__redraw) canvas.__redraw();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
canvas.dataset.bound = '1';
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
|
const displayWidth = canvas.width;
|
|
|
|
|
|
const displayHeight = canvas.height;
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
let boxes = [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
const raw = initialBoxesEl.value || initialBoxesEl.textContent || '[]';
|
|
|
|
|
|
boxes = JSON.parse(raw);
|
|
|
|
|
|
if (!Array.isArray(boxes)) boxes = [];
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
boxes = [];
|
|
|
|
|
|
}
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
const hiddenInput = document.getElementById('canvas-boxes-data');
|
|
|
|
|
|
const syncHidden = () => {
|
|
|
|
|
|
if (!hiddenInput) return;
|
|
|
|
|
|
hiddenInput.value = JSON.stringify(boxes);
|
|
|
|
|
|
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
|
|
};
|
|
|
|
|
|
syncHidden();
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
let isDragging = false;
|
|
|
|
|
|
let dragStart = null;
|
|
|
|
|
|
let selectedCorner = null;
|
|
|
|
|
|
let selectedBoxIndex = -1;
|
|
|
|
|
|
let creatingBox = false;
|
|
|
|
|
|
let createStart = null;
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
function redraw() {
|
|
|
|
|
|
ctx.clearRect(0, 0, displayWidth, displayHeight);
|
|
|
|
|
|
// Base image is rendered via <img> below the canvas.
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
boxes.forEach((box) => {
|
|
|
|
|
|
const [x1, y1, x2, y2] = box.bbox;
|
|
|
|
|
|
const label = box.label || 'knot';
|
|
|
|
|
|
const conf = box.confidence || 1.0;
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
ctx.strokeStyle = 'red';
|
|
|
|
|
|
ctx.lineWidth = 3;
|
|
|
|
|
|
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
const handleSize = 6;
|
|
|
|
|
|
ctx.fillStyle = 'red';
|
|
|
|
|
|
ctx.fillRect(x1 - handleSize, y1 - handleSize, handleSize * 2, handleSize * 2);
|
|
|
|
|
|
ctx.fillRect(x2 - handleSize, y1 - handleSize, handleSize * 2, handleSize * 2);
|
|
|
|
|
|
ctx.fillRect(x1 - handleSize, y2 - handleSize, handleSize * 2, handleSize * 2);
|
|
|
|
|
|
ctx.fillRect(x2 - handleSize, y2 - handleSize, handleSize * 2, handleSize * 2);
|
|
|
|
|
|
|
|
|
|
|
|
ctx.fillStyle = 'red';
|
|
|
|
|
|
ctx.font = '16px Arial';
|
|
|
|
|
|
const text = conf < 1.0 ? `${label} ${conf.toFixed(2)}` : label;
|
|
|
|
|
|
ctx.fillText(text, x1, y1 - 5);
|
|
|
|
|
|
});
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
if (creatingBox && createStart && dragStart) {
|
|
|
|
|
|
ctx.strokeStyle = 'blue';
|
|
|
|
|
|
ctx.lineWidth = 2;
|
|
|
|
|
|
ctx.setLineDash([5, 5]);
|
|
|
|
|
|
const x = Math.min(createStart.x, dragStart.x);
|
|
|
|
|
|
const y = Math.min(createStart.y, dragStart.y);
|
|
|
|
|
|
const w = Math.abs(createStart.x - dragStart.x);
|
|
|
|
|
|
const h = Math.abs(createStart.y - dragStart.y);
|
|
|
|
|
|
ctx.strokeRect(x, y, w, h);
|
|
|
|
|
|
ctx.setLineDash([]);
|
|
|
|
|
|
}
|
2025-12-23 16:55:59 -07:00
|
|
|
|
}
|
2025-12-23 17:04:59 -07:00
|
|
|
|
canvas.__redraw = redraw;
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
function getCornerAt(x, y) {
|
|
|
|
|
|
const handleSize = 6;
|
|
|
|
|
|
for (let i = 0; i < boxes.length; i++) {
|
|
|
|
|
|
const [x1, y1, x2, y2] = boxes[i].bbox;
|
|
|
|
|
|
const corners = [
|
|
|
|
|
|
{ x: x1, y: y1, type: 'top-left' },
|
|
|
|
|
|
{ x: x2, y: y1, type: 'top-right' },
|
|
|
|
|
|
{ x: x1, y: y2, type: 'bottom-left' },
|
|
|
|
|
|
{ x: x2, y: y2, type: 'bottom-right' },
|
|
|
|
|
|
];
|
|
|
|
|
|
for (const corner of corners) {
|
|
|
|
|
|
if (
|
|
|
|
|
|
x >= corner.x - handleSize &&
|
|
|
|
|
|
x <= corner.x + handleSize &&
|
|
|
|
|
|
y >= corner.y - handleSize &&
|
|
|
|
|
|
y <= corner.y + handleSize
|
|
|
|
|
|
) {
|
|
|
|
|
|
return { boxIndex: i, corner: corner.type, pos: corner };
|
|
|
|
|
|
}
|
2025-12-23 16:55:59 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-23 17:04:59 -07:00
|
|
|
|
return null;
|
2025-12-23 16:55:59 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
imgEl.addEventListener(
|
|
|
|
|
|
'error',
|
|
|
|
|
|
() => {
|
|
|
|
|
|
ctx.clearRect(0, 0, displayWidth, displayHeight);
|
|
|
|
|
|
ctx.fillStyle = '#ffcccc';
|
|
|
|
|
|
ctx.fillRect(0, 0, displayWidth, displayHeight);
|
|
|
|
|
|
ctx.fillStyle = 'black';
|
|
|
|
|
|
ctx.font = '16px Arial';
|
|
|
|
|
|
ctx.fillText('Image failed to load', 10, 30);
|
|
|
|
|
|
},
|
|
|
|
|
|
{ once: true }
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-12-23 16:55:59 -07:00
|
|
|
|
redraw();
|
|
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
canvas.addEventListener('mousedown', (e) => {
|
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
|
const x = (e.clientX - rect.left) * (displayWidth / rect.width);
|
|
|
|
|
|
const y = (e.clientY - rect.top) * (displayHeight / rect.height);
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
selectedCorner = getCornerAt(x, y);
|
|
|
|
|
|
if (selectedCorner) {
|
|
|
|
|
|
isDragging = true;
|
|
|
|
|
|
selectedBoxIndex = selectedCorner.boxIndex;
|
|
|
|
|
|
canvas.style.cursor = 'move';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
creatingBox = true;
|
|
|
|
|
|
createStart = { x, y };
|
|
|
|
|
|
dragStart = { x, y };
|
|
|
|
|
|
canvas.style.cursor = 'crosshair';
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
canvas.addEventListener('mousemove', (e) => {
|
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
|
const x = (e.clientX - rect.left) * (displayWidth / rect.width);
|
|
|
|
|
|
const y = (e.clientY - rect.top) * (displayHeight / rect.height);
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
if (isDragging && selectedCorner) {
|
|
|
|
|
|
const box = boxes[selectedBoxIndex];
|
|
|
|
|
|
if (selectedCorner.corner === 'top-left') {
|
|
|
|
|
|
box.bbox[0] = Math.min(x, box.bbox[2] - 10);
|
|
|
|
|
|
box.bbox[1] = Math.min(y, box.bbox[3] - 10);
|
|
|
|
|
|
} else if (selectedCorner.corner === 'top-right') {
|
|
|
|
|
|
box.bbox[2] = Math.max(x, box.bbox[0] + 10);
|
|
|
|
|
|
box.bbox[1] = Math.min(y, box.bbox[3] - 10);
|
|
|
|
|
|
} else if (selectedCorner.corner === 'bottom-left') {
|
|
|
|
|
|
box.bbox[0] = Math.min(x, box.bbox[2] - 10);
|
|
|
|
|
|
box.bbox[3] = Math.max(y, box.bbox[1] + 10);
|
|
|
|
|
|
} else if (selectedCorner.corner === 'bottom-right') {
|
|
|
|
|
|
box.bbox[2] = Math.max(x, box.bbox[0] + 10);
|
|
|
|
|
|
box.bbox[3] = Math.max(y, box.bbox[1] + 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
syncHidden();
|
|
|
|
|
|
redraw();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
if (creatingBox && createStart) {
|
|
|
|
|
|
dragStart = { x, y };
|
|
|
|
|
|
redraw();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
const corner = getCornerAt(x, y);
|
|
|
|
|
|
canvas.style.cursor = corner ? 'move' : 'crosshair';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
canvas.addEventListener('mouseup', () => {
|
|
|
|
|
|
if (creatingBox && createStart && dragStart) {
|
|
|
|
|
|
const x1 = Math.min(createStart.x, dragStart.x);
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
2025-12-23 16:55:59 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
isDragging = false;
|
|
|
|
|
|
creatingBox = false;
|
|
|
|
|
|
selectedCorner = null;
|
|
|
|
|
|
selectedBoxIndex = -1;
|
|
|
|
|
|
createStart = null;
|
|
|
|
|
|
dragStart = null;
|
|
|
|
|
|
canvas.style.cursor = 'crosshair';
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
window.__initAnnotationCanvas = init;
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
function scan() {
|
|
|
|
|
|
document.querySelectorAll('[data-annotation-root="1"]').forEach((root) => init(root));
|
|
|
|
|
|
}
|
2025-12-23 16:55:59 -07:00
|
|
|
|
|
2025-12-23 17:04:59 -07:00
|
|
|
|
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);
|
2025-12-23 16:55:59 -07:00
|
|
|
|
})();
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-22 14:11:39 -07:00
|
|
|
|
class AnnotationApp:
|
|
|
|
|
|
def __init__(self, images_dir: Path | None = None, model_weights: Path | None = None):
|
|
|
|
|
|
self.images_dir = images_dir if images_dir else Path.cwd()
|
|
|
|
|
|
self.current_model_path = model_weights
|
2025-12-22 17:56:59 -07:00
|
|
|
|
self.current_model_type = None # Track model type: 'rf-detr', 'rt-detr', 'yolov6', 'yolox'
|
|
|
|
|
|
self.available_models = [] # List of discovered models for quick switching
|
2025-12-22 14:11:39 -07:00
|
|
|
|
self.image_paths = []
|
|
|
|
|
|
self.current_idx = 0
|
|
|
|
|
|
self.annotations = {} # image_name -> list of boxes
|
|
|
|
|
|
self.model = None
|
|
|
|
|
|
self.training_process = None
|
|
|
|
|
|
self.training_thread = None
|
|
|
|
|
|
self.training_status = "Not training"
|
|
|
|
|
|
|
|
|
|
|
|
# Load images if directory provided
|
|
|
|
|
|
if images_dir and images_dir.exists():
|
|
|
|
|
|
self._load_images(images_dir)
|
|
|
|
|
|
|
|
|
|
|
|
if model_weights and model_weights.exists():
|
|
|
|
|
|
self._load_model(model_weights)
|
|
|
|
|
|
|
|
|
|
|
|
def _load_images(self, images_dir: Path):
|
|
|
|
|
|
"""Load images from directory."""
|
|
|
|
|
|
self.images_dir = images_dir
|
|
|
|
|
|
self.image_paths = sorted(
|
|
|
|
|
|
list(images_dir.glob("*.jpg")) + list(images_dir.glob("*.png"))
|
|
|
|
|
|
)
|
|
|
|
|
|
self.current_idx = 0
|
|
|
|
|
|
|
|
|
|
|
|
# Load existing annotations if present
|
|
|
|
|
|
self.ann_file = images_dir / "annotations.json"
|
|
|
|
|
|
if self.ann_file.exists():
|
|
|
|
|
|
with self.ann_file.open("r") as f:
|
|
|
|
|
|
self.annotations = json.load(f)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.annotations = {}
|
|
|
|
|
|
|
|
|
|
|
|
return f"✓ Loaded {len(self.image_paths)} images from {images_dir}"
|
|
|
|
|
|
|
2025-12-22 17:56:59 -07:00
|
|
|
|
def find_best_weights(self, directory: Path) -> tuple[Path | None, str | None]:
|
|
|
|
|
|
"""Find the best weights file in a directory based on model type detection."""
|
|
|
|
|
|
if not directory.exists():
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
|
|
# Check for RF-DETR weights (checkpoint_best_total.pth)
|
|
|
|
|
|
rf_detr_weights = directory / "checkpoint_best_total.pth"
|
|
|
|
|
|
if rf_detr_weights.exists():
|
|
|
|
|
|
return rf_detr_weights, "rf-detr"
|
|
|
|
|
|
|
|
|
|
|
|
# Check for Ultralytics weights (best.pt) in weights/ subdirectory
|
|
|
|
|
|
ultralytics_weights = directory / "weights" / "best.pt"
|
|
|
|
|
|
if ultralytics_weights.exists():
|
|
|
|
|
|
# Try to determine specific type from directory name or other clues
|
|
|
|
|
|
dir_name = directory.name.lower()
|
|
|
|
|
|
if "rtdetr" in dir_name:
|
|
|
|
|
|
return ultralytics_weights, "rt-detr"
|
|
|
|
|
|
elif "yolov6" in dir_name:
|
|
|
|
|
|
return ultralytics_weights, "yolov6"
|
|
|
|
|
|
elif "yolox" in dir_name:
|
|
|
|
|
|
return ultralytics_weights, "yolox"
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Default to rt-detr for ultralytics models
|
|
|
|
|
|
return ultralytics_weights, "rt-detr"
|
|
|
|
|
|
|
|
|
|
|
|
# Check for Ultralytics weights in training/weights/ subdirectory (YOLOv6/YOLOX format)
|
|
|
|
|
|
training_weights = directory / "training" / "weights" / "best.pt"
|
|
|
|
|
|
if training_weights.exists():
|
|
|
|
|
|
dir_name = directory.name.lower()
|
|
|
|
|
|
if "rtdetr" in dir_name:
|
|
|
|
|
|
return training_weights, "rt-detr"
|
|
|
|
|
|
elif "yolov6" in dir_name:
|
|
|
|
|
|
return training_weights, "yolov6"
|
|
|
|
|
|
elif "yolox" in dir_name:
|
|
|
|
|
|
return training_weights, "yolox"
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Default to yolox for training/weights structure
|
|
|
|
|
|
return training_weights, "yolox"
|
|
|
|
|
|
|
|
|
|
|
|
# Check for direct best.pt in directory
|
|
|
|
|
|
direct_best = directory / "best.pt"
|
|
|
|
|
|
if direct_best.exists():
|
|
|
|
|
|
return direct_best, "rt-detr" # Default assumption
|
|
|
|
|
|
|
|
|
|
|
|
# Check for any .pth or .pt files as fallback
|
|
|
|
|
|
pth_files = list(directory.glob("*.pth")) + list(directory.glob("*.pt"))
|
|
|
|
|
|
if pth_files:
|
|
|
|
|
|
# Prefer files with "best" in name
|
|
|
|
|
|
best_files = [f for f in pth_files if "best" in f.name.lower()]
|
|
|
|
|
|
if best_files:
|
|
|
|
|
|
return best_files[0], self._guess_model_type_from_path(best_files[0])
|
|
|
|
|
|
else:
|
|
|
|
|
|
return pth_files[0], self._guess_model_type_from_path(pth_files[0])
|
|
|
|
|
|
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
|
|
def _guess_model_type_from_path(self, path: Path) -> str:
|
|
|
|
|
|
"""Guess model type from file path."""
|
|
|
|
|
|
path_str = str(path).lower()
|
|
|
|
|
|
if "rf" in path_str or "checkpoint" in path_str:
|
|
|
|
|
|
return "rf-detr"
|
|
|
|
|
|
elif "rtdetr" in path_str:
|
|
|
|
|
|
return "rt-detr"
|
|
|
|
|
|
elif "yolov6" in path_str:
|
|
|
|
|
|
return "yolov6"
|
|
|
|
|
|
elif "yolox" in path_str:
|
|
|
|
|
|
return "yolox"
|
|
|
|
|
|
else:
|
|
|
|
|
|
return "rt-detr" # Default
|
|
|
|
|
|
|
|
|
|
|
|
def _load_model(self, weights_path: Path, model_type: str = None):
|
|
|
|
|
|
"""Load model for auto-labeling based on type."""
|
2025-12-22 14:11:39 -07:00
|
|
|
|
try:
|
2025-12-22 17:56:59 -07:00
|
|
|
|
import torch
|
|
|
|
|
|
|
|
|
|
|
|
if model_type is None:
|
|
|
|
|
|
model_type = self._guess_model_type_from_path(weights_path)
|
|
|
|
|
|
|
|
|
|
|
|
print(f"Loading {model_type} model from {weights_path}...")
|
|
|
|
|
|
|
|
|
|
|
|
# Check for GPU availability
|
|
|
|
|
|
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
|
|
|
|
|
print(f"Using device: {device}")
|
|
|
|
|
|
|
|
|
|
|
|
if model_type == "rf-detr":
|
|
|
|
|
|
# RF-DETR uses custom loader - try different model sizes
|
|
|
|
|
|
from rfdetr import RFDETRBase, RFDETRMedium, RFDETRNano, RFDETRSmall
|
|
|
|
|
|
|
|
|
|
|
|
# Try to determine model size from checkpoint or use nano as default
|
|
|
|
|
|
checkpoint = torch.load(weights_path, map_location='cpu', weights_only=False)
|
|
|
|
|
|
if 'model' in checkpoint:
|
|
|
|
|
|
# Training checkpoint - check the model size from the state dict
|
|
|
|
|
|
state_dict = checkpoint['model']
|
|
|
|
|
|
# Look for clues about model size in the state dict keys
|
|
|
|
|
|
if any('backbone.0.encoder.encoder.embeddings.position_embeddings' in key for key in state_dict.keys()):
|
|
|
|
|
|
# Try different model sizes to find the right one
|
|
|
|
|
|
models_to_try = [
|
|
|
|
|
|
("nano", RFDETRNano),
|
|
|
|
|
|
("small", RFDETRSmall),
|
|
|
|
|
|
("medium", RFDETRMedium),
|
|
|
|
|
|
("base", RFDETRBase)
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
for size_name, model_class in models_to_try:
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.model = model_class()
|
|
|
|
|
|
# Try loading with strict=False to handle mismatches
|
|
|
|
|
|
missing_keys, unexpected_keys = self.model.load_state_dict(state_dict, strict=False)
|
|
|
|
|
|
if len(missing_keys) < len(self.model.state_dict()): # Some keys matched
|
|
|
|
|
|
print(f"✓ Loaded RF-DETR {size_name} model (with {len(missing_keys)} missing keys)")
|
|
|
|
|
|
# Move to GPU if available
|
|
|
|
|
|
self.model = self.model.to(device)
|
|
|
|
|
|
break
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
continue
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise Exception("Could not load checkpoint with any RF-DETR model size")
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Direct weights file
|
|
|
|
|
|
self.model = RFDETRNano(pretrain_weights=str(weights_path))
|
|
|
|
|
|
self.model = self.model.to(device)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Direct weights file
|
|
|
|
|
|
self.model = RFDETRNano(pretrain_weights=str(weights_path))
|
|
|
|
|
|
self.model = self.model.to(device)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# RT-DETR, YOLOv6, YOLOX all use Ultralytics
|
|
|
|
|
|
if model_type == "rt-detr":
|
|
|
|
|
|
from ultralytics import RTDETR
|
|
|
|
|
|
self.model = RTDETR(str(weights_path))
|
|
|
|
|
|
else:
|
|
|
|
|
|
from ultralytics import YOLO
|
|
|
|
|
|
self.model = YOLO(str(weights_path))
|
|
|
|
|
|
|
|
|
|
|
|
# Ultralytics models should automatically use GPU if available
|
|
|
|
|
|
# but let's ensure they're on the right device
|
|
|
|
|
|
if hasattr(self.model, 'to'):
|
|
|
|
|
|
self.model = self.model.to(device)
|
|
|
|
|
|
|
2025-12-22 14:11:39 -07:00
|
|
|
|
self.current_model_path = weights_path
|
2025-12-22 17:56:59 -07:00
|
|
|
|
self.current_model_type = model_type
|
|
|
|
|
|
|
|
|
|
|
|
# Add to available models for quick switching
|
|
|
|
|
|
model_display = f"Custom: {weights_path.name} ({model_type.upper()})"
|
|
|
|
|
|
existing_model = next((m for m in self.available_models if m['path'] == weights_path), None)
|
|
|
|
|
|
if not existing_model:
|
|
|
|
|
|
self.available_models.append({
|
|
|
|
|
|
"path": weights_path,
|
|
|
|
|
|
"type": model_type,
|
|
|
|
|
|
"dir": weights_path.parent.name,
|
|
|
|
|
|
"display": model_display
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-22 14:11:39 -07:00
|
|
|
|
print("✓ Model loaded")
|
2025-12-22 17:56:59 -07:00
|
|
|
|
return f"✓ {model_type.upper()} model loaded from {weights_path.name}"
|
2025-12-22 14:11:39 -07:00
|
|
|
|
except Exception as e:
|
2025-12-22 17:56:59 -07:00
|
|
|
|
error_msg = f"⚠ Could not load {model_type or 'model'}: {e}"
|
2025-12-22 14:11:39 -07:00
|
|
|
|
print(error_msg)
|
|
|
|
|
|
self.model = None
|
2025-12-22 17:56:59 -07:00
|
|
|
|
self.current_model_type = None
|
2025-12-22 14:11:39 -07:00
|
|
|
|
return error_msg
|
|
|
|
|
|
|
2025-12-22 17:56:59 -07:00
|
|
|
|
def load_new_model(self, weights_path: str, model_type: str = "Auto-detect") -> str:
|
2025-12-22 14:11:39 -07:00
|
|
|
|
"""Load a new model from the GUI."""
|
|
|
|
|
|
path = Path(weights_path)
|
|
|
|
|
|
if not path.exists():
|
|
|
|
|
|
return f"❌ File not found: {weights_path}"
|
|
|
|
|
|
|
2025-12-22 17:56:59 -07:00
|
|
|
|
# Convert dropdown value to internal type
|
|
|
|
|
|
if model_type == "Auto-detect":
|
|
|
|
|
|
model_type = None
|
|
|
|
|
|
elif model_type == "rf-detr":
|
|
|
|
|
|
model_type = "rf-detr"
|
|
|
|
|
|
elif model_type == "rt-detr":
|
|
|
|
|
|
model_type = "rt-detr"
|
|
|
|
|
|
elif model_type == "yolov6":
|
|
|
|
|
|
model_type = "yolov6"
|
|
|
|
|
|
elif model_type == "yolox":
|
|
|
|
|
|
model_type = "yolox"
|
|
|
|
|
|
|
|
|
|
|
|
return self._load_model(path, model_type)
|
|
|
|
|
|
|
|
|
|
|
|
def load_model_from_directory(self, directory_path: str) -> str:
|
|
|
|
|
|
"""Load the best model found in a directory."""
|
|
|
|
|
|
path = Path(directory_path)
|
|
|
|
|
|
if not path.exists():
|
|
|
|
|
|
return f"❌ Directory not found: {directory_path}"
|
|
|
|
|
|
|
|
|
|
|
|
if not path.is_dir():
|
|
|
|
|
|
return f"❌ Not a directory: {directory_path}"
|
|
|
|
|
|
|
|
|
|
|
|
weights_path, detected_type = self.find_best_weights(path)
|
|
|
|
|
|
if weights_path is None:
|
|
|
|
|
|
return f"❌ No model weights found in {directory_path}"
|
|
|
|
|
|
|
|
|
|
|
|
return self._load_model(weights_path, detected_type)
|
|
|
|
|
|
|
|
|
|
|
|
def scan_for_models(self, return_info: bool = True) -> str:
|
|
|
|
|
|
"""Scan for available trained models in common directories."""
|
|
|
|
|
|
runs_dir = Path("runs")
|
|
|
|
|
|
available_models = []
|
|
|
|
|
|
|
|
|
|
|
|
if runs_dir.exists():
|
|
|
|
|
|
for subdir in runs_dir.iterdir():
|
|
|
|
|
|
if subdir.is_dir():
|
|
|
|
|
|
weights_path, model_type = self.find_best_weights(subdir)
|
|
|
|
|
|
if weights_path:
|
|
|
|
|
|
available_models.append({
|
|
|
|
|
|
"path": weights_path,
|
|
|
|
|
|
"type": model_type,
|
|
|
|
|
|
"dir": subdir.name,
|
|
|
|
|
|
"display": f"{subdir.name} ({model_type.upper()})"
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# Store available models for quick access
|
|
|
|
|
|
self.available_models = available_models
|
|
|
|
|
|
|
|
|
|
|
|
if not return_info:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
if not available_models:
|
|
|
|
|
|
return "❌ No trained models found in 'runs/' directory"
|
|
|
|
|
|
|
|
|
|
|
|
# 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("\n💡 Use the Model Selector dropdown above to quickly switch models")
|
|
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
def get_available_models_list(self) -> list:
|
|
|
|
|
|
"""Get list of available models for dropdown."""
|
|
|
|
|
|
if not self.available_models:
|
|
|
|
|
|
self.scan_for_models(return_info=False) # This will populate self.available_models
|
|
|
|
|
|
|
|
|
|
|
|
if not self.available_models:
|
|
|
|
|
|
return ["No models found - click '🔍 Scan for Models'"]
|
|
|
|
|
|
|
|
|
|
|
|
return [model['display'] for model in self.available_models]
|
|
|
|
|
|
|
|
|
|
|
|
def load_model_by_index(self, model_display: str) -> str:
|
|
|
|
|
|
"""Load a model by its display name from the available models list."""
|
|
|
|
|
|
if not hasattr(self, 'available_models') or not self.available_models:
|
|
|
|
|
|
return "❌ No models available. Click '🔍 Scan for Models' first."
|
|
|
|
|
|
|
|
|
|
|
|
for model in self.available_models:
|
|
|
|
|
|
if model['display'] == model_display:
|
|
|
|
|
|
return self._load_model(model['path'], model['type'])
|
|
|
|
|
|
|
|
|
|
|
|
return f"❌ Model '{model_display}' not found"
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
def load_new_images_dir(self, images_dir: str) -> tuple[str, str, str]:
|
2025-12-22 14:11:39 -07:00
|
|
|
|
"""Load a new images directory from the GUI."""
|
|
|
|
|
|
path = Path(images_dir)
|
|
|
|
|
|
if not path.exists():
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return "<div>Directory not found</div>", "", f"❌ Directory not found: {images_dir}"
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
|
|
|
|
|
if not path.is_dir():
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return "<div>Not a directory</div>", "", f"❌ Not a directory: {images_dir}"
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
|
|
|
|
|
result = self._load_images(path)
|
|
|
|
|
|
|
|
|
|
|
|
# Load first image
|
|
|
|
|
|
if self.image_paths:
|
|
|
|
|
|
img, filename = self.get_current_image()
|
|
|
|
|
|
boxes = self.annotations.get(filename, [])
|
2025-12-23 16:17:19 -07:00
|
|
|
|
img_html = self.generate_interactive_canvas(boxes)
|
2025-12-22 14:11:39 -07:00
|
|
|
|
boxes_text = self._format_boxes_text(boxes)
|
|
|
|
|
|
info = f"{result}\nImage 1/{len(self.image_paths)}: {filename}"
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return img_html, boxes_text, info
|
2025-12-22 14:11:39 -07:00
|
|
|
|
else:
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return "<div>No images found</div>", "", f"{result}\n⚠️ No .jpg or .png images found in directory"
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
|
|
|
|
|
def get_current_model_info(self) -> str:
|
|
|
|
|
|
"""Get info about currently loaded model."""
|
|
|
|
|
|
if self.model and self.current_model_path:
|
2025-12-22 17:56:59 -07:00
|
|
|
|
type_info = f" ({self.current_model_type.upper()})" if self.current_model_type else ""
|
|
|
|
|
|
return f"📦 Loaded: {self.current_model_path}{type_info}"
|
2025-12-22 14:11:39 -07:00
|
|
|
|
elif self.model:
|
|
|
|
|
|
return "📦 Model loaded (pretrained)"
|
|
|
|
|
|
else:
|
|
|
|
|
|
return "⚠️ No model loaded"
|
|
|
|
|
|
|
|
|
|
|
|
def get_current_dir_info(self) -> str:
|
|
|
|
|
|
"""Get info about current images directory."""
|
|
|
|
|
|
return f"📁 {self.images_dir} ({len(self.image_paths)} images)"
|
|
|
|
|
|
|
|
|
|
|
|
def get_current_image(self) -> tuple[Image.Image, str]:
|
|
|
|
|
|
"""Get current image and filename."""
|
|
|
|
|
|
if not self.image_paths:
|
|
|
|
|
|
return None, ""
|
|
|
|
|
|
path = self.image_paths[self.current_idx]
|
|
|
|
|
|
img = Image.open(path).convert("RGB")
|
|
|
|
|
|
return img, path.name
|
|
|
|
|
|
|
|
|
|
|
|
def draw_boxes_on_image(self, img: Image.Image, boxes: list[dict]) -> Image.Image:
|
|
|
|
|
|
"""Draw bounding boxes on image."""
|
|
|
|
|
|
img_draw = img.copy()
|
|
|
|
|
|
draw = ImageDraw.Draw(img_draw)
|
|
|
|
|
|
|
|
|
|
|
|
for box in boxes:
|
|
|
|
|
|
x1, y1, x2, y2 = box["bbox"]
|
|
|
|
|
|
label = box.get("label", "knot")
|
|
|
|
|
|
conf = box.get("confidence", 1.0)
|
|
|
|
|
|
|
|
|
|
|
|
# Draw box
|
|
|
|
|
|
draw.rectangle([x1, y1, x2, y2], outline="red", width=3)
|
|
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
# Draw corner handles for editing (small squares)
|
|
|
|
|
|
handle_size = 6
|
|
|
|
|
|
draw.rectangle([x1-handle_size, y1-handle_size, x1+handle_size, y1+handle_size], fill="red")
|
|
|
|
|
|
draw.rectangle([x2-handle_size, y1-handle_size, x2+handle_size, y1+handle_size], fill="red")
|
|
|
|
|
|
draw.rectangle([x1-handle_size, y2-handle_size, x1+handle_size, y2+handle_size], fill="red")
|
|
|
|
|
|
draw.rectangle([x2-handle_size, y2-handle_size, x2+handle_size, y2+handle_size], fill="red")
|
|
|
|
|
|
|
2025-12-22 14:11:39 -07:00
|
|
|
|
# Draw label
|
|
|
|
|
|
text = f"{label} {conf:.2f}" if conf < 1.0 else label
|
|
|
|
|
|
draw.text((x1, y1 - 20), text, fill="red")
|
|
|
|
|
|
|
|
|
|
|
|
return img_draw
|
|
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
def generate_interactive_canvas(self, boxes: list[dict] = None) -> str:
|
|
|
|
|
|
"""Generate HTML with interactive canvas for annotation."""
|
2025-12-22 14:11:39 -07:00
|
|
|
|
img, filename = self.get_current_image()
|
|
|
|
|
|
if not img:
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return "<div>No image loaded</div>"
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
if boxes is None:
|
|
|
|
|
|
boxes = self.annotations.get(filename, [])
|
|
|
|
|
|
|
|
|
|
|
|
# Resize image for display if too large
|
|
|
|
|
|
max_width = 1200
|
|
|
|
|
|
max_height = 800
|
|
|
|
|
|
if img.width > max_width or img.height > max_height:
|
|
|
|
|
|
ratio = min(max_width / img.width, max_height / img.height)
|
|
|
|
|
|
display_width = int(img.width * ratio)
|
|
|
|
|
|
display_height = int(img.height * ratio)
|
|
|
|
|
|
img = img.resize((display_width, display_height), Image.Resampling.LANCZOS)
|
|
|
|
|
|
else:
|
|
|
|
|
|
display_width = img.width
|
|
|
|
|
|
display_height = img.height
|
|
|
|
|
|
|
|
|
|
|
|
# Convert PIL image to base64
|
|
|
|
|
|
import base64
|
|
|
|
|
|
from io import BytesIO
|
|
|
|
|
|
buffer = BytesIO()
|
|
|
|
|
|
img.save(buffer, format="PNG", optimize=True)
|
|
|
|
|
|
img_base64 = base64.b64encode(buffer.getvalue()).decode()
|
|
|
|
|
|
|
2025-12-23 16:55:59 -07:00
|
|
|
|
# Build HTML without <script> tags (Gradio sanitizes them). The interactive
|
|
|
|
|
|
# canvas logic runs via CANVAS_JS_ON_LOAD.
|
2025-12-23 16:17:19 -07:00
|
|
|
|
boxes_json = json.dumps(boxes)
|
2025-12-23 16:55:59 -07:00
|
|
|
|
boxes_escaped = html.escape(boxes_json)
|
|
|
|
|
|
|
|
|
|
|
|
html_out = f"""
|
|
|
|
|
|
<div style="display: inline-block; border: 1px solid #ccc; padding: 5px;">
|
|
|
|
|
|
<div style="font-size: 12px; color: #666; margin-bottom: 4px;">Canvas Size: {display_width}x{display_height}</div>
|
|
|
|
|
|
<textarea id="annotation-initial-boxes" style="display:none;">{boxes_escaped}</textarea>
|
2025-12-23 17:04:59 -07:00
|
|
|
|
<div data-annotation-root="1" style="position: relative; width: {display_width}px; height: {display_height}px;">
|
2025-12-23 16:55:59 -07:00
|
|
|
|
<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;" />
|
|
|
|
|
|
<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>
|
2025-12-23 16:17:19 -07:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2025-12-23 16:55:59 -07:00
|
|
|
|
return html_out
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
|
|
|
|
|
def _format_boxes_text(self, boxes: list[dict]) -> str:
|
|
|
|
|
|
"""Format boxes for display."""
|
|
|
|
|
|
if not boxes:
|
|
|
|
|
|
return "No annotations"
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
def load_image(self, direction: str = "current") -> tuple[str, str, str]:
|
2025-12-22 14:11:39 -07:00
|
|
|
|
"""Load image (current/next/prev)."""
|
|
|
|
|
|
if direction == "next":
|
|
|
|
|
|
self.current_idx = min(self.current_idx + 1, len(self.image_paths) - 1)
|
|
|
|
|
|
elif direction == "prev":
|
|
|
|
|
|
self.current_idx = max(self.current_idx - 1, 0)
|
|
|
|
|
|
|
|
|
|
|
|
img, filename = self.get_current_image()
|
|
|
|
|
|
if not img:
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return "<div>No images</div>", "", "No images"
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
|
|
|
|
|
# Load existing annotations
|
|
|
|
|
|
boxes = self.annotations.get(filename, [])
|
2025-12-23 16:17:19 -07:00
|
|
|
|
img_html = self.generate_interactive_canvas(boxes)
|
2025-12-22 14:11:39 -07:00
|
|
|
|
boxes_text = self._format_boxes_text(boxes)
|
|
|
|
|
|
info = f"Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}"
|
|
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return img_html, boxes_text, info
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
def add_box_manual(self, x1: int, y1: int, x2: int, y2: int) -> tuple[str, str, str]:
|
2025-12-22 14:11:39 -07:00
|
|
|
|
"""Manually add a bounding box."""
|
|
|
|
|
|
img, filename = self.get_current_image()
|
|
|
|
|
|
if not img:
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return "<div>No images</div>", "", "No images"
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
|
|
|
|
|
# Add box
|
|
|
|
|
|
box = {
|
|
|
|
|
|
"bbox": [float(x1), float(y1), float(x2), float(y2)],
|
|
|
|
|
|
"label": "knot",
|
|
|
|
|
|
"confidence": 1.0,
|
|
|
|
|
|
"source": "manual"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if filename not in self.annotations:
|
|
|
|
|
|
self.annotations[filename] = []
|
|
|
|
|
|
self.annotations[filename].append(box)
|
|
|
|
|
|
self._save_annotations()
|
|
|
|
|
|
|
|
|
|
|
|
# Redraw
|
|
|
|
|
|
boxes = self.annotations[filename]
|
2025-12-23 16:17:19 -07:00
|
|
|
|
img_html = self.generate_interactive_canvas(boxes)
|
2025-12-22 14:11:39 -07:00
|
|
|
|
boxes_text = self._format_boxes_text(boxes)
|
|
|
|
|
|
info = f"✓ Added box: {len(boxes)} total | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}"
|
|
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return img_html, boxes_text, info
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
def delete_last_box(self) -> tuple[str, str, str]:
|
2025-12-22 14:11:39 -07:00
|
|
|
|
"""Delete the last box from current image."""
|
|
|
|
|
|
img, filename = self.get_current_image()
|
|
|
|
|
|
if not img:
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return "<div>No images</div>", "", "No images"
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
|
|
|
|
|
if filename in self.annotations and self.annotations[filename]:
|
|
|
|
|
|
self.annotations[filename].pop()
|
|
|
|
|
|
self._save_annotations()
|
|
|
|
|
|
|
|
|
|
|
|
# Redraw
|
|
|
|
|
|
boxes = self.annotations.get(filename, [])
|
2025-12-23 16:17:19 -07:00
|
|
|
|
img_html = self.generate_interactive_canvas(boxes)
|
2025-12-22 14:11:39 -07:00
|
|
|
|
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}"
|
|
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return img_html, boxes_text, info
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
def clear_boxes(self) -> tuple[str, str, str]:
|
2025-12-22 14:11:39 -07:00
|
|
|
|
"""Clear all boxes from current image."""
|
|
|
|
|
|
img, filename = self.get_current_image()
|
|
|
|
|
|
if not img:
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return "<div>No images</div>", "", "No images"
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
|
|
|
|
|
self.annotations[filename] = []
|
|
|
|
|
|
self._save_annotations()
|
|
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
boxes = []
|
|
|
|
|
|
img_html = self.generate_interactive_canvas(boxes)
|
2025-12-22 14:11:39 -07:00
|
|
|
|
boxes_text = "No annotations"
|
|
|
|
|
|
info = f"✓ Cleared all boxes | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}"
|
|
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return img_html, boxes_text, info
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
def auto_label_current(self, threshold: float = 0.5) -> tuple[str, str, str]:
|
2025-12-22 17:56:59 -07:00
|
|
|
|
"""Auto-label current image using loaded model."""
|
|
|
|
|
|
if not self.model:
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return "<div>No model loaded</div>", "", "❌ No model loaded"
|
2025-12-22 17:56:59 -07:00
|
|
|
|
|
|
|
|
|
|
img, filename = self.get_current_image()
|
|
|
|
|
|
if not img:
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return "<div>No images</div>", "", "No images"
|
2025-12-22 17:56:59 -07:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Run inference based on model type
|
|
|
|
|
|
if self.current_model_type == "rf-detr":
|
|
|
|
|
|
# RF-DETR custom prediction
|
|
|
|
|
|
detections = self.model.predict(img, threshold=threshold)
|
|
|
|
|
|
boxes = []
|
|
|
|
|
|
for i in range(len(detections)):
|
|
|
|
|
|
xyxy = detections.xyxy[i]
|
|
|
|
|
|
conf = float(detections.confidence[i]) if detections.confidence is not None else 1.0
|
|
|
|
|
|
x1, y1, x2, y2 = xyxy
|
|
|
|
|
|
boxes.append({
|
|
|
|
|
|
"bbox": [float(x1), float(y1), float(x2), float(y2)],
|
|
|
|
|
|
"label": "knot",
|
|
|
|
|
|
"confidence": conf,
|
|
|
|
|
|
"source": "auto"
|
|
|
|
|
|
})
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Ultralytics models (RT-DETR, YOLOv6, YOLOX)
|
|
|
|
|
|
results = self.model.predict(
|
|
|
|
|
|
source=img,
|
|
|
|
|
|
conf=threshold,
|
|
|
|
|
|
save=False,
|
|
|
|
|
|
verbose=False
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
boxes = []
|
|
|
|
|
|
for result in results:
|
|
|
|
|
|
for box in result.boxes:
|
|
|
|
|
|
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
|
|
|
|
|
conf = float(box.conf[0])
|
|
|
|
|
|
boxes.append({
|
|
|
|
|
|
"bbox": [x1, y1, x2, y2],
|
|
|
|
|
|
"label": "knot",
|
|
|
|
|
|
"confidence": conf,
|
|
|
|
|
|
"source": "auto"
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# Add to existing annotations
|
|
|
|
|
|
if filename not in self.annotations:
|
|
|
|
|
|
self.annotations[filename] = []
|
|
|
|
|
|
self.annotations[filename].extend(boxes)
|
|
|
|
|
|
self._save_annotations()
|
|
|
|
|
|
|
|
|
|
|
|
# Redraw
|
2025-12-23 16:17:19 -07:00
|
|
|
|
img_html = self.generate_interactive_canvas(self.annotations[filename])
|
2025-12-22 17:56:59 -07:00
|
|
|
|
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}"
|
|
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
return img_html, boxes_text, info
|
2025-12-22 17:56:59 -07:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2025-12-23 16:17:19 -07:00
|
|
|
|
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}"
|
|
|
|
|
|
|
|
|
|
|
|
def save_canvas_changes(self, boxes_json: str) -> tuple[str, str, str]:
|
|
|
|
|
|
"""Save changes made in the interactive canvas."""
|
|
|
|
|
|
img, filename = self.get_current_image()
|
|
|
|
|
|
if not img:
|
|
|
|
|
|
return "<div>No images</div>", "", "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}"
|
|
|
|
|
|
else:
|
|
|
|
|
|
boxes = self.annotations.get(filename, [])
|
|
|
|
|
|
info = f"✓ No changes to save | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}"
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
boxes = self.annotations.get(filename, [])
|
|
|
|
|
|
info = f"❌ Invalid boxes data | Image {self.current_idx + 1}/{len(self.image_paths)}: {filename}"
|
|
|
|
|
|
|
|
|
|
|
|
img_html = self.generate_interactive_canvas(boxes)
|
|
|
|
|
|
boxes_text = self._format_boxes_text(boxes)
|
|
|
|
|
|
|
|
|
|
|
|
return img_html, boxes_text, info
|
2025-12-22 17:56:59 -07:00
|
|
|
|
|
2025-12-22 14:11:39 -07:00
|
|
|
|
def _save_annotations(self):
|
|
|
|
|
|
"""Save annotations to JSON file."""
|
|
|
|
|
|
with self.ann_file.open("w") as f:
|
|
|
|
|
|
json.dump(self.annotations, f, indent=2)
|
|
|
|
|
|
|
|
|
|
|
|
def export_to_coco(self, output_path: Path):
|
|
|
|
|
|
"""Export annotations to COCO format."""
|
|
|
|
|
|
coco_data = {
|
|
|
|
|
|
"images": [],
|
|
|
|
|
|
"annotations": [],
|
|
|
|
|
|
"categories": [{"id": 0, "name": "knot", "supercategory": "defect"}]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ann_id = 0
|
|
|
|
|
|
for img_id, img_path in enumerate(self.image_paths):
|
|
|
|
|
|
filename = img_path.name
|
|
|
|
|
|
img = Image.open(img_path)
|
|
|
|
|
|
width, height = img.size
|
|
|
|
|
|
|
|
|
|
|
|
coco_data["images"].append({
|
|
|
|
|
|
"id": img_id,
|
|
|
|
|
|
"file_name": filename,
|
|
|
|
|
|
"width": width,
|
|
|
|
|
|
"height": height
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# Add annotations
|
|
|
|
|
|
boxes = self.annotations.get(filename, [])
|
|
|
|
|
|
for box in boxes:
|
|
|
|
|
|
x1, y1, x2, y2 = box["bbox"]
|
|
|
|
|
|
w = x2 - x1
|
|
|
|
|
|
h = y2 - y1
|
|
|
|
|
|
|
|
|
|
|
|
coco_data["annotations"].append({
|
|
|
|
|
|
"id": ann_id,
|
|
|
|
|
|
"image_id": img_id,
|
|
|
|
|
|
"category_id": 0,
|
|
|
|
|
|
"bbox": [x1, y1, w, h],
|
|
|
|
|
|
"area": w * h,
|
|
|
|
|
|
"iscrowd": 0,
|
|
|
|
|
|
"score": box.get("confidence", 1.0)
|
|
|
|
|
|
})
|
|
|
|
|
|
ann_id += 1
|
|
|
|
|
|
|
|
|
|
|
|
with output_path.open("w") as f:
|
|
|
|
|
|
json.dump(coco_data, f, indent=2)
|
|
|
|
|
|
|
|
|
|
|
|
return f"✓ Exported {len(coco_data['annotations'])} annotations to {output_path}"
|
|
|
|
|
|
|
|
|
|
|
|
def prepare_training_dataset(self, output_dir: Path, train_split: float = 0.8, valid_split: float = 0.1):
|
|
|
|
|
|
"""Prepare dataset in RF-DETR format (train/valid/test splits)."""
|
|
|
|
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
# Create splits
|
|
|
|
|
|
import random
|
|
|
|
|
|
annotated_images = [img for img in self.image_paths if img.name in self.annotations and self.annotations[img.name]]
|
|
|
|
|
|
|
|
|
|
|
|
if len(annotated_images) < 10:
|
|
|
|
|
|
return f"⚠️ Need at least 10 annotated images, have {len(annotated_images)}"
|
|
|
|
|
|
|
|
|
|
|
|
random.shuffle(annotated_images)
|
|
|
|
|
|
n = len(annotated_images)
|
|
|
|
|
|
train_n = int(n * train_split)
|
|
|
|
|
|
valid_n = int(n * valid_split)
|
|
|
|
|
|
|
|
|
|
|
|
splits = {
|
|
|
|
|
|
"train": annotated_images[:train_n],
|
|
|
|
|
|
"valid": annotated_images[train_n:train_n + valid_n],
|
|
|
|
|
|
"test": annotated_images[train_n + valid_n:]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Create directories and copy images
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
for split_name, split_images in splits.items():
|
|
|
|
|
|
split_dir = output_dir / split_name
|
|
|
|
|
|
split_dir.mkdir(exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
# Prepare COCO JSON for this split
|
|
|
|
|
|
coco_data = {
|
|
|
|
|
|
"images": [],
|
|
|
|
|
|
"annotations": [],
|
|
|
|
|
|
"categories": [{"id": 0, "name": "knot", "supercategory": "defect"}]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ann_id = 0
|
|
|
|
|
|
for img_id, img_path in enumerate(split_images):
|
|
|
|
|
|
# Copy image
|
|
|
|
|
|
dest = split_dir / img_path.name
|
|
|
|
|
|
shutil.copy2(img_path, dest)
|
|
|
|
|
|
|
|
|
|
|
|
# Add to COCO
|
|
|
|
|
|
img = Image.open(img_path)
|
|
|
|
|
|
width, height = img.size
|
|
|
|
|
|
|
|
|
|
|
|
coco_data["images"].append({
|
|
|
|
|
|
"id": img_id,
|
|
|
|
|
|
"file_name": img_path.name,
|
|
|
|
|
|
"width": width,
|
|
|
|
|
|
"height": height
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# Add annotations
|
|
|
|
|
|
boxes = self.annotations.get(img_path.name, [])
|
|
|
|
|
|
for box in boxes:
|
|
|
|
|
|
x1, y1, x2, y2 = box["bbox"]
|
|
|
|
|
|
w = x2 - x1
|
|
|
|
|
|
h = y2 - y1
|
|
|
|
|
|
|
|
|
|
|
|
coco_data["annotations"].append({
|
|
|
|
|
|
"id": ann_id,
|
|
|
|
|
|
"image_id": img_id,
|
|
|
|
|
|
"category_id": 0,
|
|
|
|
|
|
"bbox": [x1, y1, w, h],
|
|
|
|
|
|
"area": w * h,
|
|
|
|
|
|
"iscrowd": 0
|
|
|
|
|
|
})
|
|
|
|
|
|
ann_id += 1
|
|
|
|
|
|
|
|
|
|
|
|
# Save COCO JSON
|
|
|
|
|
|
with (split_dir / "_annotations.coco.json").open("w") as f:
|
|
|
|
|
|
json.dump(coco_data, f, indent=2)
|
|
|
|
|
|
|
|
|
|
|
|
return f"✓ Dataset prepared: {len(splits['train'])} train, {len(splits['valid'])} valid, {len(splits['test'])} test"
|
|
|
|
|
|
|
|
|
|
|
|
def start_training(self, framework: str, dataset_dir: str, output_dir: str, model_size: str,
|
|
|
|
|
|
epochs: int, batch_size: int, lr: float, progress=gr.Progress()):
|
|
|
|
|
|
"""Start training in background."""
|
|
|
|
|
|
dataset_path = Path(dataset_dir)
|
|
|
|
|
|
output_path = Path(output_dir)
|
|
|
|
|
|
|
|
|
|
|
|
if not dataset_path.exists():
|
|
|
|
|
|
return "❌ Dataset directory not found"
|
|
|
|
|
|
|
|
|
|
|
|
if self.training_process and self.training_process.poll() is None:
|
|
|
|
|
|
return "⚠️ Training already in progress"
|
|
|
|
|
|
|
|
|
|
|
|
output_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
# Build training command based on framework
|
|
|
|
|
|
venv_python = Path(__file__).parent / ".venv/bin/python"
|
|
|
|
|
|
|
2025-12-22 17:56:59 -07:00
|
|
|
|
if framework == "RF-DETR":
|
|
|
|
|
|
train_script = Path(__file__).parent / "train_rfdetr.py"
|
|
|
|
|
|
# Map sizes: nano->nano, small->small, medium->medium, base->base
|
|
|
|
|
|
size_map = {"nano": "nano", "small": "small", "medium": "medium", "base": "base"}
|
|
|
|
|
|
model_arg = size_map.get(model_size, "medium")
|
|
|
|
|
|
|
|
|
|
|
|
cmd = [
|
|
|
|
|
|
str(venv_python),
|
|
|
|
|
|
str(train_script),
|
|
|
|
|
|
"--dataset-dir", str(dataset_path),
|
|
|
|
|
|
"--output-dir", str(output_path),
|
|
|
|
|
|
"--model", model_arg,
|
|
|
|
|
|
"--epochs", str(epochs),
|
|
|
|
|
|
"--batch-size", str(batch_size),
|
|
|
|
|
|
"--grad-accum-steps", "2", # Default grad accum
|
|
|
|
|
|
"--lr", str(lr)
|
|
|
|
|
|
]
|
|
|
|
|
|
elif framework == "RT-DETR":
|
2025-12-22 14:11:39 -07:00
|
|
|
|
train_script = Path(__file__).parent / "train_rtdetr.py"
|
|
|
|
|
|
# Map sizes: nano->r18, small->r34, medium->r50, base->l
|
|
|
|
|
|
size_map = {"nano": "rtdetr-r18", "small": "rtdetr-r34", "medium": "rtdetr-r50", "base": "rtdetr-l"}
|
|
|
|
|
|
model_arg = size_map.get(model_size, "rtdetr-r18")
|
|
|
|
|
|
|
|
|
|
|
|
cmd = [
|
|
|
|
|
|
str(venv_python),
|
|
|
|
|
|
str(train_script),
|
|
|
|
|
|
"--dataset-dir", str(dataset_path),
|
|
|
|
|
|
"--output-dir", str(output_path),
|
|
|
|
|
|
"--model", model_arg,
|
|
|
|
|
|
"--epochs", str(epochs),
|
|
|
|
|
|
"--batch-size", str(batch_size),
|
|
|
|
|
|
"--lr", str(lr)
|
|
|
|
|
|
]
|
|
|
|
|
|
elif framework == "YOLOv6":
|
|
|
|
|
|
train_script = Path(__file__).parent / "train_yolov6.py"
|
|
|
|
|
|
# Map sizes: nano->n, small->s, medium->m, base->l
|
|
|
|
|
|
size_map = {"nano": "yolov6n", "small": "yolov6s", "medium": "yolov6m", "base": "yolov6l"}
|
|
|
|
|
|
model_arg = size_map.get(model_size, "yolov6n")
|
|
|
|
|
|
|
|
|
|
|
|
cmd = [
|
|
|
|
|
|
str(venv_python),
|
|
|
|
|
|
str(train_script),
|
|
|
|
|
|
"--dataset-dir", str(dataset_path),
|
|
|
|
|
|
"--output-dir", str(output_path),
|
|
|
|
|
|
"--model", model_arg,
|
|
|
|
|
|
"--epochs", str(epochs),
|
|
|
|
|
|
"--batch-size", str(batch_size),
|
|
|
|
|
|
"--lr", str(lr)
|
|
|
|
|
|
]
|
|
|
|
|
|
elif framework == "YOLOX":
|
|
|
|
|
|
train_script = Path(__file__).parent / "train_yolox.py"
|
|
|
|
|
|
# Map sizes: nano->nano, small->s, medium->m, base->l
|
|
|
|
|
|
size_map = {"nano": "yolox-nano", "small": "yolox-s", "medium": "yolox-m", "base": "yolox-l"}
|
|
|
|
|
|
model_arg = size_map.get(model_size, "yolox-nano")
|
|
|
|
|
|
|
|
|
|
|
|
cmd = [
|
|
|
|
|
|
str(venv_python),
|
|
|
|
|
|
str(train_script),
|
|
|
|
|
|
"--dataset-dir", str(dataset_path),
|
|
|
|
|
|
"--output-dir", str(output_path),
|
|
|
|
|
|
"--model", model_arg,
|
|
|
|
|
|
"--epochs", str(epochs),
|
|
|
|
|
|
"--batch-size", str(batch_size),
|
|
|
|
|
|
"--lr", str(lr)
|
|
|
|
|
|
]
|
|
|
|
|
|
else:
|
|
|
|
|
|
return f"❌ Unknown framework: {framework}"
|
|
|
|
|
|
|
|
|
|
|
|
# Start training process
|
|
|
|
|
|
log_file = output_path / "training.log"
|
|
|
|
|
|
self.training_status = f"🚀 Starting {framework} training..."
|
|
|
|
|
|
|
|
|
|
|
|
def run_training():
|
|
|
|
|
|
try:
|
|
|
|
|
|
with log_file.open("w") as f:
|
|
|
|
|
|
self.training_process = subprocess.Popen(
|
|
|
|
|
|
cmd,
|
|
|
|
|
|
stdout=f,
|
|
|
|
|
|
stderr=subprocess.STDOUT,
|
|
|
|
|
|
text=True
|
|
|
|
|
|
)
|
|
|
|
|
|
self.training_status = f"⏳ Training in progress (PID: {self.training_process.pid})"
|
|
|
|
|
|
self.training_process.wait()
|
|
|
|
|
|
|
|
|
|
|
|
if self.training_process.returncode == 0:
|
|
|
|
|
|
self.training_status = "✅ Training completed successfully!"
|
|
|
|
|
|
# Reload model with new weights
|
2025-12-22 17:56:59 -07:00
|
|
|
|
if framework == "RF-DETR":
|
|
|
|
|
|
# RF-DETR uses checkpoint_best_total.pth
|
|
|
|
|
|
best_weights = output_path / "checkpoint_best_total.pth"
|
|
|
|
|
|
model_type = "rf-detr"
|
|
|
|
|
|
elif framework == "RT-DETR":
|
|
|
|
|
|
# RT-DETR uses best.pt in weights/ subdirectory (Ultralytics)
|
|
|
|
|
|
best_weights = output_path / "weights" / "best.pt"
|
|
|
|
|
|
model_type = "rt-detr"
|
|
|
|
|
|
elif framework == "YOLOv6":
|
|
|
|
|
|
best_weights = output_path / "weights" / "best.pt"
|
|
|
|
|
|
model_type = "yolov6"
|
|
|
|
|
|
elif framework == "YOLOX":
|
|
|
|
|
|
best_weights = output_path / "weights" / "best.pt"
|
|
|
|
|
|
model_type = "yolox"
|
|
|
|
|
|
|
2025-12-22 14:11:39 -07:00
|
|
|
|
if best_weights.exists():
|
2025-12-22 17:56:59 -07:00
|
|
|
|
self._load_model(best_weights, model_type)
|
2025-12-22 14:11:39 -07:00
|
|
|
|
else:
|
|
|
|
|
|
self.training_status = f"❌ Training failed (exit code {self.training_process.returncode})"
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
self.training_status = f"❌ Error: {e}"
|
|
|
|
|
|
|
|
|
|
|
|
self.training_thread = threading.Thread(target=run_training, daemon=True)
|
|
|
|
|
|
self.training_thread.start()
|
|
|
|
|
|
|
|
|
|
|
|
return f"✓ Training started! Check {log_file} for progress"
|
|
|
|
|
|
|
|
|
|
|
|
def get_training_status(self):
|
|
|
|
|
|
"""Get current training status."""
|
|
|
|
|
|
return self.training_status
|
|
|
|
|
|
|
|
|
|
|
|
def stop_training(self):
|
|
|
|
|
|
"""Stop the training process."""
|
|
|
|
|
|
if self.training_process and self.training_process.poll() is None:
|
|
|
|
|
|
self.training_process.terminate()
|
|
|
|
|
|
self.training_status = "⏹️ Training stopped by user"
|
|
|
|
|
|
return "✓ Training process terminated"
|
|
|
|
|
|
return "⚠️ No training in progress"
|
2025-12-22 17:56:59 -07:00
|
|
|
|
|
2025-12-23 12:53:52 -07:00
|
|
|
|
def get_model_path_from_display(self, model_display: str) -> Path | None:
|
|
|
|
|
|
"""Get the actual model path from a display name."""
|
|
|
|
|
|
if not hasattr(self, 'available_models') or not self.available_models:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
for model in self.available_models:
|
|
|
|
|
|
if model['display'] == model_display:
|
|
|
|
|
|
return model['path']
|
|
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def export_for_oak_d(self, model_display: str, output_dir: str = "oak_d_export", img_size: int = 640):
|
2025-12-22 17:56:59 -07:00
|
|
|
|
"""Export trained model for OAK-D camera deployment."""
|
|
|
|
|
|
try:
|
2025-12-23 12:53:52 -07:00
|
|
|
|
# Convert display name to actual path
|
|
|
|
|
|
weights_path = self.get_model_path_from_display(model_display)
|
|
|
|
|
|
if not weights_path:
|
|
|
|
|
|
return f"❌ Model '{model_display}' not found. Try clicking '🔍 Scan for Models' first."
|
|
|
|
|
|
|
2025-12-22 17:56:59 -07:00
|
|
|
|
output_path = Path(output_dir)
|
|
|
|
|
|
|
|
|
|
|
|
if not weights_path.exists():
|
2025-12-23 12:53:52 -07:00
|
|
|
|
return f"❌ Model weights not found at: {weights_path}"
|
2025-12-22 17:56:59 -07:00
|
|
|
|
|
|
|
|
|
|
output_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
# Determine model type
|
|
|
|
|
|
model_type = self._guess_model_type_from_path(weights_path)
|
|
|
|
|
|
|
|
|
|
|
|
print(f"Exporting {model_type} model for OAK-D...")
|
|
|
|
|
|
|
|
|
|
|
|
if model_type == "rf-detr":
|
|
|
|
|
|
# RF-DETR export - use existing export_onnx.py logic
|
|
|
|
|
|
from rfdetr import RFDETRBase
|
|
|
|
|
|
|
|
|
|
|
|
model = RFDETRBase(pretrain_weights=str(weights_path))
|
|
|
|
|
|
model.export() # Creates output/model.onnx
|
|
|
|
|
|
|
|
|
|
|
|
# Move to output directory
|
|
|
|
|
|
onnx_source = Path("output/model.onnx")
|
|
|
|
|
|
if onnx_source.exists():
|
|
|
|
|
|
onnx_dest = output_path / "rf_detr_model.onnx"
|
|
|
|
|
|
onnx_source.rename(onnx_dest)
|
|
|
|
|
|
|
|
|
|
|
|
return f"✓ RF-DETR exported for OAK-D!\n📁 Output: {output_path}\n🔗 Next: Convert ONNX to blob using blobconverter.luxonis.com"
|
|
|
|
|
|
else:
|
|
|
|
|
|
return "❌ ONNX export failed"
|
|
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Ultralytics models (RT-DETR, YOLOv6, YOLOX)
|
|
|
|
|
|
if model_type == "rt-detr":
|
|
|
|
|
|
from ultralytics import RTDETR
|
|
|
|
|
|
model = RTDETR(str(weights_path))
|
|
|
|
|
|
else:
|
|
|
|
|
|
from ultralytics import YOLO
|
|
|
|
|
|
model = YOLO(str(weights_path))
|
|
|
|
|
|
|
|
|
|
|
|
# Export to ONNX
|
|
|
|
|
|
onnx_path = model.export(
|
|
|
|
|
|
format="onnx",
|
|
|
|
|
|
imgsz=img_size,
|
|
|
|
|
|
simplify=True,
|
|
|
|
|
|
opset=11, # OAK-compatible opset
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Move ONNX to output directory
|
|
|
|
|
|
if Path(onnx_path).exists():
|
|
|
|
|
|
final_onnx = output_path / f"{model_type}_model.onnx"
|
|
|
|
|
|
Path(onnx_path).rename(final_onnx)
|
|
|
|
|
|
onnx_path = final_onnx
|
|
|
|
|
|
|
|
|
|
|
|
# Try to export to OpenVINO if available
|
|
|
|
|
|
try:
|
|
|
|
|
|
openvino_path = model.export(
|
|
|
|
|
|
format="openvino",
|
|
|
|
|
|
imgsz=img_size,
|
|
|
|
|
|
half=False, # Use FP32 for better compatibility
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Move OpenVINO files to output directory
|
|
|
|
|
|
if Path(openvino_path).exists():
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
openvino_dir = Path(openvino_path)
|
|
|
|
|
|
for file in openvino_dir.glob("*"):
|
|
|
|
|
|
if file.is_file():
|
|
|
|
|
|
shutil.move(str(file), str(output_path / file.name))
|
|
|
|
|
|
openvino_dir.rmdir() # Remove empty dir
|
|
|
|
|
|
|
|
|
|
|
|
return f"✓ {model_type.upper()} exported for OAK-D!\n📁 Output: {output_path}\n🔗 Next: Convert .xml/.bin to blob using blobconverter.luxonis.com"
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
# OpenVINO not available, just return ONNX
|
2025-12-23 12:53:52 -07:00
|
|
|
|
import shutil
|
|
|
|
|
|
docker_hint = ""
|
|
|
|
|
|
if shutil.which("docker") is None:
|
|
|
|
|
|
docker_hint = "\n⚠️ Docker not found (needed for offline conversion via ModelConverter)."
|
|
|
|
|
|
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"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)}"
|
|
|
|
|
|
f"{docker_hint}"
|
|
|
|
|
|
)
|
2025-12-22 17:56:59 -07:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return f"❌ Export failed: {str(e)}"
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_ui(app: AnnotationApp) -> gr.Blocks:
|
|
|
|
|
|
"""Create Gradio UI."""
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Blocks(title="Knot Annotation Tool") as demo:
|
|
|
|
|
|
gr.Markdown("""
|
|
|
|
|
|
# 🪵 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
|
2025-12-22 17:56:59 -07:00
|
|
|
|
- Train **RF-DETR, RT-DETR, YOLOv6, or YOLOX** (all free for commercial use!)
|
2025-12-22 14:11:39 -07:00
|
|
|
|
- Optimized for OAK-D camera deployment
|
|
|
|
|
|
- Use trained model to auto-label more images
|
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
# Settings section at the top
|
|
|
|
|
|
with gr.Accordion("⚙️ Settings", open=False):
|
|
|
|
|
|
with gr.Row():
|
|
|
|
|
|
with gr.Column():
|
|
|
|
|
|
images_dir_input = gr.Textbox(
|
|
|
|
|
|
label="Images Directory",
|
|
|
|
|
|
value=str(app.images_dir),
|
|
|
|
|
|
placeholder="/path/to/images"
|
|
|
|
|
|
)
|
|
|
|
|
|
load_images_btn = gr.Button("📁 Load Images Directory")
|
|
|
|
|
|
dir_info = gr.Textbox(label="Current Directory", value=app.get_current_dir_info(), interactive=False)
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Column():
|
2025-12-22 17:56:59 -07:00
|
|
|
|
# Quick Model Selector
|
|
|
|
|
|
model_selector = gr.Dropdown(
|
|
|
|
|
|
choices=app.get_available_models_list(),
|
|
|
|
|
|
value=None,
|
|
|
|
|
|
label="🚀 Quick Model Switcher",
|
|
|
|
|
|
info="Select from available trained models (refresh with scan)",
|
|
|
|
|
|
allow_custom_value=True
|
|
|
|
|
|
)
|
|
|
|
|
|
quick_load_btn = gr.Button("⚡ Load Selected Model", variant="primary")
|
|
|
|
|
|
|
|
|
|
|
|
# Manual Model Loading
|
2025-12-22 14:11:39 -07:00
|
|
|
|
model_weights_input = gr.Textbox(
|
|
|
|
|
|
label="Model Weights Path",
|
|
|
|
|
|
value=str(app.current_model_path) if app.current_model_path else "",
|
|
|
|
|
|
placeholder="runs/training/checkpoint_best_total.pth"
|
|
|
|
|
|
)
|
2025-12-22 17:56:59 -07:00
|
|
|
|
model_type_dropdown = gr.Dropdown(
|
|
|
|
|
|
choices=["Auto-detect", "rf-detr", "rt-detr", "yolov6", "yolox"],
|
|
|
|
|
|
value="Auto-detect",
|
|
|
|
|
|
label="Model Type",
|
|
|
|
|
|
info="Auto-detect will try to determine from file path"
|
|
|
|
|
|
)
|
|
|
|
|
|
with gr.Row():
|
|
|
|
|
|
load_model_btn = gr.Button("🤖 Load Model Weights")
|
|
|
|
|
|
scan_models_btn = gr.Button("🔍 Scan for Models")
|
2025-12-22 14:11:39 -07:00
|
|
|
|
model_info = gr.Textbox(label="Current Model", value=app.get_current_model_info(), interactive=False)
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Row():
|
|
|
|
|
|
with gr.Column(scale=3):
|
2025-12-23 16:55:59 -07:00
|
|
|
|
image_display = gr.HTML(
|
|
|
|
|
|
label="Current Image",
|
|
|
|
|
|
value="<div style='width: 800px; height: 400px; border: 1px solid #ccc; display: flex; align-items: center; justify-content: center; color: #666;'>Load images from Settings to start annotating</div>",
|
|
|
|
|
|
js_on_load=CANVAS_JS_ON_LOAD,
|
|
|
|
|
|
)
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
|
|
|
|
|
with gr.Row():
|
|
|
|
|
|
prev_btn = gr.Button("⬅️ Previous")
|
|
|
|
|
|
next_btn = gr.Button("Next ➡️")
|
|
|
|
|
|
auto_label_btn = gr.Button("🤖 Auto-Label", variant="primary")
|
2025-12-23 16:17:19 -07:00
|
|
|
|
save_canvas_btn = gr.Button("💾 Save Canvas Changes")
|
|
|
|
|
|
|
|
|
|
|
|
# Hidden textbox to store canvas boxes data
|
2025-12-23 16:55:59 -07:00
|
|
|
|
canvas_boxes_data = gr.Textbox(visible=False, elem_id="canvas-boxes-data")
|
2025-12-22 14:11:39 -07:00
|
|
|
|
|
|
|
|
|
|
with gr.Row():
|
|
|
|
|
|
threshold_slider = gr.Slider(0.1, 0.9, DEFAULT_DETECTION_THRESHOLD, label="Detection Threshold")
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Column(scale=1):
|
|
|
|
|
|
info_text = gr.Textbox(label="Status", lines=2)
|
|
|
|
|
|
boxes_text = gr.Textbox(label="Annotations", lines=10)
|
|
|
|
|
|
|
|
|
|
|
|
gr.Markdown("### Manual Annotation")
|
|
|
|
|
|
with gr.Row():
|
|
|
|
|
|
x1_input = gr.Number(label="x1", value=100)
|
|
|
|
|
|
y1_input = gr.Number(label="y1", value=100)
|
|
|
|
|
|
with gr.Row():
|
|
|
|
|
|
x2_input = gr.Number(label="x2", value=200)
|
|
|
|
|
|
y2_input = gr.Number(label="y2", value=200)
|
|
|
|
|
|
|
|
|
|
|
|
add_box_btn = gr.Button("➕ Add Box")
|
|
|
|
|
|
delete_btn = gr.Button("🗑️ Delete Last")
|
|
|
|
|
|
clear_btn = gr.Button("❌ Clear All")
|
|
|
|
|
|
|
|
|
|
|
|
gr.Markdown("### Export & Training")
|
|
|
|
|
|
export_path = gr.Textbox(
|
|
|
|
|
|
label="Export Path",
|
|
|
|
|
|
value="annotations_coco.json"
|
|
|
|
|
|
)
|
|
|
|
|
|
export_btn = gr.Button("💾 Export COCO")
|
|
|
|
|
|
export_result = gr.Textbox(label="Export Result", lines=1)
|
|
|
|
|
|
|
|
|
|
|
|
# Training tab
|
|
|
|
|
|
with gr.Tab("🎯 Training"):
|
|
|
|
|
|
gr.Markdown("""
|
|
|
|
|
|
### Train Object Detection Model
|
|
|
|
|
|
|
|
|
|
|
|
**Choose your framework:**
|
2025-12-22 17:56:59 -07:00
|
|
|
|
- **RF-DETR** (MIT): Custom transformer, high accuracy
|
|
|
|
|
|
- **RT-DETR** (Apache 2.0): Ultralytics transformer, great accuracy
|
2025-12-22 14:11:39 -07:00
|
|
|
|
- **YOLOv6** (MIT): Fast, proven on OAK cameras
|
|
|
|
|
|
- **YOLOX** (MIT): Similar to YOLOv6, slight differences
|
|
|
|
|
|
|
|
|
|
|
|
**All MIT/Apache 2.0 licensed - free for commercial use!** ✅
|
|
|
|
|
|
|
|
|
|
|
|
**Steps:**
|
|
|
|
|
|
1. Annotate at least 50-100 images in the Annotation tab
|
|
|
|
|
|
2. Click "Prepare Dataset" to create train/valid/test splits
|
|
|
|
|
|
3. Select your framework and configure training parameters
|
|
|
|
|
|
4. Click "Start Training" (runs in background)
|
|
|
|
|
|
5. After training, export for OAK-D deployment
|
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Row():
|
|
|
|
|
|
with gr.Column():
|
|
|
|
|
|
dataset_prep_dir = gr.Textbox(
|
|
|
|
|
|
label="Dataset Output Directory",
|
|
|
|
|
|
value="dataset_prepared"
|
|
|
|
|
|
)
|
|
|
|
|
|
train_split = gr.Slider(0.5, 0.9, 0.8, label="Train Split Ratio")
|
|
|
|
|
|
valid_split = gr.Slider(0.05, 0.3, 0.1, label="Valid Split Ratio")
|
|
|
|
|
|
prep_btn = gr.Button("📦 Prepare Dataset", variant="secondary")
|
|
|
|
|
|
prep_result = gr.Textbox(label="Preparation Result", lines=2)
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Column():
|
|
|
|
|
|
gr.Markdown("### Training Configuration")
|
|
|
|
|
|
model_framework = gr.Dropdown(
|
2025-12-22 17:56:59 -07:00
|
|
|
|
choices=["RF-DETR", "RT-DETR", "YOLOv6", "YOLOX"],
|
2025-12-22 14:11:39 -07:00
|
|
|
|
value="RT-DETR",
|
|
|
|
|
|
label="Model Framework",
|
|
|
|
|
|
info="All MIT/Apache 2.0 licensed - free for commercial use. Optimized for OAK cameras."
|
|
|
|
|
|
)
|
|
|
|
|
|
train_dataset_dir = gr.Textbox(
|
|
|
|
|
|
label="Dataset Directory",
|
|
|
|
|
|
value="dataset_prepared"
|
|
|
|
|
|
)
|
|
|
|
|
|
train_output_dir = gr.Textbox(
|
|
|
|
|
|
label="Output Directory",
|
|
|
|
|
|
value="runs/gui_training"
|
|
|
|
|
|
)
|
|
|
|
|
|
model_size = gr.Dropdown(
|
|
|
|
|
|
choices=["nano", "small", "medium", "base"],
|
|
|
|
|
|
value=DEFAULT_MODEL_SIZE,
|
|
|
|
|
|
label="Model Size"
|
|
|
|
|
|
)
|
|
|
|
|
|
epochs = gr.Slider(5, 100, DEFAULT_TRAIN_EPOCHS, step=5, label="Epochs")
|
|
|
|
|
|
batch_size = gr.Slider(1, 16, DEFAULT_BATCH_SIZE, step=1, label="Batch Size")
|
|
|
|
|
|
learning_rate = gr.Number(value=DEFAULT_LEARNING_RATE, label="Learning Rate")
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Row():
|
|
|
|
|
|
start_train_btn = gr.Button("🚀 Start Training", variant="primary")
|
|
|
|
|
|
stop_train_btn = gr.Button("⏹️ Stop Training", variant="stop")
|
|
|
|
|
|
refresh_status_btn = gr.Button("🔄 Refresh Status")
|
|
|
|
|
|
|
|
|
|
|
|
training_status = gr.Textbox(
|
|
|
|
|
|
label="Training Status",
|
|
|
|
|
|
value="Not training",
|
|
|
|
|
|
lines=3
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
gr.Markdown("""
|
|
|
|
|
|
**Note**: Training runs in the background. You can continue annotating while training.
|
|
|
|
|
|
Check the training log file for detailed progress.
|
|
|
|
|
|
""")
|
|
|
|
|
|
|
2025-12-22 17:56:59 -07:00
|
|
|
|
# OAK-D Deployment tab
|
|
|
|
|
|
with gr.Tab("🚀 OAK-D Deployment"):
|
|
|
|
|
|
gr.Markdown("""
|
|
|
|
|
|
### Deploy Trained Model to OAK-D Camera
|
|
|
|
|
|
|
|
|
|
|
|
Convert your trained model to work with the **OAK-D 4 Pro** camera for real-time edge inference.
|
|
|
|
|
|
|
|
|
|
|
|
**Supported Models**: RF-DETR, RT-DETR, YOLOv6, YOLOX
|
|
|
|
|
|
|
|
|
|
|
|
**Process**:
|
|
|
|
|
|
1. Select a trained model from your runs/ directory
|
|
|
|
|
|
2. Export to ONNX and OpenVINO formats
|
|
|
|
|
|
3. Convert OpenVINO model to blob for OAK-D
|
|
|
|
|
|
4. Deploy blob to your OAK-D camera
|
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Row():
|
|
|
|
|
|
with gr.Column():
|
|
|
|
|
|
oak_model_selector = gr.Dropdown(
|
|
|
|
|
|
choices=app.get_available_models_list(),
|
|
|
|
|
|
value=None,
|
|
|
|
|
|
label="Select Trained Model",
|
|
|
|
|
|
info="Choose a model from your training runs",
|
|
|
|
|
|
allow_custom_value=True
|
|
|
|
|
|
)
|
|
|
|
|
|
oak_output_dir = gr.Textbox(
|
|
|
|
|
|
label="Output Directory",
|
|
|
|
|
|
value="oak_d_deployment",
|
|
|
|
|
|
placeholder="oak_d_deployment"
|
|
|
|
|
|
)
|
|
|
|
|
|
oak_img_size = gr.Dropdown(
|
|
|
|
|
|
choices=[320, 416, 512, 640, 800, 1024],
|
|
|
|
|
|
value=640,
|
|
|
|
|
|
label="Image Size",
|
|
|
|
|
|
info="Input size for the model (should match training)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Row():
|
|
|
|
|
|
oak_scan_btn = gr.Button("🔍 Scan for Models")
|
|
|
|
|
|
oak_export_btn = gr.Button("🚀 Export for OAK-D", variant="primary")
|
|
|
|
|
|
|
|
|
|
|
|
oak_status = gr.Textbox(
|
|
|
|
|
|
label="Export Status",
|
|
|
|
|
|
value="Ready to export",
|
|
|
|
|
|
lines=4
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Column():
|
|
|
|
|
|
gr.Markdown("""
|
|
|
|
|
|
### 📋 Deployment Instructions
|
|
|
|
|
|
|
|
|
|
|
|
**After Export:**
|
|
|
|
|
|
1. **Test OpenVINO Model** (optional):
|
|
|
|
|
|
```bash
|
|
|
|
|
|
python -c "from openvino.runtime import Core; core = Core(); model = core.read_model('model.xml'); print('✓ Model loaded')"
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2025-12-23 12:53:52 -07:00
|
|
|
|
2. **Convert to RVC compiled format** (recommended by Luxonis):
|
|
|
|
|
|
- Online: HubAI conversion (fastest setup)
|
|
|
|
|
|
- Offline: ModelConverter (requires Docker)
|
|
|
|
|
|
- Docs: https://docs.luxonis.com/software-v3/ai-inference/conversion/
|
2025-12-22 17:56:59 -07:00
|
|
|
|
|
|
|
|
|
|
3. **Deploy to OAK-D**:
|
|
|
|
|
|
- Use DepthAI Python API
|
|
|
|
|
|
- Or use OAK-D examples with your blob
|
|
|
|
|
|
|
|
|
|
|
|
### 💡 Tips
|
|
|
|
|
|
- **Nano models** work best on edge devices
|
2025-12-23 12:53:52 -07:00
|
|
|
|
- If you quantize, use real calibration images for best accuracy
|
2025-12-22 17:56:59 -07:00
|
|
|
|
- Test inference speed vs accuracy trade-off
|
|
|
|
|
|
""")
|
|
|
|
|
|
|
2025-12-22 14:11:39 -07:00
|
|
|
|
# Event handlers
|
|
|
|
|
|
def on_load():
|
|
|
|
|
|
return app.load_image("current")
|
|
|
|
|
|
|
|
|
|
|
|
# Settings handlers
|
|
|
|
|
|
load_images_btn.click(
|
|
|
|
|
|
app.load_new_images_dir,
|
|
|
|
|
|
inputs=[images_dir_input],
|
|
|
|
|
|
outputs=[image_display, boxes_text, info_text]
|
|
|
|
|
|
).then(
|
|
|
|
|
|
lambda: (app.get_current_dir_info(), app.get_current_model_info()),
|
|
|
|
|
|
outputs=[dir_info, model_info]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
load_model_btn.click(
|
|
|
|
|
|
app.load_new_model,
|
2025-12-22 17:56:59 -07:00
|
|
|
|
inputs=[model_weights_input, model_type_dropdown],
|
|
|
|
|
|
outputs=[model_info]
|
|
|
|
|
|
).then(
|
|
|
|
|
|
app.get_available_models_list,
|
|
|
|
|
|
outputs=[model_selector]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
scan_models_btn.click(
|
|
|
|
|
|
app.scan_for_models,
|
|
|
|
|
|
outputs=[model_info]
|
|
|
|
|
|
).then(
|
|
|
|
|
|
app.get_available_models_list,
|
|
|
|
|
|
outputs=[model_selector]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
quick_load_btn.click(
|
|
|
|
|
|
app.load_model_by_index,
|
|
|
|
|
|
inputs=[model_selector],
|
2025-12-22 14:11:39 -07:00
|
|
|
|
outputs=[model_info]
|
2025-12-22 17:56:59 -07:00
|
|
|
|
).then(
|
|
|
|
|
|
app.get_available_models_list,
|
|
|
|
|
|
outputs=[model_selector]
|
2025-12-22 14:11:39 -07:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
prev_btn.click(
|
|
|
|
|
|
lambda: app.load_image("prev"),
|
|
|
|
|
|
outputs=[image_display, boxes_text, info_text]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
next_btn.click(
|
|
|
|
|
|
lambda: app.load_image("next"),
|
|
|
|
|
|
outputs=[image_display, boxes_text, info_text]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
auto_label_btn.click(
|
|
|
|
|
|
lambda t: app.auto_label_current(t),
|
|
|
|
|
|
inputs=[threshold_slider],
|
|
|
|
|
|
outputs=[image_display, boxes_text, info_text]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-23 16:17:19 -07:00
|
|
|
|
save_canvas_btn.click(
|
|
|
|
|
|
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 '';
|
|
|
|
|
|
}"""
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-22 14:11:39 -07:00
|
|
|
|
add_box_btn.click(
|
|
|
|
|
|
app.add_box_manual,
|
|
|
|
|
|
inputs=[x1_input, y1_input, x2_input, y2_input],
|
|
|
|
|
|
outputs=[image_display, boxes_text, info_text]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
delete_btn.click(
|
|
|
|
|
|
app.delete_last_box,
|
|
|
|
|
|
outputs=[image_display, boxes_text, info_text]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
clear_btn.click(
|
|
|
|
|
|
app.clear_boxes,
|
|
|
|
|
|
outputs=[image_display, boxes_text, info_text]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
export_btn.click(
|
|
|
|
|
|
lambda path: app.export_to_coco(Path(path)),
|
|
|
|
|
|
inputs=[export_path],
|
|
|
|
|
|
outputs=[export_result]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Training handlers
|
|
|
|
|
|
prep_btn.click(
|
|
|
|
|
|
lambda out, train, valid: app.prepare_training_dataset(Path(out), train, valid),
|
|
|
|
|
|
inputs=[dataset_prep_dir, train_split, valid_split],
|
|
|
|
|
|
outputs=[prep_result]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
start_train_btn.click(
|
|
|
|
|
|
app.start_training,
|
|
|
|
|
|
inputs=[model_framework, train_dataset_dir, train_output_dir, model_size, epochs, batch_size, learning_rate],
|
|
|
|
|
|
outputs=[training_status]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
stop_train_btn.click(
|
|
|
|
|
|
app.stop_training,
|
|
|
|
|
|
outputs=[training_status]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
refresh_status_btn.click(
|
|
|
|
|
|
app.get_training_status,
|
|
|
|
|
|
outputs=[training_status]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-22 17:56:59 -07:00
|
|
|
|
# OAK-D Deployment handlers
|
|
|
|
|
|
oak_scan_btn.click(
|
|
|
|
|
|
app.scan_for_models,
|
|
|
|
|
|
outputs=[oak_status]
|
|
|
|
|
|
).then(
|
|
|
|
|
|
app.get_available_models_list,
|
|
|
|
|
|
outputs=[oak_model_selector]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
oak_export_btn.click(
|
|
|
|
|
|
app.export_for_oak_d,
|
|
|
|
|
|
inputs=[oak_model_selector, oak_output_dir, oak_img_size],
|
|
|
|
|
|
outputs=[oak_status]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-12-22 14:11:39 -07:00
|
|
|
|
# Load first image on start
|
|
|
|
|
|
demo.load(on_load, outputs=[image_display, boxes_text, info_text])
|
|
|
|
|
|
|
|
|
|
|
|
return demo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
|
parser = argparse.ArgumentParser(description="Simple annotation GUI with auto-labeling")
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--images-dir",
|
|
|
|
|
|
type=Path,
|
|
|
|
|
|
default=Path(DEFAULT_IMAGES_DIR) if DEFAULT_IMAGES_DIR else None,
|
|
|
|
|
|
help="Default directory with images (can be changed in GUI)"
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--model-weights",
|
|
|
|
|
|
type=Path,
|
|
|
|
|
|
default=Path(DEFAULT_MODEL_WEIGHTS) if DEFAULT_MODEL_WEIGHTS else None,
|
|
|
|
|
|
help="Default trained model for auto-labeling (can be changed in GUI)"
|
|
|
|
|
|
)
|
2025-12-23 16:55:59 -07:00
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--port",
|
|
|
|
|
|
type=int,
|
|
|
|
|
|
default=DEFAULT_PORT,
|
|
|
|
|
|
help="Port to run the GUI on"
|
|
|
|
|
|
)
|
2025-12-22 14:11:39 -07:00
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
# Validate paths if provided
|
|
|
|
|
|
if args.images_dir and not args.images_dir.exists():
|
|
|
|
|
|
print(f"⚠️ Warning: Images directory not found: {args.images_dir}")
|
|
|
|
|
|
print("You can load a different directory from the GUI Settings")
|
|
|
|
|
|
args.images_dir = None
|
|
|
|
|
|
|
|
|
|
|
|
if args.model_weights and not args.model_weights.exists():
|
|
|
|
|
|
print(f"⚠️ Warning: Model weights not found: {args.model_weights}")
|
|
|
|
|
|
print("You can load different weights from the GUI Settings")
|
|
|
|
|
|
args.model_weights = None
|
|
|
|
|
|
|
|
|
|
|
|
# Create app
|
|
|
|
|
|
app = AnnotationApp(args.images_dir, args.model_weights)
|
|
|
|
|
|
|
2025-12-22 17:56:59 -07:00
|
|
|
|
# Scan for available models on startup
|
|
|
|
|
|
app.scan_for_models(return_info=False)
|
|
|
|
|
|
|
2025-12-22 14:11:39 -07:00
|
|
|
|
# Create and launch UI
|
|
|
|
|
|
demo = create_ui(app)
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n{'='*60}")
|
|
|
|
|
|
print(f"🚀 Starting annotation tool...")
|
|
|
|
|
|
if args.images_dir:
|
|
|
|
|
|
print(f"📁 Default images: {args.images_dir} ({len(app.image_paths)} images)")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f"📁 No default images - load directory from Settings")
|
|
|
|
|
|
if app.model:
|
|
|
|
|
|
print(f"🤖 Model: Loaded from {args.model_weights}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f"⚠️ No model loaded - load from Settings or train one")
|
|
|
|
|
|
print(f"💡 You can change images directory and model weights from the Settings panel")
|
|
|
|
|
|
print(f"{'='*60}\n")
|
|
|
|
|
|
|
|
|
|
|
|
demo.launch(
|
|
|
|
|
|
server_name="0.0.0.0",
|
2025-12-23 16:55:59 -07:00
|
|
|
|
server_port=args.port,
|
2025-12-23 17:04:59 -07:00
|
|
|
|
js=CANVAS_GLOBAL_JS,
|
2025-12-22 14:11:39 -07:00
|
|
|
|
share=False
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|