Compare commits
1 Commits
8d30e6bb7a
...
monkey-pat
| Author | SHA1 | Date | |
|---|---|---|---|
| d03ffdc4d0 |
@@ -658,6 +658,75 @@ class DatabaseManager:
|
|||||||
|
|
||||||
# ==================== Annotation Operations ====================
|
# ==================== 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(
|
def add_annotation(
|
||||||
self,
|
self,
|
||||||
image_id: int,
|
image_id: int,
|
||||||
|
|||||||
@@ -199,6 +199,10 @@ class MainWindow(QMainWindow):
|
|||||||
self.training_tab.refresh()
|
self.training_tab.refresh()
|
||||||
if hasattr(self, "results_tab"):
|
if hasattr(self, "results_tab"):
|
||||||
self.results_tab.refresh()
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error applying settings: {e}")
|
logger.error(f"Error applying settings: {e}")
|
||||||
|
|
||||||
@@ -215,6 +219,14 @@ class MainWindow(QMainWindow):
|
|||||||
logger.debug(f"Switched to tab: {tab_name}")
|
logger.debug(f"Switched to tab: {tab_name}")
|
||||||
self._update_status(f"Viewing: {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):
|
def _show_database_stats(self):
|
||||||
"""Show database statistics dialog."""
|
"""Show database statistics dialog."""
|
||||||
try:
|
try:
|
||||||
@@ -527,6 +539,11 @@ class MainWindow(QMainWindow):
|
|||||||
if hasattr(self, "training_tab"):
|
if hasattr(self, "training_tab"):
|
||||||
self.training_tab.shutdown()
|
self.training_tab.shutdown()
|
||||||
if hasattr(self, "annotation_tab"):
|
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()
|
self.annotation_tab.save_state()
|
||||||
|
|
||||||
logger.info("Application closing")
|
logger.info("Application closing")
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ from PySide6.QtWidgets import (
|
|||||||
QFileDialog,
|
QFileDialog,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
|
QLineEdit,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QHeaderView,
|
||||||
|
QAbstractItemView,
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt, QSettings
|
from PySide6.QtCore import Qt, QSettings
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -50,6 +55,32 @@ class AnnotationTab(QWidget):
|
|||||||
self.main_splitter = QSplitter(Qt.Horizontal)
|
self.main_splitter = QSplitter(Qt.Horizontal)
|
||||||
self.main_splitter.setHandleWidth(10)
|
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
|
# { Left splitter for image display and zoom info
|
||||||
self.left_splitter = QSplitter(Qt.Vertical)
|
self.left_splitter = QSplitter(Qt.Vertical)
|
||||||
self.left_splitter.setHandleWidth(10)
|
self.left_splitter.setHandleWidth(10)
|
||||||
@@ -120,12 +151,13 @@ class AnnotationTab(QWidget):
|
|||||||
self.right_splitter.addWidget(load_group)
|
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.left_splitter)
|
||||||
self.main_splitter.addWidget(self.right_splitter)
|
self.main_splitter.addWidget(self.right_splitter)
|
||||||
|
|
||||||
# Set initial sizes: 75% for left (image), 25% for right (controls)
|
# Set initial sizes: list (left), canvas (middle), controls (right)
|
||||||
self.main_splitter.setSizes([750, 250])
|
self.main_splitter.setSizes([320, 650, 280])
|
||||||
|
|
||||||
layout.addWidget(self.main_splitter)
|
layout.addWidget(self.main_splitter)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
@@ -133,6 +165,9 @@ class AnnotationTab(QWidget):
|
|||||||
# Restore splitter positions from settings
|
# Restore splitter positions from settings
|
||||||
self._restore_state()
|
self._restore_state()
|
||||||
|
|
||||||
|
# Populate list on startup.
|
||||||
|
self._refresh_annotated_images_list()
|
||||||
|
|
||||||
def _load_image(self):
|
def _load_image(self):
|
||||||
"""Load and display an image file."""
|
"""Load and display an image file."""
|
||||||
# Get last opened directory from QSettings
|
# Get last opened directory from QSettings
|
||||||
@@ -166,7 +201,21 @@ class AnnotationTab(QWidget):
|
|||||||
settings.setValue("annotation_tab/last_directory", str(Path(file_path).parent))
|
settings.setValue("annotation_tab/last_directory", str(Path(file_path).parent))
|
||||||
|
|
||||||
# Get or create image in database
|
# 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(
|
self.current_image_id = self.db_manager.get_or_create_image(
|
||||||
relative_path,
|
relative_path,
|
||||||
Path(file_path).name,
|
Path(file_path).name,
|
||||||
@@ -180,6 +229,9 @@ class AnnotationTab(QWidget):
|
|||||||
# Load and display any existing annotations for this image
|
# Load and display any existing annotations for this image
|
||||||
self._load_annotations_for_current_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
|
# Update info label
|
||||||
self._update_image_info()
|
self._update_image_info()
|
||||||
|
|
||||||
@@ -275,6 +327,9 @@ class AnnotationTab(QWidget):
|
|||||||
# Reload annotations from DB and redraw (respecting current class filter)
|
# Reload annotations from DB and redraw (respecting current class filter)
|
||||||
self._load_annotations_for_current_image()
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save annotation: {e}")
|
logger.error(f"Failed to save annotation: {e}")
|
||||||
QMessageBox.critical(self, "Error", f"Failed to save annotation:\n{str(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.annotation_tools.set_has_selected_annotation(False)
|
||||||
self._load_annotations_for_current_image()
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete annotations: {e}")
|
logger.error(f"Failed to delete annotations: {e}")
|
||||||
QMessageBox.critical(
|
QMessageBox.critical(
|
||||||
@@ -521,4 +579,164 @@ class AnnotationTab(QWidget):
|
|||||||
|
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
"""Refresh the tab."""
|
"""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
|
||||||
|
|||||||
Reference in New Issue
Block a user