Compare commits
3 Commits
9c8931e6f3
...
monkey-pat
| Author | SHA1 | Date | |
|---|---|---|---|
| d03ffdc4d0 | |||
| 8d30e6bb7a | |||
| f810fec4d8 |
@@ -201,6 +201,28 @@ class DatabaseManager:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_model(self, model_id: int) -> bool:
|
||||
"""Delete a model from the database.
|
||||
|
||||
Note: detections referencing this model are deleted automatically via
|
||||
the `detections.model_id` foreign key (ON DELETE CASCADE).
|
||||
|
||||
Args:
|
||||
model_id: ID of the model to delete.
|
||||
|
||||
Returns:
|
||||
True if a model row was deleted, False otherwise.
|
||||
"""
|
||||
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM models WHERE id = ?", (model_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ==================== Image Operations ====================
|
||||
|
||||
def add_image(
|
||||
@@ -636,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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Main window for the microscopy object detection application.
|
||||
"""
|
||||
"""Main window for the microscopy object detection application."""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow,
|
||||
@@ -20,6 +21,7 @@ from src.database.db_manager import DatabaseManager
|
||||
from src.utils.config_manager import ConfigManager
|
||||
from src.utils.logger import get_logger
|
||||
from src.gui.dialogs.config_dialog import ConfigDialog
|
||||
from src.gui.dialogs.delete_model_dialog import DeleteModelDialog
|
||||
from src.gui.tabs.detection_tab import DetectionTab
|
||||
from src.gui.tabs.training_tab import TrainingTab
|
||||
from src.gui.tabs.validation_tab import ValidationTab
|
||||
@@ -91,6 +93,12 @@ class MainWindow(QMainWindow):
|
||||
db_stats_action.triggered.connect(self._show_database_stats)
|
||||
tools_menu.addAction(db_stats_action)
|
||||
|
||||
tools_menu.addSeparator()
|
||||
|
||||
delete_model_action = QAction("Delete &Model…", self)
|
||||
delete_model_action.triggered.connect(self._show_delete_model_dialog)
|
||||
tools_menu.addAction(delete_model_action)
|
||||
|
||||
# Help menu
|
||||
help_menu = menubar.addMenu("&Help")
|
||||
|
||||
@@ -117,10 +125,10 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# Add tabs to widget
|
||||
self.tab_widget.addTab(self.detection_tab, "Detection")
|
||||
self.tab_widget.addTab(self.results_tab, "Results")
|
||||
self.tab_widget.addTab(self.annotation_tab, "Annotation")
|
||||
self.tab_widget.addTab(self.training_tab, "Training")
|
||||
self.tab_widget.addTab(self.validation_tab, "Validation")
|
||||
self.tab_widget.addTab(self.results_tab, "Results")
|
||||
self.tab_widget.addTab(self.annotation_tab, "Annotation (Future)")
|
||||
|
||||
# Connect tab change signal
|
||||
self.tab_widget.currentChanged.connect(self._on_tab_changed)
|
||||
@@ -152,9 +160,7 @@ class MainWindow(QMainWindow):
|
||||
"""Center window on screen."""
|
||||
screen = self.screen().geometry()
|
||||
size = self.geometry()
|
||||
self.move(
|
||||
(screen.width() - size.width()) // 2, (screen.height() - size.height()) // 2
|
||||
)
|
||||
self.move((screen.width() - size.width()) // 2, (screen.height() - size.height()) // 2)
|
||||
|
||||
def _restore_window_state(self):
|
||||
"""Restore window geometry from settings or center window."""
|
||||
@@ -193,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}")
|
||||
|
||||
@@ -209,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:
|
||||
@@ -231,9 +249,229 @@ class MainWindow(QMainWindow):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting database stats: {e}")
|
||||
QMessageBox.warning(
|
||||
self, "Error", f"Failed to get database statistics:\n{str(e)}"
|
||||
)
|
||||
QMessageBox.warning(self, "Error", f"Failed to get database statistics:\n{str(e)}")
|
||||
|
||||
def _show_delete_model_dialog(self) -> None:
|
||||
"""Open the model deletion dialog."""
|
||||
dialog = DeleteModelDialog(self.db_manager, self)
|
||||
if not dialog.exec():
|
||||
return
|
||||
|
||||
model_ids = dialog.selected_model_ids
|
||||
if not model_ids:
|
||||
return
|
||||
|
||||
self._delete_models(model_ids)
|
||||
|
||||
def _delete_models(self, model_ids: list[int]) -> None:
|
||||
"""Delete one or more models from the database and remove artifacts from disk."""
|
||||
|
||||
deleted_count = 0
|
||||
removed_paths: list[str] = []
|
||||
remove_errors: list[str] = []
|
||||
|
||||
for model_id in model_ids:
|
||||
model = None
|
||||
try:
|
||||
model = self.db_manager.get_model_by_id(int(model_id))
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to load model {model_id} before deletion: {exc}")
|
||||
|
||||
if not model:
|
||||
remove_errors.append(f"Model id {model_id} not found in database.")
|
||||
continue
|
||||
|
||||
try:
|
||||
deleted = self.db_manager.delete_model(int(model_id))
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to delete model {model_id}: {exc}")
|
||||
remove_errors.append(f"Failed to delete model id {model_id} from DB: {exc}")
|
||||
continue
|
||||
|
||||
if not deleted:
|
||||
remove_errors.append(f"Model id {model_id} was not deleted (already removed?).")
|
||||
continue
|
||||
|
||||
deleted_count += 1
|
||||
removed, errors = self._delete_model_artifacts_from_disk(model)
|
||||
removed_paths.extend(removed)
|
||||
remove_errors.extend(errors)
|
||||
|
||||
# Refresh tabs to reflect the deletion(s).
|
||||
try:
|
||||
if hasattr(self, "detection_tab"):
|
||||
self.detection_tab.refresh()
|
||||
if hasattr(self, "results_tab"):
|
||||
self.results_tab.refresh()
|
||||
if hasattr(self, "validation_tab"):
|
||||
self.validation_tab.refresh()
|
||||
if hasattr(self, "training_tab"):
|
||||
self.training_tab.refresh()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to refresh tabs after model deletion: {exc}")
|
||||
|
||||
details: list[str] = []
|
||||
if removed_paths:
|
||||
details.append("Removed from disk:\n" + "\n".join(removed_paths))
|
||||
if remove_errors:
|
||||
details.append("\nDisk cleanup warnings:\n" + "\n".join(remove_errors))
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Delete Model",
|
||||
f"Deleted {deleted_count} model(s) from database." + ("\n\n" + "\n".join(details) if details else ""),
|
||||
)
|
||||
|
||||
def _delete_model(self, model_id: int) -> None:
|
||||
"""Delete a model from the database and remove its artifacts from disk."""
|
||||
|
||||
model = None
|
||||
try:
|
||||
model = self.db_manager.get_model_by_id(model_id)
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to load model {model_id} before deletion: {exc}")
|
||||
|
||||
if not model:
|
||||
QMessageBox.warning(self, "Delete Model", "Selected model was not found in the database.")
|
||||
return
|
||||
|
||||
model_path = str(model.get("model_path") or "")
|
||||
|
||||
try:
|
||||
deleted = self.db_manager.delete_model(model_id)
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to delete model {model_id}: {exc}")
|
||||
QMessageBox.critical(self, "Delete Model", f"Failed to delete model from database:\n{exc}")
|
||||
return
|
||||
|
||||
if not deleted:
|
||||
QMessageBox.warning(self, "Delete Model", "No model was deleted (it may have already been removed).")
|
||||
return
|
||||
|
||||
removed_paths, remove_errors = self._delete_model_artifacts_from_disk(model)
|
||||
|
||||
# Refresh tabs to reflect the deletion.
|
||||
try:
|
||||
if hasattr(self, "detection_tab"):
|
||||
self.detection_tab.refresh()
|
||||
if hasattr(self, "results_tab"):
|
||||
self.results_tab.refresh()
|
||||
if hasattr(self, "validation_tab"):
|
||||
self.validation_tab.refresh()
|
||||
if hasattr(self, "training_tab"):
|
||||
self.training_tab.refresh()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Failed to refresh tabs after model deletion: {exc}")
|
||||
|
||||
details = []
|
||||
if model_path:
|
||||
details.append(f"Deleted model record for: {model_path}")
|
||||
if removed_paths:
|
||||
details.append("\nRemoved from disk:\n" + "\n".join(removed_paths))
|
||||
if remove_errors:
|
||||
details.append("\nDisk cleanup warnings:\n" + "\n".join(remove_errors))
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Delete Model",
|
||||
"Model deleted from database." + ("\n\n" + "\n".join(details) if details else ""),
|
||||
)
|
||||
|
||||
def _delete_model_artifacts_from_disk(self, model: dict) -> tuple[list[str], list[str]]:
|
||||
"""Best-effort removal of model artifacts on disk.
|
||||
|
||||
Strategy:
|
||||
- Remove run directories inferred from:
|
||||
- model.model_path (…/<run>/weights/*.pt => <run>)
|
||||
- training_params.stage_results[].results.save_dir
|
||||
but only if they are under the configured models directory.
|
||||
- If the weights file itself exists and is outside the models directory, delete only the file.
|
||||
|
||||
Returns:
|
||||
(removed_paths, errors)
|
||||
"""
|
||||
|
||||
removed: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
models_root = Path(self.config_manager.get_models_directory() or "data/models").expanduser()
|
||||
try:
|
||||
models_root_resolved = models_root.resolve()
|
||||
except Exception:
|
||||
models_root_resolved = models_root
|
||||
|
||||
inferred_dirs: list[Path] = []
|
||||
|
||||
# 1) From model_path
|
||||
model_path_value = model.get("model_path")
|
||||
if model_path_value:
|
||||
try:
|
||||
p = Path(str(model_path_value)).expanduser()
|
||||
p_resolved = p.resolve() if p.exists() else p
|
||||
if p_resolved.is_file():
|
||||
if p_resolved.parent.name == "weights" and p_resolved.parent.parent.exists():
|
||||
inferred_dirs.append(p_resolved.parent.parent)
|
||||
elif p_resolved.parent.exists():
|
||||
inferred_dirs.append(p_resolved.parent)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) From training_params.stage_results[].results.save_dir
|
||||
training_params = model.get("training_params") or {}
|
||||
if isinstance(training_params, dict):
|
||||
stage_results = training_params.get("stage_results")
|
||||
if isinstance(stage_results, list):
|
||||
for stage in stage_results:
|
||||
results = (stage or {}).get("results")
|
||||
save_dir = (results or {}).get("save_dir") if isinstance(results, dict) else None
|
||||
if not save_dir:
|
||||
continue
|
||||
try:
|
||||
d = Path(str(save_dir)).expanduser()
|
||||
if d.exists() and d.is_dir():
|
||||
inferred_dirs.append(d)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Deduplicate inferred_dirs
|
||||
unique_dirs: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for d in inferred_dirs:
|
||||
try:
|
||||
key = str(d.resolve())
|
||||
except Exception:
|
||||
key = str(d)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique_dirs.append(d)
|
||||
|
||||
# Delete directories under models_root
|
||||
for d in unique_dirs:
|
||||
try:
|
||||
d_resolved = d.resolve()
|
||||
except Exception:
|
||||
d_resolved = d
|
||||
try:
|
||||
if d_resolved.exists() and d_resolved.is_dir() and d_resolved.is_relative_to(models_root_resolved):
|
||||
shutil.rmtree(d_resolved)
|
||||
removed.append(str(d_resolved))
|
||||
except Exception as exc:
|
||||
errors.append(f"Failed to remove directory {d_resolved}: {exc}")
|
||||
|
||||
# If nothing matched (e.g., model_path outside models_root), delete just the file.
|
||||
if model_path_value:
|
||||
try:
|
||||
p = Path(str(model_path_value)).expanduser()
|
||||
if p.exists() and p.is_file():
|
||||
p_resolved = p.resolve()
|
||||
if not p_resolved.is_relative_to(models_root_resolved):
|
||||
p_resolved.unlink()
|
||||
removed.append(str(p_resolved))
|
||||
except Exception as exc:
|
||||
errors.append(f"Failed to remove model file {model_path_value}: {exc}")
|
||||
|
||||
return removed, errors
|
||||
|
||||
def _show_about(self):
|
||||
"""Show about dialog."""
|
||||
@@ -301,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")
|
||||
|
||||
@@ -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
|
||||
@@ -29,9 +34,7 @@ logger = get_logger(__name__)
|
||||
class AnnotationTab(QWidget):
|
||||
"""Annotation tab for manual image annotation."""
|
||||
|
||||
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
|
||||
@@ -52,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)
|
||||
@@ -62,6 +91,9 @@ class AnnotationTab(QWidget):
|
||||
|
||||
# Use the AnnotationCanvasWidget
|
||||
self.annotation_canvas = AnnotationCanvasWidget()
|
||||
# Auto-zoom so newly loaded images fill the available canvas viewport.
|
||||
# (Matches the behavior used in ResultsTab.)
|
||||
self.annotation_canvas.set_auto_fit_to_view(True)
|
||||
self.annotation_canvas.zoom_changed.connect(self._on_zoom_changed)
|
||||
self.annotation_canvas.annotation_drawn.connect(self._on_annotation_drawn)
|
||||
# Selection of existing polylines (when tool is not in drawing mode)
|
||||
@@ -72,9 +104,7 @@ class AnnotationTab(QWidget):
|
||||
self.left_splitter.addWidget(canvas_group)
|
||||
|
||||
# Controls info
|
||||
controls_info = QLabel(
|
||||
"Zoom: Mouse wheel or +/- keys | Drawing: Enable pen and drag mouse"
|
||||
)
|
||||
controls_info = QLabel("Zoom: Mouse wheel or +/- keys | Drawing: Enable pen and drag mouse")
|
||||
controls_info.setStyleSheet("QLabel { color: #888; font-style: italic; }")
|
||||
self.left_splitter.addWidget(controls_info)
|
||||
# }
|
||||
@@ -85,36 +115,20 @@ class AnnotationTab(QWidget):
|
||||
|
||||
# Annotation tools section
|
||||
self.annotation_tools = AnnotationToolsWidget(self.db_manager)
|
||||
self.annotation_tools.polyline_enabled_changed.connect(
|
||||
self.annotation_canvas.set_polyline_enabled
|
||||
)
|
||||
self.annotation_tools.polyline_pen_color_changed.connect(
|
||||
self.annotation_canvas.set_polyline_pen_color
|
||||
)
|
||||
self.annotation_tools.polyline_pen_width_changed.connect(
|
||||
self.annotation_canvas.set_polyline_pen_width
|
||||
)
|
||||
self.annotation_tools.polyline_enabled_changed.connect(self.annotation_canvas.set_polyline_enabled)
|
||||
self.annotation_tools.polyline_pen_color_changed.connect(self.annotation_canvas.set_polyline_pen_color)
|
||||
self.annotation_tools.polyline_pen_width_changed.connect(self.annotation_canvas.set_polyline_pen_width)
|
||||
# Show / hide bounding boxes
|
||||
self.annotation_tools.show_bboxes_changed.connect(
|
||||
self.annotation_canvas.set_show_bboxes
|
||||
)
|
||||
self.annotation_tools.show_bboxes_changed.connect(self.annotation_canvas.set_show_bboxes)
|
||||
# RDP simplification controls
|
||||
self.annotation_tools.simplify_on_finish_changed.connect(
|
||||
self._on_simplify_on_finish_changed
|
||||
)
|
||||
self.annotation_tools.simplify_epsilon_changed.connect(
|
||||
self._on_simplify_epsilon_changed
|
||||
)
|
||||
self.annotation_tools.simplify_on_finish_changed.connect(self._on_simplify_on_finish_changed)
|
||||
self.annotation_tools.simplify_epsilon_changed.connect(self._on_simplify_epsilon_changed)
|
||||
# Class selection and class-color changes
|
||||
self.annotation_tools.class_selected.connect(self._on_class_selected)
|
||||
self.annotation_tools.class_color_changed.connect(self._on_class_color_changed)
|
||||
self.annotation_tools.clear_annotations_requested.connect(
|
||||
self._on_clear_annotations
|
||||
)
|
||||
self.annotation_tools.clear_annotations_requested.connect(self._on_clear_annotations)
|
||||
# Delete selected annotation on canvas
|
||||
self.annotation_tools.delete_selected_annotation_requested.connect(
|
||||
self._on_delete_selected_annotation
|
||||
)
|
||||
self.annotation_tools.delete_selected_annotation_requested.connect(self._on_delete_selected_annotation)
|
||||
self.right_splitter.addWidget(self.annotation_tools)
|
||||
|
||||
# Image loading section
|
||||
@@ -137,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)
|
||||
@@ -150,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
|
||||
@@ -180,12 +198,24 @@ class AnnotationTab(QWidget):
|
||||
self.current_image_path = file_path
|
||||
|
||||
# Store the directory for next time
|
||||
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
|
||||
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,
|
||||
@@ -199,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()
|
||||
|
||||
@@ -206,9 +239,7 @@ class AnnotationTab(QWidget):
|
||||
|
||||
except ImageLoadError as e:
|
||||
logger.error(f"Failed to load image: {e}")
|
||||
QMessageBox.critical(
|
||||
self, "Error Loading Image", f"Failed to load image:\n{str(e)}"
|
||||
)
|
||||
QMessageBox.critical(self, "Error Loading Image", f"Failed to load image:\n{str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error loading image: {e}")
|
||||
QMessageBox.critical(self, "Error", f"Unexpected error:\n{str(e)}")
|
||||
@@ -296,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)}")
|
||||
@@ -340,9 +374,7 @@ class AnnotationTab(QWidget):
|
||||
if not self.current_image_id:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"Class color changed; reloading annotations for image ID {self.current_image_id}"
|
||||
)
|
||||
logger.debug(f"Class color changed; reloading annotations for image ID {self.current_image_id}")
|
||||
self._load_annotations_for_current_image()
|
||||
|
||||
def _on_class_selected(self, class_data):
|
||||
@@ -355,9 +387,7 @@ class AnnotationTab(QWidget):
|
||||
if class_data:
|
||||
logger.debug(f"Object class selected: {class_data['class_name']}")
|
||||
else:
|
||||
logger.debug(
|
||||
'No class selected ("-- Select Class --"), showing all annotations'
|
||||
)
|
||||
logger.debug('No class selected ("-- Select Class --"), showing all annotations')
|
||||
|
||||
# Changing the class filter invalidates any previous selection
|
||||
self.selected_annotation_ids = []
|
||||
@@ -390,9 +420,7 @@ class AnnotationTab(QWidget):
|
||||
question = "Are you sure you want to delete the selected annotation?"
|
||||
title = "Delete Annotation"
|
||||
else:
|
||||
question = (
|
||||
f"Are you sure you want to delete the {count} selected annotations?"
|
||||
)
|
||||
question = f"Are you sure you want to delete the {count} selected annotations?"
|
||||
title = "Delete Annotations"
|
||||
|
||||
reply = QMessageBox.question(
|
||||
@@ -420,13 +448,11 @@ class AnnotationTab(QWidget):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Partial Failure",
|
||||
"Some annotations could not be deleted:\n"
|
||||
+ ", ".join(str(a) for a in failed_ids),
|
||||
"Some annotations could not be deleted:\n" + ", ".join(str(a) for a in failed_ids),
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Deleted {count} annotation(s): "
|
||||
+ ", ".join(str(a) for a in self.selected_annotation_ids)
|
||||
f"Deleted {count} annotation(s): " + ", ".join(str(a) for a in self.selected_annotation_ids)
|
||||
)
|
||||
|
||||
# Clear selection and reload annotations for the current image from DB
|
||||
@@ -434,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(
|
||||
@@ -456,17 +485,13 @@ class AnnotationTab(QWidget):
|
||||
return
|
||||
|
||||
try:
|
||||
self.current_annotations = self.db_manager.get_annotations_for_image(
|
||||
self.current_image_id
|
||||
)
|
||||
self.current_annotations = self.db_manager.get_annotations_for_image(self.current_image_id)
|
||||
# New annotations loaded; reset any selection
|
||||
self.selected_annotation_ids = []
|
||||
self.annotation_tools.set_has_selected_annotation(False)
|
||||
self._redraw_annotations_for_current_filter()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to load annotations for image {self.current_image_id}: {e}"
|
||||
)
|
||||
logger.error(f"Failed to load annotations for image {self.current_image_id}: {e}")
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"Error",
|
||||
@@ -490,10 +515,7 @@ class AnnotationTab(QWidget):
|
||||
drawn_count = 0
|
||||
for ann in self.current_annotations:
|
||||
# Filter by class if one is selected
|
||||
if (
|
||||
selected_class_id is not None
|
||||
and ann.get("class_id") != selected_class_id
|
||||
):
|
||||
if selected_class_id is not None and ann.get("class_id") != selected_class_id:
|
||||
continue
|
||||
|
||||
if ann.get("segmentation_mask"):
|
||||
@@ -545,22 +567,176 @@ class AnnotationTab(QWidget):
|
||||
settings = QSettings("microscopy_app", "object_detection")
|
||||
|
||||
# Save main splitter state
|
||||
settings.setValue(
|
||||
"annotation_tab/main_splitter_state", self.main_splitter.saveState()
|
||||
)
|
||||
settings.setValue("annotation_tab/main_splitter_state", self.main_splitter.saveState())
|
||||
|
||||
# Save left splitter state
|
||||
settings.setValue(
|
||||
"annotation_tab/left_splitter_state", self.left_splitter.saveState()
|
||||
)
|
||||
settings.setValue("annotation_tab/left_splitter_state", self.left_splitter.saveState())
|
||||
|
||||
# Save right splitter state
|
||||
settings.setValue(
|
||||
"annotation_tab/right_splitter_state", self.right_splitter.saveState()
|
||||
)
|
||||
settings.setValue("annotation_tab/right_splitter_state", self.right_splitter.saveState())
|
||||
|
||||
logger.debug("Saved annotation tab splitter states")
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user