diff --git a/coralnet_toolbox/Annotations/QtAnnotation.py b/coralnet_toolbox/Annotations/QtAnnotation.py index 72de639c..c05a14f8 100644 --- a/coralnet_toolbox/Annotations/QtAnnotation.py +++ b/coralnet_toolbox/Annotations/QtAnnotation.py @@ -366,4 +366,4 @@ def __repr__(self): f"image_path={self.image_path}, " f"label={self.label.short_label_code}, " f"data={self.data}, " - f"machine_confidence={self.machine_confidence})") \ No newline at end of file + f"machine_confidence={self.machine_confidence})") diff --git a/coralnet_toolbox/QtAnnotationWindow.py b/coralnet_toolbox/QtAnnotationWindow.py index 1ad2cb8a..333096f7 100644 --- a/coralnet_toolbox/QtAnnotationWindow.py +++ b/coralnet_toolbox/QtAnnotationWindow.py @@ -17,7 +17,7 @@ from coralnet_toolbox.Tools import ( PanTool, - PatchTool, + PatchTool, PolygonTool, RectangleTool, SAMTool, @@ -42,6 +42,7 @@ class AnnotationWindow(QGraphicsView): annotationSizeChanged = pyqtSignal(int) # Signal to emit when annotation size changes annotationSelected = pyqtSignal(int) # Signal to emit when annotation is selected annotationDeleted = pyqtSignal(str) # Signal to emit when annotation is deleted + annotationCreated = pyqtSignal(str) # Signal to emit when annotation is created hover_point = pyqtSignal(QPointF) # Signal to emit when mouse hovers over a point def __init__(self, main_window, parent=None): @@ -54,7 +55,7 @@ def __init__(self, main_window, parent=None): self.annotation_size = 224 self.annotation_color = None self.transparency = 128 - + self.zoom_factor = 1.0 self.pan_active = False self.pan_start = None @@ -136,7 +137,7 @@ def keyPressEvent(self, event): if self.active_image and self.selected_tool: self.tools[self.selected_tool].keyPressEvent(event) super().keyPressEvent(event) - + # Handle the hot key for deleting (backspace or delete) selected annotations if event.modifiers() & Qt.ControlModifier: if event.key() == Qt.Key_Delete or event.key() == Qt.Key_Backspace: @@ -157,7 +158,7 @@ def cursorInWindow(self, pos, mapped=False): pos = self.mapToScene(pos) return image_rect.contains(pos) - + def cursorInViewport(self, pos): if not pos: return False @@ -227,14 +228,14 @@ def set_annotation_size(self, size=None, delta=0): if len(self.selected_annotations) <= 1: # Emit that the annotation size has changed self.annotationSizeChanged.emit(self.annotation_size) - + def is_annotation_moveable(self, annotation): if annotation.show_message: self.unselect_annotations() annotation.show_warning_message() return False return True - + def toggle_cursor_annotation(self, scene_pos: QPointF = None): if self.cursor_annotation: @@ -284,7 +285,7 @@ def set_image(self, image_path): self.scene.addItem(QGraphicsPixmapItem(self.image_pixmap)) self.fitInView(self.scene.sceneRect(), Qt.KeepAspectRatio) self.tools["zoom"].calculate_min_zoom() - + self.toggle_cursor_annotation() # Set the image dimensions, and current view in status bar @@ -402,7 +403,7 @@ def load_annotations(self): # Initialize the progress bar progress_bar = ProgressBar(self, title="Loading Annotations") progress_bar.show() - + # Crop all the annotations for current image (if not already cropped) annotations = self.crop_image_annotations(return_annotations=True) progress_bar.start_progress(len(annotations)) @@ -419,12 +420,12 @@ def load_annotations(self): QApplication.processEvents() self.viewport().update() - + def load_these_annotations(self, image_path, annotations): # Initialize the progress bar progress_bar = ProgressBar(self, title="Loading Annotations") progress_bar.show() - + # Crop all the annotations for current image (if not already cropped) annotations = self.crop_these_image_annotations(image_path, annotations) progress_bar.start_progress(len(annotations)) @@ -487,7 +488,7 @@ def _crop_annotations_batch_linear(self, image_path, annotations): progress_bar = ProgressBar(self, title="Cropping Annotations") progress_bar.show() progress_bar.start_progress(len(annotations)) - + # Get the rasterio representation rasterio_image = self.main_window.image_window.rasterio_open(image_path) # Loop through the annotations, crop the image if not already cropped @@ -495,7 +496,7 @@ def _crop_annotations_batch_linear(self, image_path, annotations): if not annotation.cropped_image: annotation.create_cropped_image(rasterio_image) progress_bar.update_progress() - + progress_bar.stop_progress() progress_bar.close() @@ -504,13 +505,13 @@ def _crop_annotations_batch(self, image_path, annotations): progress_bar = ProgressBar(self, title="Cropping Annotations") progress_bar.show() progress_bar.start_progress(len(annotations)) - + # Create a lock for thread-safe access to the rasterio dataset lock = threading.Lock() - + # Get the rasterio representation rasterio_image = self.main_window.image_window.rasterio_open(image_path) - + def crop_annotation(annotation): with lock: if not annotation.cropped_image: @@ -523,7 +524,7 @@ def crop_annotation(annotation): for future in as_completed(futures): future.result() # Ensure any exceptions are raised progress_bar.update_progress() - + progress_bar.stop_progress() progress_bar.close() @@ -557,16 +558,17 @@ def add_annotation(self, scene_pos: QPointF = None): annotation.annotationUpdated.connect(self.main_window.confidence_window.display_cropped_image) self.annotations_dict[annotation.id] = annotation - self.main_window.confidence_window.display_cropped_image(annotation) + self.annotationCreated.emit(annotation.id) def delete_annotation(self, annotation_id): if annotation_id in self.annotations_dict: - # Get the annotation from dict, delete it + # Get the annotation from dict annotation = self.annotations_dict[annotation_id] + # Delete the annotation annotation.delete() - self.annotationDeleted.emit(annotation.id) del self.annotations_dict[annotation_id] + self.annotationDeleted.emit(annotation_id) # Clear the confidence window self.main_window.confidence_window.clear_display() @@ -612,4 +614,4 @@ def clear_scene(self): del item self.scene.deleteLater() self.scene = QGraphicsScene(self) - self.setScene(self.scene) \ No newline at end of file + self.setScene(self.scene) diff --git a/coralnet_toolbox/QtConfidenceWindow.py b/coralnet_toolbox/QtConfidenceWindow.py index b6f3ccbc..96c4a81d 100644 --- a/coralnet_toolbox/QtConfidenceWindow.py +++ b/coralnet_toolbox/QtConfidenceWindow.py @@ -3,7 +3,7 @@ from PyQt5.QtCore import Qt, pyqtSignal, QRectF from PyQt5.QtGui import QPixmap, QColor, QPainter, QCursor from PyQt5.QtWidgets import (QGraphicsView, QGraphicsScene, QWidget, QVBoxLayout, - QLabel, QHBoxLayout, QFrame) + QLabel, QHBoxLayout, QFrame, QGroupBox) warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -72,6 +72,11 @@ def __init__(self, main_window, parent=None): self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setSpacing(0) + # Create a groupbox and set its title + self.groupBox = QGroupBox("Confidence Window") + self.groupBoxLayout = QVBoxLayout() + self.groupBox.setLayout(self.groupBoxLayout) + self.graphics_view = None self.scene = None self.downscale_factor = 1.0 @@ -91,7 +96,10 @@ def __init__(self, main_window, parent=None): # Add QLabel for dimensions self.dimensions_label = QLabel(self) self.dimensions_label.setAlignment(Qt.AlignCenter) - self.layout.addWidget(self.dimensions_label) + self.groupBoxLayout.addWidget(self.dimensions_label) + + # Add the groupbox to the main layout + self.layout.addWidget(self.groupBox) def resizeEvent(self, event): super().resizeEvent(event) @@ -102,7 +110,7 @@ def init_graphics_view(self): self.graphics_view = QGraphicsView(self) self.scene = QGraphicsScene(self) self.graphics_view.setScene(self.scene) - self.layout.addWidget(self.graphics_view, 2) # 2 for stretch factor + self.groupBoxLayout.addWidget(self.graphics_view, 2) # 2 for stretch factor self.update_blank_pixmap() def init_bar_chart_widget(self): @@ -110,7 +118,7 @@ def init_bar_chart_widget(self): self.bar_chart_layout = QVBoxLayout(self.bar_chart_widget) self.bar_chart_layout.setContentsMargins(0, 0, 0, 0) self.bar_chart_layout.setSpacing(2) # Set spacing to make bars closer - self.layout.addWidget(self.bar_chart_widget, 1) # 1 for stretch factor + self.groupBoxLayout.addWidget(self.bar_chart_widget, 1) # 1 for stretch factor def update_blank_pixmap(self): view_size = self.graphics_view.size() @@ -212,4 +220,4 @@ def handle_bar_click(self, label): self.annotation.update_label(label) # Update everything (essentially) self.main_window.annotation_window.unselect_annotation(self.annotation) - self.main_window.annotation_window.select_annotation(self.annotation) \ No newline at end of file + self.main_window.annotation_window.select_annotation(self.annotation) diff --git a/coralnet_toolbox/QtImageWindow.py b/coralnet_toolbox/QtImageWindow.py index 5aef8147..6caf3a13 100644 --- a/coralnet_toolbox/QtImageWindow.py +++ b/coralnet_toolbox/QtImageWindow.py @@ -69,8 +69,8 @@ def __init__(self, main_window): self.layout = QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) - # Create a QGroupBox - # Create a QGroupBox + # ------------------------------------------- + # Create a QGroupBox for search and filters self.filter_group = QGroupBox("Search and Filters") self.filter_layout = QVBoxLayout() self.filter_group.setLayout(self.filter_layout) @@ -84,20 +84,20 @@ def __init__(self, main_window): self.checkbox_group.setExclusive(False) # Add checkboxes for filtering images based on annotations + self.no_annotations_checkbox = QCheckBox("No Annotations", self) + self.no_annotations_checkbox.stateChanged.connect(self.filter_images) + self.checkbox_layout.addWidget(self.no_annotations_checkbox) + self.checkbox_group.addButton(self.no_annotations_checkbox) + self.has_annotations_checkbox = QCheckBox("Has Annotations", self) self.has_annotations_checkbox.stateChanged.connect(self.filter_images) self.checkbox_layout.addWidget(self.has_annotations_checkbox) self.checkbox_group.addButton(self.has_annotations_checkbox) - self.needs_review_checkbox = QCheckBox("Needs Review", self) - self.needs_review_checkbox.stateChanged.connect(self.filter_images) - self.checkbox_layout.addWidget(self.needs_review_checkbox) - self.checkbox_group.addButton(self.needs_review_checkbox) - - self.no_annotations_checkbox = QCheckBox("No Annotations", self) - self.no_annotations_checkbox.stateChanged.connect(self.filter_images) - self.checkbox_layout.addWidget(self.no_annotations_checkbox) - self.checkbox_group.addButton(self.no_annotations_checkbox) + self.has_predictions_checkbox = QCheckBox("Has Predictions", self) + self.has_predictions_checkbox.stateChanged.connect(self.filter_images) + self.checkbox_layout.addWidget(self.has_predictions_checkbox) + self.checkbox_group.addButton(self.has_predictions_checkbox) # Create a vertical layout for the search bars self.search_layout = QVBoxLayout() @@ -140,16 +140,20 @@ def __init__(self, main_window): # Add the group box to the main layout self.layout.addWidget(self.filter_group) + # ------------------------------------------- + # Create a QGroupBox for Image Window + self.info_table_group = QGroupBox("Image Window", self) + info_table_layout = QVBoxLayout() + self.info_table_group.setLayout(info_table_layout) + # Create a horizontal layout for the labels self.info_layout = QHBoxLayout() - self.layout.addLayout(self.info_layout) + info_table_layout.addLayout(self.info_layout) # Add a label to display the index of the currently selected image self.current_image_index_label = QLabel("Current Image: None", self) self.current_image_index_label.setAlignment(Qt.AlignCenter) self.current_image_index_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - - # Set the desired height (to align with AnnotationWindow) self.current_image_index_label.setFixedHeight(24) self.info_layout.addWidget(self.current_image_index_label) @@ -157,11 +161,10 @@ def __init__(self, main_window): self.image_count_label = QLabel("Total Images: 0", self) self.image_count_label.setAlignment(Qt.AlignCenter) self.image_count_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - - # Set the desired height (to align with AnnotationWindow) self.image_count_label.setFixedHeight(24) self.info_layout.addWidget(self.image_count_label) + # Create and setup table widget self.tableWidget = QTableWidget(self) self.tableWidget.setColumnCount(2) self.tableWidget.setHorizontalHeaderLabels(["Image Name", "Annotations"]) @@ -174,22 +177,23 @@ def __init__(self, main_window): self.tableWidget.cellClicked.connect(self.load_image) self.tableWidget.keyPressEvent = self.tableWidget_keyPressEvent - # Set the stylesheet for the header self.tableWidget.horizontalHeader().setStyleSheet(""" QHeaderView::section { - background-color: #E0E0E0; - padding: 4px; - border: 1px solid #D0D0D0; + background-color: #E0E0E0; + padding: 4px; + border: 1px solid #D0D0D0; } """) - # Add the table widget to the layout - self.layout.addWidget(self.tableWidget) - - # Set the maximum width for the column to truncate text self.tableWidget.setColumnWidth(0, 200) self.tableWidget.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed) + # Add table widget to the info table group layout + info_table_layout.addWidget(self.tableWidget) + + # Add the group box to the main layout + self.layout.addWidget(self.info_table_group) + self.image_paths = [] # Store all image paths self.image_dict = {} # Dictionary to store all image information self.filtered_image_paths = [] # List to store filtered image paths @@ -212,6 +216,11 @@ def __init__(self, main_window): self.current_workers = [] # List to keep track of running workers self.last_image_selection_time = QDateTime.currentMSecsSinceEpoch() + # TODO add a dict mapping tableWidget row to image path, faster + # Connect annotationCreated, annotationDeleted signals to update annotation count in real time + self.annotation_window.annotationCreated.connect(self.update_annotation_count) + self.annotation_window.annotationDeleted.connect(self.update_annotation_count) + def add_image(self, image_path): if image_path not in self.image_paths: self.image_paths.append(image_path) @@ -219,7 +228,7 @@ def add_image(self, image_path): self.image_dict[image_path] = { 'filename': filename, 'has_annotations': False, - 'needs_review': False, + 'has_predictions': False, 'labels': set(), # Initialize an empty set for labels 'annotation_count': 0 # Initialize annotation count } @@ -275,13 +284,27 @@ def update_current_image_index_label(self): def update_image_annotations(self, image_path): if image_path in self.image_dict: + # Check for any annotations annotations = self.annotation_window.get_image_annotations(image_path) - review_annotations = self.annotation_window.get_image_review_annotations(image_path) + # Check for any predictions + predictions = [a.machine_confidence for a in annotations if a.machine_confidence != {}] + # Check for any labels + labels = {annotation.label.short_label_code for annotation in annotations} self.image_dict[image_path]['has_annotations'] = bool(annotations) - self.image_dict[image_path]['needs_review'] = bool(review_annotations) - self.image_dict[image_path]['labels'] = {annotation.label.short_label_code for annotation in annotations} - self.image_dict[image_path]['annotation_count'] = len(annotations) # Update annotation count - self.update_table_widget() # Refresh the table to show updated counts + self.image_dict[image_path]['has_predictions'] = len(predictions) + self.image_dict[image_path]['labels'] = labels + self.image_dict[image_path]['annotation_count'] = len(annotations) + self.update_table_widget() + + def update_annotation_count(self, annotation_id): + if annotation_id in self.annotation_window.annotations_dict: + # Get the image path associated with the annotation + image_path = self.annotation_window.annotations_dict[annotation_id].image_path + else: + # It's already been deleted, so get the current image path + image_path = self.annotation_window.current_image_path + # Update the image annotation count in table widget + self.update_image_annotations(image_path) def load_image(self, row, column): # Add safety checks @@ -316,7 +339,8 @@ def load_image_by_path(self, image_path, update=False): # Start processing the queue if we're under the thread limit self._process_image_queue() - self.imageChanged.emit() # Emit the signal when a new image is chosen + # Emit the signal when a new image is chosen + self.imageChanged.emit() # Update the search bars self.update_search_bars() @@ -340,6 +364,7 @@ def _process_image_queue(self): self.update_table_selection() self.update_current_image_index_label() + # Set the cursor to the wait cursor QApplication.setOverrideCursor(Qt.WaitCursor) # Load and display scaled-down version @@ -398,7 +423,8 @@ def load_scaled_image(self, image_path): if num_bands == 1: # Read a single band - downsampled_image = src.read(window=window, out_shape=(scaled_height, scaled_width)) + downsampled_image = src.read(window=window, + out_shape=(scaled_height, scaled_width)) # Grayscale image qimage = QImage(downsampled_image.data.tobytes(), @@ -408,7 +434,9 @@ def load_scaled_image(self, image_path): elif num_bands == 3 or num_bands == 4: # Read bands in the correct order (RGB) - downsampled_image = src.read([1, 2, 3], window=window, out_shape=(scaled_height, scaled_width)) + downsampled_image = src.read([1, 2, 3], + window=window, + out_shape=(scaled_height, scaled_width)) # Convert to uint8 if it's not already rgb_image = downsampled_image.astype(np.uint8) @@ -570,7 +598,7 @@ def cycle_next_image(self): self.load_image_by_path(self.filtered_image_paths[new_index]) def debounce_search(self): - self.search_timer.start(5000) + self.search_timer.start(10000) def filter_images(self): # Store the currently selected image path before filtering @@ -578,12 +606,13 @@ def filter_images(self): search_text_images = self.search_bar_images.currentText() search_text_labels = self.search_bar_labels.currentText() - has_annotations = self.has_annotations_checkbox.isChecked() - needs_review = self.needs_review_checkbox.isChecked() no_annotations = self.no_annotations_checkbox.isChecked() + has_annotations = self.has_annotations_checkbox.isChecked() + has_predictions = self.has_predictions_checkbox.isChecked() # Return early if none of the search bar or checkboxes are being used - if not (search_text_images or search_text_labels) and not (has_annotations or needs_review or no_annotations): + if (not (search_text_images or search_text_labels) and + not (no_annotations or has_annotations or has_predictions)): self.filtered_image_paths = self.image_paths.copy() self.update_table_widget() self.update_current_image_index_label() @@ -605,9 +634,9 @@ def filter_images(self): path, search_text_images, search_text_labels, - has_annotations, - needs_review, no_annotations, + has_annotations, + has_predictions, ) futures.append(future) @@ -640,21 +669,49 @@ def filter_images(self): # Stop the progress bar progress_dialog.stop_progress() - def filter_image(self, path, search_text_images, search_text_labels, has_annotations, needs_review, no_annotations): + def filter_image(self, + path, + search_text_images, + search_text_labels, + no_annotations, + has_annotations, + has_predictions): + """ + Filter images based on search text and checkboxes + + Args: + path (str): Path to the image + search_text_images (str): Search text for image names + search_text_labels (str): Search text for labels + no_annotations (bool): Filter images with no annotations + has_annotations (bool): Filter images with annotations + has_predictions (bool): Filter images with predictions + + Returns: + str: Path to the image if it passes the filters, None otherwise + """ filename = os.path.basename(path) + # Check for annotations for the provided path annotations = self.annotation_window.get_image_annotations(path) - review_annotations = self.annotation_window.get_image_review_annotations(path) + # Check for predictions for the provided path + predictions = self.image_dict[path]['has_predictions'] + # Check the labels for the provided path labels = self.image_dict[path]['labels'] + # Filter images based on search text and checkboxes if search_text_images and search_text_images not in filename: return None + # Filter images based on search text and checkboxes if search_text_labels and search_text_labels not in labels: return None - if has_annotations and not annotations: + # Filter images based on checkboxes, and if the image has annotations + if no_annotations and annotations: return None - if needs_review and not review_annotations: + # Filter images based on checkboxes, and if the image has no annotations + if has_annotations and not annotations: return None - if no_annotations and annotations: + # Filter images based on checkboxes, and if the image has predictions + if has_predictions and not predictions: return None return path diff --git a/coralnet_toolbox/QtLabelWindow.py b/coralnet_toolbox/QtLabelWindow.py index 30066183..9216f932 100644 --- a/coralnet_toolbox/QtLabelWindow.py +++ b/coralnet_toolbox/QtLabelWindow.py @@ -1,12 +1,12 @@ -import json import random import uuid import warnings from PyQt5.QtCore import Qt, pyqtSignal, QMimeData from PyQt5.QtGui import QColor, QPainter, QPen, QBrush, QFontMetrics, QDrag -from PyQt5.QtWidgets import (QFileDialog, QGridLayout, QScrollArea, QMessageBox, QCheckBox, QWidget, QVBoxLayout, - QColorDialog, QLineEdit, QDialog, QHBoxLayout, QPushButton, QApplication, QSizePolicy) +from PyQt5.QtWidgets import (QGridLayout, QScrollArea, QMessageBox, QCheckBox, QWidget, + QVBoxLayout, QColorDialog, QLineEdit, QDialog, QHBoxLayout, + QPushButton, QApplication, QGroupBox) warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -152,8 +152,8 @@ def __repr__(self): class LabelWindow(QWidget): - labelSelected = pyqtSignal(object) # Signal to emit the entire Label object - transparencyChanged = pyqtSignal(int) # Signal to emit the transparency value + labelSelected = pyqtSignal(object) + transparencyChanged = pyqtSignal(int) def __init__(self, main_window): super().__init__() @@ -168,6 +168,12 @@ def __init__(self, main_window): self.main_layout.setContentsMargins(0, 0, 0, 0) self.main_layout.setSpacing(0) + # Create a groupbox and set its title and style sheet + self.groupBox = QGroupBox("Label Window") + + self.groupBoxLayout = QVBoxLayout() + self.groupBox.setLayout(self.groupBoxLayout) + # Top bar with Add Label, Edit Label, and Delete Label buttons self.top_bar = QHBoxLayout() self.add_label_button = QPushButton("Add Label") @@ -186,8 +192,6 @@ def __init__(self, main_window): self.top_bar.addStretch() # Add stretch to the right side - self.main_layout.addLayout(self.top_bar) - # Scroll area for labels self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) @@ -197,11 +201,20 @@ def __init__(self, main_window): self.grid_layout.setSpacing(0) self.grid_layout.setContentsMargins(0, 0, 0, 0) self.scroll_area.setWidget(self.scroll_content) - self.main_layout.addWidget(self.scroll_area) + # Add layouts to the groupbox layout + self.groupBoxLayout.addLayout(self.top_bar) + self.groupBoxLayout.addWidget(self.scroll_area) + + # Add the groupbox to the main layout + self.main_layout.addWidget(self.groupBox) + + # Connections self.add_label_button.clicked.connect(self.open_add_label_dialog) self.edit_label_button.clicked.connect(self.open_edit_label_dialog) self.delete_label_button.clicked.connect(self.delete_active_label) + + # Initialize labels self.labels = [] self.active_label = None @@ -405,10 +418,10 @@ def edit_labels(self, old_label, new_label, delete_old=False): self.update_labels_per_row() self.reorganize_labels() - + # Refresh the scene with the new label self.annotation_window.set_image(self.annotation_window.current_image_path) - + def delete_label(self, label): if (label.short_label_code == "Review" and label.long_label_code == "Review" and @@ -634,4 +647,4 @@ def validate_and_accept(self): self.label.update_label_color(self.color) self.accept() - self.label_window.edit_labels(self.label, self.label, delete_old=False) \ No newline at end of file + self.label_window.edit_labels(self.label, self.label, delete_old=False)