diff --git a/src/gui/tabs/results_tab.py b/src/gui/tabs/results_tab.py index 59c0c8e..97edfec 100644 --- a/src/gui/tabs/results_tab.py +++ b/src/gui/tabs/results_tab.py @@ -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", "")), diff --git a/src/gui/widgets/annotation_canvas_widget.py b/src/gui/widgets/annotation_canvas_widget.py index f67118d..0611475 100644 --- a/src/gui/widgets/annotation_canvas_widget.py +++ b/src/gui/widgets/annotation_canvas_widget.py @@ -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()