diff --git a/src/database/db_manager.py b/src/database/db_manager.py index 5e4dcc4..9efa5c2 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -658,6 +658,75 @@ class DatabaseManager: # ==================== Annotation Operations ==================== + def get_annotated_images_summary( + self, + name_filter: Optional[str] = None, + order_by: str = "filename", + order_dir: str = "ASC", + limit: Optional[int] = None, + offset: int = 0, + ) -> List[Dict]: + """Return images that have at least one manual annotation. + + Args: + name_filter: Optional substring filter applied to filename/relative_path. + order_by: One of: 'filename', 'relative_path', 'annotation_count', 'added_at'. + order_dir: 'ASC' or 'DESC'. + limit: Optional max number of rows. + offset: Pagination offset. + + Returns: + List of dicts: {id, relative_path, filename, added_at, annotation_count} + """ + + allowed_order_by = { + "filename": "i.filename", + "relative_path": "i.relative_path", + "annotation_count": "annotation_count", + "added_at": "i.added_at", + } + order_expr = allowed_order_by.get(order_by, "i.filename") + dir_norm = str(order_dir).upper().strip() + if dir_norm not in {"ASC", "DESC"}: + dir_norm = "ASC" + + conn = self.get_connection() + try: + params: List[Any] = [] + where_sql = "" + if name_filter: + # Case-insensitive substring search. + token = f"%{name_filter}%" + where_sql = "WHERE (i.filename LIKE ? OR i.relative_path LIKE ?)" + params.extend([token, token]) + + limit_sql = "" + if limit is not None: + limit_sql = " LIMIT ? OFFSET ?" + params.extend([int(limit), int(offset)]) + + query = f""" + SELECT + i.id, + i.relative_path, + i.filename, + i.added_at, + COUNT(a.id) AS annotation_count + FROM images i + JOIN annotations a ON a.image_id = i.id + {where_sql} + GROUP BY i.id + HAVING annotation_count > 0 + ORDER BY {order_expr} {dir_norm} + {limit_sql} + """ + + cursor = conn.cursor() + cursor.execute(query, params) + return [dict(row) for row in cursor.fetchall()] + finally: + conn.close() + def add_annotation( self, image_id: int, diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 33626ef..c4dee97 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -199,6 +199,10 @@ class MainWindow(QMainWindow): self.training_tab.refresh() if hasattr(self, "results_tab"): self.results_tab.refresh() + if hasattr(self, "annotation_tab"): + self.annotation_tab.refresh() + if hasattr(self, "validation_tab"): + self.validation_tab.refresh() except Exception as e: logger.error(f"Error applying settings: {e}") @@ -215,6 +219,14 @@ class MainWindow(QMainWindow): logger.debug(f"Switched to tab: {tab_name}") self._update_status(f"Viewing: {tab_name}") + # Ensure the Annotation tab always shows up-to-date DB-backed lists. + try: + current_widget = self.tab_widget.widget(index) + if hasattr(self, "annotation_tab") and current_widget is self.annotation_tab: + self.annotation_tab.refresh() + except Exception as exc: + logger.debug(f"Failed to refresh annotation tab on selection: {exc}") + def _show_database_stats(self): """Show database statistics dialog.""" try: @@ -527,6 +539,11 @@ class MainWindow(QMainWindow): if hasattr(self, "training_tab"): self.training_tab.shutdown() if hasattr(self, "annotation_tab"): + # Best-effort refresh so DB-backed UI state is consistent at shutdown. + try: + self.annotation_tab.refresh() + except Exception: + pass self.annotation_tab.save_state() logger.info("Application closing") diff --git a/src/gui/tabs/annotation_tab.py b/src/gui/tabs/annotation_tab.py index e813c9d..9520a5e 100644 --- a/src/gui/tabs/annotation_tab.py +++ b/src/gui/tabs/annotation_tab.py @@ -13,6 +13,11 @@ from PySide6.QtWidgets import ( QFileDialog, QMessageBox, QSplitter, + QLineEdit, + QTableWidget, + QTableWidgetItem, + QHeaderView, + QAbstractItemView, ) from PySide6.QtCore import Qt, QSettings from pathlib import Path @@ -50,6 +55,32 @@ class AnnotationTab(QWidget): self.main_splitter = QSplitter(Qt.Horizontal) self.main_splitter.setHandleWidth(10) + # { Left-most pane: annotated images list + annotated_group = QGroupBox("Annotated Images") + annotated_layout = QVBoxLayout() + + filter_row = QHBoxLayout() + filter_row.addWidget(QLabel("Filter:")) + self.annotated_filter_edit = QLineEdit() + self.annotated_filter_edit.setPlaceholderText("Type to filter by image name…") + self.annotated_filter_edit.textChanged.connect(self._refresh_annotated_images_list) + filter_row.addWidget(self.annotated_filter_edit, 1) + annotated_layout.addLayout(filter_row) + + self.annotated_images_table = QTableWidget(0, 2) + self.annotated_images_table.setHorizontalHeaderLabels(["Image", "Annotations"]) + self.annotated_images_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self.annotated_images_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) + self.annotated_images_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.annotated_images_table.setSelectionMode(QAbstractItemView.SingleSelection) + self.annotated_images_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.annotated_images_table.setSortingEnabled(True) + self.annotated_images_table.itemSelectionChanged.connect(self._on_annotated_image_selected) + annotated_layout.addWidget(self.annotated_images_table, 1) + + annotated_group.setLayout(annotated_layout) + # } + # { Left splitter for image display and zoom info self.left_splitter = QSplitter(Qt.Vertical) self.left_splitter.setHandleWidth(10) @@ -120,12 +151,13 @@ class AnnotationTab(QWidget): self.right_splitter.addWidget(load_group) # } - # Add both splitters to the main horizontal splitter + # Add list + both splitters to the main horizontal splitter + self.main_splitter.addWidget(annotated_group) self.main_splitter.addWidget(self.left_splitter) self.main_splitter.addWidget(self.right_splitter) - # Set initial sizes: 75% for left (image), 25% for right (controls) - self.main_splitter.setSizes([750, 250]) + # Set initial sizes: list (left), canvas (middle), controls (right) + self.main_splitter.setSizes([320, 650, 280]) layout.addWidget(self.main_splitter) self.setLayout(layout) @@ -133,6 +165,9 @@ class AnnotationTab(QWidget): # Restore splitter positions from settings self._restore_state() + # Populate list on startup. + self._refresh_annotated_images_list() + def _load_image(self): """Load and display an image file.""" # Get last opened directory from QSettings @@ -166,7 +201,21 @@ class AnnotationTab(QWidget): settings.setValue("annotation_tab/last_directory", str(Path(file_path).parent)) # Get or create image in database - relative_path = str(Path(file_path).name) # Simplified for now + repo_root = self.config_manager.get_image_repository_path() + relative_path: str + try: + if repo_root: + repo_root_path = Path(repo_root).expanduser().resolve() + file_resolved = Path(file_path).expanduser().resolve() + if file_resolved.is_relative_to(repo_root_path): + relative_path = file_resolved.relative_to(repo_root_path).as_posix() + else: + # Fallback: store filename only to avoid leaking absolute paths. + relative_path = file_resolved.name + else: + relative_path = str(Path(file_path).name) + except Exception: + relative_path = str(Path(file_path).name) self.current_image_id = self.db_manager.get_or_create_image( relative_path, Path(file_path).name, @@ -180,6 +229,9 @@ class AnnotationTab(QWidget): # Load and display any existing annotations for this image self._load_annotations_for_current_image() + # Update annotated images list (newly annotated image added/selected). + self._refresh_annotated_images_list(select_image_id=self.current_image_id) + # Update info label self._update_image_info() @@ -275,6 +327,9 @@ class AnnotationTab(QWidget): # Reload annotations from DB and redraw (respecting current class filter) self._load_annotations_for_current_image() + # Update list counts. + self._refresh_annotated_images_list(select_image_id=self.current_image_id) + except Exception as e: logger.error(f"Failed to save annotation: {e}") QMessageBox.critical(self, "Error", f"Failed to save annotation:\n{str(e)}") @@ -405,6 +460,9 @@ class AnnotationTab(QWidget): self.annotation_tools.set_has_selected_annotation(False) self._load_annotations_for_current_image() + # Update list counts. + self._refresh_annotated_images_list(select_image_id=self.current_image_id) + except Exception as e: logger.error(f"Failed to delete annotations: {e}") QMessageBox.critical( @@ -521,4 +579,164 @@ class AnnotationTab(QWidget): def refresh(self): """Refresh the tab.""" - pass + self._refresh_annotated_images_list(select_image_id=self.current_image_id) + + # ==================== Annotated images list ==================== + + def _refresh_annotated_images_list(self, select_image_id: int | None = None) -> None: + """Reload annotated-images list from the database.""" + if not hasattr(self, "annotated_images_table"): + return + + # Preserve selection if possible + desired_id = select_image_id if select_image_id is not None else self.current_image_id + + name_filter = "" + if hasattr(self, "annotated_filter_edit"): + name_filter = self.annotated_filter_edit.text().strip() + + try: + rows = self.db_manager.get_annotated_images_summary(name_filter=name_filter) + except Exception as exc: + logger.error(f"Failed to load annotated images summary: {exc}") + rows = [] + + sorting_enabled = self.annotated_images_table.isSortingEnabled() + self.annotated_images_table.setSortingEnabled(False) + self.annotated_images_table.blockSignals(True) + try: + self.annotated_images_table.setRowCount(len(rows)) + for r, entry in enumerate(rows): + image_name = str(entry.get("filename") or "") + count = int(entry.get("annotation_count") or 0) + rel_path = str(entry.get("relative_path") or "") + + name_item = QTableWidgetItem(image_name) + # Tooltip shows full path of the image (best-effort: repository_root + relative_path) + full_path = rel_path + repo_root = self.config_manager.get_image_repository_path() + if repo_root and rel_path and not Path(rel_path).is_absolute(): + try: + full_path = str((Path(repo_root) / rel_path).resolve()) + except Exception: + full_path = str(Path(repo_root) / rel_path) + name_item.setToolTip(full_path) + name_item.setData(Qt.UserRole, int(entry.get("id"))) + name_item.setData(Qt.UserRole + 1, rel_path) + + count_item = QTableWidgetItem() + # Use EditRole to ensure numeric sorting. + count_item.setData(Qt.EditRole, count) + count_item.setData(Qt.UserRole, int(entry.get("id"))) + count_item.setData(Qt.UserRole + 1, rel_path) + + self.annotated_images_table.setItem(r, 0, name_item) + self.annotated_images_table.setItem(r, 1, count_item) + + # Re-select desired row + if desired_id is not None: + for r in range(self.annotated_images_table.rowCount()): + item = self.annotated_images_table.item(r, 0) + if item and item.data(Qt.UserRole) == desired_id: + self.annotated_images_table.selectRow(r) + break + finally: + self.annotated_images_table.blockSignals(False) + self.annotated_images_table.setSortingEnabled(sorting_enabled) + + def _on_annotated_image_selected(self) -> None: + """When user clicks an item in the list, load that image in the annotation canvas.""" + selected = self.annotated_images_table.selectedItems() + if not selected: + return + + # Row selection -> take the first column item + row = self.annotated_images_table.currentRow() + item = self.annotated_images_table.item(row, 0) + if not item: + return + + image_id = item.data(Qt.UserRole) + rel_path = item.data(Qt.UserRole + 1) or "" + if not image_id: + return + + image_path = self._resolve_image_path_for_relative_path(rel_path) + if not image_path: + QMessageBox.warning( + self, + "Image Not Found", + "Unable to locate image on disk for:\n" + f"{rel_path}\n\n" + "Tip: set Settings → Image repository path to the folder containing your images.", + ) + return + + try: + self.current_image = Image(image_path) + self.current_image_path = image_path + self.current_image_id = int(image_id) + self.annotation_canvas.load_image(self.current_image) + self._load_annotations_for_current_image() + self._update_image_info() + except ImageLoadError as exc: + logger.error(f"Failed to load image '{image_path}': {exc}") + QMessageBox.critical(self, "Error Loading Image", f"Failed to load image:\n{exc}") + except Exception as exc: + logger.error(f"Unexpected error loading image '{image_path}': {exc}") + QMessageBox.critical(self, "Error", f"Unexpected error:\n{exc}") + + def _resolve_image_path_for_relative_path(self, relative_path: str) -> str | None: + """Best-effort conversion from a DB relative_path to an on-disk file path.""" + + rel = (relative_path or "").strip() + if not rel: + return None + + candidates: list[Path] = [] + + # 1) Repository root + relative + repo_root = (self.config_manager.get_image_repository_path() or "").strip() + if repo_root: + candidates.append(Path(repo_root) / rel) + + # 2) If the DB path is absolute, try it directly. + candidates.append(Path(rel)) + + # 3) Try the directory of the currently loaded image (helps when DB stores only filenames) + if self.current_image_path: + try: + candidates.append(Path(self.current_image_path).expanduser().resolve().parent / Path(rel).name) + except Exception: + pass + + # 4) Try the last directory used by the annotation file picker + try: + settings = QSettings("microscopy_app", "object_detection") + last_dir = settings.value("annotation_tab/last_directory", None) + if last_dir: + candidates.append(Path(str(last_dir)) / Path(rel).name) + except Exception: + pass + + for p in candidates: + try: + expanded = p.expanduser() + if expanded.exists() and expanded.is_file(): + return str(expanded.resolve()) + except Exception: + continue + + # 5) Fallback: search by filename within repository root. + filename = Path(rel).name + if repo_root and filename: + root = Path(repo_root).expanduser() + try: + if root.exists(): + for match in root.rglob(filename): + if match.is_file(): + return str(match.resolve()) + except Exception as exc: + logger.debug(f"Search for {filename} under {root} failed: {exc}") + + return None