Adding auto zoom when result is loaded
This commit is contained in:
@@ -35,9 +35,7 @@ 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
|
||||
):
|
||||
def __init__(self, db_manager: DatabaseManager, config_manager: ConfigManager, parent=None):
|
||||
super().__init__(parent)
|
||||
self.db_manager = db_manager
|
||||
self.config_manager = config_manager
|
||||
@@ -71,24 +69,12 @@ class ResultsTab(QWidget):
|
||||
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.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)
|
||||
@@ -106,6 +92,8 @@ class ResultsTab(QWidget):
|
||||
preview_layout = QVBoxLayout()
|
||||
|
||||
self.preview_canvas = AnnotationCanvasWidget()
|
||||
# Auto-zoom so newly loaded images fill the available preview viewport.
|
||||
self.preview_canvas.set_auto_fit_to_view(True)
|
||||
self.preview_canvas.set_polyline_enabled(False)
|
||||
self.preview_canvas.set_show_bboxes(True)
|
||||
preview_layout.addWidget(self.preview_canvas)
|
||||
@@ -119,9 +107,7 @@ class ResultsTab(QWidget):
|
||||
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
|
||||
)
|
||||
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)
|
||||
@@ -169,8 +155,7 @@ class ResultsTab(QWidget):
|
||||
"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"),
|
||||
"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"),
|
||||
@@ -183,8 +168,7 @@ class ResultsTab(QWidget):
|
||||
|
||||
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"))
|
||||
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"):
|
||||
@@ -214,9 +198,7 @@ class ResultsTab(QWidget):
|
||||
|
||||
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 "-"
|
||||
)
|
||||
class_list = ", ".join(sorted(entry["classes"])) if entry["classes"] else "-"
|
||||
|
||||
items = [
|
||||
QTableWidgetItem(entry.get("image_filename", "")),
|
||||
|
||||
@@ -18,7 +18,7 @@ from PySide6.QtGui import (
|
||||
QPaintEvent,
|
||||
QPolygonF,
|
||||
)
|
||||
from PySide6.QtCore import Qt, QEvent, Signal, QPoint, QPointF, QRect
|
||||
from PySide6.QtCore import Qt, QEvent, Signal, QPoint, QPointF, QRect, QTimer
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from src.utils.image import Image, ImageLoadError
|
||||
@@ -79,9 +79,7 @@ def rdp(points: List[Tuple[float, float]], epsilon: float) -> List[Tuple[float,
|
||||
return [start, end]
|
||||
|
||||
|
||||
def simplify_polyline(
|
||||
points: List[Tuple[float, float]], epsilon: float
|
||||
) -> List[Tuple[float, float]]:
|
||||
def simplify_polyline(points: List[Tuple[float, float]], epsilon: float) -> List[Tuple[float, float]]:
|
||||
"""
|
||||
Simplify a polyline with RDP while preserving closure semantics.
|
||||
|
||||
@@ -145,6 +143,10 @@ class AnnotationCanvasWidget(QWidget):
|
||||
self.zoom_step = 0.1
|
||||
self.zoom_wheel_step = 0.15
|
||||
|
||||
# Auto-fit behavior (opt-in): when enabled, newly loaded images (and resizes)
|
||||
# will scale to fill the available viewport while preserving aspect ratio.
|
||||
self._auto_fit_to_view: bool = False
|
||||
|
||||
# Drawing / interaction state
|
||||
self.is_drawing = False
|
||||
self.polyline_enabled = False
|
||||
@@ -175,6 +177,35 @@ class AnnotationCanvasWidget(QWidget):
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def set_auto_fit_to_view(self, enabled: bool):
|
||||
"""Enable/disable automatic zoom-to-fit behavior."""
|
||||
self._auto_fit_to_view = bool(enabled)
|
||||
if self._auto_fit_to_view and self.original_pixmap is not None:
|
||||
QTimer.singleShot(0, self.fit_to_view)
|
||||
|
||||
def fit_to_view(self, padding_px: int = 6):
|
||||
"""Zoom the image so it fits the scroll area's viewport (aspect preserved)."""
|
||||
if self.original_pixmap is None:
|
||||
return
|
||||
|
||||
viewport = self.scroll_area.viewport().size()
|
||||
available_w = max(1, int(viewport.width()) - int(padding_px))
|
||||
available_h = max(1, int(viewport.height()) - int(padding_px))
|
||||
|
||||
img_w = max(1, int(self.original_pixmap.width()))
|
||||
img_h = max(1, int(self.original_pixmap.height()))
|
||||
|
||||
scale_w = available_w / img_w
|
||||
scale_h = available_h / img_h
|
||||
new_scale = min(scale_w, scale_h)
|
||||
new_scale = max(self.zoom_min, min(self.zoom_max, float(new_scale)))
|
||||
|
||||
if abs(new_scale - self.zoom_scale) < 1e-4:
|
||||
return
|
||||
|
||||
self.zoom_scale = new_scale
|
||||
self._apply_zoom()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup user interface."""
|
||||
layout = QVBoxLayout()
|
||||
@@ -187,9 +218,7 @@ class AnnotationCanvasWidget(QWidget):
|
||||
|
||||
self.canvas_label = QLabel("No image loaded")
|
||||
self.canvas_label.setAlignment(Qt.AlignCenter)
|
||||
self.canvas_label.setStyleSheet(
|
||||
"QLabel { background-color: #2b2b2b; color: #888; }"
|
||||
)
|
||||
self.canvas_label.setStyleSheet("QLabel { background-color: #2b2b2b; color: #888; }")
|
||||
self.canvas_label.setScaledContents(False)
|
||||
self.canvas_label.setMouseTracking(True)
|
||||
|
||||
@@ -212,9 +241,18 @@ class AnnotationCanvasWidget(QWidget):
|
||||
self.zoom_scale = 1.0
|
||||
self.clear_annotations()
|
||||
self._display_image()
|
||||
logger.debug(
|
||||
f"Loaded image into annotation canvas: {image.width}x{image.height}"
|
||||
)
|
||||
|
||||
# Defer fit-to-view until the widget has a valid viewport size.
|
||||
if self._auto_fit_to_view:
|
||||
QTimer.singleShot(0, self.fit_to_view)
|
||||
|
||||
logger.debug(f"Loaded image into annotation canvas: {image.width}x{image.height}")
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Optionally keep the image fitted when the widget is resized."""
|
||||
super().resizeEvent(event)
|
||||
if self._auto_fit_to_view and self.original_pixmap is not None:
|
||||
QTimer.singleShot(0, self.fit_to_view)
|
||||
|
||||
def clear(self):
|
||||
"""Clear the displayed image and all annotations."""
|
||||
@@ -289,22 +327,14 @@ class AnnotationCanvasWidget(QWidget):
|
||||
scaled_width,
|
||||
scaled_height,
|
||||
Qt.KeepAspectRatio,
|
||||
(
|
||||
Qt.SmoothTransformation
|
||||
if self.zoom_scale >= 1.0
|
||||
else Qt.FastTransformation
|
||||
),
|
||||
(Qt.SmoothTransformation if self.zoom_scale >= 1.0 else Qt.FastTransformation),
|
||||
)
|
||||
|
||||
scaled_annotations = self.annotation_pixmap.scaled(
|
||||
scaled_width,
|
||||
scaled_height,
|
||||
Qt.KeepAspectRatio,
|
||||
(
|
||||
Qt.SmoothTransformation
|
||||
if self.zoom_scale >= 1.0
|
||||
else Qt.FastTransformation
|
||||
),
|
||||
(Qt.SmoothTransformation if self.zoom_scale >= 1.0 else Qt.FastTransformation),
|
||||
)
|
||||
|
||||
# Composite image and annotations
|
||||
@@ -390,16 +420,11 @@ class AnnotationCanvasWidget(QWidget):
|
||||
y = (pos.y() - offset_y) / self.zoom_scale
|
||||
|
||||
# Check bounds
|
||||
if (
|
||||
0 <= x < self.original_pixmap.width()
|
||||
and 0 <= y < self.original_pixmap.height()
|
||||
):
|
||||
if 0 <= x < self.original_pixmap.width() and 0 <= y < self.original_pixmap.height():
|
||||
return (int(x), int(y))
|
||||
return None
|
||||
|
||||
def _find_polyline_at(
|
||||
self, img_x: float, img_y: float, threshold_px: float = 5.0
|
||||
) -> Optional[int]:
|
||||
def _find_polyline_at(self, img_x: float, img_y: float, threshold_px: float = 5.0) -> Optional[int]:
|
||||
"""
|
||||
Find index of polyline whose geometry is within threshold_px of (img_x, img_y).
|
||||
Returns the index in self.polylines, or None if none is close enough.
|
||||
@@ -421,9 +446,7 @@ class AnnotationCanvasWidget(QWidget):
|
||||
|
||||
# Precise distance to all segments
|
||||
for (x1, y1), (x2, y2) in zip(polyline[:-1], polyline[1:]):
|
||||
d = perpendicular_distance(
|
||||
(img_x, img_y), (float(x1), float(y1)), (float(x2), float(y2))
|
||||
)
|
||||
d = perpendicular_distance((img_x, img_y), (float(x1), float(y1)), (float(x2), float(y2)))
|
||||
if d < best_dist:
|
||||
best_dist = d
|
||||
best_index = idx
|
||||
@@ -624,11 +647,7 @@ class AnnotationCanvasWidget(QWidget):
|
||||
|
||||
def mouseMoveEvent(self, event: QMouseEvent):
|
||||
"""Handle mouse move events for drawing."""
|
||||
if (
|
||||
not self.is_drawing
|
||||
or not self.polyline_enabled
|
||||
or self.annotation_pixmap is None
|
||||
):
|
||||
if not self.is_drawing or not self.polyline_enabled or self.annotation_pixmap is None:
|
||||
super().mouseMoveEvent(event)
|
||||
return
|
||||
|
||||
@@ -688,15 +707,10 @@ class AnnotationCanvasWidget(QWidget):
|
||||
|
||||
if len(simplified) >= 2:
|
||||
# Store polyline and redraw all annotations
|
||||
self._add_polyline(
|
||||
simplified, self.polyline_pen_color, self.polyline_pen_width
|
||||
)
|
||||
self._add_polyline(simplified, self.polyline_pen_color, self.polyline_pen_width)
|
||||
|
||||
# Convert to normalized coordinates for metadata + signal
|
||||
normalized_stroke = [
|
||||
self._image_to_normalized_coords(int(x), int(y))
|
||||
for (x, y) in simplified
|
||||
]
|
||||
normalized_stroke = [self._image_to_normalized_coords(int(x), int(y)) for (x, y) in simplified]
|
||||
self.all_strokes.append(
|
||||
{
|
||||
"points": normalized_stroke,
|
||||
@@ -709,8 +723,7 @@ class AnnotationCanvasWidget(QWidget):
|
||||
# Emit signal with normalized coordinates
|
||||
self.annotation_drawn.emit(normalized_stroke)
|
||||
logger.debug(
|
||||
f"Completed stroke with {len(simplified)} points "
|
||||
f"(normalized len={len(normalized_stroke)})"
|
||||
f"Completed stroke with {len(simplified)} points " f"(normalized len={len(normalized_stroke)})"
|
||||
)
|
||||
|
||||
self.current_stroke = []
|
||||
@@ -750,9 +763,7 @@ class AnnotationCanvasWidget(QWidget):
|
||||
|
||||
# Store polyline as [y_norm, x_norm] to match DB convention and
|
||||
# the expectations of draw_saved_polyline().
|
||||
normalized_polyline = [
|
||||
[y / img_height, x / img_width] for (x, y) in polyline
|
||||
]
|
||||
normalized_polyline = [[y / img_height, x / img_width] for (x, y) in polyline]
|
||||
|
||||
logger.debug(
|
||||
f"Polyline {idx}: {len(polyline)} points, "
|
||||
@@ -772,7 +783,7 @@ class AnnotationCanvasWidget(QWidget):
|
||||
self,
|
||||
polyline: List[List[float]],
|
||||
color: str,
|
||||
width: int = 3,
|
||||
width: int = 1,
|
||||
annotation_id: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
@@ -810,17 +821,13 @@ class AnnotationCanvasWidget(QWidget):
|
||||
|
||||
# Store and redraw using common pipeline
|
||||
pen_color = QColor(color)
|
||||
pen_color.setAlpha(128) # Add semi-transparency
|
||||
pen_color.setAlpha(255) # Add semi-transparency
|
||||
self._add_polyline(img_coords, pen_color, width, annotation_id=annotation_id)
|
||||
|
||||
# Store in all_strokes for consistency (uses normalized coordinates)
|
||||
self.all_strokes.append(
|
||||
{"points": polyline, "color": color, "alpha": 128, "width": width}
|
||||
)
|
||||
self.all_strokes.append({"points": polyline, "color": color, "alpha": 128, "width": width})
|
||||
|
||||
logger.debug(
|
||||
f"Drew saved polyline with {len(polyline)} points in color {color}"
|
||||
)
|
||||
logger.debug(f"Drew saved polyline with {len(polyline)} points in color {color}")
|
||||
|
||||
def draw_saved_bbox(
|
||||
self,
|
||||
@@ -844,9 +851,7 @@ class AnnotationCanvasWidget(QWidget):
|
||||
return
|
||||
|
||||
if len(bbox) != 4:
|
||||
logger.warning(
|
||||
f"Invalid bounding box format: expected 4 values, got {len(bbox)}"
|
||||
)
|
||||
logger.warning(f"Invalid bounding box format: expected 4 values, got {len(bbox)}")
|
||||
return
|
||||
|
||||
# Convert normalized coordinates to image coordinates (for logging/debug)
|
||||
@@ -867,15 +872,11 @@ class AnnotationCanvasWidget(QWidget):
|
||||
# in _redraw_annotations() together with all polylines.
|
||||
pen_color = QColor(color)
|
||||
pen_color.setAlpha(128) # Add semi-transparency
|
||||
self.bboxes.append(
|
||||
[float(x_min_norm), float(y_min_norm), float(x_max_norm), float(y_max_norm)]
|
||||
)
|
||||
self.bboxes.append([float(x_min_norm), float(y_min_norm), float(x_max_norm), float(y_max_norm)])
|
||||
self.bbox_meta.append({"color": pen_color, "width": int(width), "label": label})
|
||||
|
||||
# Store in all_strokes for consistency
|
||||
self.all_strokes.append(
|
||||
{"bbox": bbox, "color": color, "alpha": 128, "width": width, "label": label}
|
||||
)
|
||||
self.all_strokes.append({"bbox": bbox, "color": color, "alpha": 128, "width": width, "label": label})
|
||||
|
||||
# Redraw overlay (polylines + all bounding boxes)
|
||||
self._redraw_annotations()
|
||||
|
||||
Reference in New Issue
Block a user