From 5b6efc71580cc4791ff62848fba7d8ecee59fed0 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 14:17:45 +0200 Subject: [PATCH] Add Previous button, remove Save button, warn before overwriting annotations - Previous: saves current clip and navigates back through session history; disabled on the first clip, re-enabled automatically as you advance. - Next: shows a dialog when a saved annotation already exists, letting the annotator choose to replace it or keep the existing save before advancing. - Removed the standalone Save button; Next auto-saves on every advance. - Skip already wrote nothing to disk; clarified in README. - Refactored _advance_clip into _switch_ui_to_clip (shared with prev/next). Co-Authored-By: Claude Sonnet 4.6 --- README.md | 6 +- src/river_annotation_tool/annotator.py | 92 +++++++++++++++++++++----- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index be5e0e6..e3cae21 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,9 @@ The window shows the video on the left (auto-playing) and the survey panel on th | Undo last stroke | **Undo** | | Clear entire mask | **Clear** | | Adjust brush size | Slider next to the erase controls | -| Save and continue | **Next** — saves current clip and loads the next one | -| Skip without saving | **Skip** — discards changes and loads the next one | -| Save only | **Save** — writes to disk without advancing | +| Save and continue | **Next** — saves current clip and loads the next one. If the clip already has a saved annotation a dialog asks whether to replace it or keep the existing save. | +| Go back | **Previous** — saves current clip and returns to the previously viewed clip. Disabled on the first clip. | +| Skip without saving | **Skip** — discards any unsaved changes and loads the next clip without writing anything to disk. | | Restore last save | **Reload Saved** — reverts mask and answers to what was last written | ## Output diff --git a/src/river_annotation_tool/annotator.py b/src/river_annotation_tool/annotator.py index f484c9d..508dbbb 100644 --- a/src/river_annotation_tool/annotator.py +++ b/src/river_annotation_tool/annotator.py @@ -48,14 +48,21 @@ class Annotator(QMainWindow): skip_annotated=skip_annotated, ) + self.history: list[Path] = [] + self.history_pos: int = -1 + self.setWindowTitle("River Annotator") self._load_clip(specific=clip) + self._history_push() self._init_ui() self._init_timer() # ── clip loading ─────────────────────────────────────────────── - def _load_clip(self, specific: str = None): - self.filename = self.selector.next(specific=specific) + def _load_clip(self, specific: str = None, path: Path = None): + if path is not None: + self.filename = path + else: + self.filename = self.selector.next(specific=specific) self.frames, self.fps, self.dh, self.dw, self.h, self.w = load_frames( self.filename, self.cfg.max_frames, @@ -66,6 +73,11 @@ class Annotator(QMainWindow): ) self._pending_answers = self._read_saved_answers() + def _history_push(self): + del self.history[self.history_pos + 1 :] + self.history.append(self.filename) + self.history_pos = len(self.history) - 1 + def _read_saved_mask(self): mask_path = self.out_dir / self.filename.stem / self.cfg.filenames.mask if not mask_path.exists(): @@ -93,7 +105,8 @@ class Annotator(QMainWindow): self.q_widgets = {} question_panel = self._build_question_panel() - btn_save = QPushButton("Save") + self.btn_prev = QPushButton("Previous") + self.btn_prev.setEnabled(False) btn_next = QPushButton("Next") btn_skip = QPushButton("Skip") btn_clear = QPushButton("Clear") @@ -101,7 +114,7 @@ class Annotator(QMainWindow): btn_reload = QPushButton("Reload Saved") row1 = QHBoxLayout() - for b in [btn_save, btn_next, btn_skip]: + for b in [self.btn_prev, btn_next, btn_skip]: row1.addWidget(b) row2 = QHBoxLayout() @@ -128,7 +141,7 @@ class Annotator(QMainWindow): container.setLayout(main) self.setCentralWidget(container) - btn_save.clicked.connect(self.save) + self.btn_prev.clicked.connect(self.prev_clip) btn_next.clicked.connect(self.next_clip) btn_skip.clicked.connect(self.skip_clip) btn_clear.clicked.connect(self.mc.clear) @@ -254,17 +267,7 @@ class Annotator(QMainWindow): if answers: self._set_answers(answers) - def _advance_clip(self): - try: - self._load_clip() - except RuntimeError: - msg = QMessageBox(self) - msg.setWindowTitle("All done!") - msg.setText("You have reached the end of all clips.") - msg.setStandardButtons(QMessageBox.StandardButton.Ok) - msg.exec() - QApplication.instance().quit() - return + def _switch_ui_to_clip(self): self.frame_i = 0 self.mc.load_clip( self.frames, @@ -276,10 +279,63 @@ class Annotator(QMainWindow): if self._pending_answers: self._set_answers(self._pending_answers) self._pending_answers = None + self.btn_prev.setEnabled(self.history_pos > 0) + + def _advance_clip(self): + if self.history_pos < len(self.history) - 1: + self.history_pos += 1 + self._load_clip(path=self.history[self.history_pos]) + self._switch_ui_to_clip() + return + try: + self._load_clip() + except RuntimeError: + msg = QMessageBox(self) + msg.setWindowTitle("All done!") + msg.setText("You have reached the end of all clips.") + msg.setStandardButtons(QMessageBox.StandardButton.Ok) + msg.exec() + QApplication.instance().quit() + return + self._history_push() + self._switch_ui_to_clip() + + def prev_clip(self): + if self.history_pos <= 0: + return + self.save() + self.history_pos -= 1 + self._load_clip(path=self.history[self.history_pos]) + self._switch_ui_to_clip() def next_clip(self): - self.save() - self._advance_clip() + mask_path = self.out_dir / self.filename.stem / self.cfg.filenames.mask + if mask_path.exists(): + msg = QMessageBox(self) + msg.setWindowTitle("Existing annotation found") + msg.setText( + f"'{self.filename.stem}' already has a saved annotation.\n" + "Replace it with your current work, or keep the existing save?" + ) + btn_replace = msg.addButton( + "Replace & Continue", QMessageBox.ButtonRole.AcceptRole + ) + btn_keep = msg.addButton( + "Keep Existing & Continue", QMessageBox.ButtonRole.AcceptRole + ) + msg.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) + msg.setDefaultButton(btn_replace) + msg.exec() + clicked = msg.clickedButton() + if clicked == btn_replace: + self.save() + self._advance_clip() + elif clicked == btn_keep: + self._advance_clip() + # Cancel: do nothing + else: + self.save() + self._advance_clip() def skip_clip(self): self._advance_clip()