diff --git a/README.md b/README.md index 6e9bf3d..4e0a9a5 100644 --- a/README.md +++ b/README.md @@ -124,17 +124,38 @@ Copy `config/clips.example.txt` as a starting point. The window shows the video on the left (auto-playing) and the survey panel on the right. +### Mask drawing + | Action | How | |---|---| | Draw water mask | Click and drag on the video | -| Erase mask | Toggle **Eraser** button, then drag | +| Erase mask | Toggle **Eraser** button (turns orange when active), then drag | | Brush preview | A white circle follows the cursor showing the current brush size | +| Adjust brush size | **Brush size** slider below the controls; click **↺** to reset | | Undo last stroke | **Undo** | | Undo 10 strokes | **Undo×10** | | Redo | **Redo** — steps forward through undone strokes | | Clear entire mask | **Clear** | -| Adjust brush size | Slider next to the erase controls | -| Toggle mask overlay | **Hide Mask / Show Mask** — hides or reveals the green overlay without affecting the mask data | +| Toggle mask overlay | **Hide Mask / Show Mask** — button turns red when hidden; does not affect mask data | +| Mask transparency | **Alpha** slider below the controls; click **↺** to reset | +| Load mask from previous clip | **Load Prev Mask** — copies the saved mask of the previous clip in the list onto the current clip; the action is undoable with **Undo** | + +### Image display adjustments + +Three vertical sliders sit to the left of the video and affect display only — they do not change what is saved. + +| Slider | Effect | Range | +|---|---|---| +| Brightness | Shifts all pixel values up or down | −100 to +100 | +| Contrast | Scales pixel values around the midpoint | −100 to +100 | +| Gamma | Applies a power-law correction (higher = brighter) | 0.1× to 3.0× | + +Click **↺** below any slider to restore its default value. + +### Navigation + +| Action | How | +|---|---| | 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. | diff --git a/src/river_annotation_tool/annotator.py b/src/river_annotation_tool/annotator.py index 1ae2d27..2ab0aa1 100644 --- a/src/river_annotation_tool/annotator.py +++ b/src/river_annotation_tool/annotator.py @@ -4,7 +4,7 @@ from pathlib import Path import cv2 import numpy as np from PIL import Image -from PySide6.QtCore import QTimer +from PySide6.QtCore import Qt, QTimer from PySide6.QtWidgets import ( QApplication, QButtonGroup, @@ -113,9 +113,10 @@ class Annotator(QMainWindow): btn_undo = QPushButton("Undo") btn_undo10 = QPushButton("Undo×10") btn_redo = QPushButton("Redo") + btn_load_prev_mask = QPushButton("Load Prev Mask") row1 = QHBoxLayout() - for b in [self.btn_prev, btn_next, btn_skip]: + for b in [self.btn_prev, btn_next, btn_skip, btn_load_prev_mask]: row1.addWidget(b) row2 = QHBoxLayout() @@ -128,13 +129,42 @@ class Annotator(QMainWindow): self.mc.btn_mask, ]: row2.addWidget(b) - row2.addWidget(QLabel("Brush")) - row2.addWidget(self.mc.brush_slider) + + row3 = QHBoxLayout() + row3.addWidget(QLabel("Brush size")) + row3.addWidget(self.mc.brush_slider) + row3.addWidget(self.mc.brush_reset) + + row4 = QHBoxLayout() + row4.addWidget(QLabel("Alpha")) + row4.addWidget(self.mc.alpha_slider) + row4.addWidget(self.mc.alpha_reset) + + vert_panel = QHBoxLayout() + vert_panel.setContentsMargins(0, 0, 4, 0) + for label_text, slider, reset_btn in [ + ("Brightness", self.mc.brightness_slider, self.mc.brightness_reset), + ("Contrast", self.mc.contrast_slider, self.mc.contrast_reset), + ("Gamma", self.mc.gamma_slider, self.mc.gamma_reset), + ]: + col = QVBoxLayout() + lbl = QLabel(label_text) + lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + col.addWidget(lbl) + col.addWidget(slider, 1) + col.addWidget(reset_btn) + vert_panel.addLayout(col) + + canvas_row = QHBoxLayout() + canvas_row.addLayout(vert_panel) + canvas_row.addWidget(self.mc.canvas, 1) left = QVBoxLayout() - left.addWidget(self.mc.canvas) + left.addLayout(canvas_row) left.addLayout(row1) left.addLayout(row2) + left.addLayout(row3) + left.addLayout(row4) left_widget = QWidget() left_widget.setLayout(left) @@ -143,7 +173,7 @@ class Annotator(QMainWindow): main = QHBoxLayout() main.addWidget(left_widget, 3) - main.addWidget(right_widget, 2) + main.addWidget(right_widget, 1) container = QWidget() container.setLayout(main) @@ -156,6 +186,7 @@ class Annotator(QMainWindow): btn_undo.clicked.connect(self.mc.undo) btn_undo10.clicked.connect(self.mc.undo10) btn_redo.clicked.connect(self.mc.redo) + btn_load_prev_mask.clicked.connect(self.load_prev_mask) if self._pending_answers: self._set_answers(self._pending_answers) @@ -339,3 +370,28 @@ class Annotator(QMainWindow): def skip_clip(self): self._advance_clip() + + def load_prev_mask(self): + try: + idx = self.selector.clips.index(self.filename) + except ValueError: + return + if idx == 0: + QMessageBox.information( + self, "No previous clip", "This is the first clip in the list." + ) + return + prev_clip = self.selector.clips[idx - 1] + mask_path = self.out_dir / prev_clip.stem / self.cfg.filenames.mask + if not mask_path.exists(): + QMessageBox.information( + self, "No mask found", f"No saved mask found for '{prev_clip.stem}'." + ) + return + mask_full = np.array(Image.open(mask_path).convert("L")) + mask = cv2.resize( + (mask_full > 127).astype(np.uint8), + (self.dw, self.dh), + interpolation=cv2.INTER_NEAREST, + ) + self.mc.set_mask(mask) diff --git a/src/river_annotation_tool/mask_canvas.py b/src/river_annotation_tool/mask_canvas.py index 357b77d..c5d4e15 100644 --- a/src/river_annotation_tool/mask_canvas.py +++ b/src/river_annotation_tool/mask_canvas.py @@ -9,6 +9,12 @@ from PySide6.QtWidgets import QPushButton, QSlider class MaskCanvas: """Matplotlib canvas with brush-based mask drawing, undo/redo, and erase.""" + _BRUSH_DEFAULT = 5 + _ALPHA_DEFAULT = 40 + _BRIGHTNESS_DEFAULT = 0 + _CONTRAST_DEFAULT = 0 + _GAMMA_DEFAULT = 100 + def __init__(self, frames, dh: int, dw: int): self.dh = dh self.dw = dw @@ -19,6 +25,7 @@ class MaskCanvas: self.erase_mode = False self.drawing = False self.mask_visible = True + self._current_frame = frames[0] self._build_figure(frames) self._build_controls() @@ -40,9 +47,36 @@ class MaskCanvas: def _build_controls(self): self.btn_erase = QPushButton("Eraser") self.btn_mask = QPushButton("Hide Mask") + self.brush_slider = QSlider(Qt.Horizontal) self.brush_slider.setRange(2, 50) - self.brush_slider.setValue(5) + self.brush_slider.setValue(self._BRUSH_DEFAULT) + self.brush_reset = QPushButton("↺") + self.brush_reset.setFixedWidth(28) + + self.alpha_slider = QSlider(Qt.Horizontal) + self.alpha_slider.setRange(0, 100) + self.alpha_slider.setValue(self._ALPHA_DEFAULT) + self.alpha_reset = QPushButton("↺") + self.alpha_reset.setFixedWidth(28) + + self.brightness_slider = QSlider(Qt.Vertical) + self.brightness_slider.setRange(-100, 100) + self.brightness_slider.setValue(self._BRIGHTNESS_DEFAULT) + self.brightness_reset = QPushButton("↺") + self.brightness_reset.setFixedWidth(28) + + self.contrast_slider = QSlider(Qt.Vertical) + self.contrast_slider.setRange(-100, 100) + self.contrast_slider.setValue(self._CONTRAST_DEFAULT) + self.contrast_reset = QPushButton("↺") + self.contrast_reset.setFixedWidth(28) + + self.gamma_slider = QSlider(Qt.Vertical) + self.gamma_slider.setRange(10, 300) + self.gamma_slider.setValue(self._GAMMA_DEFAULT) + self.gamma_reset = QPushButton("↺") + self.gamma_reset.setFixedWidth(28) def _connect_events(self): self.canvas.mpl_connect("button_press_event", self._on_press) @@ -51,6 +85,25 @@ class MaskCanvas: self.canvas.mpl_connect("axes_leave_event", self._on_axes_leave) self.btn_erase.clicked.connect(self.toggle_erase) self.btn_mask.clicked.connect(self.toggle_mask) + self.alpha_slider.valueChanged.connect(self.redraw) + self.brightness_slider.valueChanged.connect(self._refresh_frame) + self.contrast_slider.valueChanged.connect(self._refresh_frame) + self.gamma_slider.valueChanged.connect(self._refresh_frame) + self.brush_reset.clicked.connect( + lambda: self.brush_slider.setValue(self._BRUSH_DEFAULT) + ) + self.alpha_reset.clicked.connect( + lambda: self.alpha_slider.setValue(self._ALPHA_DEFAULT) + ) + self.brightness_reset.clicked.connect( + lambda: self.brightness_slider.setValue(self._BRIGHTNESS_DEFAULT) + ) + self.contrast_reset.clicked.connect( + lambda: self.contrast_slider.setValue(self._CONTRAST_DEFAULT) + ) + self.gamma_reset.clicked.connect( + lambda: self.gamma_slider.setValue(self._GAMMA_DEFAULT) + ) # ── clip transition ──────────────────────────────────────────── def load_clip(self, frames, dh: int, dw: int, mask=None, title: str = ""): @@ -59,15 +112,33 @@ class MaskCanvas: self.mask = mask if mask is not None else np.zeros((dh, dw), dtype=np.uint8) self.history = [] self.redo_stack = [] - self.img_artist.set_data(frames[0]) + self._current_frame = frames[0] + self.img_artist.set_data(self._apply_image_adjustments(frames[0])) self.set_title(title) self.redraw() # ── frame / title ────────────────────────────────────────────── def set_frame(self, frame): - self.img_artist.set_data(frame) + self._current_frame = frame + self.img_artist.set_data(self._apply_image_adjustments(frame)) self.canvas.draw_idle() + # ── image adjustments ────────────────────────────────────────── + def _apply_image_adjustments(self, frame): + img = frame.astype(np.float32) + img += self.brightness_slider.value() + c = self.contrast_slider.value() / 100.0 + img = (1.0 + c) * (img - 128.0) + 128.0 + np.clip(img, 0, 255, out=img) + g = self.gamma_slider.value() / 100.0 + img = (img / 255.0) ** (1.0 / g) * 255.0 + return np.clip(img, 0, 255).astype(np.uint8) + + def _refresh_frame(self): + if self._current_frame is not None: + self.img_artist.set_data(self._apply_image_adjustments(self._current_frame)) + self.canvas.draw_idle() + def set_title(self, text: str): self.title_text.set_text(text) @@ -80,11 +151,19 @@ class MaskCanvas: self.redo_stack = [] self.redraw() + def set_mask(self, mask): + """Replace the mask and push the previous state onto the undo stack.""" + self.history.append(self.mask.copy()) + self.redo_stack.clear() + self.mask = mask + self.redraw() + def redraw(self): if self.mask_visible: + alpha = self.alpha_slider.value() / 100.0 rgba = np.zeros((self.dh, self.dw, 4)) rgba[..., 1] = self.mask * 0.7 - rgba[..., 3] = self.mask * 0.4 + rgba[..., 3] = self.mask * alpha else: rgba = np.zeros((self.dh, self.dw, 4)) self.mask_artist.set_data(rgba) @@ -116,11 +195,21 @@ class MaskCanvas: def toggle_erase(self): self.erase_mode = not self.erase_mode - self.btn_erase.setText("Eraser ON" if self.erase_mode else "Eraser") + if self.erase_mode: + self.btn_erase.setText("Eraser ON") + self.btn_erase.setStyleSheet("background-color: orange; color: black;") + else: + self.btn_erase.setText("Eraser") + self.btn_erase.setStyleSheet("") def toggle_mask(self): self.mask_visible = not self.mask_visible - self.btn_mask.setText("Show Mask" if not self.mask_visible else "Hide Mask") + if self.mask_visible: + self.btn_mask.setText("Hide Mask") + self.btn_mask.setStyleSheet("") + else: + self.btn_mask.setText("Show Mask") + self.btn_mask.setStyleSheet("background-color: red; color: white;") self.redraw() def stamp(self, x, y):