Add image adjustment sliders, mask alpha, Load Prev Mask, and button state colours

- Three vertical sliders (Brightness, Contrast, Gamma) to the left of the
  canvas for display-only image adjustment; all use power/linear formulae
  applied on-the-fly without touching saved data
- Alpha slider controls mask overlay transparency
- Brush size slider moved to its own row
- Each slider has a reset (↺) button restoring its default value
- Hide Mask button turns red when active; Eraser button turns orange
- Load Prev Mask button copies the saved mask from the previous clip in
  the list onto the current clip; the action is pushed onto the undo stack
  so it can be reverted with Undo
- Right survey panel narrowed (stretch factor 2 → 1)
- README Controls section updated to document all new features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 14:41:10 +02:00
parent d0f7cc64fc
commit d13ad1743a
3 changed files with 181 additions and 15 deletions

View File

@@ -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. The window shows the video on the left (auto-playing) and the survey panel on the right.
### Mask drawing
| Action | How | | Action | How |
|---|---| |---|---|
| Draw water mask | Click and drag on the video | | 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 | | 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 last stroke | **Undo** |
| Undo 10 strokes | **Undo×10** | | Undo 10 strokes | **Undo×10** |
| Redo | **Redo** — steps forward through undone strokes | | Redo | **Redo** — steps forward through undone strokes |
| Clear entire mask | **Clear** | | Clear entire mask | **Clear** |
| Adjust brush size | Slider next to the erase controls | | Toggle mask overlay | **Hide Mask / Show Mask** — button turns red when hidden; does not affect mask data |
| Toggle mask overlay | **Hide Mask / Show Mask** — hides or reveals the green overlay without affecting the 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. | | 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. | | 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. | | Skip without saving | **Skip** — discards any unsaved changes and loads the next clip without writing anything to disk. |

View File

@@ -4,7 +4,7 @@ from pathlib import Path
import cv2 import cv2
import numpy as np import numpy as np
from PIL import Image from PIL import Image
from PySide6.QtCore import QTimer from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
QButtonGroup, QButtonGroup,
@@ -113,9 +113,10 @@ class Annotator(QMainWindow):
btn_undo = QPushButton("Undo") btn_undo = QPushButton("Undo")
btn_undo10 = QPushButton("Undo×10") btn_undo10 = QPushButton("Undo×10")
btn_redo = QPushButton("Redo") btn_redo = QPushButton("Redo")
btn_load_prev_mask = QPushButton("Load Prev Mask")
row1 = QHBoxLayout() 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) row1.addWidget(b)
row2 = QHBoxLayout() row2 = QHBoxLayout()
@@ -128,13 +129,42 @@ class Annotator(QMainWindow):
self.mc.btn_mask, self.mc.btn_mask,
]: ]:
row2.addWidget(b) 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 = QVBoxLayout()
left.addWidget(self.mc.canvas) left.addLayout(canvas_row)
left.addLayout(row1) left.addLayout(row1)
left.addLayout(row2) left.addLayout(row2)
left.addLayout(row3)
left.addLayout(row4)
left_widget = QWidget() left_widget = QWidget()
left_widget.setLayout(left) left_widget.setLayout(left)
@@ -143,7 +173,7 @@ class Annotator(QMainWindow):
main = QHBoxLayout() main = QHBoxLayout()
main.addWidget(left_widget, 3) main.addWidget(left_widget, 3)
main.addWidget(right_widget, 2) main.addWidget(right_widget, 1)
container = QWidget() container = QWidget()
container.setLayout(main) container.setLayout(main)
@@ -156,6 +186,7 @@ class Annotator(QMainWindow):
btn_undo.clicked.connect(self.mc.undo) btn_undo.clicked.connect(self.mc.undo)
btn_undo10.clicked.connect(self.mc.undo10) btn_undo10.clicked.connect(self.mc.undo10)
btn_redo.clicked.connect(self.mc.redo) btn_redo.clicked.connect(self.mc.redo)
btn_load_prev_mask.clicked.connect(self.load_prev_mask)
if self._pending_answers: if self._pending_answers:
self._set_answers(self._pending_answers) self._set_answers(self._pending_answers)
@@ -339,3 +370,28 @@ class Annotator(QMainWindow):
def skip_clip(self): def skip_clip(self):
self._advance_clip() 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)

View File

@@ -9,6 +9,12 @@ from PySide6.QtWidgets import QPushButton, QSlider
class MaskCanvas: class MaskCanvas:
"""Matplotlib canvas with brush-based mask drawing, undo/redo, and erase.""" """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): def __init__(self, frames, dh: int, dw: int):
self.dh = dh self.dh = dh
self.dw = dw self.dw = dw
@@ -19,6 +25,7 @@ class MaskCanvas:
self.erase_mode = False self.erase_mode = False
self.drawing = False self.drawing = False
self.mask_visible = True self.mask_visible = True
self._current_frame = frames[0]
self._build_figure(frames) self._build_figure(frames)
self._build_controls() self._build_controls()
@@ -40,9 +47,36 @@ class MaskCanvas:
def _build_controls(self): def _build_controls(self):
self.btn_erase = QPushButton("Eraser") self.btn_erase = QPushButton("Eraser")
self.btn_mask = QPushButton("Hide Mask") self.btn_mask = QPushButton("Hide Mask")
self.brush_slider = QSlider(Qt.Horizontal) self.brush_slider = QSlider(Qt.Horizontal)
self.brush_slider.setRange(2, 50) 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): def _connect_events(self):
self.canvas.mpl_connect("button_press_event", self._on_press) 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.canvas.mpl_connect("axes_leave_event", self._on_axes_leave)
self.btn_erase.clicked.connect(self.toggle_erase) self.btn_erase.clicked.connect(self.toggle_erase)
self.btn_mask.clicked.connect(self.toggle_mask) 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 ──────────────────────────────────────────── # ── clip transition ────────────────────────────────────────────
def load_clip(self, frames, dh: int, dw: int, mask=None, title: str = ""): def load_clip(self, frames, dh: int, dw: int, mask=None, title: str = ""):
@@ -59,13 +112,31 @@ class MaskCanvas:
self.mask = mask if mask is not None else np.zeros((dh, dw), dtype=np.uint8) self.mask = mask if mask is not None else np.zeros((dh, dw), dtype=np.uint8)
self.history = [] self.history = []
self.redo_stack = [] 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.set_title(title)
self.redraw() self.redraw()
# ── frame / title ────────────────────────────────────────────── # ── frame / title ──────────────────────────────────────────────
def set_frame(self, frame): 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() self.canvas.draw_idle()
def set_title(self, text: str): def set_title(self, text: str):
@@ -80,11 +151,19 @@ class MaskCanvas:
self.redo_stack = [] self.redo_stack = []
self.redraw() 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): def redraw(self):
if self.mask_visible: if self.mask_visible:
alpha = self.alpha_slider.value() / 100.0
rgba = np.zeros((self.dh, self.dw, 4)) rgba = np.zeros((self.dh, self.dw, 4))
rgba[..., 1] = self.mask * 0.7 rgba[..., 1] = self.mask * 0.7
rgba[..., 3] = self.mask * 0.4 rgba[..., 3] = self.mask * alpha
else: else:
rgba = np.zeros((self.dh, self.dw, 4)) rgba = np.zeros((self.dh, self.dw, 4))
self.mask_artist.set_data(rgba) self.mask_artist.set_data(rgba)
@@ -116,11 +195,21 @@ class MaskCanvas:
def toggle_erase(self): def toggle_erase(self):
self.erase_mode = not self.erase_mode 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): def toggle_mask(self):
self.mask_visible = not self.mask_visible 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() self.redraw()
def stamp(self, x, y): def stamp(self, x, y):