440 lines
16 KiB
Python
440 lines
16 KiB
Python
"""
|
|
Results tab for browsing stored detections and visualizing overlays.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
from PySide6.QtWidgets import (
|
|
QWidget,
|
|
QVBoxLayout,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QGroupBox,
|
|
QPushButton,
|
|
QSplitter,
|
|
QTableWidget,
|
|
QTableWidgetItem,
|
|
QHeaderView,
|
|
QAbstractItemView,
|
|
QMessageBox,
|
|
QCheckBox,
|
|
)
|
|
from PySide6.QtCore import Qt
|
|
|
|
from src.database.db_manager import DatabaseManager
|
|
from src.utils.config_manager import ConfigManager
|
|
from src.utils.logger import get_logger
|
|
from src.utils.image import Image, ImageLoadError
|
|
from src.gui.widgets import AnnotationCanvasWidget
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class ResultsTab(QWidget):
|
|
"""Results tab showing detection history and preview overlays."""
|
|
|
|
def __init__(
|
|
self, db_manager: DatabaseManager, config_manager: ConfigManager, parent=None
|
|
):
|
|
super().__init__(parent)
|
|
self.db_manager = db_manager
|
|
self.config_manager = config_manager
|
|
|
|
self.detection_summary: List[Dict] = []
|
|
self.current_selection: Optional[Dict] = None
|
|
self.current_image: Optional[Image] = None
|
|
self.current_detections: List[Dict] = []
|
|
self._image_path_cache: Dict[str, str] = {}
|
|
|
|
self._setup_ui()
|
|
self.refresh()
|
|
|
|
def _setup_ui(self):
|
|
"""Setup user interface."""
|
|
layout = QVBoxLayout()
|
|
|
|
# Splitter for list + preview
|
|
splitter = QSplitter(Qt.Horizontal)
|
|
|
|
# Left pane: detection list
|
|
left_container = QWidget()
|
|
left_layout = QVBoxLayout()
|
|
left_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
controls_layout = QHBoxLayout()
|
|
self.refresh_btn = QPushButton("Refresh")
|
|
self.refresh_btn.clicked.connect(self.refresh)
|
|
controls_layout.addWidget(self.refresh_btn)
|
|
controls_layout.addStretch()
|
|
left_layout.addLayout(controls_layout)
|
|
|
|
self.results_table = QTableWidget(0, 5)
|
|
self.results_table.setHorizontalHeaderLabels(
|
|
["Image", "Model", "Detections", "Classes", "Last Updated"]
|
|
)
|
|
self.results_table.horizontalHeader().setSectionResizeMode(
|
|
0, QHeaderView.Stretch
|
|
)
|
|
self.results_table.horizontalHeader().setSectionResizeMode(
|
|
1, QHeaderView.Stretch
|
|
)
|
|
self.results_table.horizontalHeader().setSectionResizeMode(
|
|
2, QHeaderView.ResizeToContents
|
|
)
|
|
self.results_table.horizontalHeader().setSectionResizeMode(
|
|
3, QHeaderView.Stretch
|
|
)
|
|
self.results_table.horizontalHeader().setSectionResizeMode(
|
|
4, QHeaderView.ResizeToContents
|
|
)
|
|
self.results_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
self.results_table.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
self.results_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
self.results_table.itemSelectionChanged.connect(self._on_result_selected)
|
|
|
|
left_layout.addWidget(self.results_table)
|
|
left_container.setLayout(left_layout)
|
|
|
|
# Right pane: preview canvas and controls
|
|
right_container = QWidget()
|
|
right_layout = QVBoxLayout()
|
|
right_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
preview_group = QGroupBox("Detection Preview")
|
|
preview_layout = QVBoxLayout()
|
|
|
|
self.preview_canvas = AnnotationCanvasWidget()
|
|
self.preview_canvas.set_polyline_enabled(False)
|
|
self.preview_canvas.set_show_bboxes(True)
|
|
preview_layout.addWidget(self.preview_canvas)
|
|
|
|
toggles_layout = QHBoxLayout()
|
|
self.show_masks_checkbox = QCheckBox("Show Masks")
|
|
self.show_masks_checkbox.setChecked(False)
|
|
self.show_masks_checkbox.stateChanged.connect(self._apply_detection_overlays)
|
|
self.show_bboxes_checkbox = QCheckBox("Show Bounding Boxes")
|
|
self.show_bboxes_checkbox.setChecked(True)
|
|
self.show_bboxes_checkbox.stateChanged.connect(self._toggle_bboxes)
|
|
self.show_confidence_checkbox = QCheckBox("Show Confidence")
|
|
self.show_confidence_checkbox.setChecked(False)
|
|
self.show_confidence_checkbox.stateChanged.connect(
|
|
self._apply_detection_overlays
|
|
)
|
|
toggles_layout.addWidget(self.show_masks_checkbox)
|
|
toggles_layout.addWidget(self.show_bboxes_checkbox)
|
|
toggles_layout.addWidget(self.show_confidence_checkbox)
|
|
toggles_layout.addStretch()
|
|
preview_layout.addLayout(toggles_layout)
|
|
|
|
self.summary_label = QLabel("Select a detection result to preview.")
|
|
self.summary_label.setWordWrap(True)
|
|
preview_layout.addWidget(self.summary_label)
|
|
|
|
preview_group.setLayout(preview_layout)
|
|
right_layout.addWidget(preview_group)
|
|
right_container.setLayout(right_layout)
|
|
|
|
splitter.addWidget(left_container)
|
|
splitter.addWidget(right_container)
|
|
splitter.setStretchFactor(0, 1)
|
|
splitter.setStretchFactor(1, 2)
|
|
|
|
layout.addWidget(splitter)
|
|
self.setLayout(layout)
|
|
|
|
def refresh(self):
|
|
"""Refresh the detection list and preview."""
|
|
self._load_detection_summary()
|
|
self._populate_results_table()
|
|
self.current_selection = None
|
|
self.current_image = None
|
|
self.current_detections = []
|
|
self.preview_canvas.clear()
|
|
self.summary_label.setText("Select a detection result to preview.")
|
|
|
|
def _load_detection_summary(self):
|
|
"""Load latest detection summaries grouped by image + model."""
|
|
try:
|
|
detections = self.db_manager.get_detections(limit=500)
|
|
summary_map: Dict[tuple, Dict] = {}
|
|
|
|
for det in detections:
|
|
key = (det["image_id"], det["model_id"])
|
|
metadata = det.get("metadata") or {}
|
|
entry = summary_map.setdefault(
|
|
key,
|
|
{
|
|
"image_id": det["image_id"],
|
|
"model_id": det["model_id"],
|
|
"image_path": det.get("image_path"),
|
|
"image_filename": det.get("image_filename")
|
|
or det.get("image_path"),
|
|
"model_name": det.get("model_name", ""),
|
|
"model_version": det.get("model_version", ""),
|
|
"last_detected": det.get("detected_at"),
|
|
"count": 0,
|
|
"classes": set(),
|
|
"source_path": metadata.get("source_path"),
|
|
"repository_root": metadata.get("repository_root"),
|
|
},
|
|
)
|
|
|
|
entry["count"] += 1
|
|
if det.get("detected_at") and (
|
|
not entry.get("last_detected")
|
|
or str(det.get("detected_at")) > str(entry.get("last_detected"))
|
|
):
|
|
entry["last_detected"] = det.get("detected_at")
|
|
if det.get("class_name"):
|
|
entry["classes"].add(det["class_name"])
|
|
if metadata.get("source_path") and not entry.get("source_path"):
|
|
entry["source_path"] = metadata.get("source_path")
|
|
if metadata.get("repository_root") and not entry.get("repository_root"):
|
|
entry["repository_root"] = metadata.get("repository_root")
|
|
|
|
self.detection_summary = sorted(
|
|
summary_map.values(),
|
|
key=lambda x: str(x.get("last_detected") or ""),
|
|
reverse=True,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load detection summary: {e}")
|
|
QMessageBox.critical(
|
|
self,
|
|
"Error",
|
|
f"Failed to load detection results:\n{str(e)}",
|
|
)
|
|
self.detection_summary = []
|
|
|
|
def _populate_results_table(self):
|
|
"""Populate the table widget with detection summaries."""
|
|
self.results_table.setRowCount(len(self.detection_summary))
|
|
|
|
for row, entry in enumerate(self.detection_summary):
|
|
model_label = f"{entry['model_name']} {entry['model_version']}".strip()
|
|
class_list = (
|
|
", ".join(sorted(entry["classes"])) if entry["classes"] else "-"
|
|
)
|
|
|
|
items = [
|
|
QTableWidgetItem(entry.get("image_filename", "")),
|
|
QTableWidgetItem(model_label),
|
|
QTableWidgetItem(str(entry.get("count", 0))),
|
|
QTableWidgetItem(class_list),
|
|
QTableWidgetItem(str(entry.get("last_detected") or "")),
|
|
]
|
|
|
|
for col, item in enumerate(items):
|
|
item.setData(Qt.UserRole, row)
|
|
self.results_table.setItem(row, col, item)
|
|
|
|
self.results_table.clearSelection()
|
|
|
|
def _on_result_selected(self):
|
|
"""Handle selection changes in the detection table."""
|
|
selected_items = self.results_table.selectedItems()
|
|
if not selected_items:
|
|
return
|
|
|
|
row = selected_items[0].data(Qt.UserRole)
|
|
if row is None or row >= len(self.detection_summary):
|
|
return
|
|
|
|
entry = self.detection_summary[row]
|
|
if (
|
|
self.current_selection
|
|
and self.current_selection.get("image_id") == entry["image_id"]
|
|
and self.current_selection.get("model_id") == entry["model_id"]
|
|
):
|
|
return
|
|
|
|
self.current_selection = entry
|
|
|
|
image_path = self._resolve_image_path(entry)
|
|
if not image_path:
|
|
QMessageBox.warning(
|
|
self,
|
|
"Image Not Found",
|
|
"Unable to locate the image file for this detection.",
|
|
)
|
|
return
|
|
|
|
try:
|
|
self.current_image = Image(image_path)
|
|
self.preview_canvas.load_image(self.current_image)
|
|
except ImageLoadError as e:
|
|
logger.error(f"Failed to load image '{image_path}': {e}")
|
|
QMessageBox.critical(
|
|
self,
|
|
"Image Error",
|
|
f"Failed to load image for preview:\n{str(e)}",
|
|
)
|
|
return
|
|
|
|
self._load_detections_for_selection(entry)
|
|
self._apply_detection_overlays()
|
|
self._update_summary_label(entry)
|
|
|
|
def _load_detections_for_selection(self, entry: Dict):
|
|
"""Load detection records for the selected image/model pair."""
|
|
self.current_detections = []
|
|
if not entry:
|
|
return
|
|
|
|
try:
|
|
filters = {"image_id": entry["image_id"], "model_id": entry["model_id"]}
|
|
self.current_detections = self.db_manager.get_detections(filters)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load detections for preview: {e}")
|
|
QMessageBox.critical(
|
|
self,
|
|
"Error",
|
|
f"Failed to load detections for this image:\n{str(e)}",
|
|
)
|
|
self.current_detections = []
|
|
|
|
def _apply_detection_overlays(self):
|
|
"""Draw detections onto the preview canvas based on current toggles."""
|
|
self.preview_canvas.clear_annotations()
|
|
self.preview_canvas.set_show_bboxes(self.show_bboxes_checkbox.isChecked())
|
|
|
|
if not self.current_detections or not self.current_image:
|
|
return
|
|
|
|
for det in self.current_detections:
|
|
color = self._get_class_color(det.get("class_name"))
|
|
|
|
if self.show_masks_checkbox.isChecked() and det.get("segmentation_mask"):
|
|
mask_points = self._convert_mask(det["segmentation_mask"])
|
|
if mask_points:
|
|
self.preview_canvas.draw_saved_polyline(mask_points, color)
|
|
|
|
bbox = [
|
|
det.get("x_min"),
|
|
det.get("y_min"),
|
|
det.get("x_max"),
|
|
det.get("y_max"),
|
|
]
|
|
if all(v is not None for v in bbox):
|
|
label = None
|
|
if self.show_confidence_checkbox.isChecked():
|
|
confidence = det.get("confidence")
|
|
if confidence is not None:
|
|
label = f"{confidence:.2f}"
|
|
self.preview_canvas.draw_saved_bbox(bbox, color, label=label)
|
|
|
|
def _convert_mask(self, mask_points: List[List[float]]) -> List[List[float]]:
|
|
"""Convert stored [x, y] masks to [y, x] format for the canvas."""
|
|
converted = []
|
|
for point in mask_points:
|
|
if len(point) >= 2:
|
|
x, y = point[0], point[1]
|
|
converted.append([y, x])
|
|
return converted
|
|
|
|
def _toggle_bboxes(self):
|
|
"""Update bounding box visibility on the canvas."""
|
|
self.preview_canvas.set_show_bboxes(self.show_bboxes_checkbox.isChecked())
|
|
# Re-render to respect show/hide when toggled
|
|
self._apply_detection_overlays()
|
|
|
|
def _update_summary_label(self, entry: Dict):
|
|
"""Display textual summary for the selected detection run."""
|
|
classes = ", ".join(sorted(entry.get("classes", []))) or "-"
|
|
summary_text = (
|
|
f"Image: {entry.get('image_filename', 'unknown')}\n"
|
|
f"Model: {entry.get('model_name', '')} {entry.get('model_version', '')}\n"
|
|
f"Detections: {entry.get('count', 0)}\n"
|
|
f"Classes: {classes}\n"
|
|
f"Last Updated: {entry.get('last_detected', 'n/a')}"
|
|
)
|
|
self.summary_label.setText(summary_text)
|
|
|
|
def _resolve_image_path(self, entry: Dict) -> Optional[str]:
|
|
"""Resolve an image path using metadata, cache, and repository hints."""
|
|
relative_path = entry.get("image_path") if entry else None
|
|
cache_key = relative_path or entry.get("source_path")
|
|
if cache_key and cache_key in self._image_path_cache:
|
|
cached = Path(self._image_path_cache[cache_key])
|
|
if cached.exists():
|
|
return self._image_path_cache[cache_key]
|
|
del self._image_path_cache[cache_key]
|
|
|
|
candidates = []
|
|
source_path = entry.get("source_path") if entry else None
|
|
if source_path:
|
|
candidates.append(Path(source_path))
|
|
|
|
repo_roots = []
|
|
if entry.get("repository_root"):
|
|
repo_roots.append(entry["repository_root"])
|
|
config_repo = self.config_manager.get_image_repository_path()
|
|
if config_repo:
|
|
repo_roots.append(config_repo)
|
|
|
|
for root in repo_roots:
|
|
if relative_path:
|
|
candidates.append(Path(root) / relative_path)
|
|
|
|
if relative_path:
|
|
candidates.append(Path(relative_path))
|
|
|
|
for candidate in candidates:
|
|
try:
|
|
if candidate and candidate.exists():
|
|
resolved = str(candidate.resolve())
|
|
if cache_key:
|
|
self._image_path_cache[cache_key] = resolved
|
|
return resolved
|
|
except Exception:
|
|
continue
|
|
|
|
# Fallback: search by filename in known roots
|
|
filename = Path(relative_path).name if relative_path else None
|
|
if filename:
|
|
search_roots = [Path(root) for root in repo_roots if root]
|
|
if not search_roots:
|
|
search_roots = [Path("data")]
|
|
match = self._search_in_roots(filename, search_roots)
|
|
if match:
|
|
resolved = str(match.resolve())
|
|
if cache_key:
|
|
self._image_path_cache[cache_key] = resolved
|
|
return resolved
|
|
|
|
return None
|
|
|
|
def _search_in_roots(self, filename: str, roots: List[Path]) -> Optional[Path]:
|
|
"""Search for a file name within a list of root directories."""
|
|
for root in roots:
|
|
try:
|
|
if not root.exists():
|
|
continue
|
|
for candidate in root.rglob(filename):
|
|
return candidate
|
|
except Exception as e:
|
|
logger.debug(f"Error searching for {filename} in {root}: {e}")
|
|
return None
|
|
|
|
def _get_class_color(self, class_name: Optional[str]) -> str:
|
|
"""Return consistent color hex for a class name."""
|
|
if not class_name:
|
|
return "#FF6B6B"
|
|
|
|
color_map = self.config_manager.get_bbox_colors()
|
|
if class_name in color_map:
|
|
return color_map[class_name]
|
|
|
|
# Deterministic fallback color based on hash
|
|
palette = [
|
|
"#FF6B6B",
|
|
"#4ECDC4",
|
|
"#FFD166",
|
|
"#1D3557",
|
|
"#F4A261",
|
|
"#E76F51",
|
|
]
|
|
return palette[hash(class_name) % len(palette)]
|