Compare commits

2 Commits

3 changed files with 285 additions and 79 deletions

View File

@@ -201,6 +201,28 @@ class DatabaseManager:
finally: finally:
conn.close() 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 ==================== # ==================== Image Operations ====================
def add_image( def add_image(

View File

@@ -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 ( from PySide6.QtWidgets import (
QMainWindow, QMainWindow,
@@ -20,6 +21,7 @@ from src.database.db_manager import DatabaseManager
from src.utils.config_manager import ConfigManager from src.utils.config_manager import ConfigManager
from src.utils.logger import get_logger from src.utils.logger import get_logger
from src.gui.dialogs.config_dialog import ConfigDialog 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.detection_tab import DetectionTab
from src.gui.tabs.training_tab import TrainingTab from src.gui.tabs.training_tab import TrainingTab
from src.gui.tabs.validation_tab import ValidationTab from src.gui.tabs.validation_tab import ValidationTab
@@ -91,6 +93,12 @@ class MainWindow(QMainWindow):
db_stats_action.triggered.connect(self._show_database_stats) db_stats_action.triggered.connect(self._show_database_stats)
tools_menu.addAction(db_stats_action) 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
help_menu = menubar.addMenu("&Help") help_menu = menubar.addMenu("&Help")
@@ -117,10 +125,10 @@ class MainWindow(QMainWindow):
# Add tabs to widget # Add tabs to widget
self.tab_widget.addTab(self.detection_tab, "Detection") 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.training_tab, "Training")
self.tab_widget.addTab(self.validation_tab, "Validation") 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 # Connect tab change signal
self.tab_widget.currentChanged.connect(self._on_tab_changed) self.tab_widget.currentChanged.connect(self._on_tab_changed)
@@ -152,9 +160,7 @@ class MainWindow(QMainWindow):
"""Center window on screen.""" """Center window on screen."""
screen = self.screen().geometry() screen = self.screen().geometry()
size = self.geometry() size = self.geometry()
self.move( self.move((screen.width() - size.width()) // 2, (screen.height() - size.height()) // 2)
(screen.width() - size.width()) // 2, (screen.height() - size.height()) // 2
)
def _restore_window_state(self): def _restore_window_state(self):
"""Restore window geometry from settings or center window.""" """Restore window geometry from settings or center window."""
@@ -231,9 +237,229 @@ class MainWindow(QMainWindow):
except Exception as e: except Exception as e:
logger.error(f"Error getting database stats: {e}") logger.error(f"Error getting database stats: {e}")
QMessageBox.warning( QMessageBox.warning(self, "Error", f"Failed to get database statistics:\n{str(e)}")
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): def _show_about(self):
"""Show about dialog.""" """Show about dialog."""

View File

@@ -29,9 +29,7 @@ logger = get_logger(__name__)
class AnnotationTab(QWidget): class AnnotationTab(QWidget):
"""Annotation tab for manual image annotation.""" """Annotation tab for manual image annotation."""
def __init__( def __init__(self, db_manager: DatabaseManager, config_manager: ConfigManager, parent=None):
self, db_manager: DatabaseManager, config_manager: ConfigManager, parent=None
):
super().__init__(parent) super().__init__(parent)
self.db_manager = db_manager self.db_manager = db_manager
self.config_manager = config_manager self.config_manager = config_manager
@@ -62,6 +60,9 @@ class AnnotationTab(QWidget):
# Use the AnnotationCanvasWidget # Use the AnnotationCanvasWidget
self.annotation_canvas = 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.zoom_changed.connect(self._on_zoom_changed)
self.annotation_canvas.annotation_drawn.connect(self._on_annotation_drawn) self.annotation_canvas.annotation_drawn.connect(self._on_annotation_drawn)
# Selection of existing polylines (when tool is not in drawing mode) # Selection of existing polylines (when tool is not in drawing mode)
@@ -72,9 +73,7 @@ class AnnotationTab(QWidget):
self.left_splitter.addWidget(canvas_group) self.left_splitter.addWidget(canvas_group)
# Controls info # Controls info
controls_info = QLabel( controls_info = QLabel("Zoom: Mouse wheel or +/- keys | Drawing: Enable pen and drag mouse")
"Zoom: Mouse wheel or +/- keys | Drawing: Enable pen and drag mouse"
)
controls_info.setStyleSheet("QLabel { color: #888; font-style: italic; }") controls_info.setStyleSheet("QLabel { color: #888; font-style: italic; }")
self.left_splitter.addWidget(controls_info) self.left_splitter.addWidget(controls_info)
# } # }
@@ -85,36 +84,20 @@ class AnnotationTab(QWidget):
# Annotation tools section # Annotation tools section
self.annotation_tools = AnnotationToolsWidget(self.db_manager) self.annotation_tools = AnnotationToolsWidget(self.db_manager)
self.annotation_tools.polyline_enabled_changed.connect( self.annotation_tools.polyline_enabled_changed.connect(self.annotation_canvas.set_polyline_enabled)
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_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 # Show / hide bounding boxes
self.annotation_tools.show_bboxes_changed.connect( self.annotation_tools.show_bboxes_changed.connect(self.annotation_canvas.set_show_bboxes)
self.annotation_canvas.set_show_bboxes
)
# RDP simplification controls # RDP simplification controls
self.annotation_tools.simplify_on_finish_changed.connect( self.annotation_tools.simplify_on_finish_changed.connect(self._on_simplify_on_finish_changed)
self._on_simplify_on_finish_changed self.annotation_tools.simplify_epsilon_changed.connect(self._on_simplify_epsilon_changed)
)
self.annotation_tools.simplify_epsilon_changed.connect(
self._on_simplify_epsilon_changed
)
# Class selection and class-color changes # Class selection and class-color changes
self.annotation_tools.class_selected.connect(self._on_class_selected) 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.class_color_changed.connect(self._on_class_color_changed)
self.annotation_tools.clear_annotations_requested.connect( self.annotation_tools.clear_annotations_requested.connect(self._on_clear_annotations)
self._on_clear_annotations
)
# Delete selected annotation on canvas # Delete selected annotation on canvas
self.annotation_tools.delete_selected_annotation_requested.connect( self.annotation_tools.delete_selected_annotation_requested.connect(self._on_delete_selected_annotation)
self._on_delete_selected_annotation
)
self.right_splitter.addWidget(self.annotation_tools) self.right_splitter.addWidget(self.annotation_tools)
# Image loading section # Image loading section
@@ -180,9 +163,7 @@ class AnnotationTab(QWidget):
self.current_image_path = file_path self.current_image_path = file_path
# Store the directory for next time # Store the directory for next time
settings.setValue( settings.setValue("annotation_tab/last_directory", str(Path(file_path).parent))
"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 relative_path = str(Path(file_path).name) # Simplified for now
@@ -206,9 +187,7 @@ class AnnotationTab(QWidget):
except ImageLoadError as e: except ImageLoadError as e:
logger.error(f"Failed to load image: {e}") logger.error(f"Failed to load image: {e}")
QMessageBox.critical( QMessageBox.critical(self, "Error Loading Image", f"Failed to load image:\n{str(e)}")
self, "Error Loading Image", f"Failed to load image:\n{str(e)}"
)
except Exception as e: except Exception as e:
logger.error(f"Unexpected error loading image: {e}") logger.error(f"Unexpected error loading image: {e}")
QMessageBox.critical(self, "Error", f"Unexpected error:\n{str(e)}") QMessageBox.critical(self, "Error", f"Unexpected error:\n{str(e)}")
@@ -340,9 +319,7 @@ class AnnotationTab(QWidget):
if not self.current_image_id: if not self.current_image_id:
return return
logger.debug( logger.debug(f"Class color changed; reloading annotations for image ID {self.current_image_id}")
f"Class color changed; reloading annotations for image ID {self.current_image_id}"
)
self._load_annotations_for_current_image() self._load_annotations_for_current_image()
def _on_class_selected(self, class_data): def _on_class_selected(self, class_data):
@@ -355,9 +332,7 @@ class AnnotationTab(QWidget):
if class_data: if class_data:
logger.debug(f"Object class selected: {class_data['class_name']}") logger.debug(f"Object class selected: {class_data['class_name']}")
else: else:
logger.debug( logger.debug('No class selected ("-- Select Class --"), showing all annotations')
'No class selected ("-- Select Class --"), showing all annotations'
)
# Changing the class filter invalidates any previous selection # Changing the class filter invalidates any previous selection
self.selected_annotation_ids = [] self.selected_annotation_ids = []
@@ -390,9 +365,7 @@ class AnnotationTab(QWidget):
question = "Are you sure you want to delete the selected annotation?" question = "Are you sure you want to delete the selected annotation?"
title = "Delete Annotation" title = "Delete Annotation"
else: else:
question = ( question = f"Are you sure you want to delete the {count} selected annotations?"
f"Are you sure you want to delete the {count} selected annotations?"
)
title = "Delete Annotations" title = "Delete Annotations"
reply = QMessageBox.question( reply = QMessageBox.question(
@@ -420,13 +393,11 @@ class AnnotationTab(QWidget):
QMessageBox.warning( QMessageBox.warning(
self, self,
"Partial Failure", "Partial Failure",
"Some annotations could not be deleted:\n" "Some annotations could not be deleted:\n" + ", ".join(str(a) for a in failed_ids),
+ ", ".join(str(a) for a in failed_ids),
) )
else: else:
logger.info( logger.info(
f"Deleted {count} annotation(s): " f"Deleted {count} annotation(s): " + ", ".join(str(a) for a in self.selected_annotation_ids)
+ ", ".join(str(a) for a in self.selected_annotation_ids)
) )
# Clear selection and reload annotations for the current image from DB # Clear selection and reload annotations for the current image from DB
@@ -456,17 +427,13 @@ class AnnotationTab(QWidget):
return return
try: try:
self.current_annotations = self.db_manager.get_annotations_for_image( self.current_annotations = self.db_manager.get_annotations_for_image(self.current_image_id)
self.current_image_id
)
# New annotations loaded; reset any selection # New annotations loaded; reset any selection
self.selected_annotation_ids = [] self.selected_annotation_ids = []
self.annotation_tools.set_has_selected_annotation(False) self.annotation_tools.set_has_selected_annotation(False)
self._redraw_annotations_for_current_filter() self._redraw_annotations_for_current_filter()
except Exception as e: except Exception as e:
logger.error( logger.error(f"Failed to load annotations for image {self.current_image_id}: {e}")
f"Failed to load annotations for image {self.current_image_id}: {e}"
)
QMessageBox.critical( QMessageBox.critical(
self, self,
"Error", "Error",
@@ -490,10 +457,7 @@ class AnnotationTab(QWidget):
drawn_count = 0 drawn_count = 0
for ann in self.current_annotations: for ann in self.current_annotations:
# Filter by class if one is selected # Filter by class if one is selected
if ( if selected_class_id is not None and ann.get("class_id") != selected_class_id:
selected_class_id is not None
and ann.get("class_id") != selected_class_id
):
continue continue
if ann.get("segmentation_mask"): if ann.get("segmentation_mask"):
@@ -545,19 +509,13 @@ class AnnotationTab(QWidget):
settings = QSettings("microscopy_app", "object_detection") settings = QSettings("microscopy_app", "object_detection")
# Save main splitter state # Save main splitter state
settings.setValue( settings.setValue("annotation_tab/main_splitter_state", self.main_splitter.saveState())
"annotation_tab/main_splitter_state", self.main_splitter.saveState()
)
# Save left splitter state # Save left splitter state
settings.setValue( settings.setValue("annotation_tab/left_splitter_state", self.left_splitter.saveState())
"annotation_tab/left_splitter_state", self.left_splitter.saveState()
)
# Save right splitter state # Save right splitter state
settings.setValue( settings.setValue("annotation_tab/right_splitter_state", self.right_splitter.saveState())
"annotation_tab/right_splitter_state", self.right_splitter.saveState()
)
logger.debug("Saved annotation tab splitter states") logger.debug("Saved annotation tab splitter states")