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:
27
README.md
27
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.
|
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. |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user