From 5f8c579247f4cb4c2d6c36489d2c4d09d279b5c6 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 13:26:03 +0200 Subject: [PATCH 01/23] Splittend in several files --- README.md | 138 ++-- .../annotation_script.py | 624 ++---------------- src/river_annotation_tool/annotator.py | 268 ++++++++ src/river_annotation_tool/clip_selector.py | 108 +++ src/river_annotation_tool/config.py | 44 ++ src/river_annotation_tool/mask_canvas.py | 114 ++++ src/river_annotation_tool/video_loader.py | 40 ++ 7 files changed, 715 insertions(+), 621 deletions(-) create mode 100644 src/river_annotation_tool/annotator.py create mode 100644 src/river_annotation_tool/clip_selector.py create mode 100644 src/river_annotation_tool/config.py create mode 100644 src/river_annotation_tool/mask_canvas.py create mode 100644 src/river_annotation_tool/video_loader.py diff --git a/README.md b/README.md index 4af6ddd..816d1c5 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,6 @@ # River Annotation Tool -A desktop application for manually annotating river video clips as part of the [HydroScan](https://github.com/HydroScan) project. It lets annotators draw pixel-level masks over river regions of interest and answer structured survey questions about flow conditions, lighting, and scene quality. - -## Features - -- Load river video clips from ZIP archives (containing MP4s) -- Draw and erase masks with an adjustable brush on video frames -- Cycle through all frames with auto-playback at native FPS -- Answer structured questions across three categories: **River**, **Scene**, and **Weather** -- Resume saved annotation sessions; exports masks, metadata, and overlay GIFs +A desktop application for manually annotating river video clips as part of the [HydroScan](https://github.com/HydroScan) project. Annotators draw pixel-level water masks over river footage and answer structured survey questions about flow conditions, lighting, and scene quality. ## Requirements @@ -22,66 +14,134 @@ A desktop application for manually annotating river video clips as part of the [ git clone cd river-annotation-tool -# Install dependencies (creates a virtual environment automatically with uv) +# Install with uv (creates the virtual environment automatically) uv sync # Or with pip python -m venv .venv -.venv\Scripts\activate # Windows -# source .venv/bin/activate # macOS/Linux +.venv\Scripts\activate # Windows +# source .venv/bin/activate # macOS/Linux pip install -e . ``` ## Usage ```sh -python -m river_annotation_tool.annotation_script \ - --data \ - --out \ - [--clip ] +python -m river_annotation_tool.annotation_script --data --out ``` +### Arguments + | Argument | Default | Description | |---|---|---| -| `--data` | `../torrent-flow/data/examples_for_annotations/` | Directory containing ZIP files | -| `--out` | `data/annotation_results/` | Output directory for saved annotations | -| `--clip` | *(first clip)* | Specific clip to open (e.g. `left_20230501`) | +| `--data` | *(hardcoded path)* | Directory containing ZIP archives of clips | +| `--out` | `data/annotation_results/` | Directory where annotations are written | +| `--clip` | *(first unannotated clip)* | Open a specific clip by stem name (e.g. `left_20230501`) | +| `--time` | — | Target time of day `HH:MM` — picks the clip closest to this time for each day | +| `--daily` | off | Annotate one clip per day (at `--time`, default noon); advances to the next day on **Next** | +| `--skip-existing-day` | off | With `--daily`, skip entire days that already have any annotated clip | +| `--extras` | off | Also save GIFs and extra PNGs (see Output section) | -### Controls +### Typical workflows + +```sh +# Annotate clips in chronological order (default) +python -m river_annotation_tool.annotation_script --data data/clips --out data/out + +# One clip per day, always at the noon recording +python -m river_annotation_tool.annotation_script --data data/clips --out data/out --daily --time 12:00 + +# Resume a daily run, skip days already touched +python -m river_annotation_tool.annotation_script --data data/clips --out data/out \ + --daily --time 12:00 --skip-existing-day + +# Annotate a single specific clip +python -m river_annotation_tool.annotation_script --data data/clips --out data/out \ + --clip left_20230615T120000 +``` + +## Controls + +The window shows the video on the left (auto-playing) and the survey panel on the right. | Action | How | |---|---| -| Draw mask | Click and drag on the canvas | +| Draw water mask | Click and drag on the video | | Erase mask | Toggle **Eraser** button, then drag | -| Undo last stroke | **Undo** button | -| Play/pause frames | **Play / Pause** button | -| Save annotation | **Save** button | -| Change brush size | Slider in the toolbar | +| 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 | +| Restore last save | **Reload Saved** — reverts mask and answers to what was last written | ## Output -Each clip is saved to `//`: +Each annotated clip produces a folder `//` with: ``` -mask.png # Binary mask at full resolution -metadata.json # Survey answers -frame.png # Key frame -mask_vis.png # Mask visualisation -overlay.png # Frame + mask overlay -video_original_hires.gif -video_original_lowres.gif -video_overlay_hires.gif -video_overlay_lowres.gif +mask.png # Binary water mask at full source resolution (always) +metadata.json # Survey answers as JSON (always) +frame.png # Middle frame of the clip (always) +overlay.png # That frame with the mask blended in green (always) + +# Only with --extras: +mask_vis.png # Mask rendered as a greyscale PNG +video_original_hires.gif # All frames at display resolution +video_original_lowres.gif # All frames at 50% of display resolution +video_overlay_hires.gif # Overlay GIF at display resolution +video_overlay_lowres.gif # Overlay GIF at 50% of display resolution ``` -## Repository Structure +### Survey answers (`metadata.json`) + +```json +{ + "flow": "Turbulent | Laminar | Uncertain", + "shadows": "Yes | No | Uncertain", + "artifacts": "Yes | No | Uncertain", + "lighting": "Day | Night | Uncertain", + "exposure": "Overexposed | Underexposed | Both | Normal | Uncertain", + "snowing": "Yes | No | Uncertain", + "snow_on_ground":"Yes | No | Uncertain" +} +``` + +## How it works + +### Clip format + +Each clip is a ZIP archive containing a `left.mp4` video. The filename encodes the recording timestamp (e.g. `left_20230615T120000.zip`), which is used for sorting and daily filtering. + +### Frame loading + +Up to 100 frames are extracted from the video and scaled so the longest side is 480 px. This display-resolution copy is what the annotator works on; the full-resolution dimensions are remembered separately so the saved mask is upscaled back to the original size on export. + +### Mask drawing + +The mask is a binary NumPy array matching the display frame size. Each brush stroke stamps a filled circle of the selected radius, setting pixels to 1 (draw) or 0 (erase). The history stack stores a copy of the mask before each stroke, enabling unlimited undo. On save the mask is resized to the original video resolution with nearest-neighbour interpolation and written as an 8-bit PNG (0 or 255). + +### Clip selection + +`ClipSelector` scans the data directory, builds a sorted DataFrame of clips ordered by timestamp, and filters out clips that already have a `mask.png`. In daily mode it groups the remaining clips by calendar day and picks the one whose recording time is closest to the target hour; on **Next**, it moves to the first clip of the following day. + +### Resuming + +When a clip is loaded that already has a saved `mask.png` and `metadata.json`, the mask is restored at display resolution and the survey answers are pre-filled. **Reload Saved** lets you revert to the last save at any point during the current session. + +## Repository structure ``` src/river_annotation_tool/ - annotation_script.py # Main GUI application + annotation_script.py # Entry point — argument parsing and app launch + annotator.py # Main QMainWindow — orchestrates all components + clip_selector.py # Clip-picking logic (daily mode, time filtering) + mask_canvas.py # Drawing widget — brush, undo, erase, mouse events + video_loader.py # ZIP extraction and frame resizing + config.py # Config constants, question definitions, defaults __init__.py # Package version pyproject.toml # Project metadata and dependencies -requirements.txt # Pinned dependencies (generated) ``` ## Development @@ -89,7 +149,7 @@ requirements.txt # Pinned dependencies (generated) ```sh # Install pre-commit hooks pre-commit install -pre-commit run --all-files # Run hooks manually once +pre-commit run --all-files # Run manually once # Add a dependency uv add diff --git a/src/river_annotation_tool/annotation_script.py b/src/river_annotation_tool/annotation_script.py index 7c3f385..06ac0fb 100644 --- a/src/river_annotation_tool/annotation_script.py +++ b/src/river_annotation_tool/annotation_script.py @@ -1,595 +1,49 @@ -import os -import zipfile -import tempfile -import json import argparse from pathlib import Path -import cv2 -import numpy as np -import pandas as pd -from PIL import Image - from matplotlib import use + use("QtAgg") -from PySide6.QtWidgets import ( - QApplication, - QMainWindow, - QWidget, - QPushButton, - QVBoxLayout, - QHBoxLayout, - QLabel, - QRadioButton, - QButtonGroup, - QGroupBox, - QSlider, -) -from PySide6.QtCore import Qt, QTimer +from PySide6.QtWidgets import QApplication -from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas -from matplotlib.figure import Figure +from .annotator import Annotator -# ───────────────────────────────────────────── -# CONFIG -# ───────────────────────────────────────────── -class Config: - DISPLAY_MAX = 480 - FPS_FALLBACK = 25 - MAX_FRAMES = 100 - - -# ───────────────────────────────────────────── -# QUESTIONS -# ───────────────────────────────────────────── -QUESTIONS = [ - ( - "River", - [ - ("flow", "Flow Regime", ["Turbulent", "Laminar", "Uncertain"]), - ("shadows", "Strong Shadows", ["Yes", "No", "Uncertain"]), - ("artifacts", "Artifacts on River", ["Yes", "No", "Uncertain"]), - ], - ), - ( - "Scene", - [ - ("lighting", "Lighting", ["Day", "Night", "Uncertain"]), - ( - "exposure", - "Exposure", - ["Overexposed", "Underexposed", "Both", "Normal", "Uncertain"], - ), - ], - ), - ( - "Weather", - [ - ("snowing", "Snowing", ["Yes", "No", "Uncertain"]), - ("snow_on_ground", "Snow on Ground", ["Yes", "No", "Uncertain"]), - ], - ), -] - - -# ───────────────────────────────────────────── -# DEFAULTS -# ───────────────────────────────────────────── -DEFAULTS = { - "flow": "Laminar", - "shadows": "No", - "artifacts": "No", - "lighting": "Day", - "exposure": "Normal", - "snowing": "No", - "snow_on_ground": "No", -} - - -# ───────────────────────────────────────────── -# VIDEO LOADING -# ───────────────────────────────────────────── -def load_frames(zip_path: Path, max_frames: int): - video_bytes = zipfile.ZipFile(zip_path).read("left.mp4") - - with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f: - f.write(video_bytes) - tmp_path = f.name - - cap = cv2.VideoCapture(tmp_path) - fps = cap.get(cv2.CAP_PROP_FPS) or Config.FPS_FALLBACK - - frames = [] - while len(frames) < max_frames: - ok, frame = cap.read() - if not ok: - break - frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) - - cap.release() - os.unlink(tmp_path) - - if not frames: - raise RuntimeError(f"No frames found in {zip_path}") - - h, w = frames[0].shape[:2] - scale = Config.DISPLAY_MAX / max(h, w) - dh, dw = int(h * scale), int(w * scale) - - frames = [cv2.resize(f, (dw, dh)) for f in frames] - - return frames, fps, dh, dw, h, w - - -# ───────────────────────────────────────────── -# MAIN APP -# ───────────────────────────────────────────── -class Annotator(QMainWindow): - def __init__(self, data_dir: Path, out_dir: Path, clip: str = None, target_time: str = None, daily: bool = False, extras: bool = False, skip_existing_day: bool = False): - super().__init__() - - self.data_dir = Path(data_dir) - self.out_dir = Path(out_dir) - self.target_time = target_time - self.daily = daily - self.extras = extras - self.skip_existing_day = skip_existing_day - self.current_date = None - - self.history = [] - self.erase_mode = False - self.frame_i = 0 - self.drawing = False - self._pending_answers = None - - self.setWindowTitle("River Annotator") - - self.df = self._load_dataset() - self._load_clip(specific=clip) - - self._init_canvas() - self._init_ui() - self._init_timer() - - # ───────────────────────────── - # DATA - # ───────────────────────────── - def _load_dataset(self): - files = list(self.data_dir.glob("*.zip")) - if not files: - raise FileNotFoundError(f"No zip files in {self.data_dir}") - - df = pd.DataFrame({"filename": files}) - df["datetime"] = df["filename"].apply( - lambda x: pd.to_datetime(x.stem.split("_")[1], errors="coerce") - ) - # sort by datetime - df = df.sort_values("datetime").reset_index(drop=True) - return df - - def _load_clip(self, specific: str = None, next_day: bool = False): - if specific is not None: - matches = list(self.data_dir.glob(f"{specific}.zip")) - if not matches: - p = self.data_dir / specific - matches = [p] if p.exists() else [] - if not matches: - raise FileNotFoundError(f"Clip '{specific}' not found in {self.data_dir}") - self.filename = matches[0] - else: - remaining = [ - f - for f in self.df["filename"] - if not (self.out_dir / f.stem / "mask.png").exists() - ] - if not remaining: - raise RuntimeError("No remaining clips to annotate") - - if self.target_time or self.daily: - # Parse target time (format: HH:MM) - if self.target_time: - target_hour, target_minute = map(int, self.target_time.split(":")) - else: - target_hour, target_minute = 12, 0 # Default to noon - target_seconds = target_hour * 3600 + target_minute * 60 - - # Get datetimes for remaining files - remaining_datetimes = [ - self.df[self.df["filename"] == f]["datetime"].values[0] - for f in remaining - ] - - # Group by day - df_remaining = pd.DataFrame({ - "filename": remaining, - "datetime": remaining_datetimes - }) - df_remaining["date"] = df_remaining["datetime"].dt.date - - # In daily mode, filter to next day if needed - if self.daily and next_day and self.current_date is not None: - import datetime - next_date = self.current_date + datetime.timedelta(days=1) - df_remaining = df_remaining[df_remaining["date"] >= next_date] - - # In daily mode, skip entire days that already have any annotated clip - if self.daily and self.skip_existing_day: - annotated_dates = set() - for f in self.df["filename"]: - if (self.out_dir / f.stem / "mask.png").exists(): - dt = self.df[self.df["filename"] == f]["datetime"].values[0] - annotated_dates.add(pd.Timestamp(dt).date()) - df_remaining = df_remaining[~df_remaining["date"].isin(annotated_dates)] - - if df_remaining.empty: - raise RuntimeError("No remaining clips to annotate") - - # For each day, find the clip closest to target time - closest_clips = [] - dates_list = [] - for date, group in df_remaining.groupby("date"): - group = group.copy() - group["time_seconds"] = group["datetime"].dt.hour * 3600 + group["datetime"].dt.minute * 60 - group["time_diff"] = (group["time_seconds"] - target_seconds).abs() - closest = group.loc[group["time_diff"].idxmin()] - closest_clips.append(closest["filename"]) - dates_list.append(date) - - # In daily mode, take only the first day's clip - if self.daily: - self.filename = closest_clips[0] - self.current_date = dates_list[0] - else: - # Take the first one (earliest by date/time) - self.filename = closest_clips[0] - self.current_date = dates_list[0] - else: - # take the earliest one (after sorting by datetime) - self.filename = remaining[0] - # Extract date from filename - import datetime - dt = self.df[self.df["filename"] == self.filename]["datetime"].values[0] - self.current_date = pd.Timestamp(dt).date() - - self.frames, self.fps, self.dh, self.dw, self.h, self.w = load_frames( - self.filename, Config.MAX_FRAMES - ) - - self.history = [] - self.mask = np.zeros((self.dh, self.dw), dtype=np.uint8) - self._pending_answers = None - - out = self.out_dir / self.filename.stem - mask_path = out / "mask.png" - meta_path = out / "metadata.json" - - if mask_path.exists(): - mask_full = np.array(Image.open(mask_path).convert("L")) - self.mask = cv2.resize( - (mask_full > 127).astype(np.uint8), - (self.dw, self.dh), - interpolation=cv2.INTER_NEAREST, - ) - - if meta_path.exists(): - with open(meta_path) as f: - self._pending_answers = json.load(f) - - def _set_answers(self, answers: dict): - for key, value in answers.items(): - if key not in self.q_widgets: - continue - _, buttons, options = self.q_widgets[key] - for i, btn in enumerate(buttons): - btn.setChecked(options[i] == value) - - # ───────────────────────────── - # UI - # ───────────────────────────── - def _init_canvas(self): - self.fig = Figure() - self.canvas = FigureCanvas(self.fig) - - self.ax = self.fig.add_subplot(111) - self.ax.axis("off") - - self.img = self.ax.imshow(self.frames[0]) - self.mask_img = self.ax.imshow(np.zeros((self.dh, self.dw, 4))) - - self.title_text = self.ax.set_title(self.filename.name, fontsize=10, pad=4) - - def _init_ui(self): - self.q_widgets = {} - - question_box = QVBoxLayout() - - for section, qs in QUESTIONS: - group = QGroupBox(section) - vbox = QVBoxLayout() - - for key, label, options in qs: - vbox.addWidget(QLabel(label)) - - btn_group = QButtonGroup(self) - row = QHBoxLayout() - buttons = [] - - default_value = DEFAULTS.get(key) - - for opt in options: - btn = QRadioButton(opt) - btn_group.addButton(btn) - row.addWidget(btn) - buttons.append(btn) - - if default_value == opt: - btn.setChecked(True) - - if default_value is None and buttons: - buttons[-1].setChecked(True) - - self.q_widgets[key] = (btn_group, buttons, options) - vbox.addLayout(row) - - group.setLayout(vbox) - question_box.addWidget(group) - - # Controls - self.btn_save = QPushButton("Save") - self.btn_next = QPushButton("Next") - self.btn_skip = QPushButton("Skip") - self.btn_clear = QPushButton("Clear") - self.btn_erase = QPushButton("Eraser") - self.btn_undo = QPushButton("Undo") - self.btn_reload = QPushButton("Reload Saved") - - self.brush_slider = QSlider(Qt.Horizontal) - self.brush_slider.setRange(2, 50) - self.brush_slider.setValue(5) - - row1 = QHBoxLayout() - for b in [self.btn_save, self.btn_next, self.btn_skip]: - row1.addWidget(b) - - row2 = QHBoxLayout() - for b in [self.btn_clear, self.btn_erase, self.btn_undo, self.btn_reload]: - row2.addWidget(b) - row2.addWidget(QLabel("Brush")) - row2.addWidget(self.brush_slider) - - left = QVBoxLayout() - left.addWidget(self.canvas) - left.addLayout(row1) - left.addLayout(row2) - - main = QHBoxLayout() - - left_widget = QWidget() - left_widget.setLayout(left) - - right_widget = QWidget() - right_widget.setLayout(question_box) - - main.addWidget(left_widget, 3) - main.addWidget(right_widget, 2) - - container = QWidget() - container.setLayout(main) - self.setCentralWidget(container) - - # events - self.btn_save.clicked.connect(self.save) - self.btn_next.clicked.connect(self.next_clip) - self.btn_skip.clicked.connect(self.skip_clip) - self.btn_clear.clicked.connect(self.clear_mask) - self.btn_erase.clicked.connect(self.toggle_eraser) - self.btn_undo.clicked.connect(self.undo) - self.btn_reload.clicked.connect(self.reload_saved) - - self.canvas.mpl_connect("button_press_event", self.on_press) - self.canvas.mpl_connect("motion_notify_event", self.on_move) - self.canvas.mpl_connect("button_release_event", self.on_release) - - if self._pending_answers: - self._set_answers(self._pending_answers) - self._pending_answers = None - - def _init_timer(self): - self.timer = QTimer() - self.timer.timeout.connect(self.update_frame) - self.timer.start(int(1000 / self.fps)) - - # ───────────────────────────── - # ANNOTATION - # ───────────────────────────── - def get_answers(self): - out = {} - for key, (group, buttons, options) in self.q_widgets.items(): - for i, btn in enumerate(buttons): - if btn.isChecked(): - out[key] = options[i] - return out - - def stamp(self, x, y): - if x is None or y is None: - return - - self.history.append(self.mask.copy()) - - r = self.brush_slider.value() - ix, iy = int(x), int(y) - - y0, y1 = max(0, iy - r), min(self.dh, iy + r + 1) - x0, x1 = max(0, ix - r), min(self.dw, ix + r + 1) - - Y, X = np.ogrid[y0:y1, x0:x1] - circle = (X - ix) ** 2 + (Y - iy) ** 2 <= r**2 - - self.mask[y0:y1, x0:x1][circle] = 0 if self.erase_mode else 1 - self.redraw_mask() - - def redraw_mask(self): - rgba = np.zeros((self.dh, self.dw, 4)) - rgba[..., 1] = self.mask * 0.7 - rgba[..., 3] = self.mask * 0.4 - - self.mask_img.set_data(rgba) - self.canvas.draw_idle() - - # ───────────────────────────── - # EVENTS - # ───────────────────────────── - def on_press(self, e): - if e.xdata is None: - return - self.drawing = True - self.stamp(e.xdata, e.ydata) - - def on_move(self, e): - if self.drawing: - self.stamp(e.xdata, e.ydata) - - def on_release(self, _): - self.drawing = False - - def update_frame(self): - self.frame_i = (self.frame_i + 1) % len(self.frames) - self.img.set_data(self.frames[self.frame_i]) - self.canvas.draw_idle() - - # ───────────────────────────── - # HELPERS - # ───────────────────────────── - def _make_overlay(self, frame, alpha=0.4): - overlay = frame.copy() - green = np.zeros_like(frame) - green[..., 1] = 255 - m = self.mask.astype(bool) - overlay[m] = (1 - alpha) * overlay[m] + alpha * green[m] - return overlay.astype(np.uint8) - - def _save_gif(self, frames, out_path, scale=1.0): - h, w = frames[0].shape[:2] - nh, nw = max(1, int(h * scale)), max(1, int(w * scale)) - pil_frames = [Image.fromarray(cv2.resize(f, (nw, nh))) for f in frames] - pil_frames[0].save( - out_path, - save_all=True, - append_images=pil_frames[1:], - duration=int(1000 / self.fps), - loop=0, - ) - - # ───────────────────────────── - # ACTIONS - # ───────────────────────────── - def reload_saved(self): - out = self.out_dir / self.filename.stem - mask_path = out / "mask.png" - meta_path = out / "metadata.json" - - if not mask_path.exists(): - return - - mask_full = np.array(Image.open(mask_path).convert("L")) - self.mask = cv2.resize( - (mask_full > 127).astype(np.uint8), - (self.dw, self.dh), - interpolation=cv2.INTER_NEAREST, - ) - self.history = [] - self.redraw_mask() - - if meta_path.exists(): - with open(meta_path) as f: - self._set_answers(json.load(f)) - - def clear_mask(self): - self.mask[:] = 0 - self.redraw_mask() - - def undo(self): - if self.history: - self.mask = self.history.pop() - self.redraw_mask() - - def toggle_eraser(self): - self.erase_mode = not self.erase_mode - self.btn_erase.setText("Eraser ON" if self.erase_mode else "Eraser") - - def save(self): - out = self.out_dir / self.filename.stem - out.mkdir(parents=True, exist_ok=True) - - mask_full = cv2.resize( - self.mask.astype(np.uint8), - (self.w, self.h), - interpolation=cv2.INTER_NEAREST, - ) - - Image.fromarray(mask_full * 255).save(out / "mask.png") - - with open(out / "metadata.json", "w") as f: - json.dump(self.get_answers(), f, indent=2) - - mid = len(self.frames) // 2 - frame = self.frames[mid] - overlay_frame = self._make_overlay(frame) - Image.fromarray(frame).save(out / "frame.png") - Image.fromarray(overlay_frame).save(out / "overlay.png") - - if self.extras: - Image.fromarray((self.mask * 255).astype(np.uint8)).save(out / "mask_vis.png") - - overlay_frames = [self._make_overlay(f) for f in self.frames] - self._save_gif(self.frames, out / "video_original_hires.gif", scale=1.0) - self._save_gif(self.frames, out / "video_original_lowres.gif", scale=0.5) - self._save_gif(overlay_frames, out / "video_overlay_hires.gif", scale=1.0) - self._save_gif(overlay_frames, out / "video_overlay_lowres.gif", scale=0.5) - - print("Saved:", out) - - def next_clip(self): - self.save() - self._load_clip(next_day=self.daily) - - self.frame_i = 0 - self.img.set_data(self.frames[0]) - self.title_text.set_text(self.filename.name) - self.redraw_mask() - - if self._pending_answers: - self._set_answers(self._pending_answers) - self._pending_answers = None - - def skip_clip(self): - self._load_clip(next_day=self.daily) - - self.frame_i = 0 - self.img.set_data(self.frames[0]) - self.title_text.set_text(self.filename.name) - self.redraw_mask() - - if self._pending_answers: - self._set_answers(self._pending_answers) - self._pending_answers = None - - -# ───────────────────────────────────────────── -# ENTRY POINT -# ───────────────────────────────────────────── def parse_args(): parser = argparse.ArgumentParser() - parser.add_argument("--data", default=r"C:\Users\sieverin\HydroScan\Code\river-annotation-tool\data\filtered_data") + parser.add_argument( + "--data", + default=r"C:\Users\sieverin\HydroScan\Code\river-annotation-tool\data\filtered_data", + ) parser.add_argument("--out", default="data/annotation_results/") - parser.add_argument("--clip", default=None, help="Stem name of a specific clip to load (e.g. 'left_20230501')") - parser.add_argument("--time", default=None, help="Target time to filter clips by day (format: HH:MM, e.g. '14:30'). Selects the closest clip to this time for each day.") - parser.add_argument("--daily", action="store_true", help="Load only 1 clip per day at the specified time (requires --time).") - parser.add_argument("--extras", action="store_true", help="Also save GIFs, frame PNG, overlay PNG, and mask_vis PNG alongside the mask.") - parser.add_argument("--skip-existing-day", action="store_true", help="In --daily mode, skip days that already have any annotated clip.") + parser.add_argument( + "--clip", + default=None, + help="Stem name of a specific clip to load (e.g. 'left_20230501')", + ) + parser.add_argument( + "--time", + default=None, + help="Target time to filter clips by day (format: HH:MM, e.g. '14:30'). " + "Selects the closest clip to this time for each day.", + ) + parser.add_argument( + "--daily", + action="store_true", + help="Load only 1 clip per day at the specified time (requires --time).", + ) + parser.add_argument( + "--extras", + action="store_true", + help="Also save GIFs, frame PNG, overlay PNG, and mask_vis PNG alongside the mask.", + ) + parser.add_argument( + "--skip-existing-day", + action="store_true", + help="In --daily mode, skip days that already have any annotated clip.", + ) return parser.parse_args() @@ -597,8 +51,14 @@ if __name__ == "__main__": args = parse_args() app = QApplication([]) - - win = Annotator(Path(args.data), Path(args.out), clip=args.clip, target_time=args.time, daily=args.daily, extras=args.extras, skip_existing_day=args.skip_existing_day) + win = Annotator( + Path(args.data), + Path(args.out), + clip=args.clip, + target_time=args.time, + daily=args.daily, + extras=args.extras, + skip_existing_day=args.skip_existing_day, + ) win.show() - app.exec() diff --git a/src/river_annotation_tool/annotator.py b/src/river_annotation_tool/annotator.py new file mode 100644 index 0000000..e33895b --- /dev/null +++ b/src/river_annotation_tool/annotator.py @@ -0,0 +1,268 @@ +import json +from pathlib import Path + +import cv2 +import numpy as np +from PIL import Image +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import ( + QButtonGroup, + QGroupBox, + QHBoxLayout, + QLabel, + QMainWindow, + QPushButton, + QRadioButton, + QVBoxLayout, + QWidget, +) + +from .clip_selector import ClipSelector +from .config import DEFAULTS, QUESTIONS, Config +from .mask_canvas import MaskCanvas +from .video_loader import load_frames + + +class Annotator(QMainWindow): + def __init__( + self, + data_dir: Path, + out_dir: Path, + clip: str = None, + target_time: str = None, + daily: bool = False, + extras: bool = False, + skip_existing_day: bool = False, + ): + super().__init__() + + self.out_dir = Path(out_dir) + self.extras = extras + + self.selector = ClipSelector( + data_dir=Path(data_dir), + out_dir=self.out_dir, + target_time=target_time, + daily=daily, + skip_existing_day=skip_existing_day, + ) + + self.setWindowTitle("River Annotator") + self._load_clip(specific=clip) + self._init_ui() + self._init_timer() + + # ── clip loading ─────────────────────────────────────────────── + def _load_clip(self, specific: str = None, next_day: bool = False): + self.filename = self.selector.next(specific=specific, next_day=next_day) + self.frames, self.fps, self.dh, self.dw, self.h, self.w = load_frames( + self.filename, Config.MAX_FRAMES + ) + self._pending_answers = self._read_saved_answers() + + def _read_saved_mask(self): + mask_path = self.out_dir / self.filename.stem / "mask.png" + if not mask_path.exists(): + return None + mask_full = np.array(Image.open(mask_path).convert("L")) + return cv2.resize( + (mask_full > 127).astype(np.uint8), + (self.dw, self.dh), + interpolation=cv2.INTER_NEAREST, + ) + + def _read_saved_answers(self): + meta_path = self.out_dir / self.filename.stem / "metadata.json" + if not meta_path.exists(): + return None + with open(meta_path) as f: + return json.load(f) + + # ── UI setup ─────────────────────────────────────────────────── + def _init_ui(self): + self.mc = MaskCanvas(self.frames, self.dh, self.dw) + self.mc.set_title(self.filename.name) + self.mc.reset(self._read_saved_mask()) + + self.q_widgets = {} + question_panel = self._build_question_panel() + + btn_save = QPushButton("Save") + btn_next = QPushButton("Next") + btn_skip = QPushButton("Skip") + btn_clear = QPushButton("Clear") + btn_undo = QPushButton("Undo") + btn_reload = QPushButton("Reload Saved") + + row1 = QHBoxLayout() + for b in [btn_save, btn_next, btn_skip]: + row1.addWidget(b) + + row2 = QHBoxLayout() + for b in [btn_clear, self.mc.btn_erase, btn_undo, btn_reload]: + row2.addWidget(b) + row2.addWidget(QLabel("Brush")) + row2.addWidget(self.mc.brush_slider) + + left = QVBoxLayout() + left.addWidget(self.mc.canvas) + left.addLayout(row1) + left.addLayout(row2) + + left_widget = QWidget() + left_widget.setLayout(left) + right_widget = QWidget() + right_widget.setLayout(question_panel) + + main = QHBoxLayout() + main.addWidget(left_widget, 3) + main.addWidget(right_widget, 2) + + container = QWidget() + container.setLayout(main) + self.setCentralWidget(container) + + btn_save.clicked.connect(self.save) + btn_next.clicked.connect(self.next_clip) + btn_skip.clicked.connect(self.skip_clip) + btn_clear.clicked.connect(self.mc.clear) + btn_undo.clicked.connect(self.mc.undo) + btn_reload.clicked.connect(self.reload_saved) + + if self._pending_answers: + self._set_answers(self._pending_answers) + self._pending_answers = None + + def _build_question_panel(self) -> QVBoxLayout: + vbox = QVBoxLayout() + for section, qs in QUESTIONS: + group = QGroupBox(section) + gvbox = QVBoxLayout() + for key, label, options in qs: + gvbox.addWidget(QLabel(label)) + btn_group = QButtonGroup(self) + row = QHBoxLayout() + buttons = [] + default_value = DEFAULTS.get(key) + for opt in options: + btn = QRadioButton(opt) + btn_group.addButton(btn) + row.addWidget(btn) + buttons.append(btn) + if default_value == opt: + btn.setChecked(True) + if default_value is None and buttons: + buttons[-1].setChecked(True) + self.q_widgets[key] = (btn_group, buttons, options) + gvbox.addLayout(row) + group.setLayout(gvbox) + vbox.addWidget(group) + return vbox + + def _set_answers(self, answers: dict): + for key, value in answers.items(): + if key not in self.q_widgets: + continue + _, buttons, options = self.q_widgets[key] + for i, btn in enumerate(buttons): + btn.setChecked(options[i] == value) + + def _init_timer(self): + self.frame_i = 0 + self.timer = QTimer() + self.timer.timeout.connect(self._tick) + self.timer.start(int(1000 / self.fps)) + + def _tick(self): + self.frame_i = (self.frame_i + 1) % len(self.frames) + self.mc.set_frame(self.frames[self.frame_i]) + + # ── answers ──────────────────────────────────────────────────── + def get_answers(self) -> dict: + out = {} + for key, (_, buttons, options) in self.q_widgets.items(): + for i, btn in enumerate(buttons): + if btn.isChecked(): + out[key] = options[i] + return out + + # ── save helpers ─────────────────────────────────────────────── + def _make_overlay(self, frame, alpha=0.4): + overlay = frame.copy() + green = np.zeros_like(frame) + green[..., 1] = 255 + m = self.mc.mask.astype(bool) + overlay[m] = (1 - alpha) * overlay[m] + alpha * green[m] + return overlay.astype(np.uint8) + + def _save_gif(self, frames, out_path, scale=1.0): + h, w = frames[0].shape[:2] + nh, nw = max(1, int(h * scale)), max(1, int(w * scale)) + pil_frames = [Image.fromarray(cv2.resize(f, (nw, nh))) for f in frames] + pil_frames[0].save( + out_path, + save_all=True, + append_images=pil_frames[1:], + duration=int(1000 / self.fps), + loop=0, + ) + + # ── actions ──────────────────────────────────────────────────── + def save(self): + out = self.out_dir / self.filename.stem + out.mkdir(parents=True, exist_ok=True) + + mask_full = cv2.resize( + self.mc.mask.astype(np.uint8), + (self.w, self.h), + interpolation=cv2.INTER_NEAREST, + ) + Image.fromarray(mask_full * 255).save(out / "mask.png") + + with open(out / "metadata.json", "w") as f: + json.dump(self.get_answers(), f, indent=2) + + mid = len(self.frames) // 2 + frame = self.frames[mid] + Image.fromarray(frame).save(out / "frame.png") + Image.fromarray(self._make_overlay(frame)).save(out / "overlay.png") + + if self.extras: + Image.fromarray((self.mc.mask * 255).astype(np.uint8)).save(out / "mask_vis.png") + overlay_frames = [self._make_overlay(f) for f in self.frames] + self._save_gif(self.frames, out / "video_original_hires.gif", scale=1.0) + self._save_gif(self.frames, out / "video_original_lowres.gif", scale=0.5) + self._save_gif(overlay_frames, out / "video_overlay_hires.gif", scale=1.0) + self._save_gif(overlay_frames, out / "video_overlay_lowres.gif", scale=0.5) + + print("Saved:", out) + + def reload_saved(self): + mask = self._read_saved_mask() + if mask is None: + return + self.mc.reset(mask) + answers = self._read_saved_answers() + if answers: + self._set_answers(answers) + + def _advance_clip(self, next_day: bool): + self._load_clip(next_day=next_day) + self.frame_i = 0 + self.mc.load_clip( + self.frames, + self.dh, + self.dw, + mask=self._read_saved_mask(), + title=self.filename.name, + ) + if self._pending_answers: + self._set_answers(self._pending_answers) + self._pending_answers = None + + def next_clip(self): + self.save() + self._advance_clip(next_day=self.selector.daily) + + def skip_clip(self): + self._advance_clip(next_day=self.selector.daily) diff --git a/src/river_annotation_tool/clip_selector.py b/src/river_annotation_tool/clip_selector.py new file mode 100644 index 0000000..8c4e8c4 --- /dev/null +++ b/src/river_annotation_tool/clip_selector.py @@ -0,0 +1,108 @@ +import datetime +from pathlib import Path + +import pandas as pd + + +class ClipSelector: + """Picks which clip to annotate next, handling daily/time-based filtering.""" + + def __init__( + self, + data_dir: Path, + out_dir: Path, + target_time: str = None, + daily: bool = False, + skip_existing_day: bool = False, + ): + self.data_dir = data_dir + self.out_dir = out_dir + self.target_time = target_time + self.daily = daily + self.skip_existing_day = skip_existing_day + self.current_date = None + + self.df = self._load_dataset() + + def _load_dataset(self) -> pd.DataFrame: + files = list(self.data_dir.glob("*.zip")) + if not files: + raise FileNotFoundError(f"No zip files in {self.data_dir}") + + df = pd.DataFrame({"filename": files}) + df["datetime"] = df["filename"].apply( + lambda x: pd.to_datetime(x.stem.split("_")[1], errors="coerce") + ) + return df.sort_values("datetime").reset_index(drop=True) + + def is_annotated(self, path: Path) -> bool: + return (self.out_dir / path.stem / "mask.png").exists() + + def next(self, specific: str = None, next_day: bool = False) -> Path: + if specific is not None: + return self._resolve_specific(specific) + return self._pick_next(next_day=next_day) + + def _resolve_specific(self, specific: str) -> Path: + matches = list(self.data_dir.glob(f"{specific}.zip")) + if not matches: + p = self.data_dir / specific + matches = [p] if p.exists() else [] + if not matches: + raise FileNotFoundError(f"Clip '{specific}' not found in {self.data_dir}") + return matches[0] + + def _pick_next(self, next_day: bool = False) -> Path: + remaining = [f for f in self.df["filename"] if not self.is_annotated(f)] + if not remaining: + raise RuntimeError("No remaining clips to annotate") + + if not (self.target_time or self.daily): + filename = remaining[0] + dt = self.df[self.df["filename"] == filename]["datetime"].values[0] + self.current_date = pd.Timestamp(dt).date() + return filename + + return self._pick_by_time(remaining, next_day) + + def _pick_by_time(self, remaining: list, next_day: bool) -> Path: + if self.target_time: + target_hour, target_minute = map(int, self.target_time.split(":")) + else: + target_hour, target_minute = 12, 0 + target_seconds = target_hour * 3600 + target_minute * 60 + + remaining_datetimes = [ + self.df[self.df["filename"] == f]["datetime"].values[0] for f in remaining + ] + df_remaining = pd.DataFrame({"filename": remaining, "datetime": remaining_datetimes}) + df_remaining["date"] = df_remaining["datetime"].dt.date + + if self.daily and next_day and self.current_date is not None: + next_date = self.current_date + datetime.timedelta(days=1) + df_remaining = df_remaining[df_remaining["date"] >= next_date] + + if self.daily and self.skip_existing_day: + annotated_dates = set() + for f in self.df["filename"]: + if self.is_annotated(f): + dt = self.df[self.df["filename"] == f]["datetime"].values[0] + annotated_dates.add(pd.Timestamp(dt).date()) + df_remaining = df_remaining[~df_remaining["date"].isin(annotated_dates)] + + if df_remaining.empty: + raise RuntimeError("No remaining clips to annotate") + + closest_clips, dates_list = [], [] + for date, group in df_remaining.groupby("date"): + group = group.copy() + group["time_seconds"] = ( + group["datetime"].dt.hour * 3600 + group["datetime"].dt.minute * 60 + ) + group["time_diff"] = (group["time_seconds"] - target_seconds).abs() + closest = group.loc[group["time_diff"].idxmin()] + closest_clips.append(closest["filename"]) + dates_list.append(date) + + self.current_date = dates_list[0] + return closest_clips[0] diff --git a/src/river_annotation_tool/config.py b/src/river_annotation_tool/config.py new file mode 100644 index 0000000..d88bb7c --- /dev/null +++ b/src/river_annotation_tool/config.py @@ -0,0 +1,44 @@ +class Config: + DISPLAY_MAX = 480 + FPS_FALLBACK = 25 + MAX_FRAMES = 100 + + +QUESTIONS = [ + ( + "River", + [ + ("flow", "Flow Regime", ["Turbulent", "Laminar", "Uncertain"]), + ("shadows", "Strong Shadows", ["Yes", "No", "Uncertain"]), + ("artifacts", "Artifacts on River", ["Yes", "No", "Uncertain"]), + ], + ), + ( + "Scene", + [ + ("lighting", "Lighting", ["Day", "Night", "Uncertain"]), + ( + "exposure", + "Exposure", + ["Overexposed", "Underexposed", "Both", "Normal", "Uncertain"], + ), + ], + ), + ( + "Weather", + [ + ("snowing", "Snowing", ["Yes", "No", "Uncertain"]), + ("snow_on_ground", "Snow on Ground", ["Yes", "No", "Uncertain"]), + ], + ), +] + +DEFAULTS = { + "flow": "Laminar", + "shadows": "No", + "artifacts": "No", + "lighting": "Day", + "exposure": "Normal", + "snowing": "No", + "snow_on_ground": "No", +} diff --git a/src/river_annotation_tool/mask_canvas.py b/src/river_annotation_tool/mask_canvas.py new file mode 100644 index 0000000..e98d37b --- /dev/null +++ b/src/river_annotation_tool/mask_canvas.py @@ -0,0 +1,114 @@ +import numpy as np +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QPushButton, QSlider + + +class MaskCanvas: + """Matplotlib canvas with brush-based mask drawing, undo, and erase.""" + + def __init__(self, frames, dh: int, dw: int): + self.dh = dh + self.dw = dw + + self.mask = np.zeros((dh, dw), dtype=np.uint8) + self.history: list[np.ndarray] = [] + self.erase_mode = False + self.drawing = False + + self._build_figure(frames) + self._build_controls() + self._connect_events() + + def _build_figure(self, frames): + self.fig = Figure() + self.canvas = FigureCanvas(self.fig) + self.ax = self.fig.add_subplot(111) + self.ax.axis("off") + self.img_artist = self.ax.imshow(frames[0]) + self.mask_artist = self.ax.imshow(np.zeros((self.dh, self.dw, 4))) + self.title_text = self.ax.set_title("", fontsize=10, pad=4) + + def _build_controls(self): + self.btn_erase = QPushButton("Eraser") + self.brush_slider = QSlider(Qt.Horizontal) + self.brush_slider.setRange(2, 50) + self.brush_slider.setValue(5) + + def _connect_events(self): + self.canvas.mpl_connect("button_press_event", self._on_press) + self.canvas.mpl_connect("motion_notify_event", self._on_move) + self.canvas.mpl_connect("button_release_event", self._on_release) + self.btn_erase.clicked.connect(self.toggle_erase) + + # ── clip transition ──────────────────────────────────────────── + def load_clip(self, frames, dh: int, dw: int, mask=None, title: str = ""): + self.dh = dh + self.dw = dw + self.mask = mask if mask is not None else np.zeros((dh, dw), dtype=np.uint8) + self.history = [] + self.img_artist.set_data(frames[0]) + self.set_title(title) + self.redraw() + + # ── frame / title ────────────────────────────────────────────── + def set_frame(self, frame): + self.img_artist.set_data(frame) + self.canvas.draw_idle() + + def set_title(self, text: str): + self.title_text.set_text(text) + + # ── mask ops ─────────────────────────────────────────────────── + def reset(self, mask=None): + self.mask = mask if mask is not None else np.zeros((self.dh, self.dw), dtype=np.uint8) + self.history = [] + self.redraw() + + def redraw(self): + rgba = np.zeros((self.dh, self.dw, 4)) + rgba[..., 1] = self.mask * 0.7 + rgba[..., 3] = self.mask * 0.4 + self.mask_artist.set_data(rgba) + self.canvas.draw_idle() + + def clear(self): + self.mask[:] = 0 + self.redraw() + + def undo(self): + if self.history: + self.mask = self.history.pop() + self.redraw() + + def toggle_erase(self): + self.erase_mode = not self.erase_mode + self.btn_erase.setText("Eraser ON" if self.erase_mode else "Eraser") + + def stamp(self, x, y): + if x is None or y is None: + return + self.history.append(self.mask.copy()) + r = self.brush_slider.value() + ix, iy = int(x), int(y) + y0, y1 = max(0, iy - r), min(self.dh, iy + r + 1) + x0, x1 = max(0, ix - r), min(self.dw, ix + r + 1) + Y, X = np.ogrid[y0:y1, x0:x1] + circle = (X - ix) ** 2 + (Y - iy) ** 2 <= r**2 + self.mask[y0:y1, x0:x1][circle] = 0 if self.erase_mode else 1 + self.redraw() + + # ── mouse events ─────────────────────────────────────────────── + def _on_press(self, e): + if e.xdata is None: + return + self.drawing = True + self.stamp(e.xdata, e.ydata) + + def _on_move(self, e): + if self.drawing: + self.stamp(e.xdata, e.ydata) + + def _on_release(self, _): + self.drawing = False diff --git a/src/river_annotation_tool/video_loader.py b/src/river_annotation_tool/video_loader.py new file mode 100644 index 0000000..abbc33f --- /dev/null +++ b/src/river_annotation_tool/video_loader.py @@ -0,0 +1,40 @@ +import os +import tempfile +import zipfile +from pathlib import Path + +import cv2 + +from .config import Config + + +def load_frames(zip_path: Path, max_frames: int): + video_bytes = zipfile.ZipFile(zip_path).read("left.mp4") + + with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f: + f.write(video_bytes) + tmp_path = f.name + + cap = cv2.VideoCapture(tmp_path) + fps = cap.get(cv2.CAP_PROP_FPS) or Config.FPS_FALLBACK + + frames = [] + while len(frames) < max_frames: + ok, frame = cap.read() + if not ok: + break + frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) + + cap.release() + os.unlink(tmp_path) + + if not frames: + raise RuntimeError(f"No frames found in {zip_path}") + + h, w = frames[0].shape[:2] + scale = Config.DISPLAY_MAX / max(h, w) + dh, dw = int(h * scale), int(w * scale) + + frames = [cv2.resize(f, (dw, dh)) for f in frames] + + return frames, fps, dh, dw, h, w From 5468712a4a042d83342257ccab819f8c7f48629b Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 13:42:48 +0200 Subject: [PATCH 02/23] Replace hardcoded config and directory scan with YAML config and explicit clip list - config.py constants -> config/config.yaml (user-editable, git-ignored) - Questions and defaults now defined in the YAML, including per-question defaults - ClipSelector no longer scans the data dir; reads a user-provided clips.txt instead - Removed --daily / --time / --skip-existing-day args - video_loader now samples frames evenly across the full clip - pyyaml added as a dependency Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 6 +- README.md | 112 +++++++++++++----- config/clips.example.txt | 10 ++ config/config.example.yaml | 43 +++++++ pyproject.toml | 1 + .../annotation_script.py | 44 +++---- src/river_annotation_tool/annotator.py | 43 ++++--- src/river_annotation_tool/clip_selector.py | 106 ++++------------- src/river_annotation_tool/config.py | 74 ++++++------ src/river_annotation_tool/video_loader.py | 14 ++- uv.lock | 2 + 11 files changed, 240 insertions(+), 215 deletions(-) create mode 100644 config/clips.example.txt create mode 100644 config/config.example.yaml diff --git a/.gitignore b/.gitignore index 588b3eb..414b3d7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,8 @@ .DS_Store # Data -data/** \ No newline at end of file +data/** + +# User-specific config (copy from *.example.* files) +config/config.yaml +config/clips.txt \ No newline at end of file diff --git a/README.md b/README.md index 816d1c5..bb3b846 100644 --- a/README.md +++ b/README.md @@ -24,42 +24,87 @@ python -m venv .venv pip install -e . ``` +## Setup + +Before running, create your config and clip list from the provided examples: + +```sh +cp config/config.example.yaml config/config.yaml +cp config/clips.example.txt config/clips.txt +``` + +Edit `config/config.yaml` to set your `data_dir` and `out_dir`, then edit `config/clips.txt` to list the clips you want to annotate. + ## Usage ```sh -python -m river_annotation_tool.annotation_script --data --out +python -m river_annotation_tool.annotation_script ``` ### Arguments | Argument | Default | Description | |---|---|---| -| `--data` | *(hardcoded path)* | Directory containing ZIP archives of clips | -| `--out` | `data/annotation_results/` | Directory where annotations are written | -| `--clip` | *(first unannotated clip)* | Open a specific clip by stem name (e.g. `left_20230501`) | -| `--time` | — | Target time of day `HH:MM` — picks the clip closest to this time for each day | -| `--daily` | off | Annotate one clip per day (at `--time`, default noon); advances to the next day on **Next** | -| `--skip-existing-day` | off | With `--daily`, skip entire days that already have any annotated clip | +| `--config` | `config/config.yaml` | Path to the config YAML file | +| `--data` | *(from config)* | Override `data_dir` from config | +| `--out` | *(from config)* | Override `out_dir` from config | +| `--clips` | *(from config)* | Override `clips_file` from config | +| `--clip` | *(first unannotated in list)* | Open a specific clip by stem name (e.g. `left_20230501`) | | `--extras` | off | Also save GIFs and extra PNGs (see Output section) | ### Typical workflows ```sh -# Annotate clips in chronological order (default) +# Annotate clips listed in config/clips.txt (default) +python -m river_annotation_tool.annotation_script + +# Use a different config file +python -m river_annotation_tool.annotation_script --config config/my_config.yaml + +# Override paths from the command line python -m river_annotation_tool.annotation_script --data data/clips --out data/out -# One clip per day, always at the noon recording -python -m river_annotation_tool.annotation_script --data data/clips --out data/out --daily --time 12:00 - -# Resume a daily run, skip days already touched -python -m river_annotation_tool.annotation_script --data data/clips --out data/out \ - --daily --time 12:00 --skip-existing-day - # Annotate a single specific clip -python -m river_annotation_tool.annotation_script --data data/clips --out data/out \ - --clip left_20230615T120000 +python -m river_annotation_tool.annotation_script --clip left_20230615T120000 ``` +## Configuration + +All settings live in `config/config.yaml`. Copy `config/config.example.yaml` to get started. + +```yaml +display_max: 720 # longest side in pixels for display +fps_fallback: 25 # FPS to use if the video header is missing +max_frames: 100 # max frames to extract per clip + +data_dir: data/clips # directory containing ZIP archives +out_dir: data/annotation_results +clips_file: config/clips.txt + +questions: + - section: River + items: + - key: flow + label: "Flow Regime" + options: [Turbulent, Laminar, Uncertain] + default: Laminar + # add more items or sections as needed +``` + +Add, remove, or reorder questions directly in the YAML — the UI rebuilds automatically. `key` is what gets saved in `metadata.json`; `default` selects the pre-checked option (omit or set to `null` to leave unselected). + +## Clip list file + +`config/clips.txt` lists the clip filenames to annotate, one per line. Lines starting with `#` are ignored. Clips are processed in order; already-annotated clips (those with an existing `mask.png`) are skipped automatically. + +``` +# Example clips.txt +left_20230501T120000.zip +left_20230502T120000.zip +``` + +Copy `config/clips.example.txt` as a starting point. + ## Controls The window shows the video on the left (auto-playing) and the survey panel on the right. @@ -78,7 +123,7 @@ The window shows the video on the left (auto-playing) and the survey panel on th ## Output -Each annotated clip produces a folder `//` with: +Each annotated clip produces a folder `//` with: ``` mask.png # Binary water mask at full source resolution (always) @@ -96,6 +141,8 @@ video_overlay_lowres.gif # Overlay GIF at 50% of display resolution ### Survey answers (`metadata.json`) +Keys and values are determined by the `questions` section in `config/config.yaml`. With the default config: + ```json { "flow": "Turbulent | Laminar | Uncertain", @@ -112,20 +159,16 @@ video_overlay_lowres.gif # Overlay GIF at 50% of display resolution ### Clip format -Each clip is a ZIP archive containing a `left.mp4` video. The filename encodes the recording timestamp (e.g. `left_20230615T120000.zip`), which is used for sorting and daily filtering. +Each clip is a ZIP archive containing a `left.mp4` video. The filename encodes the recording timestamp (e.g. `left_20230615T120000.zip`). ### Frame loading -Up to 100 frames are extracted from the video and scaled so the longest side is 480 px. This display-resolution copy is what the annotator works on; the full-resolution dimensions are remembered separately so the saved mask is upscaled back to the original size on export. +Up to `max_frames` frames are extracted from the video and scaled so the longest side is `display_max` px. This display-resolution copy is what the annotator works on; the full-resolution dimensions are remembered separately so the saved mask is upscaled back to the original size on export. ### Mask drawing The mask is a binary NumPy array matching the display frame size. Each brush stroke stamps a filled circle of the selected radius, setting pixels to 1 (draw) or 0 (erase). The history stack stores a copy of the mask before each stroke, enabling unlimited undo. On save the mask is resized to the original video resolution with nearest-neighbour interpolation and written as an 8-bit PNG (0 or 255). -### Clip selection - -`ClipSelector` scans the data directory, builds a sorted DataFrame of clips ordered by timestamp, and filters out clips that already have a `mask.png`. In daily mode it groups the remaining clips by calendar day and picks the one whose recording time is closest to the target hour; on **Next**, it moves to the first clip of the following day. - ### Resuming When a clip is loaded that already has a saved `mask.png` and `metadata.json`, the mask is restored at display resolution and the survey answers are pre-filled. **Reload Saved** lets you revert to the last save at any point during the current session. @@ -133,15 +176,20 @@ When a clip is loaded that already has a saved `mask.png` and `metadata.json`, t ## Repository structure ``` +config/ + config.yaml # Your local config (git-ignored, copy from example) + config.example.yaml # Example config to copy and edit + clips.txt # Your clip list (git-ignored, copy from example) + clips.example.txt # Example clip list src/river_annotation_tool/ - annotation_script.py # Entry point — argument parsing and app launch - annotator.py # Main QMainWindow — orchestrates all components - clip_selector.py # Clip-picking logic (daily mode, time filtering) - mask_canvas.py # Drawing widget — brush, undo, erase, mouse events - video_loader.py # ZIP extraction and frame resizing - config.py # Config constants, question definitions, defaults - __init__.py # Package version -pyproject.toml # Project metadata and dependencies + annotation_script.py # Entry point — argument parsing and app launch + annotator.py # Main QMainWindow — orchestrates all components + clip_selector.py # Reads the clip list and picks the next clip + mask_canvas.py # Drawing widget — brush, undo, erase, mouse events + video_loader.py # ZIP extraction and frame resizing + config.py # AppConfig dataclass and YAML loader + __init__.py # Package version +pyproject.toml # Project metadata and dependencies ``` ## Development diff --git a/config/clips.example.txt b/config/clips.example.txt new file mode 100644 index 0000000..3721792 --- /dev/null +++ b/config/clips.example.txt @@ -0,0 +1,10 @@ +# List the clip filenames (without path) to annotate, one per line. +# Lines starting with # are ignored. Order is preserved. +GRAMMONT_2025-11-17T11_31_38.546953+00_00.zip +GRAMMONT_2025-11-17T12_31_39.650554+00_00.zip +GRAMMONT_2025-11-17T15_32_07.184007+00_00.zip +GRAMMONT_2025-11-17T15_32_07.184007+00_00.zip +GRAMMONT_2025-11-17T15_47_10.070449+00_00.zip +GRAMMONT_2025-11-22T10_47_02.705611+00_00.zip +GRAMMONT_2025-11-22T14_47_00.096714+00_00.zip +GRAMMONT_2025-11-22T15_32_01.015469+00_00.zip \ No newline at end of file diff --git a/config/config.example.yaml b/config/config.example.yaml new file mode 100644 index 0000000..b34a59e --- /dev/null +++ b/config/config.example.yaml @@ -0,0 +1,43 @@ +display_max: 720 +fps_fallback: 25 +max_frames: 100 + +data_dir: data/filtered_data +out_dir: data/annotation_results +clips_file: config/clips.txt + +questions: + - section: River + items: + - key: flow + label: Flow Regime + options: [Turbulent, Laminar, Uncertain] + default: Laminar + - key: shadows + label: Strong Shadows + options: [Yes, No, Uncertain] + default: No + - key: artifacts + label: Artifacts on River + options: [Yes, No, Uncertain] + default: No + - section: Scene + items: + - key: lighting + label: Lighting + options: [Day, Night, Uncertain] + default: Day + - key: exposure + label: Exposure + options: [Overexposed, Underexposed, Both, Normal, Uncertain] + default: Normal + - section: Weather + items: + - key: snowing + label: Snowing + options: [Yes, No, Uncertain] + default: No + - key: snow_on_ground + label: Snow on Ground + options: [Yes, No, Uncertain] + default: No diff --git a/pyproject.toml b/pyproject.toml index 4153de8..162c719 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "matplotlib-inline>=0.2.1", "pillow>=12.2.0", "pyside6>=6.11.0", + "pyyaml>=6.0", ] dynamic = ["version"] diff --git a/src/river_annotation_tool/annotation_script.py b/src/river_annotation_tool/annotation_script.py index 06ac0fb..4d43146 100644 --- a/src/river_annotation_tool/annotation_script.py +++ b/src/river_annotation_tool/annotation_script.py @@ -9,56 +9,44 @@ use("QtAgg") from PySide6.QtWidgets import QApplication from .annotator import Annotator +from .config import load_config def parse_args(): parser = argparse.ArgumentParser() parser.add_argument( - "--data", - default=r"C:\Users\sieverin\HydroScan\Code\river-annotation-tool\data\filtered_data", + "--config", + default="config/config.yaml", + help="Path to config YAML file (default: config/config.yaml)", ) - parser.add_argument("--out", default="data/annotation_results/") + parser.add_argument("--data", default=None, help="Override data_dir from config") + parser.add_argument("--out", default=None, help="Override out_dir from config") + parser.add_argument("--clips", default=None, help="Override clips_file from config") parser.add_argument( "--clip", default=None, help="Stem name of a specific clip to load (e.g. 'left_20230501')", ) - parser.add_argument( - "--time", - default=None, - help="Target time to filter clips by day (format: HH:MM, e.g. '14:30'). " - "Selects the closest clip to this time for each day.", - ) - parser.add_argument( - "--daily", - action="store_true", - help="Load only 1 clip per day at the specified time (requires --time).", - ) parser.add_argument( "--extras", action="store_true", help="Also save GIFs, frame PNG, overlay PNG, and mask_vis PNG alongside the mask.", ) - parser.add_argument( - "--skip-existing-day", - action="store_true", - help="In --daily mode, skip days that already have any annotated clip.", - ) return parser.parse_args() if __name__ == "__main__": args = parse_args() + cfg = load_config(Path(args.config)) + if args.data: + cfg.data_dir = args.data + if args.out: + cfg.out_dir = args.out + if args.clips: + cfg.clips_file = args.clips + app = QApplication([]) - win = Annotator( - Path(args.data), - Path(args.out), - clip=args.clip, - target_time=args.time, - daily=args.daily, - extras=args.extras, - skip_existing_day=args.skip_existing_day, - ) + win = Annotator(cfg, clip=args.clip, extras=args.extras) win.show() app.exec() diff --git a/src/river_annotation_tool/annotator.py b/src/river_annotation_tool/annotator.py index e33895b..cc3f6a6 100644 --- a/src/river_annotation_tool/annotator.py +++ b/src/river_annotation_tool/annotator.py @@ -18,7 +18,7 @@ from PySide6.QtWidgets import ( ) from .clip_selector import ClipSelector -from .config import DEFAULTS, QUESTIONS, Config +from .config import AppConfig from .mask_canvas import MaskCanvas from .video_loader import load_frames @@ -26,25 +26,20 @@ from .video_loader import load_frames class Annotator(QMainWindow): def __init__( self, - data_dir: Path, - out_dir: Path, + config: AppConfig, clip: str = None, - target_time: str = None, - daily: bool = False, extras: bool = False, - skip_existing_day: bool = False, ): super().__init__() - self.out_dir = Path(out_dir) + self.cfg = config + self.out_dir = Path(config.out_dir) self.extras = extras self.selector = ClipSelector( - data_dir=Path(data_dir), + data_dir=Path(config.data_dir), out_dir=self.out_dir, - target_time=target_time, - daily=daily, - skip_existing_day=skip_existing_day, + clips_file=Path(config.clips_file), ) self.setWindowTitle("River Annotator") @@ -53,10 +48,13 @@ class Annotator(QMainWindow): self._init_timer() # ── clip loading ─────────────────────────────────────────────── - def _load_clip(self, specific: str = None, next_day: bool = False): - self.filename = self.selector.next(specific=specific, next_day=next_day) + def _load_clip(self, specific: str = None): + self.filename = self.selector.next(specific=specific) self.frames, self.fps, self.dh, self.dw, self.h, self.w = load_frames( - self.filename, Config.MAX_FRAMES + self.filename, + self.cfg.max_frames, + self.cfg.display_max, + self.cfg.fps_fallback, ) self._pending_answers = self._read_saved_answers() @@ -135,23 +133,22 @@ class Annotator(QMainWindow): def _build_question_panel(self) -> QVBoxLayout: vbox = QVBoxLayout() - for section, qs in QUESTIONS: + for section, qs in self.cfg.get_questions(): group = QGroupBox(section) gvbox = QVBoxLayout() - for key, label, options in qs: + for key, label, options, default in qs: gvbox.addWidget(QLabel(label)) btn_group = QButtonGroup(self) row = QHBoxLayout() buttons = [] - default_value = DEFAULTS.get(key) for opt in options: btn = QRadioButton(opt) btn_group.addButton(btn) row.addWidget(btn) buttons.append(btn) - if default_value == opt: + if default == opt: btn.setChecked(True) - if default_value is None and buttons: + if default is None and buttons: buttons[-1].setChecked(True) self.q_widgets[key] = (btn_group, buttons, options) gvbox.addLayout(row) @@ -246,8 +243,8 @@ class Annotator(QMainWindow): if answers: self._set_answers(answers) - def _advance_clip(self, next_day: bool): - self._load_clip(next_day=next_day) + def _advance_clip(self): + self._load_clip() self.frame_i = 0 self.mc.load_clip( self.frames, @@ -262,7 +259,7 @@ class Annotator(QMainWindow): def next_clip(self): self.save() - self._advance_clip(next_day=self.selector.daily) + self._advance_clip() def skip_clip(self): - self._advance_clip(next_day=self.selector.daily) + self._advance_clip() diff --git a/src/river_annotation_tool/clip_selector.py b/src/river_annotation_tool/clip_selector.py index 8c4e8c4..775d593 100644 --- a/src/river_annotation_tool/clip_selector.py +++ b/src/river_annotation_tool/clip_selector.py @@ -1,47 +1,28 @@ -import datetime from pathlib import Path -import pandas as pd - class ClipSelector: - """Picks which clip to annotate next, handling daily/time-based filtering.""" - - def __init__( - self, - data_dir: Path, - out_dir: Path, - target_time: str = None, - daily: bool = False, - skip_existing_day: bool = False, - ): + def __init__(self, data_dir: Path, out_dir: Path, clips_file: Path): self.data_dir = data_dir self.out_dir = out_dir - self.target_time = target_time - self.daily = daily - self.skip_existing_day = skip_existing_day - self.current_date = None + self.clips = self._load_clips(clips_file) + self.index = 0 - self.df = self._load_dataset() - - def _load_dataset(self) -> pd.DataFrame: - files = list(self.data_dir.glob("*.zip")) - if not files: - raise FileNotFoundError(f"No zip files in {self.data_dir}") - - df = pd.DataFrame({"filename": files}) - df["datetime"] = df["filename"].apply( - lambda x: pd.to_datetime(x.stem.split("_")[1], errors="coerce") - ) - return df.sort_values("datetime").reset_index(drop=True) + def _load_clips(self, clips_file: Path) -> list[Path]: + lines = clips_file.read_text().splitlines() + return [ + self.data_dir / name.strip() + for name in lines + if name.strip() and not name.strip().startswith("#") + ] def is_annotated(self, path: Path) -> bool: return (self.out_dir / path.stem / "mask.png").exists() - def next(self, specific: str = None, next_day: bool = False) -> Path: - if specific is not None: + def next(self, specific: str = None) -> Path: + if specific: return self._resolve_specific(specific) - return self._pick_next(next_day=next_day) + return self._pick_next() def _resolve_specific(self, specific: str) -> Path: matches = list(self.data_dir.glob(f"{specific}.zip")) @@ -52,57 +33,10 @@ class ClipSelector: raise FileNotFoundError(f"Clip '{specific}' not found in {self.data_dir}") return matches[0] - def _pick_next(self, next_day: bool = False) -> Path: - remaining = [f for f in self.df["filename"] if not self.is_annotated(f)] - if not remaining: - raise RuntimeError("No remaining clips to annotate") - - if not (self.target_time or self.daily): - filename = remaining[0] - dt = self.df[self.df["filename"] == filename]["datetime"].values[0] - self.current_date = pd.Timestamp(dt).date() - return filename - - return self._pick_by_time(remaining, next_day) - - def _pick_by_time(self, remaining: list, next_day: bool) -> Path: - if self.target_time: - target_hour, target_minute = map(int, self.target_time.split(":")) - else: - target_hour, target_minute = 12, 0 - target_seconds = target_hour * 3600 + target_minute * 60 - - remaining_datetimes = [ - self.df[self.df["filename"] == f]["datetime"].values[0] for f in remaining - ] - df_remaining = pd.DataFrame({"filename": remaining, "datetime": remaining_datetimes}) - df_remaining["date"] = df_remaining["datetime"].dt.date - - if self.daily and next_day and self.current_date is not None: - next_date = self.current_date + datetime.timedelta(days=1) - df_remaining = df_remaining[df_remaining["date"] >= next_date] - - if self.daily and self.skip_existing_day: - annotated_dates = set() - for f in self.df["filename"]: - if self.is_annotated(f): - dt = self.df[self.df["filename"] == f]["datetime"].values[0] - annotated_dates.add(pd.Timestamp(dt).date()) - df_remaining = df_remaining[~df_remaining["date"].isin(annotated_dates)] - - if df_remaining.empty: - raise RuntimeError("No remaining clips to annotate") - - closest_clips, dates_list = [], [] - for date, group in df_remaining.groupby("date"): - group = group.copy() - group["time_seconds"] = ( - group["datetime"].dt.hour * 3600 + group["datetime"].dt.minute * 60 - ) - group["time_diff"] = (group["time_seconds"] - target_seconds).abs() - closest = group.loc[group["time_diff"].idxmin()] - closest_clips.append(closest["filename"]) - dates_list.append(date) - - self.current_date = dates_list[0] - return closest_clips[0] + def _pick_next(self) -> Path: + while self.index < len(self.clips): + clip = self.clips[self.index] + self.index += 1 + if not self.is_annotated(clip): + return clip + raise RuntimeError("No remaining clips to annotate") diff --git a/src/river_annotation_tool/config.py b/src/river_annotation_tool/config.py index d88bb7c..dddcc28 100644 --- a/src/river_annotation_tool/config.py +++ b/src/river_annotation_tool/config.py @@ -1,44 +1,38 @@ -class Config: - DISPLAY_MAX = 480 - FPS_FALLBACK = 25 - MAX_FRAMES = 100 +from dataclasses import dataclass, field +from pathlib import Path + +import yaml -QUESTIONS = [ - ( - "River", - [ - ("flow", "Flow Regime", ["Turbulent", "Laminar", "Uncertain"]), - ("shadows", "Strong Shadows", ["Yes", "No", "Uncertain"]), - ("artifacts", "Artifacts on River", ["Yes", "No", "Uncertain"]), - ], - ), - ( - "Scene", - [ - ("lighting", "Lighting", ["Day", "Night", "Uncertain"]), +@dataclass +class AppConfig: + display_max: int = 480 + fps_fallback: int = 25 + max_frames: int = 100 + data_dir: str = "data/clips" + out_dir: str = "data/annotation_results" + clips_file: str = "config/clips.txt" + questions: list = field(default_factory=list) + + def get_questions(self): + return [ ( - "exposure", - "Exposure", - ["Overexposed", "Underexposed", "Both", "Normal", "Uncertain"], - ), - ], - ), - ( - "Weather", - [ - ("snowing", "Snowing", ["Yes", "No", "Uncertain"]), - ("snow_on_ground", "Snow on Ground", ["Yes", "No", "Uncertain"]), - ], - ), -] + s["section"], + [ + ( + item["key"], + item["label"], + [str(o) for o in item["options"]], + str(item["default"]) if item.get("default") is not None else None, + ) + for item in s["items"] + ], + ) + for s in self.questions + ] -DEFAULTS = { - "flow": "Laminar", - "shadows": "No", - "artifacts": "No", - "lighting": "Day", - "exposure": "Normal", - "snowing": "No", - "snow_on_ground": "No", -} + +def load_config(path: Path) -> AppConfig: + with open(path) as f: + data = yaml.safe_load(f) + return AppConfig(**data) diff --git a/src/river_annotation_tool/video_loader.py b/src/river_annotation_tool/video_loader.py index abbc33f..75b437e 100644 --- a/src/river_annotation_tool/video_loader.py +++ b/src/river_annotation_tool/video_loader.py @@ -5,10 +5,8 @@ from pathlib import Path import cv2 -from .config import Config - -def load_frames(zip_path: Path, max_frames: int): +def load_frames(zip_path: Path, max_frames: int, display_max: int, fps_fallback: int): video_bytes = zipfile.ZipFile(zip_path).read("left.mp4") with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f: @@ -16,14 +14,20 @@ def load_frames(zip_path: Path, max_frames: int): tmp_path = f.name cap = cv2.VideoCapture(tmp_path) - fps = cap.get(cv2.CAP_PROP_FPS) or Config.FPS_FALLBACK + fps = cap.get(cv2.CAP_PROP_FPS) or fps_fallback + + total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + step = max(1, total // max_frames) frames = [] + i = 0 while len(frames) < max_frames: + cap.set(cv2.CAP_PROP_POS_FRAMES, i) ok, frame = cap.read() if not ok: break frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) + i += step cap.release() os.unlink(tmp_path) @@ -32,7 +36,7 @@ def load_frames(zip_path: Path, max_frames: int): raise RuntimeError(f"No frames found in {zip_path}") h, w = frames[0].shape[:2] - scale = Config.DISPLAY_MAX / max(h, w) + scale = display_max / max(h, w) dh, dw = int(h * scale), int(w * scale) frames = [cv2.resize(f, (dw, dh)) for f in frames] diff --git a/uv.lock b/uv.lock index f5ece01..0ef712f 100644 --- a/uv.lock +++ b/uv.lock @@ -1336,6 +1336,7 @@ dependencies = [ { name = "pandas" }, { name = "pillow" }, { name = "pyside6" }, + { name = "pyyaml" }, ] [package.dev-dependencies] @@ -1353,6 +1354,7 @@ requires-dist = [ { name = "pandas", specifier = ">=2.3.3" }, { name = "pillow", specifier = ">=12.2.0" }, { name = "pyside6", specifier = ">=6.11.0" }, + { name = "pyyaml", specifier = ">=6.0" }, ] [package.metadata.requires-dev] From 263be51767b3cd4e427a4cdda1c38d56ee094af0 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 13:47:17 +0200 Subject: [PATCH 03/23] Removed old notebook --- notebooks/annotation_segmentation.ipynb | 100 ------------------------ 1 file changed, 100 deletions(-) delete mode 100644 notebooks/annotation_segmentation.ipynb diff --git a/notebooks/annotation_segmentation.ipynb b/notebooks/annotation_segmentation.ipynb deleted file mode 100644 index bbb4814..0000000 --- a/notebooks/annotation_segmentation.ipynb +++ /dev/null @@ -1,100 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "fe0521db", - "metadata": {}, - "outputs": [], - "source": [ - "from pathlib import Path\n", - "import json\n", - "import numpy as np\n", - "import matplotlib.pyplot as plt\n", - "from PIL import Image\n", - "from IPython.display import display, Image as IPImage" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c6d7ebbf", - "metadata": {}, - "outputs": [], - "source": [ - "out_dir = Path(\"../data/annotation_results/\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "75efc15a", - "metadata": {}, - "outputs": [], - "source": [ - "def show_result(folder):\n", - " folder = Path(folder)\n", - "\n", - " with open(folder / \"metadata.json\") as f:\n", - " metadata = json.load(f)\n", - "\n", - " frame = np.array(Image.open(folder / \"frame.png\"))\n", - " mask = np.array(Image.open(folder / \"mask_vis.png\"))\n", - " overlay = np.array(Image.open(folder / \"overlay.png\"))\n", - "\n", - " title = \" | \".join(f\"{k}: {v}\" for k, v in metadata.items())\n", - " fig, axs = plt.subplots(1, 3, figsize=(15, 5))\n", - " axs[0].imshow(frame)\n", - " axs[0].set_title(\"Frame\")\n", - " axs[0].axis(\"off\")\n", - " axs[1].imshow(mask, cmap=\"gray\")\n", - " axs[1].set_title(\"Mask\")\n", - " axs[1].axis(\"off\")\n", - " axs[2].imshow(overlay)\n", - " axs[2].set_title(\"Overlay\")\n", - " axs[2].axis(\"off\")\n", - " plt.suptitle(f\"{folder.name}\\n{title}\", fontsize=9)\n", - " plt.tight_layout()\n", - " plt.show()\n", - "\n", - " for gif_name in [\"video_original_lowres.gif\", \"video_overlay_lowres.gif\"]:\n", - " gif_path = folder / gif_name\n", - " if gif_path.exists():\n", - " display(IPImage(filename=str(gif_path)))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "02ff1ae9", - "metadata": {}, - "outputs": [], - "source": [ - "for folder in sorted(out_dir.iterdir()):\n", - " if folder.is_dir() and (folder / \"metadata.json\").exists():\n", - " show_result(folder)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "river-annotation-tool", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.13" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} From cc115f6f3fbdd13e4a7fe1e77d6c3b735be761f6 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 13:52:17 +0200 Subject: [PATCH 04/23] Stop tracking gitignored files and remove them from remote Remove .claude/, .github/workflows/, data/, and notebooks/ from git tracking so they no longer appear in the remote repository. Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 8 -------- .github/workflows/tests.yml | 38 ------------------------------------- .gitignore | 5 ++++- data/.gitkeep | 0 notebooks/.gitkeep | 0 5 files changed, 4 insertions(+), 47 deletions(-) delete mode 100644 .claude/settings.local.json delete mode 100644 .github/workflows/tests.yml delete mode 100644 data/.gitkeep delete mode 100644 notebooks/.gitkeep diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index e52b9a5..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(Get-ChildItem -Recurse -Depth 2)", - "Bash(Select-Object FullName)" - ] - } -} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index aa9a7e4..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Run tests - -on: [push] - -jobs: - - test: - - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12"] - env: - PIP_ROOT_USER_ACTION: ignore - UV_LINK_MODE: copy - - steps: - - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install uv - uv sync --locked - - - name: Check format with ruff - run: | - uv run ruff format --check - - - name: Check code linting with ruff - run: | - uv run ruff check diff --git a/.gitignore b/.gitignore index 414b3d7..91d7631 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,7 @@ data/** # User-specific config (copy from *.example.* files) config/config.yaml -config/clips.txt \ No newline at end of file +config/clips.txt + +# Notebooks +notebooks/ diff --git a/data/.gitkeep b/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/notebooks/.gitkeep b/notebooks/.gitkeep deleted file mode 100644 index e69de29..0000000 From 6a0259c6cf9cac3e0fcfa3114be3c3bd0b3a6f82 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 13:53:06 +0200 Subject: [PATCH 05/23] Update gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 91d7631..a1f9796 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ *.pyc .ipynb_checkpoints/ *.egg-info/ +.claude/ +.github/ # IDE settings .vscode/ From b4daa283541431cfe5b28493f49626055236bf70 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 14:00:11 +0200 Subject: [PATCH 06/23] All constants are in config --- README.md | 18 +++++++++++- config/config.example.yaml | 14 +++++++++ .../annotation_script.py | 4 +-- src/river_annotation_tool/annotator.py | 29 ++++++++++++------- src/river_annotation_tool/clip_selector.py | 15 ++++++++-- src/river_annotation_tool/config.py | 26 +++++++++++++++-- src/river_annotation_tool/video_loader.py | 13 +++++++-- 7 files changed, 96 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index bb3b846..edabfa5 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,20 @@ python -m river_annotation_tool.annotation_script --clip left_20230615T120000 All settings live in `config/config.yaml`. Copy `config/config.example.yaml` to get started. ```yaml +filenames: + video_in_zip: left.mp4 # video filename inside each ZIP archive + video_tmp_suffix: .mp4 # suffix for the extraction temp file + zip_extension: .zip # extension used when resolving clip names + mask: mask.png # saved water mask + metadata: metadata.json # saved survey answers + frame: frame.png # middle frame snapshot + overlay: overlay.png # frame with mask blended in green + mask_vis: mask_vis.png # greyscale mask PNG (--extras only) + gif_original_hires: video_original_hires.gif + gif_original_lowres: video_original_lowres.gif + gif_overlay_hires: video_overlay_hires.gif + gif_overlay_lowres: video_overlay_lowres.gif + display_max: 720 # longest side in pixels for display fps_fallback: 25 # FPS to use if the video header is missing max_frames: 100 # max frames to extract per clip @@ -139,6 +153,8 @@ video_overlay_hires.gif # Overlay GIF at display resolution video_overlay_lowres.gif # Overlay GIF at 50% of display resolution ``` +All output filenames can be overridden via the `filenames:` section in `config/config.yaml`. + ### Survey answers (`metadata.json`) Keys and values are determined by the `questions` section in `config/config.yaml`. With the default config: @@ -159,7 +175,7 @@ Keys and values are determined by the `questions` section in `config/config.yaml ### Clip format -Each clip is a ZIP archive containing a `left.mp4` video. The filename encodes the recording timestamp (e.g. `left_20230615T120000.zip`). +Each clip is a ZIP archive containing a video file (default `left.mp4`, configurable via `filenames.video_in_zip`). The filename encodes the recording timestamp (e.g. `left_20230615T120000.zip`). ### Frame loading diff --git a/config/config.example.yaml b/config/config.example.yaml index b34a59e..18c3c4c 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -1,3 +1,17 @@ +filenames: + video_in_zip: left.mp4 + video_tmp_suffix: .mp4 + zip_extension: .zip + mask: mask.png + metadata: metadata.json + frame: frame.png + overlay: overlay.png + mask_vis: mask_vis.png + gif_original_hires: video_original_hires.gif + gif_original_lowres: video_original_lowres.gif + gif_overlay_hires: video_overlay_hires.gif + gif_overlay_lowres: video_overlay_lowres.gif + display_max: 720 fps_fallback: 25 max_frames: 100 diff --git a/src/river_annotation_tool/annotation_script.py b/src/river_annotation_tool/annotation_script.py index 4d43146..72b3ab7 100644 --- a/src/river_annotation_tool/annotation_script.py +++ b/src/river_annotation_tool/annotation_script.py @@ -23,9 +23,7 @@ def parse_args(): parser.add_argument("--out", default=None, help="Override out_dir from config") parser.add_argument("--clips", default=None, help="Override clips_file from config") parser.add_argument( - "--clip", - default=None, - help="Stem name of a specific clip to load (e.g. 'left_20230501')", + "--clip", default=None, help="Stem name of a specific clip to load" ) parser.add_argument( "--extras", diff --git a/src/river_annotation_tool/annotator.py b/src/river_annotation_tool/annotator.py index cc3f6a6..921920e 100644 --- a/src/river_annotation_tool/annotator.py +++ b/src/river_annotation_tool/annotator.py @@ -40,6 +40,8 @@ class Annotator(QMainWindow): data_dir=Path(config.data_dir), out_dir=self.out_dir, clips_file=Path(config.clips_file), + mask_filename=config.filenames.mask, + zip_extension=config.filenames.zip_extension, ) self.setWindowTitle("River Annotator") @@ -55,11 +57,13 @@ class Annotator(QMainWindow): self.cfg.max_frames, self.cfg.display_max, self.cfg.fps_fallback, + self.cfg.filenames.video_in_zip, + self.cfg.filenames.video_tmp_suffix, ) self._pending_answers = self._read_saved_answers() def _read_saved_mask(self): - mask_path = self.out_dir / self.filename.stem / "mask.png" + mask_path = self.out_dir / self.filename.stem / self.cfg.filenames.mask if not mask_path.exists(): return None mask_full = np.array(Image.open(mask_path).convert("L")) @@ -70,7 +74,7 @@ class Annotator(QMainWindow): ) def _read_saved_answers(self): - meta_path = self.out_dir / self.filename.stem / "metadata.json" + meta_path = self.out_dir / self.filename.stem / self.cfg.filenames.metadata if not meta_path.exists(): return None with open(meta_path) as f: @@ -214,23 +218,26 @@ class Annotator(QMainWindow): (self.w, self.h), interpolation=cv2.INTER_NEAREST, ) - Image.fromarray(mask_full * 255).save(out / "mask.png") + fn = self.cfg.filenames + Image.fromarray(mask_full * 255).save(out / fn.mask) - with open(out / "metadata.json", "w") as f: + with open(out / fn.metadata, "w") as f: json.dump(self.get_answers(), f, indent=2) mid = len(self.frames) // 2 frame = self.frames[mid] - Image.fromarray(frame).save(out / "frame.png") - Image.fromarray(self._make_overlay(frame)).save(out / "overlay.png") + Image.fromarray(frame).save(out / fn.frame) + Image.fromarray(self._make_overlay(frame)).save(out / fn.overlay) if self.extras: - Image.fromarray((self.mc.mask * 255).astype(np.uint8)).save(out / "mask_vis.png") + Image.fromarray((self.mc.mask * 255).astype(np.uint8)).save( + out / fn.mask_vis + ) overlay_frames = [self._make_overlay(f) for f in self.frames] - self._save_gif(self.frames, out / "video_original_hires.gif", scale=1.0) - self._save_gif(self.frames, out / "video_original_lowres.gif", scale=0.5) - self._save_gif(overlay_frames, out / "video_overlay_hires.gif", scale=1.0) - self._save_gif(overlay_frames, out / "video_overlay_lowres.gif", scale=0.5) + self._save_gif(self.frames, out / fn.gif_original_hires, scale=1.0) + self._save_gif(self.frames, out / fn.gif_original_lowres, scale=0.5) + self._save_gif(overlay_frames, out / fn.gif_overlay_hires, scale=1.0) + self._save_gif(overlay_frames, out / fn.gif_overlay_lowres, scale=0.5) print("Saved:", out) diff --git a/src/river_annotation_tool/clip_selector.py b/src/river_annotation_tool/clip_selector.py index 775d593..c7ef319 100644 --- a/src/river_annotation_tool/clip_selector.py +++ b/src/river_annotation_tool/clip_selector.py @@ -2,9 +2,18 @@ from pathlib import Path class ClipSelector: - def __init__(self, data_dir: Path, out_dir: Path, clips_file: Path): + def __init__( + self, + data_dir: Path, + out_dir: Path, + clips_file: Path, + mask_filename: str = "mask.png", + zip_extension: str = ".zip", + ): self.data_dir = data_dir self.out_dir = out_dir + self.mask_filename = mask_filename + self.zip_extension = zip_extension self.clips = self._load_clips(clips_file) self.index = 0 @@ -17,7 +26,7 @@ class ClipSelector: ] def is_annotated(self, path: Path) -> bool: - return (self.out_dir / path.stem / "mask.png").exists() + return (self.out_dir / path.stem / self.mask_filename).exists() def next(self, specific: str = None) -> Path: if specific: @@ -25,7 +34,7 @@ class ClipSelector: return self._pick_next() def _resolve_specific(self, specific: str) -> Path: - matches = list(self.data_dir.glob(f"{specific}.zip")) + matches = list(self.data_dir.glob(f"{specific}{self.zip_extension}")) if not matches: p = self.data_dir / specific matches = [p] if p.exists() else [] diff --git a/src/river_annotation_tool/config.py b/src/river_annotation_tool/config.py index dddcc28..46f1c59 100644 --- a/src/river_annotation_tool/config.py +++ b/src/river_annotation_tool/config.py @@ -4,6 +4,22 @@ from pathlib import Path import yaml +@dataclass +class FilenameConfig: + video_in_zip: str = "left.mp4" + video_tmp_suffix: str = ".mp4" + zip_extension: str = ".zip" + mask: str = "mask.png" + metadata: str = "metadata.json" + frame: str = "frame.png" + overlay: str = "overlay.png" + mask_vis: str = "mask_vis.png" + gif_original_hires: str = "video_original_hires.gif" + gif_original_lowres: str = "video_original_lowres.gif" + gif_overlay_hires: str = "video_overlay_hires.gif" + gif_overlay_lowres: str = "video_overlay_lowres.gif" + + @dataclass class AppConfig: display_max: int = 480 @@ -13,6 +29,7 @@ class AppConfig: out_dir: str = "data/annotation_results" clips_file: str = "config/clips.txt" questions: list = field(default_factory=list) + filenames: FilenameConfig = field(default_factory=FilenameConfig) def get_questions(self): return [ @@ -23,7 +40,9 @@ class AppConfig: item["key"], item["label"], [str(o) for o in item["options"]], - str(item["default"]) if item.get("default") is not None else None, + str(item["default"]) + if item.get("default") is not None + else None, ) for item in s["items"] ], @@ -35,4 +54,7 @@ class AppConfig: def load_config(path: Path) -> AppConfig: with open(path) as f: data = yaml.safe_load(f) - return AppConfig(**data) + fn_data = data.pop("filenames", {}) + cfg = AppConfig(**data) + cfg.filenames = FilenameConfig(**fn_data) + return cfg diff --git a/src/river_annotation_tool/video_loader.py b/src/river_annotation_tool/video_loader.py index 75b437e..59a07dc 100644 --- a/src/river_annotation_tool/video_loader.py +++ b/src/river_annotation_tool/video_loader.py @@ -6,10 +6,17 @@ from pathlib import Path import cv2 -def load_frames(zip_path: Path, max_frames: int, display_max: int, fps_fallback: int): - video_bytes = zipfile.ZipFile(zip_path).read("left.mp4") +def load_frames( + zip_path: Path, + max_frames: int, + display_max: int, + fps_fallback: int, + video_in_zip: str = "left.mp4", + video_tmp_suffix: str = ".mp4", +): + video_bytes = zipfile.ZipFile(zip_path).read(video_in_zip) - with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f: + with tempfile.NamedTemporaryFile(suffix=video_tmp_suffix, delete=False) as f: f.write(video_bytes) tmp_path = f.name From 4aa1e326819e30aa9dae6c7937d35326bd919f47 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 14:08:47 +0200 Subject: [PATCH 07/23] Add end-of-clips dialog and --no-skip flag Show a modal dialog when all clips have been processed and quit cleanly. Add --no-skip CLI flag to include already-annotated clips (default remains to skip them). Co-Authored-By: Claude Sonnet 4.6 --- README.md | 3 ++- .../annotation_script.py | 19 +++++++++++++++++-- src/river_annotation_tool/annotator.py | 15 ++++++++++++++- src/river_annotation_tool/clip_selector.py | 4 +++- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index edabfa5..be5e0e6 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ python -m river_annotation_tool.annotation_script | `--clips` | *(from config)* | Override `clips_file` from config | | `--clip` | *(first unannotated in list)* | Open a specific clip by stem name (e.g. `left_20230501`) | | `--extras` | off | Also save GIFs and extra PNGs (see Output section) | +| `--no-skip` | off | Show already-annotated clips instead of skipping them | ### Typical workflows @@ -109,7 +110,7 @@ Add, remove, or reorder questions directly in the YAML — the UI rebuilds autom ## Clip list file -`config/clips.txt` lists the clip filenames to annotate, one per line. Lines starting with `#` are ignored. Clips are processed in order; already-annotated clips (those with an existing `mask.png`) are skipped automatically. +`config/clips.txt` lists the clip filenames to annotate, one per line. Lines starting with `#` are ignored. Clips are processed in order; already-annotated clips (those with an existing `mask.png`) are skipped automatically. Pass `--no-skip` to include them. When the last clip is reached, a dialog appears and the app exits. ``` # Example clips.txt diff --git a/src/river_annotation_tool/annotation_script.py b/src/river_annotation_tool/annotation_script.py index 72b3ab7..0ac4864 100644 --- a/src/river_annotation_tool/annotation_script.py +++ b/src/river_annotation_tool/annotation_script.py @@ -1,4 +1,5 @@ import argparse +import sys from pathlib import Path from matplotlib import use @@ -6,7 +7,7 @@ from matplotlib import use use("QtAgg") -from PySide6.QtWidgets import QApplication +from PySide6.QtWidgets import QApplication, QMessageBox from .annotator import Annotator from .config import load_config @@ -30,6 +31,11 @@ def parse_args(): action="store_true", help="Also save GIFs, frame PNG, overlay PNG, and mask_vis PNG alongside the mask.", ) + parser.add_argument( + "--no-skip", + action="store_true", + help="Show already-annotated clips instead of skipping them.", + ) return parser.parse_args() @@ -45,6 +51,15 @@ if __name__ == "__main__": cfg.clips_file = args.clips app = QApplication([]) - win = Annotator(cfg, clip=args.clip, extras=args.extras) + try: + win = Annotator( + cfg, + clip=args.clip, + extras=args.extras, + skip_annotated=not args.no_skip, + ) + except RuntimeError as e: + QMessageBox.information(None, "No clips", str(e)) + sys.exit(0) win.show() app.exec() diff --git a/src/river_annotation_tool/annotator.py b/src/river_annotation_tool/annotator.py index 921920e..f484c9d 100644 --- a/src/river_annotation_tool/annotator.py +++ b/src/river_annotation_tool/annotator.py @@ -6,11 +6,13 @@ import numpy as np from PIL import Image from PySide6.QtCore import QTimer from PySide6.QtWidgets import ( + QApplication, QButtonGroup, QGroupBox, QHBoxLayout, QLabel, QMainWindow, + QMessageBox, QPushButton, QRadioButton, QVBoxLayout, @@ -29,6 +31,7 @@ class Annotator(QMainWindow): config: AppConfig, clip: str = None, extras: bool = False, + skip_annotated: bool = True, ): super().__init__() @@ -42,6 +45,7 @@ class Annotator(QMainWindow): clips_file=Path(config.clips_file), mask_filename=config.filenames.mask, zip_extension=config.filenames.zip_extension, + skip_annotated=skip_annotated, ) self.setWindowTitle("River Annotator") @@ -251,7 +255,16 @@ class Annotator(QMainWindow): self._set_answers(answers) def _advance_clip(self): - self._load_clip() + 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.frame_i = 0 self.mc.load_clip( self.frames, diff --git a/src/river_annotation_tool/clip_selector.py b/src/river_annotation_tool/clip_selector.py index c7ef319..02b34ae 100644 --- a/src/river_annotation_tool/clip_selector.py +++ b/src/river_annotation_tool/clip_selector.py @@ -9,11 +9,13 @@ class ClipSelector: clips_file: Path, mask_filename: str = "mask.png", zip_extension: str = ".zip", + skip_annotated: bool = True, ): self.data_dir = data_dir self.out_dir = out_dir self.mask_filename = mask_filename self.zip_extension = zip_extension + self.skip_annotated = skip_annotated self.clips = self._load_clips(clips_file) self.index = 0 @@ -46,6 +48,6 @@ class ClipSelector: while self.index < len(self.clips): clip = self.clips[self.index] self.index += 1 - if not self.is_annotated(clip): + if not self.skip_annotated or not self.is_annotated(clip): return clip raise RuntimeError("No remaining clips to annotate") From 5b6efc71580cc4791ff62848fba7d8ecee59fed0 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 14:17:45 +0200 Subject: [PATCH 08/23] 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() From a139a2e2bd231d18faa1021759077e98463fe6e8 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 14:22:58 +0200 Subject: [PATCH 09/23] =?UTF-8?q?Add=20redo,=20undo=C3=9710,=20brush=20pre?= =?UTF-8?q?view,=20hide/show=20mask;=20drop=20Reload=20Saved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mask_canvas.py: - Redo stack: new strokes clear it, undo pushes onto it, redo pops from it. - undo10(): undoes up to 10 steps in one call with a single redraw. - Brush circle preview: white Circle patch tracks mouse position and shows current brush radius; hidden when cursor leaves the axes. - toggle_mask() / btn_mask: hides or shows the green mask overlay without affecting the underlying mask data. annotator.py: - Removed Reload Saved button and reload_saved() — clip already loads its saved state on navigation, making the button redundant. - Added Undo×10 and Redo buttons wired to mc.undo10 / mc.redo. - Added Hide Mask button (mc.btn_mask) to the toolbar row. Co-Authored-By: Claude Sonnet 4.6 --- src/river_annotation_tool/annotator.py | 24 ++++----- src/river_annotation_tool/mask_canvas.py | 63 ++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/river_annotation_tool/annotator.py b/src/river_annotation_tool/annotator.py index 508dbbb..1ae2d27 100644 --- a/src/river_annotation_tool/annotator.py +++ b/src/river_annotation_tool/annotator.py @@ -111,14 +111,22 @@ class Annotator(QMainWindow): btn_skip = QPushButton("Skip") btn_clear = QPushButton("Clear") btn_undo = QPushButton("Undo") - btn_reload = QPushButton("Reload Saved") + btn_undo10 = QPushButton("Undo×10") + btn_redo = QPushButton("Redo") row1 = QHBoxLayout() for b in [self.btn_prev, btn_next, btn_skip]: row1.addWidget(b) row2 = QHBoxLayout() - for b in [btn_clear, self.mc.btn_erase, btn_undo, btn_reload]: + for b in [ + btn_clear, + self.mc.btn_erase, + btn_undo, + btn_undo10, + btn_redo, + self.mc.btn_mask, + ]: row2.addWidget(b) row2.addWidget(QLabel("Brush")) row2.addWidget(self.mc.brush_slider) @@ -146,7 +154,8 @@ class Annotator(QMainWindow): btn_skip.clicked.connect(self.skip_clip) btn_clear.clicked.connect(self.mc.clear) btn_undo.clicked.connect(self.mc.undo) - btn_reload.clicked.connect(self.reload_saved) + btn_undo10.clicked.connect(self.mc.undo10) + btn_redo.clicked.connect(self.mc.redo) if self._pending_answers: self._set_answers(self._pending_answers) @@ -258,15 +267,6 @@ class Annotator(QMainWindow): print("Saved:", out) - def reload_saved(self): - mask = self._read_saved_mask() - if mask is None: - return - self.mc.reset(mask) - answers = self._read_saved_answers() - if answers: - self._set_answers(answers) - def _switch_ui_to_clip(self): self.frame_i = 0 self.mc.load_clip( diff --git a/src/river_annotation_tool/mask_canvas.py b/src/river_annotation_tool/mask_canvas.py index e98d37b..357b77d 100644 --- a/src/river_annotation_tool/mask_canvas.py +++ b/src/river_annotation_tool/mask_canvas.py @@ -1,12 +1,13 @@ import numpy as np from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure +from matplotlib.patches import Circle from PySide6.QtCore import Qt from PySide6.QtWidgets import QPushButton, QSlider class MaskCanvas: - """Matplotlib canvas with brush-based mask drawing, undo, and erase.""" + """Matplotlib canvas with brush-based mask drawing, undo/redo, and erase.""" def __init__(self, frames, dh: int, dw: int): self.dh = dh @@ -14,8 +15,10 @@ class MaskCanvas: self.mask = np.zeros((dh, dw), dtype=np.uint8) self.history: list[np.ndarray] = [] + self.redo_stack: list[np.ndarray] = [] self.erase_mode = False self.drawing = False + self.mask_visible = True self._build_figure(frames) self._build_controls() @@ -29,9 +32,14 @@ class MaskCanvas: self.img_artist = self.ax.imshow(frames[0]) self.mask_artist = self.ax.imshow(np.zeros((self.dh, self.dw, 4))) self.title_text = self.ax.set_title("", fontsize=10, pad=4) + self.brush_circle = Circle( + (0, 0), radius=5, fill=False, color="white", linewidth=1.5, visible=False + ) + self.ax.add_patch(self.brush_circle) 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) @@ -40,7 +48,9 @@ class MaskCanvas: self.canvas.mpl_connect("button_press_event", self._on_press) self.canvas.mpl_connect("motion_notify_event", self._on_move) self.canvas.mpl_connect("button_release_event", self._on_release) + 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) # ── clip transition ──────────────────────────────────────────── def load_clip(self, frames, dh: int, dw: int, mask=None, title: str = ""): @@ -48,6 +58,7 @@ class MaskCanvas: self.dw = dw 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.set_title(title) self.redraw() @@ -62,14 +73,20 @@ class MaskCanvas: # ── mask ops ─────────────────────────────────────────────────── def reset(self, mask=None): - self.mask = mask if mask is not None else np.zeros((self.dh, self.dw), dtype=np.uint8) + self.mask = ( + mask if mask is not None else np.zeros((self.dh, self.dw), dtype=np.uint8) + ) self.history = [] + self.redo_stack = [] self.redraw() def redraw(self): - rgba = np.zeros((self.dh, self.dw, 4)) - rgba[..., 1] = self.mask * 0.7 - rgba[..., 3] = self.mask * 0.4 + if self.mask_visible: + rgba = np.zeros((self.dh, self.dw, 4)) + rgba[..., 1] = self.mask * 0.7 + rgba[..., 3] = self.mask * 0.4 + else: + rgba = np.zeros((self.dh, self.dw, 4)) self.mask_artist.set_data(rgba) self.canvas.draw_idle() @@ -79,17 +96,38 @@ class MaskCanvas: def undo(self): if self.history: + self.redo_stack.append(self.mask.copy()) self.mask = self.history.pop() self.redraw() + def undo10(self): + for _ in range(10): + if not self.history: + break + self.redo_stack.append(self.mask.copy()) + self.mask = self.history.pop() + self.redraw() + + def redo(self): + if self.redo_stack: + self.history.append(self.mask.copy()) + self.mask = self.redo_stack.pop() + self.redraw() + def toggle_erase(self): self.erase_mode = not self.erase_mode self.btn_erase.setText("Eraser ON" if self.erase_mode else "Eraser") + 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") + self.redraw() + def stamp(self, x, y): if x is None or y is None: return self.history.append(self.mask.copy()) + self.redo_stack.clear() r = self.brush_slider.value() ix, iy = int(x), int(y) y0, y1 = max(0, iy - r), min(self.dh, iy + r + 1) @@ -99,6 +137,20 @@ class MaskCanvas: self.mask[y0:y1, x0:x1][circle] = 0 if self.erase_mode else 1 self.redraw() + # ── brush preview ────────────────────────────────────────────── + def _update_brush_preview(self, e): + if e.inaxes == self.ax and e.xdata is not None: + self.brush_circle.center = (e.xdata, e.ydata) + self.brush_circle.set_radius(self.brush_slider.value()) + self.brush_circle.set_visible(True) + else: + self.brush_circle.set_visible(False) + self.canvas.draw_idle() + + def _on_axes_leave(self, _): + self.brush_circle.set_visible(False) + self.canvas.draw_idle() + # ── mouse events ─────────────────────────────────────────────── def _on_press(self, e): if e.xdata is None: @@ -107,6 +159,7 @@ class MaskCanvas: self.stamp(e.xdata, e.ydata) def _on_move(self, e): + self._update_brush_preview(e) if self.drawing: self.stamp(e.xdata, e.ydata) From d0f7cc64fceec95fd94ce8d07ff925dad18e497f Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 14:24:05 +0200 Subject: [PATCH 10/23] Update README controls table for latest UI changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds brush preview, Undo×10, Redo, and Hide/Show Mask. Removes Reload Saved (button was dropped). Co-Authored-By: Claude Sonnet 4.6 --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e3cae21..6e9bf3d 100644 --- a/README.md +++ b/README.md @@ -128,13 +128,16 @@ The window shows the video on the left (auto-playing) and the survey panel on th |---|---| | Draw water mask | Click and drag on the video | | Erase mask | Toggle **Eraser** button, then drag | +| Brush preview | A white circle follows the cursor showing the current brush size | | 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 | | 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 From d13ad1743ad420765f5ae49ed1caa7e61ac58d22 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 14:41:10 +0200 Subject: [PATCH 11/23] Add image adjustment sliders, mask alpha, Load Prev Mask, and button state colours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 27 +++++- src/river_annotation_tool/annotator.py | 68 +++++++++++++-- src/river_annotation_tool/mask_canvas.py | 101 +++++++++++++++++++++-- 3 files changed, 181 insertions(+), 15 deletions(-) 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): From 47432cec4f3677bd3387436e0000577dcbac3d65 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 15:02:46 +0200 Subject: [PATCH 12/23] Add polygon drawing and click-to-fill tools Introduce two new drawing modes alongside the existing brush: - Polygon mode: left-click to place vertices connected by lines; right-click removes the last vertex; clicking near the first vertex (red dot) when >= 3 points are placed closes the shape (bold cyan outline). Multiple shapes can coexist as canvas overlays. Cancel Current Poly discards the in-progress polygon; Del Shape removes the last completed shape. - Fill mode: left-click inside any closed polygon to rasterise it onto the mask. Selects the innermost shape containing the click (smallest area via cv2.contourArea). Polygons whose centroid lies inside the target are punched out as holes. Each fill is a single undoable step in the mask history. Also renames the Alpha slider label to Mask Alpha and removes the stale Reload Saved reference from the README. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 65 +++++-- src/river_annotation_tool/annotator.py | 13 +- src/river_annotation_tool/mask_canvas.py | 207 ++++++++++++++++++++++- 3 files changed, 267 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 4e0a9a5..6386027 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ python -m river_annotation_tool.annotation_script | `--data` | *(from config)* | Override `data_dir` from config | | `--out` | *(from config)* | Override `out_dir` from config | | `--clips` | *(from config)* | Override `clips_file` from config | -| `--clip` | *(first unannotated in list)* | Open a specific clip by stem name (e.g. `left_20230501`) | +| `--clip` | *(first unannotated in list)* | Open a specific clip by stem name | | `--extras` | off | Also save GIFs and extra PNGs (see Output section) | | `--no-skip` | off | Show already-annotated clips instead of skipping them | @@ -124,21 +124,58 @@ 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 +### Tool modes + +Three drawing tools are available in the tool row. The active tool is highlighted in blue. + +| Tool | How to activate | Description | +|---|---|---| +| **Brush** | Click **Brush** | Click and drag to paint the mask with a circular brush (default) | +| **Polygon** | Click **Polygon** | Click to place vertices and build closed shapes; use **Fill** mode to commit them | +| **Fill** | Click **Fill** | Click inside a closed polygon to fill it onto the mask | + +### Brush tool | Action | How | |---|---| | Draw water mask | Click and drag on the video | | 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 | +| Adjust brush size | **Brush size** slider; click **↺** to reset | + +### Polygon tool + +Polygons are drawn as overlays and do not affect the mask until you use **Fill** mode. + +| Action | How | +|---|---| +| Add vertex | Left-click on the canvas | +| Remove last vertex | Right-click | +| Close a shape | Left-click near the first vertex (red dot) when ≥ 3 vertices are placed; completed shapes turn bold cyan | +| Draw multiple shapes | Each closed shape is kept independently; draw as many as needed | +| Cancel in-progress polygon | **Cancel Current Poly** — discards the unfinished polygon, keeps completed shapes | +| Delete last completed shape | **Del Shape** | + +### Fill tool + +| Action | How | +|---|---| +| Fill a shape | Left-click anywhere inside a closed polygon; that shape's interior is painted onto the mask | +| Nested shapes | If a closed polygon lies entirely inside the target, its interior is left unfilled (acts as a hole) | +| Innermost shape | Clicking inside nested shapes always fills the innermost (smallest) polygon containing the click | +| Undo fill | **Undo** — each fill is a single undoable step | + +### Common mask actions + +| Action | How | +|---|---| +| Undo last action | **Undo** | +| Undo 10 actions | **Undo×10** | +| Redo | **Redo** | | Clear entire mask | **Clear** | | 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** | +| Mask transparency | **Mask Alpha** slider; click **↺** to reset | +| Load mask from previous clip | **Load Prev Mask** — copies the saved mask of the previous clip; undoable with **Undo** | ### Image display adjustments @@ -208,11 +245,19 @@ Up to `max_frames` frames are extracted from the video and scaled so the longest ### Mask drawing -The mask is a binary NumPy array matching the display frame size. Each brush stroke stamps a filled circle of the selected radius, setting pixels to 1 (draw) or 0 (erase). The history stack stores a copy of the mask before each stroke, enabling unlimited undo. On save the mask is resized to the original video resolution with nearest-neighbour interpolation and written as an 8-bit PNG (0 or 255). +The mask is a binary NumPy array matching the display frame size. + +**Brush:** each stroke stamps a filled circle of the selected radius, setting pixels to 1 (draw) or 0 (erase). + +**Polygon:** vertices are stored as a list of floating-point canvas coordinates. Multiple closed shapes can coexist. Completed shapes are rendered as cyan overlays on the canvas but do not touch the mask until a fill is applied. + +**Fill:** clicking inside a closed polygon rasterises it with `cv2.fillPoly` and ORs the result into the mask. Among all shapes containing the click, the innermost (smallest area, determined by `cv2.contourArea`) is selected as the fill target. Any polygon whose centroid lies inside the target is then punched out as a hole. + +Every mask-changing operation (brush stroke, fill) pushes the previous mask onto the undo stack before modifying it. On save the mask is resized to the original video resolution with nearest-neighbour interpolation and written as an 8-bit PNG (0 or 255). ### Resuming -When a clip is loaded that already has a saved `mask.png` and `metadata.json`, the mask is restored at display resolution and the survey answers are pre-filled. **Reload Saved** lets you revert to the last save at any point during the current session. +When a clip is loaded that already has a saved `mask.png` and `metadata.json`, the mask is restored at display resolution and the survey answers are pre-filled. ## Repository structure diff --git a/src/river_annotation_tool/annotator.py b/src/river_annotation_tool/annotator.py index 2ab0aa1..f8c3753 100644 --- a/src/river_annotation_tool/annotator.py +++ b/src/river_annotation_tool/annotator.py @@ -119,6 +119,16 @@ class Annotator(QMainWindow): for b in [self.btn_prev, btn_next, btn_skip, btn_load_prev_mask]: row1.addWidget(b) + row_tools = QHBoxLayout() + for b in [ + self.mc.btn_brush, + self.mc.btn_polygon, + self.mc.btn_fill, + self.mc.btn_del_shape, + self.mc.btn_cancel_poly, + ]: + row_tools.addWidget(b) + row2 = QHBoxLayout() for b in [ btn_clear, @@ -136,7 +146,7 @@ class Annotator(QMainWindow): row3.addWidget(self.mc.brush_reset) row4 = QHBoxLayout() - row4.addWidget(QLabel("Alpha")) + row4.addWidget(QLabel("Mask Alpha")) row4.addWidget(self.mc.alpha_slider) row4.addWidget(self.mc.alpha_reset) @@ -162,6 +172,7 @@ class Annotator(QMainWindow): left = QVBoxLayout() left.addLayout(canvas_row) left.addLayout(row1) + left.addLayout(row_tools) left.addLayout(row2) left.addLayout(row3) left.addLayout(row4) diff --git a/src/river_annotation_tool/mask_canvas.py b/src/river_annotation_tool/mask_canvas.py index c5d4e15..34020e5 100644 --- a/src/river_annotation_tool/mask_canvas.py +++ b/src/river_annotation_tool/mask_canvas.py @@ -1,3 +1,4 @@ +import cv2 import numpy as np from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure @@ -7,13 +8,14 @@ from PySide6.QtWidgets import QPushButton, QSlider class MaskCanvas: - """Matplotlib canvas with brush-based mask drawing, undo/redo, and erase.""" + """Matplotlib canvas with brush/polygon mask drawing, undo/redo, and erase.""" _BRUSH_DEFAULT = 5 _ALPHA_DEFAULT = 40 _BRIGHTNESS_DEFAULT = 0 _CONTRAST_DEFAULT = 0 _GAMMA_DEFAULT = 100 + _CLOSE_THRESHOLD = 15 # image-pixel distance to first vertex that closes a polygon def __init__(self, frames, dh: int, dw: int): self.dh = dh @@ -27,6 +29,12 @@ class MaskCanvas: self.mask_visible = True self._current_frame = frames[0] + self.tool_mode = "brush" + self._shapes: list[list[tuple]] = [] + self._current_poly: list[tuple] = [] + self._poly_artists: list = [] + self._mouse_pos: tuple | None = None + self._build_figure(frames) self._build_controls() self._connect_events() @@ -48,6 +56,15 @@ class MaskCanvas: self.btn_erase = QPushButton("Eraser") self.btn_mask = QPushButton("Hide Mask") + self.btn_brush = QPushButton("Brush") + self.btn_brush.setStyleSheet("background-color: #4488ff; color: white;") + self.btn_polygon = QPushButton("Polygon") + self.btn_fill = QPushButton("Fill") + self.btn_fill.setEnabled(False) + self.btn_del_shape = QPushButton("Del Shape") + self.btn_del_shape.setEnabled(False) + self.btn_cancel_poly = QPushButton("Cancel Current Poly") + self.brush_slider = QSlider(Qt.Horizontal) self.brush_slider.setRange(2, 50) self.brush_slider.setValue(self._BRUSH_DEFAULT) @@ -85,6 +102,11 @@ 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.btn_brush.clicked.connect(lambda: self.set_tool_mode("brush")) + self.btn_polygon.clicked.connect(lambda: self.set_tool_mode("polygon")) + self.btn_fill.clicked.connect(lambda: self.set_tool_mode("fill")) + self.btn_del_shape.clicked.connect(self.delete_last_shape) + self.btn_cancel_poly.clicked.connect(self.cancel_polygon) self.alpha_slider.valueChanged.connect(self.redraw) self.brightness_slider.valueChanged.connect(self._refresh_frame) self.contrast_slider.valueChanged.connect(self._refresh_frame) @@ -113,10 +135,20 @@ class MaskCanvas: self.history = [] self.redo_stack = [] self._current_frame = frames[0] + self._clear_poly_state() self.img_artist.set_data(self._apply_image_adjustments(frames[0])) self.set_title(title) self.redraw() + def _clear_poly_state(self): + self._shapes = [] + self._current_poly = [] + self._mouse_pos = None + for a in self._poly_artists: + a.remove() + self._poly_artists = [] + self._update_poly_buttons() + # ── frame / title ────────────────────────────────────────────── def set_frame(self, frame): self._current_frame = frame @@ -226,6 +258,131 @@ class MaskCanvas: self.mask[y0:y1, x0:x1][circle] = 0 if self.erase_mode else 1 self.redraw() + # ── tool mode ────────────────────────────────────────────────── + def set_tool_mode(self, mode: str): + self.tool_mode = mode + active = "background-color: #4488ff; color: white;" + self.btn_brush.setStyleSheet(active if mode == "brush" else "") + self.btn_polygon.setStyleSheet(active if mode == "polygon" else "") + self.btn_fill.setStyleSheet(active if mode == "fill" else "") + if mode != "brush": + self.brush_circle.set_visible(False) + self.canvas.draw_idle() + + # ── polygon ops ──────────────────────────────────────────────── + def _near_first(self, x: float, y: float) -> bool: + if not self._current_poly: + return False + fx, fy = self._current_poly[0] + return (x - fx) ** 2 + (y - fy) ** 2 <= self._CLOSE_THRESHOLD**2 + + def _update_poly_buttons(self): + has = bool(self._shapes) + self.btn_fill.setEnabled(has) + self.btn_del_shape.setEnabled(has) + + def _draw_polygon_overlay(self, mouse_pos=None): + for a in self._poly_artists: + a.remove() + self._poly_artists.clear() + + # Completed shapes — thick closed outline + for shape in self._shapes: + xs = [p[0] for p in shape] + [shape[0][0]] + ys = [p[1] for p in shape] + [shape[0][1]] + (line,) = self.ax.plot(xs, ys, color="cyan", linewidth=3, zorder=5) + (dots,) = self.ax.plot( + [p[0] for p in shape], + [p[1] for p in shape], + "o", + color="cyan", + markersize=4, + zorder=6, + ) + self._poly_artists.extend([line, dots]) + + # In-progress polygon + if self._current_poly: + xs = [p[0] for p in self._current_poly] + ys = [p[1] for p in self._current_poly] + + if len(self._current_poly) > 1: + (edge,) = self.ax.plot(xs, ys, color="yellow", linewidth=1.5, zorder=5) + self._poly_artists.append(edge) + + (verts,) = self.ax.plot(xs, ys, "o", color="yellow", markersize=5, zorder=6) + # Red dot on first vertex as close-target indicator + (first,) = self.ax.plot( + [xs[0]], [ys[0]], "o", color="red", markersize=8, zorder=7 + ) + self._poly_artists.extend([verts, first]) + + # Rubber-band line from last vertex to cursor + if mouse_pos: + mx, my = mouse_pos + near = len(self._current_poly) >= 3 and self._near_first(mx, my) + clr = "lime" if near else "yellow" + (rband,) = self.ax.plot( + [xs[-1], mx], [ys[-1], my], "--", color=clr, linewidth=1, zorder=5 + ) + self._poly_artists.append(rband) + + self.canvas.draw_idle() + + def cancel_polygon(self): + self._current_poly = [] + self._draw_polygon_overlay(mouse_pos=self._mouse_pos) + + def delete_last_shape(self): + if self._shapes: + self._shapes.pop() + self._update_poly_buttons() + self._draw_polygon_overlay(mouse_pos=self._mouse_pos) + + def _fill_shape_at(self, x: float, y: float): + if not self._shapes: + return + + polys = [ + np.array( + [(int(round(px)), int(round(py))) for px, py in shape], dtype=np.int32 + ) + for shape in self._shapes + ] + + # Find all shapes that contain the click point + containing = [] + for i, poly in enumerate(polys): + poly_f32 = poly.reshape(-1, 1, 2).astype(np.float32) + if cv2.pointPolygonTest(poly_f32, (x, y), False) >= 0: + containing.append((i, poly)) + + if not containing: + return # click was outside all shapes + + # Pick the innermost (smallest area) shape that contains the click + containing.sort(key=lambda t: cv2.contourArea(t[1])) + target_idx, target_poly = containing[0] + + self.history.append(self.mask.copy()) + self.redo_stack.clear() + + temp = np.zeros((self.dh, self.dw), dtype=np.uint8) + cv2.fillPoly(temp, [target_poly], 1) + + # Punch holes for any shapes completely inside the target + target_f32 = target_poly.reshape(-1, 1, 2).astype(np.float32) + for i, poly in enumerate(polys): + if i == target_idx: + continue + cx = float(np.mean(poly[:, 0])) + cy = float(np.mean(poly[:, 1])) + if cv2.pointPolygonTest(target_f32, (cx, cy), False) > 0: + cv2.fillPoly(temp, [poly], 0) + + self.mask |= temp + self.redraw() + # ── brush preview ────────────────────────────────────────────── def _update_brush_preview(self, e): if e.inaxes == self.ax and e.xdata is not None: @@ -238,19 +395,55 @@ class MaskCanvas: def _on_axes_leave(self, _): self.brush_circle.set_visible(False) - self.canvas.draw_idle() + if self.tool_mode == "polygon": + self._mouse_pos = None + self._draw_polygon_overlay() + else: + self.canvas.draw_idle() # ── mouse events ─────────────────────────────────────────────── def _on_press(self, e): if e.xdata is None: return - self.drawing = True - self.stamp(e.xdata, e.ydata) + if self.tool_mode == "brush": + self.drawing = True + self.stamp(e.xdata, e.ydata) + elif self.tool_mode == "polygon": + self._handle_polygon_click(e) + elif self.tool_mode == "fill" and e.button == 1: + self._fill_shape_at(e.xdata, e.ydata) + + def _handle_polygon_click(self, e): + if e.button == 3: # right-click: remove last vertex + if self._current_poly: + self._current_poly.pop() + self._draw_polygon_overlay(mouse_pos=self._mouse_pos) + return + if e.button != 1: + return + x, y = e.xdata, e.ydata + if len(self._current_poly) >= 3 and self._near_first(x, y): + self._shapes.append(list(self._current_poly)) + self._current_poly = [] + self._update_poly_buttons() + self._draw_polygon_overlay(mouse_pos=self._mouse_pos) + else: + self._current_poly.append((x, y)) + self._draw_polygon_overlay(mouse_pos=self._mouse_pos) def _on_move(self, e): - self._update_brush_preview(e) - if self.drawing: - self.stamp(e.xdata, e.ydata) + if self.tool_mode == "brush": + self._update_brush_preview(e) + if self.drawing: + self.stamp(e.xdata, e.ydata) + elif self.tool_mode == "polygon": + self.brush_circle.set_visible(False) + if e.inaxes == self.ax and e.xdata is not None: + self._mouse_pos = (e.xdata, e.ydata) + self._draw_polygon_overlay(mouse_pos=self._mouse_pos) + else: + self._mouse_pos = None + self._draw_polygon_overlay() def _on_release(self, _): self.drawing = False From 67c9a1152c36511feeee09fb9031d475f1a8ad8e Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 15:13:10 +0200 Subject: [PATCH 13/23] Add optical flow Auto Segment button Annotators can now press Auto Segment to replace the current mask with an automatic river segmentation based on dense optical flow magnitude and frame brightness. The result is pushed onto the undo stack, so it can be refined or reverted like any other mask operation. Parameters (norm_squared_threshold, gaussian_kernel, brightness_range) live in a separate config/optical_flow_config.yaml; the button is only enabled when optical_flow_config_file is set in config.yaml. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 43 +++++++++++----- config/config.example.yaml | 1 + config/optical_flow_config.yaml | 4 ++ src/river_annotation_tool/annotator.py | 29 ++++++++++- .../compute_optical_flow.py | 49 +++++++++++++++++++ src/river_annotation_tool/config.py | 17 +++++++ 6 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 config/optical_flow_config.yaml create mode 100644 src/river_annotation_tool/compute_optical_flow.py diff --git a/README.md b/README.md index 6386027..266daf2 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ max_frames: 100 # max frames to extract per clip data_dir: data/clips # directory containing ZIP archives out_dir: data/annotation_results clips_file: config/clips.txt +# optical_flow_config_file: config/optical_flow_config.yaml # optional, enables Auto Segment questions: - section: River @@ -108,6 +109,23 @@ questions: Add, remove, or reorder questions directly in the YAML — the UI rebuilds automatically. `key` is what gets saved in `metadata.json`; `default` selects the pre-checked option (omit or set to `null` to leave unselected). +### Optical flow segmentation (optional) + +Set `optical_flow_config_file` in `config.yaml` to point to a YAML file that enables the **Auto Segment** button. When pressed, the tool computes a river mask from the loaded frames and replaces the current mask (undoable). The segmentation combines two criteria: + +- **Optical flow magnitude** — pixels where the temporal median of frame-to-frame flow (scaled by FPS) exceeds a fraction of the maximum are considered moving water. +- **Brightness** — pixels outside a brightness window are excluded (removes sky, saturated glare, etc.). + +```yaml +# config/optical_flow_config.yaml +enabled: true +norm_squared_threshold: 0.06 # fraction of max flow² that counts as moving +gaussian_kernel: [5, 5] # blur kernel applied to the reference frame before brightness check +brightness_range: [2, 253] # [min, max] greyscale brightness to keep +``` + +`enabled: false` disables the button without removing the config file. + ## Clip list file `config/clips.txt` lists the clip filenames to annotate, one per line. Lines starting with `#` are ignored. Clips are processed in order; already-annotated clips (those with an existing `mask.png`) are skipped automatically. Pass `--no-skip` to include them. When the last clip is reached, a dialog appears and the app exits. @@ -176,6 +194,7 @@ Polygons are drawn as overlays and do not affect the mask until you use **Fill** | Toggle mask overlay | **Hide Mask / Show Mask** — button turns red when hidden; does not affect mask data | | Mask transparency | **Mask Alpha** slider; click **↺** to reset | | Load mask from previous clip | **Load Prev Mask** — copies the saved mask of the previous clip; undoable with **Undo** | +| Optical flow first guess | **Auto Segment** — runs automatic river segmentation and replaces the current mask; undoable with **Undo**. Only enabled when `optical_flow_config_file` is set in `config.yaml`. | ### Image display adjustments @@ -263,18 +282,20 @@ When a clip is loaded that already has a saved `mask.png` and `metadata.json`, t ``` config/ - config.yaml # Your local config (git-ignored, copy from example) - config.example.yaml # Example config to copy and edit - clips.txt # Your clip list (git-ignored, copy from example) - clips.example.txt # Example clip list + config.yaml # Your local config (git-ignored, copy from example) + config.example.yaml # Example config to copy and edit + clips.txt # Your clip list (git-ignored, copy from example) + clips.example.txt # Example clip list + optical_flow_config.yaml # Optional optical flow parameters (enable via config.yaml) src/river_annotation_tool/ - annotation_script.py # Entry point — argument parsing and app launch - annotator.py # Main QMainWindow — orchestrates all components - clip_selector.py # Reads the clip list and picks the next clip - mask_canvas.py # Drawing widget — brush, undo, erase, mouse events - video_loader.py # ZIP extraction and frame resizing - config.py # AppConfig dataclass and YAML loader - __init__.py # Package version + annotation_script.py # Entry point — argument parsing and app launch + annotator.py # Main QMainWindow — orchestrates all components + clip_selector.py # Reads the clip list and picks the next clip + mask_canvas.py # Drawing widget — brush, undo, erase, mouse events + video_loader.py # ZIP extraction and frame resizing + compute_optical_flow.py # Optical flow river segmentation (Auto Segment button) + config.py # AppConfig dataclass and YAML loader + __init__.py # Package version pyproject.toml # Project metadata and dependencies ``` diff --git a/config/config.example.yaml b/config/config.example.yaml index 18c3c4c..28abc59 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -19,6 +19,7 @@ max_frames: 100 data_dir: data/filtered_data out_dir: data/annotation_results clips_file: config/clips.txt +# optical_flow_config_file: config/optical_flow_config.yaml questions: - section: River diff --git a/config/optical_flow_config.yaml b/config/optical_flow_config.yaml new file mode 100644 index 0000000..3049af6 --- /dev/null +++ b/config/optical_flow_config.yaml @@ -0,0 +1,4 @@ +enabled: true +norm_squared_threshold: 0.06 +gaussian_kernel: [5, 5] +brightness_range: [2, 253] diff --git a/src/river_annotation_tool/annotator.py b/src/river_annotation_tool/annotator.py index f8c3753..8c01bfd 100644 --- a/src/river_annotation_tool/annotator.py +++ b/src/river_annotation_tool/annotator.py @@ -20,7 +20,8 @@ from PySide6.QtWidgets import ( ) from .clip_selector import ClipSelector -from .config import AppConfig +from .compute_optical_flow import compute_optical_flow_mask +from .config import AppConfig, load_optical_flow_config from .mask_canvas import MaskCanvas from .video_loader import load_frames @@ -38,6 +39,11 @@ class Annotator(QMainWindow): self.cfg = config self.out_dir = Path(config.out_dir) self.extras = extras + self.of_cfg = ( + load_optical_flow_config(Path(config.optical_flow_config_file)) + if config.optical_flow_config_file + else None + ) self.selector = ClipSelector( data_dir=Path(config.data_dir), @@ -114,9 +120,17 @@ class Annotator(QMainWindow): btn_undo10 = QPushButton("Undo×10") btn_redo = QPushButton("Redo") btn_load_prev_mask = QPushButton("Load Prev Mask") + btn_auto_segment = QPushButton("Auto Segment") + btn_auto_segment.setEnabled(self.of_cfg is not None and self.of_cfg.enabled) row1 = QHBoxLayout() - for b in [self.btn_prev, btn_next, btn_skip, btn_load_prev_mask]: + for b in [ + self.btn_prev, + btn_next, + btn_skip, + btn_load_prev_mask, + btn_auto_segment, + ]: row1.addWidget(b) row_tools = QHBoxLayout() @@ -198,6 +212,7 @@ class Annotator(QMainWindow): btn_undo10.clicked.connect(self.mc.undo10) btn_redo.clicked.connect(self.mc.redo) btn_load_prev_mask.clicked.connect(self.load_prev_mask) + btn_auto_segment.clicked.connect(self.run_optical_flow) if self._pending_answers: self._set_answers(self._pending_answers) @@ -406,3 +421,13 @@ class Annotator(QMainWindow): interpolation=cv2.INTER_NEAREST, ) self.mc.set_mask(mask) + + def run_optical_flow(self): + mask = compute_optical_flow_mask( + self.frames, + self.fps, + self.of_cfg.norm_squared_threshold, + self.of_cfg.gaussian_kernel, + self.of_cfg.brightness_range, + ) + self.mc.set_mask(mask) diff --git a/src/river_annotation_tool/compute_optical_flow.py b/src/river_annotation_tool/compute_optical_flow.py new file mode 100644 index 0000000..308fe7f --- /dev/null +++ b/src/river_annotation_tool/compute_optical_flow.py @@ -0,0 +1,49 @@ +import cv2 +import numpy as np + + +def compute_optical_flow_mask( + frames: list[np.ndarray], + fps: float, + norm_squared_threshold: float, + gaussian_kernel: tuple[int, int], + brightness_range: tuple[int, int], +) -> np.ndarray: + """Return a binary mask (uint8, values 0/1) from optical flow + brightness.""" + if len(frames) < 2: + return np.zeros(frames[0].shape[:2], dtype=np.uint8) + + frames_arr = np.stack(frames).astype(np.float64) + frames_sub_mean = frames_arr - np.mean(frames_arr, axis=0) + mn, mx = frames_sub_mean.min(), frames_sub_mean.max() + if mx > mn: + standardized = ((frames_sub_mean - mn) / (mx - mn) * 255).astype(np.uint8) + else: + standardized = np.zeros_like(frames_arr, dtype=np.uint8) + + N = len(standardized) + gray = np.stack([cv2.cvtColor(f, cv2.COLOR_RGB2GRAY) for f in standardized]) + + flow_data = np.zeros((N - 1,) + gray.shape[1:] + (2,)) + for i in range(N - 1): + flow_data[i] = fps * cv2.optflow.calcOpticalFlowSparseToDense( + gray[i], gray[i + 1] + ) + + optical_flow = np.median(flow_data, axis=0) + + flow_norm_sq = np.sum(optical_flow**2, axis=-1) + max_norm = np.max(flow_norm_sq) + if max_norm > 0: + flow_mask = flow_norm_sq >= max_norm * norm_squared_threshold**2 + else: + flow_mask = np.zeros(flow_norm_sq.shape, dtype=bool) + + reference_frame = frames[len(frames) // 2] + smoothed = cv2.GaussianBlur(reference_frame, gaussian_kernel, 0) + gray_ref = cv2.cvtColor(smoothed, cv2.COLOR_RGB2GRAY) + brightness_mask = (gray_ref > brightness_range[0]) & ( + gray_ref < brightness_range[1] + ) + + return np.logical_and(brightness_mask, flow_mask).astype(np.uint8) diff --git a/src/river_annotation_tool/config.py b/src/river_annotation_tool/config.py index 46f1c59..80a3a64 100644 --- a/src/river_annotation_tool/config.py +++ b/src/river_annotation_tool/config.py @@ -28,6 +28,7 @@ class AppConfig: data_dir: str = "data/clips" out_dir: str = "data/annotation_results" clips_file: str = "config/clips.txt" + optical_flow_config_file: str = "" questions: list = field(default_factory=list) filenames: FilenameConfig = field(default_factory=FilenameConfig) @@ -51,6 +52,22 @@ class AppConfig: ] +@dataclass +class OpticalFlowConfig: + enabled: bool = False + norm_squared_threshold: float = 0.3 + gaussian_kernel: tuple[int, int] = (5, 5) + brightness_range: tuple[int, int] = (20, 235) + + +def load_optical_flow_config(path: Path) -> OpticalFlowConfig: + with open(path) as f: + data = yaml.safe_load(f) + data["gaussian_kernel"] = tuple(data["gaussian_kernel"]) + data["brightness_range"] = tuple(data["brightness_range"]) + return OpticalFlowConfig(**data) + + def load_config(path: Path) -> AppConfig: with open(path) as f: data = yaml.safe_load(f) From bc83e609100b39453c14cf418ca44d54230b9e53 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 15:18:10 +0200 Subject: [PATCH 14/23] Improve README clarity and add defaults to controls - Expand Controls intro with two-panel layout description - Add brush size range/default and mask alpha range/default to tables - Split 'Starting-point shortcuts' (Load Prev Mask, Auto Segment) from mask editing table - Trim 'How it works' mask drawing section to remove implementation internals - Enable optical_flow_config_file in config.example.yaml Co-Authored-By: Claude Sonnet 4.6 --- README.md | 27 +++++++++++++-------------- config/config.example.yaml | 2 +- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 266daf2..71db46f 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ Copy `config/clips.example.txt` as a starting point. ## Controls -The window shows the video on the left (auto-playing) and the survey panel on the right. +The window is split into two panels: the **video canvas** on the left (~70% of the width) and the **survey panel** on the right. The video auto-plays as a looping preview. Drawing tools and mask controls are arranged above and beside the canvas; navigation buttons (**Previous / Next / Skip**) sit at the top. ### Tool modes @@ -159,7 +159,7 @@ Three drawing tools are available in the tool row. The active tool is highlighte | Draw water mask | Click and drag on the video | | 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; click **↺** to reset | +| Adjust brush size | **Brush size** slider (2–50 px, default 5); click **↺** to reset | ### Polygon tool @@ -183,7 +183,7 @@ Polygons are drawn as overlays and do not affect the mask until you use **Fill** | Innermost shape | Clicking inside nested shapes always fills the innermost (smallest) polygon containing the click | | Undo fill | **Undo** — each fill is a single undoable step | -### Common mask actions +### Mask editing | Action | How | |---|---| @@ -192,9 +192,14 @@ Polygons are drawn as overlays and do not affect the mask until you use **Fill** | Redo | **Redo** | | Clear entire mask | **Clear** | | Toggle mask overlay | **Hide Mask / Show Mask** — button turns red when hidden; does not affect mask data | -| Mask transparency | **Mask Alpha** slider; click **↺** to reset | -| Load mask from previous clip | **Load Prev Mask** — copies the saved mask of the previous clip; undoable with **Undo** | -| Optical flow first guess | **Auto Segment** — runs automatic river segmentation and replaces the current mask; undoable with **Undo**. Only enabled when `optical_flow_config_file` is set in `config.yaml`. | +| Mask transparency | **Mask Alpha** slider (0–100%, default 40%); click **↺** to reset | + +### Starting-point shortcuts + +| Action | How | +|---|---| +| Load mask from previous clip | **Load Prev Mask** — copies the saved mask of the previous clip onto the current one; undoable | +| Optical flow first guess | **Auto Segment** — replaces the current mask with an automatic river segmentation; undoable. Only available when `optical_flow_config_file` is set in `config.yaml`. | ### Image display adjustments @@ -264,15 +269,9 @@ Up to `max_frames` frames are extracted from the video and scaled so the longest ### Mask drawing -The mask is a binary NumPy array matching the display frame size. +The mask is a binary array at display resolution. **Brush** strokes stamp a filled circle (draw or erase). **Polygon** shapes are stored as overlays and don't touch the mask until a **Fill** click rasterises them — the innermost polygon containing the click is filled, and any polygon whose centroid falls inside it is punched out as a hole. -**Brush:** each stroke stamps a filled circle of the selected radius, setting pixels to 1 (draw) or 0 (erase). - -**Polygon:** vertices are stored as a list of floating-point canvas coordinates. Multiple closed shapes can coexist. Completed shapes are rendered as cyan overlays on the canvas but do not touch the mask until a fill is applied. - -**Fill:** clicking inside a closed polygon rasterises it with `cv2.fillPoly` and ORs the result into the mask. Among all shapes containing the click, the innermost (smallest area, determined by `cv2.contourArea`) is selected as the fill target. Any polygon whose centroid lies inside the target is then punched out as a hole. - -Every mask-changing operation (brush stroke, fill) pushes the previous mask onto the undo stack before modifying it. On save the mask is resized to the original video resolution with nearest-neighbour interpolation and written as an 8-bit PNG (0 or 255). +Every mask-changing operation is pushed onto an undo stack before it executes. On save, the mask is upscaled to the original video resolution and written as an 8-bit PNG (0 or 255). ### Resuming diff --git a/config/config.example.yaml b/config/config.example.yaml index 28abc59..2862383 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -19,7 +19,7 @@ max_frames: 100 data_dir: data/filtered_data out_dir: data/annotation_results clips_file: config/clips.txt -# optical_flow_config_file: config/optical_flow_config.yaml +optical_flow_config_file: config/optical_flow_config.yaml questions: - section: River From 8579bad2e2661eca2e94f3e20f9885b73d99a267 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 15:22:25 +0200 Subject: [PATCH 15/23] Minor config changes --- config/config.example.yaml | 36 ++++++++++++------------ src/river_annotation_tool/mask_canvas.py | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/config/config.example.yaml b/config/config.example.yaml index 2862383..209584f 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -1,26 +1,12 @@ -filenames: - video_in_zip: left.mp4 - video_tmp_suffix: .mp4 - zip_extension: .zip - mask: mask.png - metadata: metadata.json - frame: frame.png - overlay: overlay.png - mask_vis: mask_vis.png - gif_original_hires: video_original_hires.gif - gif_original_lowres: video_original_lowres.gif - gif_overlay_hires: video_overlay_hires.gif - gif_overlay_lowres: video_overlay_lowres.gif - -display_max: 720 -fps_fallback: 25 -max_frames: 100 - data_dir: data/filtered_data out_dir: data/annotation_results clips_file: config/clips.txt optical_flow_config_file: config/optical_flow_config.yaml +display_max: 720 +fps_fallback: 25 +max_frames: 100 + questions: - section: River items: @@ -56,3 +42,17 @@ questions: label: Snow on Ground options: [Yes, No, Uncertain] default: No + +filenames: + video_in_zip: left.mp4 + video_tmp_suffix: .mp4 + zip_extension: .zip + mask: mask.png + metadata: metadata.json + frame: frame.png + overlay: overlay.png + mask_vis: mask_vis.png + gif_original_hires: video_original_hires.gif + gif_original_lowres: video_original_lowres.gif + gif_overlay_hires: video_overlay_hires.gif + gif_overlay_lowres: video_overlay_lowres.gif diff --git a/src/river_annotation_tool/mask_canvas.py b/src/river_annotation_tool/mask_canvas.py index 34020e5..6055f62 100644 --- a/src/river_annotation_tool/mask_canvas.py +++ b/src/river_annotation_tool/mask_canvas.py @@ -40,7 +40,7 @@ class MaskCanvas: self._connect_events() def _build_figure(self, frames): - self.fig = Figure() + self.fig = Figure(figsize=(self.dw / 80, self.dh / 80)) self.canvas = FigureCanvas(self.fig) self.ax = self.fig.add_subplot(111) self.ax.axis("off") From dc59b8affbd1c04b539e8bba523439ab0358c7a6 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 16:15:38 +0200 Subject: [PATCH 16/23] Add S3 storage support via s3fs; make storage field required - New filesystem.py: make_fs() factory (returns s3fs.S3FileSystem or None), plus fsjoin/fsstem/fsname path helpers - config.py: storage field is now required ('local' or 's3'); load_config raises a clear ValueError when it is missing - video_loader, clip_selector, annotator: thread fs through all file I/O; local paths unchanged, S3 paths use fs.open/fs.exists/fs.pipe - annotation_script: load .env via python-dotenv at startup, create fs from config and pass to Annotator - Add .env.example with SwitchEngines endpoint and AWS checksum env vars - pyproject.toml: add s3fs and python-dotenv dependencies - Reduce default mask alpha from 40% to 15% - Update example clip names to colon-separated timestamps Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 5 + .gitignore | 1 + README.md | 97 +- config/clips.example.txt | 16 +- config/config.example.yaml | 8 + pyproject.toml | 2 + requirements.txt | 972 +++++++++++++++++- .../annotation_script.py | 11 + src/river_annotation_tool/annotator.py | 151 ++- src/river_annotation_tool/clip_selector.py | 58 +- src/river_annotation_tool/config.py | 5 + src/river_annotation_tool/filesystem.py | 35 + src/river_annotation_tool/mask_canvas.py | 2 +- src/river_annotation_tool/video_loader.py | 11 +- uv.lock | 271 +++++ 15 files changed, 1539 insertions(+), 106 deletions(-) create mode 100644 .env.example create mode 100644 src/river_annotation_tool/filesystem.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..49be4b5 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +S3_ACCESS_KEY=your-access-key-here +S3_SECRET_ACCESS_KEY=your-secret-key-here +S3_ENDPOINT_URL=https://os.zhdk.cloud.switch.ch +AWS_REQUEST_CHECKSUM_CALCULATION="when_required" +AWS_RESPONSE_CHECKSUM_VALIDATION="when_required" diff --git a/.gitignore b/.gitignore index a1f9796..2662552 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ data/** # User-specific config (copy from *.example.* files) config/config.yaml config/clips.txt +.env # Notebooks notebooks/ diff --git a/README.md b/README.md index 71db46f..34a201a 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,33 @@ cp config/clips.example.txt config/clips.txt Edit `config/config.yaml` to set your `data_dir` and `out_dir`, then edit `config/clips.txt` to list the clips you want to annotate. +### S3 storage (optional) + +By default the tool reads clips from and writes annotations to the local filesystem (`storage: local`). To use an S3-compatible object store instead, set `storage: s3` in `config/config.yaml` and give `data_dir` / `out_dir` as `bucket/prefix` paths: + +```yaml +storage: s3 +data_dir: my-bucket/clips +out_dir: my-bucket/annotation_results +``` + +Copy `.env.example` to `.env` and fill in your credentials — the app loads this file automatically at startup: + +```sh +cp .env.example .env +# edit .env with your credentials +``` + +| Variable | Description | +|---|---| +| `S3_ACCESS_KEY` | Access key ID | +| `S3_SECRET_ACCESS_KEY` | Secret access key | +| `S3_ENDPOINT_URL` | Endpoint URL (defaults to `https://os.zhdk.cloud.switch.ch` if not set) | +| `AWS_REQUEST_CHECKSUM_CALCULATION` | Set to `when_required` to avoid checksum errors on SwitchEngines/Ceph | +| `AWS_RESPONSE_CHECKSUM_VALIDATION` | Set to `when_required` to avoid checksum errors on SwitchEngines/Ceph | + +The `clips_file` (the list of clip filenames to annotate) is always read from the local filesystem even when `storage: s3`. + ## Usage ```sh @@ -74,6 +101,26 @@ python -m river_annotation_tool.annotation_script --clip left_20230615T120000 All settings live in `config/config.yaml`. Copy `config/config.example.yaml` to get started. ```yaml +storage: local # required: 'local' or 's3' + +data_dir: data/clips # directory containing ZIP archives (local path or bucket/prefix for S3) +out_dir: data/annotation_results +clips_file: config/clips.txt +# optical_flow_config_file: config/optical_flow_config.yaml # optional, enables Auto Segment + +display_max: 720 # longest side in pixels for display +fps_fallback: 25 # FPS to use if the video header is missing +max_frames: 100 # max frames to extract per clip + +questions: + - section: River + items: + - key: flow + label: "Flow Regime" + options: [Turbulent, Laminar, Uncertain] + default: Laminar + # add more items or sections as needed + filenames: video_in_zip: left.mp4 # video filename inside each ZIP archive video_tmp_suffix: .mp4 # suffix for the extraction temp file @@ -87,24 +134,6 @@ filenames: gif_original_lowres: video_original_lowres.gif gif_overlay_hires: video_overlay_hires.gif gif_overlay_lowres: video_overlay_lowres.gif - -display_max: 720 # longest side in pixels for display -fps_fallback: 25 # FPS to use if the video header is missing -max_frames: 100 # max frames to extract per clip - -data_dir: data/clips # directory containing ZIP archives -out_dir: data/annotation_results -clips_file: config/clips.txt -# optical_flow_config_file: config/optical_flow_config.yaml # optional, enables Auto Segment - -questions: - - section: River - items: - - key: flow - label: "Flow Regime" - options: [Turbulent, Laminar, Uncertain] - default: Laminar - # add more items or sections as needed ``` Add, remove, or reorder questions directly in the YAML — the UI rebuilds automatically. `key` is what gets saved in `metadata.json`; `default` selects the pre-checked option (omit or set to `null` to leave unselected). @@ -192,7 +221,7 @@ Polygons are drawn as overlays and do not affect the mask until you use **Fill** | Redo | **Redo** | | Clear entire mask | **Clear** | | Toggle mask overlay | **Hide Mask / Show Mask** — button turns red when hidden; does not affect mask data | -| Mask transparency | **Mask Alpha** slider (0–100%, default 40%); click **↺** to reset | +| Mask transparency | **Mask Alpha** slider (0–100%, default 15%); click **↺** to reset | ### Starting-point shortcuts @@ -280,22 +309,24 @@ When a clip is loaded that already has a saved `mask.png` and `metadata.json`, t ## Repository structure ``` +.env.example # S3 credential template (copy to .env and fill in) config/ - config.yaml # Your local config (git-ignored, copy from example) - config.example.yaml # Example config to copy and edit - clips.txt # Your clip list (git-ignored, copy from example) - clips.example.txt # Example clip list - optical_flow_config.yaml # Optional optical flow parameters (enable via config.yaml) + config.yaml # Your local config (git-ignored, copy from example) + config.example.yaml # Example config to copy and edit + clips.txt # Your clip list (git-ignored, copy from example) + clips.example.txt # Example clip list + optical_flow_config.yaml # Optional optical flow parameters (enable via config.yaml) src/river_annotation_tool/ - annotation_script.py # Entry point — argument parsing and app launch - annotator.py # Main QMainWindow — orchestrates all components - clip_selector.py # Reads the clip list and picks the next clip - mask_canvas.py # Drawing widget — brush, undo, erase, mouse events - video_loader.py # ZIP extraction and frame resizing - compute_optical_flow.py # Optical flow river segmentation (Auto Segment button) - config.py # AppConfig dataclass and YAML loader - __init__.py # Package version -pyproject.toml # Project metadata and dependencies + annotation_script.py # Entry point — argument parsing and app launch + annotator.py # Main QMainWindow — orchestrates all components + clip_selector.py # Reads the clip list and picks the next clip + filesystem.py # Storage backend — local passthrough or S3 via s3fs + mask_canvas.py # Drawing widget — brush, undo, erase, mouse events + video_loader.py # ZIP extraction and frame resizing + compute_optical_flow.py # Optical flow river segmentation (Auto Segment button) + config.py # AppConfig dataclass and YAML loader + __init__.py # Package version +pyproject.toml # Project metadata and dependencies ``` ## Development diff --git a/config/clips.example.txt b/config/clips.example.txt index 3721792..4491270 100644 --- a/config/clips.example.txt +++ b/config/clips.example.txt @@ -1,10 +1,10 @@ # List the clip filenames (without path) to annotate, one per line. # Lines starting with # are ignored. Order is preserved. -GRAMMONT_2025-11-17T11_31_38.546953+00_00.zip -GRAMMONT_2025-11-17T12_31_39.650554+00_00.zip -GRAMMONT_2025-11-17T15_32_07.184007+00_00.zip -GRAMMONT_2025-11-17T15_32_07.184007+00_00.zip -GRAMMONT_2025-11-17T15_47_10.070449+00_00.zip -GRAMMONT_2025-11-22T10_47_02.705611+00_00.zip -GRAMMONT_2025-11-22T14_47_00.096714+00_00.zip -GRAMMONT_2025-11-22T15_32_01.015469+00_00.zip \ No newline at end of file +GRAMMONT_2025-11-17T11:31:38.546953+00:00.zip +GRAMMONT_2025-11-17T12:31:39.650554+00:00.zip +GRAMMONT_2025-11-17T15:32:07.184007+00:00.zip +GRAMMONT_2025-11-17T15:32:07.184007+00:00.zip +GRAMMONT_2025-11-17T15:47:10.070449+00:00.zip +GRAMMONT_2025-11-22T10:47:02.705611+00:00.zip +GRAMMONT_2025-11-22T14:47:00.096714+00:00.zip +GRAMMONT_2025-11-22T15:32:01.015469+00:00.zip diff --git a/config/config.example.yaml b/config/config.example.yaml index 209584f..b27316b 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -1,5 +1,13 @@ +# For local storage, set data_dir and out_dir to file-system paths: +storage: local # 'local' (default) or 's3' data_dir: data/filtered_data out_dir: data/annotation_results +# For S3 storage, set storage: s3 and use bucket/prefix paths: +# storage: s3 +# data_dir: my-bucket/clips +# out_dir: my-bucket/annotation_results +# Credentials are read from env vars (copy .env.example to .env): +# S3_ACCESS_KEY, S3_SECRET_ACCESS_KEY, S3_ENDPOINT_URL clips_file: config/clips.txt optical_flow_config_file: config/optical_flow_config.yaml diff --git a/pyproject.toml b/pyproject.toml index 162c719..f80a502 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,9 @@ dependencies = [ "matplotlib-inline>=0.2.1", "pillow>=12.2.0", "pyside6>=6.11.0", + "python-dotenv>=1.0", "pyyaml>=6.0", + "s3fs>=2024.0", ] dynamic = ["version"] diff --git a/requirements.txt b/requirements.txt index de6f95a..de3dc9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,971 @@ -# This file will be autogenerated from pyproject.toml +# This file was autogenerated by uv via the following command: +# uv export --frozen --output-file=requirements.txt +-e . +aiobotocore==3.7.0 \ + --hash=sha256:680bde7c64679a821a9312641b759d9497f790ba8b2e88c6959e6273ee765b8e \ + --hash=sha256:c64d871ed5491a6571948dd48eabd185b46c6c23b64e3afd0c059fc7593ada30 + # via s3fs +aiohappyeyeballs==2.6.1 \ + --hash=sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558 \ + --hash=sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8 + # via aiohttp +aiohttp==3.13.5 \ + --hash=sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9 \ + --hash=sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b \ + --hash=sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1 \ + --hash=sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416 \ + --hash=sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe \ + --hash=sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9 \ + --hash=sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286 \ + --hash=sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9 \ + --hash=sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88 \ + --hash=sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14 \ + --hash=sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3 \ + --hash=sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1 \ + --hash=sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4 \ + --hash=sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2 \ + --hash=sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1 \ + --hash=sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e \ + --hash=sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5 \ + --hash=sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3 + # via + # aiobotocore + # s3fs +aioitertools==0.13.0 \ + --hash=sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be \ + --hash=sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c + # via aiobotocore +aiosignal==1.4.0 \ + --hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e \ + --hash=sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7 + # via aiohttp +anyio==4.13.0 \ + --hash=sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708 \ + --hash=sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc + # via + # httpx + # jupyter-server +appnope==0.1.4 ; sys_platform == 'darwin' \ + --hash=sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee \ + --hash=sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c + # via ipykernel +argon2-cffi==25.1.0 \ + --hash=sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1 \ + --hash=sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741 + # via jupyter-server +argon2-cffi-bindings==25.1.0 \ + --hash=sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99 \ + --hash=sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6 \ + --hash=sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44 \ + --hash=sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2 \ + --hash=sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0 \ + --hash=sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98 \ + --hash=sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500 \ + --hash=sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94 \ + --hash=sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d \ + --hash=sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d \ + --hash=sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a + # via argon2-cffi +arrow==1.4.0 \ + --hash=sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205 \ + --hash=sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7 + # via isoduration +asttokens==3.0.1 \ + --hash=sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a \ + --hash=sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7 + # via stack-data +async-lru==2.3.0 \ + --hash=sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6 \ + --hash=sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315 + # via jupyterlab +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # aiohttp + # jsonschema + # referencing +babel==2.18.0 \ + --hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \ + --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 + # via jupyterlab-server +beautifulsoup4==4.14.3 \ + --hash=sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb \ + --hash=sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86 + # via nbconvert +bleach==6.3.0 \ + --hash=sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22 \ + --hash=sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6 + # via nbconvert +botocore==1.43.0 \ + --hash=sha256:cc5b15eaec3c6eac05d8012cb5ef17ebe891beb88a16ca13c374bfaece1241e6 \ + --hash=sha256:e933b31a2d644253e1d029d7d39e99ba41b87e29300534f189744cc438cdf928 + # via aiobotocore +certifi==2026.4.22 \ + --hash=sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a \ + --hash=sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580 + # via + # httpcore + # httpx + # requests +cffi==2.0.0 \ + --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ + --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ + --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ + --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ + --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ + --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ + --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ + --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ + --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ + --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ + --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ + --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ + --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 + # via + # argon2-cffi-bindings + # pyzmq +cfgv==3.5.0 \ + --hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \ + --hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132 + # via pre-commit +charset-normalizer==3.4.7 \ + --hash=sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5 \ + --hash=sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb \ + --hash=sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c \ + --hash=sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1 \ + --hash=sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d \ + --hash=sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d \ + --hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116 \ + --hash=sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d \ + --hash=sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6 \ + --hash=sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2 \ + --hash=sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15 \ + --hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \ + --hash=sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7 \ + --hash=sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49 \ + --hash=sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b \ + --hash=sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46 \ + --hash=sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a \ + --hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464 + # via requests +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via ipython +comm==0.2.3 \ + --hash=sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971 \ + --hash=sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417 + # via ipykernel +contourpy==1.3.3 \ + --hash=sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69 \ + --hash=sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc \ + --hash=sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880 \ + --hash=sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7 \ + --hash=sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411 \ + --hash=sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1 \ + --hash=sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6 \ + --hash=sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea \ + --hash=sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b \ + --hash=sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7 \ + --hash=sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb \ + --hash=sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8 + # via matplotlib +cycler==0.12.1 \ + --hash=sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30 \ + --hash=sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c + # via matplotlib +debugpy==1.8.20 \ + --hash=sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390 \ + --hash=sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d \ + --hash=sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33 \ + --hash=sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7 \ + --hash=sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b \ + --hash=sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3 + # via ipykernel +decorator==5.2.1 \ + --hash=sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360 \ + --hash=sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a + # via ipython +defusedxml==0.7.1 \ + --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \ + --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 + # via nbconvert +distlib==0.4.0 \ + --hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \ + --hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d + # via virtualenv +executing==2.2.1 \ + --hash=sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4 \ + --hash=sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017 + # via stack-data +fastjsonschema==2.21.2 \ + --hash=sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463 \ + --hash=sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de + # via nbformat +filelock==3.29.0 \ + --hash=sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90 \ + --hash=sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258 + # via + # python-discovery + # virtualenv +fonttools==4.62.1 \ + --hash=sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04 \ + --hash=sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9 \ + --hash=sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392 \ + --hash=sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d \ + --hash=sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd \ + --hash=sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974 \ + --hash=sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936 \ + --hash=sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42 \ + --hash=sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c \ + --hash=sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d + # via matplotlib +fqdn==1.5.1 \ + --hash=sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f \ + --hash=sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014 + # via jsonschema +frozenlist==1.8.0 \ + --hash=sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d \ + --hash=sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b \ + --hash=sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143 \ + --hash=sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746 \ + --hash=sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8 \ + --hash=sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad \ + --hash=sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29 \ + --hash=sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf \ + --hash=sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 \ + --hash=sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608 \ + --hash=sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa \ + --hash=sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1 \ + --hash=sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3 \ + --hash=sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4 \ + --hash=sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b \ + --hash=sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52 \ + --hash=sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4 \ + --hash=sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd + # via + # aiohttp + # aiosignal +fsspec==2026.4.0 \ + --hash=sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2 \ + --hash=sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4 + # via s3fs +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via httpcore +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via jupyterlab +identify==2.6.19 \ + --hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \ + --hash=sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842 + # via pre-commit +idna==3.13 \ + --hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \ + --hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3 + # via + # anyio + # httpx + # jsonschema + # requests + # yarl +ipykernel==7.2.0 \ + --hash=sha256:18ed160b6dee2cbb16e5f3575858bc19d8f1fe6046a9a680c708494ce31d909e \ + --hash=sha256:3bbd4420d2b3cc105cbdf3756bfc04500b1e52f090a90716851f3916c62e1661 + # via jupyterlab +ipython==9.13.0 \ + --hash=sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201 \ + --hash=sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967 + # via ipykernel +ipython-pygments-lexers==1.1.1 \ + --hash=sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81 \ + --hash=sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c + # via ipython +isoduration==20.11.0 \ + --hash=sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9 \ + --hash=sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042 + # via jsonschema +jedi==0.19.2 \ + --hash=sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0 \ + --hash=sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9 + # via ipython +jinja2==3.1.6 \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via + # jupyter-server + # jupyterlab + # jupyterlab-server + # nbconvert +jmespath==1.1.0 \ + --hash=sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d \ + --hash=sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64 + # via + # aiobotocore + # botocore +json5==0.14.0 \ + --hash=sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a \ + --hash=sha256:b3f492fad9f6cdbced8b7d40b28b9b1c9701c5f561bef0d33b81c2ff433fefcb + # via jupyterlab-server +jsonpointer==3.1.1 \ + --hash=sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900 \ + --hash=sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca + # via jsonschema +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + # via + # jupyter-events + # jupyterlab-server + # nbformat +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via jsonschema +jupyter-client==8.8.0 \ + --hash=sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e \ + --hash=sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a + # via + # ipykernel + # jupyter-server + # nbclient +jupyter-core==5.9.1 \ + --hash=sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508 \ + --hash=sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407 + # via + # ipykernel + # jupyter-client + # jupyter-server + # jupyterlab + # nbclient + # nbconvert + # nbformat +jupyter-events==0.12.1 \ + --hash=sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf \ + --hash=sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3 + # via jupyter-server +jupyter-lsp==2.3.1 \ + --hash=sha256:71b954d834e85ff3096400554f2eefaf7fe37053036f9a782b0f7c5e42dadb81 \ + --hash=sha256:fdf8a4aa7d85813976d6e29e95e6a2c8f752701f926f2715305249a3829805a6 + # via jupyterlab +jupyter-server==2.17.0 \ + --hash=sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5 \ + --hash=sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f + # via + # jupyter-lsp + # jupyterlab + # jupyterlab-server + # notebook + # notebook-shim +jupyter-server-terminals==0.5.4 \ + --hash=sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14 \ + --hash=sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5 + # via jupyter-server +jupyterlab==4.5.7 \ + --hash=sha256:55a9822c4754da305f41e113452c68383e214dcf96de760146af89ce5d5117b0 \ + --hash=sha256:fba4cb0e2c44a52859669d8c98b45de029d5e515f8407bf8534d2a8fc5f0964d + # via notebook +jupyterlab-pygments==0.3.0 \ + --hash=sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d \ + --hash=sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780 + # via nbconvert +jupyterlab-server==2.28.0 \ + --hash=sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c \ + --hash=sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968 + # via + # jupyterlab + # notebook +kiwisolver==1.5.0 \ + --hash=sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15 \ + --hash=sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9 \ + --hash=sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57 \ + --hash=sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9 \ + --hash=sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314 \ + --hash=sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797 \ + --hash=sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083 \ + --hash=sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588 \ + --hash=sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1 \ + --hash=sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d \ + --hash=sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf \ + --hash=sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f \ + --hash=sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7 \ + --hash=sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6 \ + --hash=sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a \ + --hash=sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0 \ + --hash=sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819 \ + --hash=sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384 \ + --hash=sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203 \ + --hash=sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7 + # via matplotlib +lark==1.3.1 \ + --hash=sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905 \ + --hash=sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12 + # via rfc3987-syntax +markupsafe==3.0.3 \ + --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \ + --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \ + --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \ + --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \ + --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \ + --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \ + --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \ + --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \ + --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \ + --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \ + --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \ + --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d + # via + # jinja2 + # nbconvert +matplotlib==3.10.9 \ + --hash=sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42 \ + --hash=sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320 \ + --hash=sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2 \ + --hash=sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285 \ + --hash=sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6 \ + --hash=sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf \ + --hash=sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1 \ + --hash=sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358 + # via river-annotation-tool +matplotlib-inline==0.2.1 \ + --hash=sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76 \ + --hash=sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe + # via + # ipykernel + # ipython + # river-annotation-tool +mistune==3.2.0 \ + --hash=sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a \ + --hash=sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1 + # via nbconvert +multidict==6.7.1 \ + --hash=sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511 \ + --hash=sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582 \ + --hash=sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e \ + --hash=sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a \ + --hash=sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd \ + --hash=sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56 \ + --hash=sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733 \ + --hash=sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6 \ + --hash=sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172 \ + --hash=sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7 \ + --hash=sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf \ + --hash=sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961 \ + --hash=sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3 \ + --hash=sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b \ + --hash=sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53 \ + --hash=sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a \ + --hash=sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75 \ + --hash=sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d \ + --hash=sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba \ + --hash=sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19 + # via + # aiobotocore + # aiohttp + # yarl +nbclient==0.10.4 \ + --hash=sha256:1e54091b16e6da39e297b0ece3e10f6f29f4ac4e8ee515d29f8a7099bd6553c9 \ + --hash=sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440 + # via nbconvert +nbconvert==7.17.1 \ + --hash=sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2 \ + --hash=sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8 + # via jupyter-server +nbformat==5.10.4 \ + --hash=sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a \ + --hash=sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b + # via + # jupyter-server + # nbclient + # nbconvert +nest-asyncio==1.6.0 \ + --hash=sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe \ + --hash=sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c + # via ipykernel +nodeenv==1.10.0 \ + --hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \ + --hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb + # via pre-commit +notebook==7.5.6 \ + --hash=sha256:4dde3f8fb55fa8fb7946d58c6e869ce9baf46d00fc070664f62604569d0faca0 \ + --hash=sha256:621174aade80108f0020b0f00738000b215f75fa3cd90771ad7aa0f24536a4e1 +notebook-shim==0.2.4 \ + --hash=sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef \ + --hash=sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb + # via + # jupyterlab + # notebook +numpy==2.2.6 \ + --hash=sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49 \ + --hash=sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff \ + --hash=sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4 \ + --hash=sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282 \ + --hash=sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3 \ + --hash=sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2 \ + --hash=sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c \ + --hash=sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd \ + --hash=sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87 \ + --hash=sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249 \ + --hash=sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de + # via + # contourpy + # matplotlib + # opencv-contrib-python-headless + # pandas +opencv-contrib-python-headless==4.12.0.88 \ + --hash=sha256:3d8a7b23a5faba4ad34e13f51668c56be791e57ab02d68d9016200fed3c12c77 \ + --hash=sha256:85b520e527052a85a682f09cdc12e5f156f56d8c277261b4b65b48431abae96f \ + --hash=sha256:902888b4e1b4826c721840d9107e91d32f146a2c3bc8cb728f0088bf44204e4b \ + --hash=sha256:a17ebb914f309afe72447c33b9187ff02f23f1483faa5c0ffde7aadc88711e2a \ + --hash=sha256:b183e2322468c9d3bd9cac4ba44b272d828ec22842395bcfa51df31765224c0a \ + --hash=sha256:c57e32812fea2a542bb220088fb3ce8a210fe114c9454d1c9e8cd162e1a1fde8 \ + --hash=sha256:d60a12b915c55a50468c013fcd839e941b49ccc1f37b914b62543382c36bf81d + # via river-annotation-tool +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 + # via + # ipykernel + # jupyter-events + # jupyter-server + # jupyterlab + # jupyterlab-server + # matplotlib + # nbconvert +pandas==3.0.2 \ + --hash=sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd \ + --hash=sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18 \ + --hash=sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d \ + --hash=sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14 \ + --hash=sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4 \ + --hash=sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d \ + --hash=sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f \ + --hash=sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043 \ + --hash=sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab + # via river-annotation-tool +pandocfilters==1.5.1 \ + --hash=sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e \ + --hash=sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc + # via nbconvert +parso==0.8.6 \ + --hash=sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd \ + --hash=sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff + # via jedi +pexpect==4.9.0 ; sys_platform != 'emscripten' and sys_platform != 'win32' \ + --hash=sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523 \ + --hash=sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f + # via ipython +pillow==12.2.0 \ + --hash=sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5 \ + --hash=sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987 \ + --hash=sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5 \ + --hash=sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940 \ + --hash=sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780 \ + --hash=sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005 \ + --hash=sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5 \ + --hash=sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5 \ + --hash=sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414 \ + --hash=sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76 \ + --hash=sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421 \ + --hash=sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5 + # via + # matplotlib + # river-annotation-tool +platformdirs==4.9.6 \ + --hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \ + --hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917 + # via + # jupyter-core + # python-discovery + # virtualenv +pre-commit==4.6.0 \ + --hash=sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9 \ + --hash=sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b +prometheus-client==0.25.0 \ + --hash=sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28 \ + --hash=sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1 + # via jupyter-server +prompt-toolkit==3.0.52 \ + --hash=sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855 \ + --hash=sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955 + # via ipython +propcache==0.5.2 \ + --hash=sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427 \ + --hash=sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42 \ + --hash=sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33 \ + --hash=sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84 \ + --hash=sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64 \ + --hash=sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba \ + --hash=sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144 \ + --hash=sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476 \ + --hash=sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a \ + --hash=sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba \ + --hash=sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9 \ + --hash=sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031 \ + --hash=sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913 \ + --hash=sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe \ + --hash=sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1 \ + --hash=sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a \ + --hash=sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42 \ + --hash=sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a \ + --hash=sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf + # via + # aiohttp + # yarl +psutil==7.2.2 \ + --hash=sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372 \ + --hash=sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 \ + --hash=sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979 \ + --hash=sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee \ + --hash=sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e \ + --hash=sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc \ + --hash=sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988 \ + --hash=sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486 \ + --hash=sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8 + # via + # ipykernel + # ipython +ptyprocess==0.7.0 ; os_name != 'nt' or (sys_platform != 'emscripten' and sys_platform != 'win32') \ + --hash=sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35 \ + --hash=sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220 + # via + # pexpect + # terminado +pure-eval==0.2.3 \ + --hash=sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0 \ + --hash=sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42 + # via stack-data +pycparser==3.0 ; implementation_name != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + # via + # ipython + # ipython-pygments-lexers + # nbconvert +pyparsing==3.3.2 \ + --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \ + --hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc + # via matplotlib +pyside6==6.11.0 \ + --hash=sha256:1f2735dc4f2bd4ec452ae50502c8a22128bba0aced35358a2bbc58384b820c6f \ + --hash=sha256:267b344c73580ac938ca63c611881fb42a3922ebfe043e271005f4f06c372c4e \ + --hash=sha256:9092cb002ca43c64006afb2e0d0f6f51aef17aa737c33a45e502326a081ddcbc \ + --hash=sha256:b15f39acc2b8f46251a630acad0d97f9a0a0461f2baffcd66d7adfada8eb641e \ + --hash=sha256:c642e2d25704ca746fd37f56feacf25c5aecc4cd40bef23d18eec81f87d9dc00 + # via river-annotation-tool +pyside6-addons==6.11.0 \ + --hash=sha256:413e6121c24f5ffdce376298059eddecff74aa6d638e94e0f6015b33d29b889e \ + --hash=sha256:8ffb40222456078930816ebcac2f2511716d2acbc11716dd5acc5c365179a753 \ + --hash=sha256:aaaee83385977a0fe134b2f4fbfb92b45a880d5b656e4d90a708eef10b1b6de8 \ + --hash=sha256:ac6fe3d4ef4497dde3efc5e896b0acd53ff6c93be4bf485f045690f919419f35 \ + --hash=sha256:d5eaa4643302e3a0fa94c5766234bee4073d7d5ab9c2b7fd222692a176faf182 + # via pyside6 +pyside6-essentials==6.11.0 \ + --hash=sha256:3b3362882ad9389357a80504e600180006a957731fec05786fced7b038461fdf \ + --hash=sha256:4854cb0a1b061e7a576d8fb7bb7cf9f49540d558b1acb7df0742a7afefe61e4e \ + --hash=sha256:81ca603dbf21bc39f89bb42db215c25ebe0c879a1a4c387625c321d2730ec187 \ + --hash=sha256:85d6ca87ef35fa6565d385ede72ae48420dd3f63113929d10fc800f6b0360e01 \ + --hash=sha256:dc20e7afd5fc6fe51297db91cef997ce60844be578f7a49fc61b7ab9657a8849 + # via + # pyside6 + # pyside6-addons +python-dateutil==2.9.0.post0 \ + --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 + # via + # aiobotocore + # arrow + # botocore + # jupyter-client + # matplotlib + # pandas +python-discovery==1.2.2 \ + --hash=sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb \ + --hash=sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a + # via virtualenv +python-dotenv==1.2.2 \ + --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ + --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 + # via river-annotation-tool +python-json-logger==4.1.0 \ + --hash=sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2 \ + --hash=sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195 + # via jupyter-events +pywinpty==3.0.3 ; os_name == 'nt' \ + --hash=sha256:15e79d870e18b678fb8a5a6105fd38496b55697c66e6fc0378236026bc4d59e9 \ + --hash=sha256:523441dc34d231fb361b4b00f8c99d3f16de02f5005fd544a0183112bcc22412 \ + --hash=sha256:c9081df0e49ffa86d15db4a6ba61530630e48707f987df42c9d3313537e81fc0 + # via + # jupyter-server + # jupyter-server-terminals + # terminado +pyyaml==6.0.3 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via + # jupyter-events + # pre-commit + # river-annotation-tool +pyzmq==27.1.0 \ + --hash=sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28 \ + --hash=sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113 \ + --hash=sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd \ + --hash=sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233 \ + --hash=sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31 \ + --hash=sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc \ + --hash=sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f \ + --hash=sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf \ + --hash=sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540 \ + --hash=sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856 \ + --hash=sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496 + # via + # ipykernel + # jupyter-client + # jupyter-server +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # jsonschema + # jsonschema-specifications + # jupyter-events +requests==2.33.1 \ + --hash=sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517 \ + --hash=sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a + # via jupyterlab-server +rfc3339-validator==0.1.4 \ + --hash=sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b \ + --hash=sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa + # via + # jsonschema + # jupyter-events +rfc3986-validator==0.1.1 \ + --hash=sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9 \ + --hash=sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055 + # via + # jsonschema + # jupyter-events +rfc3987-syntax==1.1.0 \ + --hash=sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f \ + --hash=sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d + # via jsonschema +rpds-py==0.30.0 \ + --hash=sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf \ + --hash=sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6 \ + --hash=sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23 \ + --hash=sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e \ + --hash=sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e \ + --hash=sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05 \ + --hash=sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5 \ + --hash=sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394 \ + --hash=sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b \ + --hash=sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd \ + --hash=sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad \ + --hash=sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51 \ + --hash=sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28 \ + --hash=sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1 \ + --hash=sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84 \ + --hash=sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f + # via + # jsonschema + # referencing +ruff==0.15.0 \ + --hash=sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3 \ + --hash=sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662 \ + --hash=sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621 \ + --hash=sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a \ + --hash=sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3 \ + --hash=sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179 \ + --hash=sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d \ + --hash=sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16 \ + --hash=sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78 \ + --hash=sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e \ + --hash=sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9 \ + --hash=sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455 \ + --hash=sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1 \ + --hash=sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4 \ + --hash=sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a \ + --hash=sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce \ + --hash=sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d \ + --hash=sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18 +s3fs==2026.4.0 \ + --hash=sha256:5bdce0abb00b0435ee150807a45fea727451dbc22de4cbc116464f8504ab9d37 \ + --hash=sha256:de0d2a1f33cdf03831fd2382d278c6e4e31fe57c3bf2f703c61f8aec6b703e2a + # via river-annotation-tool +send2trash==2.1.0 \ + --hash=sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c \ + --hash=sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459 + # via jupyter-server +setuptools==82.0.1 \ + --hash=sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9 \ + --hash=sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb + # via jupyterlab +shiboken6==6.11.0 \ + --hash=sha256:3bd76cf56105ab2d62ecaff630366f11264f69b88d488f10f048da9a065781f4 \ + --hash=sha256:483ff78a73c7b3189ca924abc694318084f078bcfeaffa68e32024ff2d025ee1 \ + --hash=sha256:a10dc7718104ea2dc15d5b0b96909b77162ce1c76fcc6968e6df692b947a00e9 \ + --hash=sha256:ad54e64f8192ddbdff0c54ac82b89edcd62ed623f502ea21c960541d19514053 \ + --hash=sha256:d88e8a1eb705f2b9ad21db08a61ae1dc0c773e5cd86a069de0754c4cf1f9b43b + # via + # pyside6 + # pyside6-addons + # pyside6-essentials +six==1.17.0 \ + --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ + --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 + # via + # python-dateutil + # rfc3339-validator +soupsieve==2.8.3 \ + --hash=sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349 \ + --hash=sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95 + # via beautifulsoup4 +stack-data==0.6.3 \ + --hash=sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9 \ + --hash=sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695 + # via ipython +terminado==0.18.1 \ + --hash=sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0 \ + --hash=sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e + # via + # jupyter-server + # jupyter-server-terminals +tinycss2==1.4.0 \ + --hash=sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7 \ + --hash=sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289 + # via bleach +tornado==6.5.5 \ + --hash=sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9 \ + --hash=sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6 \ + --hash=sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca \ + --hash=sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e \ + --hash=sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07 \ + --hash=sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa \ + --hash=sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b \ + --hash=sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521 \ + --hash=sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7 \ + --hash=sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5 + # via + # ipykernel + # jupyter-client + # jupyter-server + # jupyterlab + # notebook + # terminado +traitlets==5.14.3 \ + --hash=sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7 \ + --hash=sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f + # via + # ipykernel + # ipython + # jupyter-client + # jupyter-core + # jupyter-events + # jupyter-server + # jupyterlab + # matplotlib-inline + # nbclient + # nbconvert + # nbformat +typing-extensions==4.15.0 \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 + # via + # aiosignal + # anyio + # beautifulsoup4 + # referencing +tzdata==2026.2 \ + --hash=sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10 \ + --hash=sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7 + # via + # arrow + # pandas +uri-template==1.3.0 \ + --hash=sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7 \ + --hash=sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363 + # via jsonschema +urllib3==2.6.3 \ + --hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \ + --hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4 + # via + # botocore + # requests +virtualenv==21.3.0 \ + --hash=sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7 \ + --hash=sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e + # via pre-commit +wcwidth==0.6.0 \ + --hash=sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad \ + --hash=sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159 + # via prompt-toolkit +webcolors==25.10.0 \ + --hash=sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d \ + --hash=sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf + # via jsonschema +webencodings==0.5.1 \ + --hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \ + --hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923 + # via + # bleach + # tinycss2 +websocket-client==1.9.0 \ + --hash=sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98 \ + --hash=sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef + # via jupyter-server +wrapt==2.1.2 \ + --hash=sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63 \ + --hash=sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e \ + --hash=sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748 \ + --hash=sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1 \ + --hash=sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8 \ + --hash=sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2 \ + --hash=sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8 \ + --hash=sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e \ + --hash=sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf \ + --hash=sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c \ + --hash=sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0 \ + --hash=sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c \ + --hash=sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9 + # via aiobotocore +yarl==1.24.2 \ + --hash=sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30 \ + --hash=sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9 \ + --hash=sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db \ + --hash=sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2 \ + --hash=sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d \ + --hash=sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035 \ + --hash=sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7 \ + --hash=sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad \ + --hash=sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c \ + --hash=sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d \ + --hash=sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996 \ + --hash=sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8 \ + --hash=sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b \ + --hash=sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8 \ + --hash=sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576 \ + --hash=sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712 \ + --hash=sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1 \ + --hash=sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c \ + --hash=sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8 + # via aiohttp diff --git a/src/river_annotation_tool/annotation_script.py b/src/river_annotation_tool/annotation_script.py index 0ac4864..68c0cf4 100644 --- a/src/river_annotation_tool/annotation_script.py +++ b/src/river_annotation_tool/annotation_script.py @@ -11,6 +11,7 @@ from PySide6.QtWidgets import QApplication, QMessageBox from .annotator import Annotator from .config import load_config +from .filesystem import make_fs def parse_args(): @@ -40,6 +41,13 @@ def parse_args(): if __name__ == "__main__": + try: + from dotenv import load_dotenv + + load_dotenv() + except ImportError: + pass + args = parse_args() cfg = load_config(Path(args.config)) @@ -50,6 +58,8 @@ if __name__ == "__main__": if args.clips: cfg.clips_file = args.clips + fs = make_fs(cfg.storage) + app = QApplication([]) try: win = Annotator( @@ -57,6 +67,7 @@ if __name__ == "__main__": clip=args.clip, extras=args.extras, skip_annotated=not args.no_skip, + fs=fs, ) except RuntimeError as e: QMessageBox.information(None, "No clips", str(e)) diff --git a/src/river_annotation_tool/annotator.py b/src/river_annotation_tool/annotator.py index 8c01bfd..d678e8f 100644 --- a/src/river_annotation_tool/annotator.py +++ b/src/river_annotation_tool/annotator.py @@ -1,3 +1,4 @@ +import io import json from pathlib import Path @@ -22,6 +23,7 @@ from PySide6.QtWidgets import ( from .clip_selector import ClipSelector from .compute_optical_flow import compute_optical_flow_mask from .config import AppConfig, load_optical_flow_config +from .filesystem import fsjoin, fsname, fsstem from .mask_canvas import MaskCanvas from .video_loader import load_frames @@ -33,11 +35,13 @@ class Annotator(QMainWindow): clip: str = None, extras: bool = False, skip_annotated: bool = True, + fs=None, ): super().__init__() self.cfg = config - self.out_dir = Path(config.out_dir) + self.fs = fs + self.out_dir = config.out_dir self.extras = extras self.of_cfg = ( load_optical_flow_config(Path(config.optical_flow_config_file)) @@ -46,15 +50,16 @@ class Annotator(QMainWindow): ) self.selector = ClipSelector( - data_dir=Path(config.data_dir), + data_dir=config.data_dir, out_dir=self.out_dir, clips_file=Path(config.clips_file), mask_filename=config.filenames.mask, zip_extension=config.filenames.zip_extension, skip_annotated=skip_annotated, + fs=fs, ) - self.history: list[Path] = [] + self.history: list[str] = [] self.history_pos: int = -1 self.setWindowTitle("River Annotator") @@ -63,8 +68,53 @@ class Annotator(QMainWindow): self._init_ui() self._init_timer() + # ── filesystem helpers ───────────────────────────────────────── + def _out_path(self, *parts: str) -> str: + return fsjoin(self.out_dir, fsstem(self.filename), *parts) + + def _fs_exists(self, path: str) -> bool: + if self.fs is None: + return Path(path).exists() + return self.fs.exists(path) + + def _fs_makedirs(self, path: str): + if self.fs is None: + Path(path).mkdir(parents=True, exist_ok=True) + else: + self.fs.makedirs(path, exist_ok=True) + + def _pil_open(self, path: str) -> Image.Image: + if self.fs is None: + return Image.open(path) + with self.fs.open(path, "rb") as f: + return Image.open(io.BytesIO(f.read())) + + def _pil_save(self, img: Image.Image, path: str): + if self.fs is None: + img.save(path) + else: + ext = str(path).rsplit(".", 1)[-1].lower() + fmt = "JPEG" if ext in ("jpg", "jpeg") else ext.upper() + buf = io.BytesIO() + img.save(buf, format=fmt) + self.fs.pipe(path, buf.getvalue()) + + def _json_read(self, path: str): + if self.fs is None: + with open(path) as f: + return json.load(f) + with self.fs.open(path, "r") as f: + return json.load(f) + + def _json_write(self, data, path: str): + if self.fs is None: + with open(path, "w") as f: + json.dump(data, f, indent=2) + else: + self.fs.pipe(path, json.dumps(data, indent=2).encode()) + # ── clip loading ─────────────────────────────────────────────── - def _load_clip(self, specific: str = None, path: Path = None): + def _load_clip(self, specific: str = None, path: str = None): if path is not None: self.filename = path else: @@ -76,6 +126,7 @@ class Annotator(QMainWindow): self.cfg.fps_fallback, self.cfg.filenames.video_in_zip, self.cfg.filenames.video_tmp_suffix, + fs=self.fs, ) self._pending_answers = self._read_saved_answers() @@ -85,10 +136,10 @@ class Annotator(QMainWindow): 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(): + mask_path = self._out_path(self.cfg.filenames.mask) + if not self._fs_exists(mask_path): return None - mask_full = np.array(Image.open(mask_path).convert("L")) + mask_full = np.array(self._pil_open(mask_path).convert("L")) return cv2.resize( (mask_full > 127).astype(np.uint8), (self.dw, self.dh), @@ -96,16 +147,15 @@ class Annotator(QMainWindow): ) def _read_saved_answers(self): - meta_path = self.out_dir / self.filename.stem / self.cfg.filenames.metadata - if not meta_path.exists(): + meta_path = self._out_path(self.cfg.filenames.metadata) + if not self._fs_exists(meta_path): return None - with open(meta_path) as f: - return json.load(f) + return self._json_read(meta_path) # ── UI setup ─────────────────────────────────────────────────── def _init_ui(self): self.mc = MaskCanvas(self.frames, self.dh, self.dw) - self.mc.set_title(self.filename.name) + self.mc.set_title(fsname(self.filename)) self.mc.reset(self._read_saved_mask()) self.q_widgets = {} @@ -279,22 +329,34 @@ class Annotator(QMainWindow): overlay[m] = (1 - alpha) * overlay[m] + alpha * green[m] return overlay.astype(np.uint8) - def _save_gif(self, frames, out_path, scale=1.0): + def _save_gif(self, frames, out_path: str, scale=1.0): h, w = frames[0].shape[:2] nh, nw = max(1, int(h * scale)), max(1, int(w * scale)) pil_frames = [Image.fromarray(cv2.resize(f, (nw, nh))) for f in frames] - pil_frames[0].save( - out_path, - save_all=True, - append_images=pil_frames[1:], - duration=int(1000 / self.fps), - loop=0, - ) + if self.fs is None: + pil_frames[0].save( + out_path, + save_all=True, + append_images=pil_frames[1:], + duration=int(1000 / self.fps), + loop=0, + ) + else: + buf = io.BytesIO() + pil_frames[0].save( + buf, + format="GIF", + save_all=True, + append_images=pil_frames[1:], + duration=int(1000 / self.fps), + loop=0, + ) + self.fs.pipe(out_path, buf.getvalue()) # ── actions ──────────────────────────────────────────────────── def save(self): - out = self.out_dir / self.filename.stem - out.mkdir(parents=True, exist_ok=True) + out = fsjoin(self.out_dir, fsstem(self.filename)) + self._fs_makedirs(out) mask_full = cv2.resize( self.mc.mask.astype(np.uint8), @@ -302,25 +364,28 @@ class Annotator(QMainWindow): interpolation=cv2.INTER_NEAREST, ) fn = self.cfg.filenames - Image.fromarray(mask_full * 255).save(out / fn.mask) - - with open(out / fn.metadata, "w") as f: - json.dump(self.get_answers(), f, indent=2) + self._pil_save(Image.fromarray(mask_full * 255), fsjoin(out, fn.mask)) + self._json_write(self.get_answers(), fsjoin(out, fn.metadata)) mid = len(self.frames) // 2 frame = self.frames[mid] - Image.fromarray(frame).save(out / fn.frame) - Image.fromarray(self._make_overlay(frame)).save(out / fn.overlay) + self._pil_save(Image.fromarray(frame), fsjoin(out, fn.frame)) + self._pil_save( + Image.fromarray(self._make_overlay(frame)), fsjoin(out, fn.overlay) + ) if self.extras: - Image.fromarray((self.mc.mask * 255).astype(np.uint8)).save( - out / fn.mask_vis + self._pil_save( + Image.fromarray((self.mc.mask * 255).astype(np.uint8)), + fsjoin(out, fn.mask_vis), ) overlay_frames = [self._make_overlay(f) for f in self.frames] - self._save_gif(self.frames, out / fn.gif_original_hires, scale=1.0) - self._save_gif(self.frames, out / fn.gif_original_lowres, scale=0.5) - self._save_gif(overlay_frames, out / fn.gif_overlay_hires, scale=1.0) - self._save_gif(overlay_frames, out / fn.gif_overlay_lowres, scale=0.5) + self._save_gif(self.frames, fsjoin(out, fn.gif_original_hires), scale=1.0) + self._save_gif(self.frames, fsjoin(out, fn.gif_original_lowres), scale=0.5) + self._save_gif(overlay_frames, fsjoin(out, fn.gif_overlay_hires), scale=1.0) + self._save_gif( + overlay_frames, fsjoin(out, fn.gif_overlay_lowres), scale=0.5 + ) print("Saved:", out) @@ -331,7 +396,7 @@ class Annotator(QMainWindow): self.dh, self.dw, mask=self._read_saved_mask(), - title=self.filename.name, + title=fsname(self.filename), ) if self._pending_answers: self._set_answers(self._pending_answers) @@ -366,12 +431,12 @@ class Annotator(QMainWindow): self._switch_ui_to_clip() def next_clip(self): - mask_path = self.out_dir / self.filename.stem / self.cfg.filenames.mask - if mask_path.exists(): + mask_path = self._out_path(self.cfg.filenames.mask) + if self._fs_exists(mask_path): msg = QMessageBox(self) msg.setWindowTitle("Existing annotation found") msg.setText( - f"'{self.filename.stem}' already has a saved annotation.\n" + f"'{fsstem(self.filename)}' already has a saved annotation.\n" "Replace it with your current work, or keep the existing save?" ) btn_replace = msg.addButton( @@ -408,13 +473,15 @@ class Annotator(QMainWindow): ) 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(): + mask_path = fsjoin(self.out_dir, fsstem(prev_clip), self.cfg.filenames.mask) + if not self._fs_exists(mask_path): QMessageBox.information( - self, "No mask found", f"No saved mask found for '{prev_clip.stem}'." + self, + "No mask found", + f"No saved mask found for '{fsstem(prev_clip)}'.", ) return - mask_full = np.array(Image.open(mask_path).convert("L")) + mask_full = np.array(self._pil_open(mask_path).convert("L")) mask = cv2.resize( (mask_full > 127).astype(np.uint8), (self.dw, self.dh), diff --git a/src/river_annotation_tool/clip_selector.py b/src/river_annotation_tool/clip_selector.py index 02b34ae..a73b71f 100644 --- a/src/river_annotation_tool/clip_selector.py +++ b/src/river_annotation_tool/clip_selector.py @@ -1,50 +1,72 @@ from pathlib import Path +from .filesystem import fsjoin, fsstem + class ClipSelector: def __init__( self, - data_dir: Path, - out_dir: Path, + data_dir, + out_dir, clips_file: Path, mask_filename: str = "mask.png", zip_extension: str = ".zip", skip_annotated: bool = True, + fs=None, ): - self.data_dir = data_dir - self.out_dir = out_dir + self.data_dir = str(data_dir) + self.out_dir = str(out_dir) self.mask_filename = mask_filename self.zip_extension = zip_extension self.skip_annotated = skip_annotated + self.fs = fs self.clips = self._load_clips(clips_file) self.index = 0 - def _load_clips(self, clips_file: Path) -> list[Path]: + def _load_clips(self, clips_file: Path) -> list: lines = clips_file.read_text().splitlines() return [ - self.data_dir / name.strip() + fsjoin(self.data_dir, name.strip()) for name in lines if name.strip() and not name.strip().startswith("#") ] - def is_annotated(self, path: Path) -> bool: - return (self.out_dir / path.stem / self.mask_filename).exists() + def is_annotated(self, path) -> bool: + mask_path = fsjoin(self.out_dir, fsstem(path), self.mask_filename) + if self.fs is None: + return Path(mask_path).exists() + return self.fs.exists(mask_path) - def next(self, specific: str = None) -> Path: + def next(self, specific: str = None) -> str: if specific: return self._resolve_specific(specific) return self._pick_next() - def _resolve_specific(self, specific: str) -> Path: - matches = list(self.data_dir.glob(f"{specific}{self.zip_extension}")) - if not matches: - p = self.data_dir / specific - matches = [p] if p.exists() else [] - if not matches: - raise FileNotFoundError(f"Clip '{specific}' not found in {self.data_dir}") - return matches[0] + def _resolve_specific(self, specific: str) -> str: + if self.fs is None: + data_dir = Path(self.data_dir) + matches = list(data_dir.glob(f"{specific}{self.zip_extension}")) + if not matches: + p = data_dir / specific + matches = [p] if p.exists() else [] + if not matches: + raise FileNotFoundError( + f"Clip '{specific}' not found in {self.data_dir}" + ) + return str(matches[0]) + else: + pattern = fsjoin(self.data_dir, f"{specific}{self.zip_extension}") + matches = self.fs.glob(pattern) + if not matches: + p = fsjoin(self.data_dir, specific) + matches = [p] if self.fs.exists(p) else [] + if not matches: + raise FileNotFoundError( + f"Clip '{specific}' not found in {self.data_dir}" + ) + return matches[0] - def _pick_next(self) -> Path: + def _pick_next(self) -> str: while self.index < len(self.clips): clip = self.clips[self.index] self.index += 1 diff --git a/src/river_annotation_tool/config.py b/src/river_annotation_tool/config.py index 80a3a64..230bd9e 100644 --- a/src/river_annotation_tool/config.py +++ b/src/river_annotation_tool/config.py @@ -22,6 +22,7 @@ class FilenameConfig: @dataclass class AppConfig: + storage: str # required: 'local' or 's3' display_max: int = 480 fps_fallback: int = 25 max_frames: int = 100 @@ -71,6 +72,10 @@ def load_optical_flow_config(path: Path) -> OpticalFlowConfig: def load_config(path: Path) -> AppConfig: with open(path) as f: data = yaml.safe_load(f) + if "storage" not in data: + raise ValueError( + f"{path}: missing required field 'storage'. Set it to 'local' or 's3'." + ) fn_data = data.pop("filenames", {}) cfg = AppConfig(**data) cfg.filenames = FilenameConfig(**fn_data) diff --git a/src/river_annotation_tool/filesystem.py b/src/river_annotation_tool/filesystem.py new file mode 100644 index 0000000..b63ba16 --- /dev/null +++ b/src/river_annotation_tool/filesystem.py @@ -0,0 +1,35 @@ +import os + + +_DEFAULT_ENDPOINT = "https://os.zhdk.cloud.switch.ch" + + +def make_fs(storage: str): + """Return an S3FileSystem for storage='s3', or None for local.""" + if storage != "s3": + return None + import s3fs + + return s3fs.S3FileSystem( + key=os.environ["S3_ACCESS_KEY"], + secret=os.environ["S3_SECRET_ACCESS_KEY"], + client_kwargs={ + "endpoint_url": os.environ.get("S3_ENDPOINT_URL", _DEFAULT_ENDPOINT) + }, + ) + + +def fsjoin(base, *parts: str) -> str: + """Join path segments with forward slashes (works for both local and S3).""" + return "/".join([str(base).rstrip("/"), *[str(p).strip("/") for p in parts if p]]) + + +def fsstem(path) -> str: + """Filename stem (no extension) for local Path or S3 string.""" + name = str(path).replace("\\", "/").split("/")[-1] + return name.rsplit(".", 1)[0] if "." in name else name + + +def fsname(path) -> str: + """Filename component (with extension) for local Path or S3 string.""" + return str(path).replace("\\", "/").split("/")[-1] diff --git a/src/river_annotation_tool/mask_canvas.py b/src/river_annotation_tool/mask_canvas.py index 6055f62..43d4f3c 100644 --- a/src/river_annotation_tool/mask_canvas.py +++ b/src/river_annotation_tool/mask_canvas.py @@ -11,7 +11,7 @@ class MaskCanvas: """Matplotlib canvas with brush/polygon mask drawing, undo/redo, and erase.""" _BRUSH_DEFAULT = 5 - _ALPHA_DEFAULT = 40 + _ALPHA_DEFAULT = 15 _BRIGHTNESS_DEFAULT = 0 _CONTRAST_DEFAULT = 0 _GAMMA_DEFAULT = 100 diff --git a/src/river_annotation_tool/video_loader.py b/src/river_annotation_tool/video_loader.py index 59a07dc..8c866cb 100644 --- a/src/river_annotation_tool/video_loader.py +++ b/src/river_annotation_tool/video_loader.py @@ -1,20 +1,25 @@ +import io import os import tempfile import zipfile -from pathlib import Path import cv2 def load_frames( - zip_path: Path, + zip_path, max_frames: int, display_max: int, fps_fallback: int, video_in_zip: str = "left.mp4", video_tmp_suffix: str = ".mp4", + fs=None, ): - video_bytes = zipfile.ZipFile(zip_path).read(video_in_zip) + if fs is None: + video_bytes = zipfile.ZipFile(zip_path).read(video_in_zip) + else: + with fs.open(str(zip_path), "rb") as f: + video_bytes = zipfile.ZipFile(io.BytesIO(f.read())).read(video_in_zip) with tempfile.NamedTemporaryFile(suffix=video_tmp_suffix, delete=False) as f: f.write(video_bytes) diff --git a/uv.lock b/uv.lock index 0ef712f..bb4a696 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,89 @@ resolution-markers = [ "sys_platform != 'emscripten' and sys_platform != 'win32'", ] +[[package]] +name = "aiobotocore" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "multidict" }, + { name = "python-dateutil" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/75/42cce839c2ec263ff74b10b650fe36b066fbb124cbee6f247eac0983e1ab/aiobotocore-3.7.0.tar.gz", hash = "sha256:c64d871ed5491a6571948dd48eabd185b46c6c23b64e3afd0c059fc7593ada30", size = 127054, upload-time = "2026-05-09T10:02:52.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/5f/85535dfb3cfd6442d66d1df1694062c5d6df02f895329e7e120b2a3d2b8b/aiobotocore-3.7.0-py3-none-any.whl", hash = "sha256:680bde7c64679a821a9312641b759d9497f790ba8b2e88c6959e6273ee765b8e", size = 89539, upload-time = "2026-05-09T10:02:50.389Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, +] + +[[package]] +name = "aioitertools" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "anyio" version = "4.13.0" @@ -141,6 +224,20 @@ css = [ { name = "tinycss2" }, ] +[[package]] +name = "botocore" +version = "1.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/79/2f4be1896db3db7ccf44504253a175d56b6bd6b669619edc5147d1aa21ea/botocore-1.43.0.tar.gz", hash = "sha256:e933b31a2d644253e1d029d7d39e99ba41b87e29300534f189744cc438cdf928", size = 15286817, upload-time = "2026-04-29T22:07:31.723Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/4b/afc1fef8a43bafb139f57f73bbd70df82807af5934321e8112ae50668827/botocore-1.43.0-py3-none-any.whl", hash = "sha256:cc5b15eaec3c6eac05d8012cb5ef17ebe891beb88a16ca13c374bfaece1241e6", size = 14970102, upload-time = "2026-04-29T22:07:27Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -349,6 +446,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -498,6 +629,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "json5" version = "0.14.0" @@ -811,6 +951,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, ] +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "nbclient" version = "0.10.4" @@ -1072,6 +1239,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + [[package]] name = "psutil" version = "7.2.2" @@ -1206,6 +1399,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "python-json-logger" version = "4.1.0" @@ -1336,7 +1538,9 @@ dependencies = [ { name = "pandas" }, { name = "pillow" }, { name = "pyside6" }, + { name = "python-dotenv" }, { name = "pyyaml" }, + { name = "s3fs" }, ] [package.dev-dependencies] @@ -1354,7 +1558,9 @@ requires-dist = [ { name = "pandas", specifier = ">=2.3.3" }, { name = "pillow", specifier = ">=12.2.0" }, { name = "pyside6", specifier = ">=6.11.0" }, + { name = "python-dotenv", specifier = ">=1.0" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "s3fs", specifier = ">=2024.0" }, ] [package.metadata.requires-dev] @@ -1412,6 +1618,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, ] +[[package]] +name = "s3fs" +version = "2026.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiobotocore" }, + { name = "aiohttp" }, + { name = "fsspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/d8/76f3dc1558bdf4494b117a9f7a9cc0a5d9d34edadc9e5d7ceabc5a6a7c37/s3fs-2026.4.0.tar.gz", hash = "sha256:5bdce0abb00b0435ee150807a45fea727451dbc22de4cbc116464f8504ab9d37", size = 85986, upload-time = "2026-04-29T20:52:51.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a4/9d1ea10ebc9e028a289a72fec84da170689549a8102c8aacfcad26bc5035/s3fs-2026.4.0-py3-none-any.whl", hash = "sha256:de0d2a1f33cdf03831fd2382d278c6e4e31fe57c3bf2f703c61f8aec6b703e2a", size = 32392, upload-time = "2026-04-29T20:52:50.295Z" }, +] + [[package]] name = "send2trash" version = "2.1.0" @@ -1612,3 +1832,54 @@ sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c wheels = [ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, ] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] From 9e135ea28c6e8b78edc190f21f422e5c8434a5e1 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 16:19:54 +0200 Subject: [PATCH 17/23] Improve README clarity and onboarding flow Add Quick start section, surface uv run throughout, fix repo URL placeholder, and rename 'How it works' to 'Internals'. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 47 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 34a201a..57c32b9 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,41 @@ # River Annotation Tool -A desktop application for manually annotating river video clips as part of the [HydroScan](https://github.com/HydroScan) project. Annotators draw pixel-level water masks over river footage and answer structured survey questions about flow conditions, lighting, and scene quality. +A desktop GUI application for manually annotating river video clips as part of the [HydroScan](https://github.com/HydroScan) project. Annotators draw pixel-level water masks over river footage and answer structured survey questions about flow conditions, lighting, and scene quality. ## Requirements - Python 3.12 - [uv](https://docs.astral.sh/uv/) (recommended) or pip +## Quick start + +```sh +# 1. Clone and install +git clone https://github.com/HydroScan/river-annotation-tool +cd river-annotation-tool +uv sync + +# 2. Create config and clip list from examples +cp config/config.example.yaml config/config.yaml +cp config/clips.example.txt config/clips.txt + +# 3. Edit config/config.yaml (set data_dir and out_dir) +# Edit config/clips.txt (list clips to annotate) + +# 4. Run +uv run python -m river_annotation_tool.annotation_script +``` + ## Installation ```sh -# Clone the repository -git clone -cd river-annotation-tool - # Install with uv (creates the virtual environment automatically) uv sync # Or with pip python -m venv .venv .venv\Scripts\activate # Windows -# source .venv/bin/activate # macOS/Linux +source .venv/bin/activate # macOS/Linux pip install -e . ``` @@ -33,7 +48,7 @@ cp config/config.example.yaml config/config.yaml cp config/clips.example.txt config/clips.txt ``` -Edit `config/config.yaml` to set your `data_dir` and `out_dir`, then edit `config/clips.txt` to list the clips you want to annotate. +Edit `config/config.yaml` to set your `data_dir` and `out_dir`, then edit `config/clips.txt` to list the clips you want to annotate. See the [Configuration](#configuration) section for all available options. ### S3 storage (optional) @@ -65,6 +80,8 @@ The `clips_file` (the list of clip filenames to annotate) is always read from th ## Usage ```sh +uv run python -m river_annotation_tool.annotation_script +# or, if you have the venv activated: python -m river_annotation_tool.annotation_script ``` @@ -84,16 +101,16 @@ python -m river_annotation_tool.annotation_script ```sh # Annotate clips listed in config/clips.txt (default) -python -m river_annotation_tool.annotation_script +uv run python -m river_annotation_tool.annotation_script # Use a different config file -python -m river_annotation_tool.annotation_script --config config/my_config.yaml +uv run python -m river_annotation_tool.annotation_script --config config/my_config.yaml # Override paths from the command line -python -m river_annotation_tool.annotation_script --data data/clips --out data/out +uv run python -m river_annotation_tool.annotation_script --data data/clips --out data/out # Annotate a single specific clip -python -m river_annotation_tool.annotation_script --clip left_20230615T120000 +uv run python -m river_annotation_tool.annotation_script --clip left_20230615T120000 ``` ## Configuration @@ -286,7 +303,7 @@ Keys and values are determined by the `questions` section in `config/config.yaml } ``` -## How it works +## Internals ### Clip format @@ -333,10 +350,10 @@ pyproject.toml # Project metadata and dependencies ```sh # Install pre-commit hooks -pre-commit install -pre-commit run --all-files # Run manually once +uv run pre-commit install +uv run pre-commit run --all-files # Run manually once # Add a dependency uv add -uv add --dev # Development-only +uv add --dev # Development-only ``` From 3036a93d04d86b971dfdbda7f59e552fca1da624 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 16:30:59 +0200 Subject: [PATCH 18/23] Move questions and optical flow to separate config files; clean up config.example.yaml - questions: extracted from config.yaml into config/questions.yaml (committed, like optical_flow_config.yaml) - optical_flow_config_file and questions_config_file are now required fields - data_dir and out_dir are now required (no defaults) - filenames: trimmed to input-only in example; output filenames stay as code defaults - annotator: remove optional guard around optical flow config loading Co-Authored-By: Claude Sonnet 4.6 --- README.md | 55 ++++++++++------------ config/config.example.yaml | 63 ++++---------------------- config/questions.yaml | 34 ++++++++++++++ src/river_annotation_tool/annotator.py | 8 +--- src/river_annotation_tool/config.py | 30 ++++++++---- 5 files changed, 89 insertions(+), 101 deletions(-) create mode 100644 config/questions.yaml diff --git a/README.md b/README.md index 57c32b9..7f52768 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ cp config/clips.example.txt config/clips.txt # 3. Edit config/config.yaml (set data_dir and out_dir) # Edit config/clips.txt (list clips to annotate) +# Edit config/questions.yaml to customise survey questions (optional) # 4. Run uv run python -m river_annotation_tool.annotation_script @@ -48,7 +49,7 @@ cp config/config.example.yaml config/config.yaml cp config/clips.example.txt config/clips.txt ``` -Edit `config/config.yaml` to set your `data_dir` and `out_dir`, then edit `config/clips.txt` to list the clips you want to annotate. See the [Configuration](#configuration) section for all available options. +Edit `config/config.yaml` to set your `data_dir` and `out_dir`, then edit `config/clips.txt` to list the clips you want to annotate. Survey questions are defined in `config/questions.yaml` (committed to the repo; edit to customise). See the [Configuration](#configuration) section for all available options. ### S3 storage (optional) @@ -115,49 +116,38 @@ uv run python -m river_annotation_tool.annotation_script --clip left_20230615T12 ## Configuration -All settings live in `config/config.yaml`. Copy `config/config.example.yaml` to get started. +Main settings live in `config/config.yaml`. Copy `config/config.example.yaml` to get started. ```yaml storage: local # required: 'local' or 's3' -data_dir: data/clips # directory containing ZIP archives (local path or bucket/prefix for S3) -out_dir: data/annotation_results +data_dir: # required: directory containing ZIP archives (local path or bucket/prefix for S3) +out_dir: # required: where to write annotations + clips_file: config/clips.txt -# optical_flow_config_file: config/optical_flow_config.yaml # optional, enables Auto Segment +optical_flow_config_file: config/optical_flow_config.yaml +questions_config_file: config/questions.yaml display_max: 720 # longest side in pixels for display fps_fallback: 25 # FPS to use if the video header is missing max_frames: 100 # max frames to extract per clip -questions: - - section: River - items: - - key: flow - label: "Flow Regime" - options: [Turbulent, Laminar, Uncertain] - default: Laminar - # add more items or sections as needed - +# Override input filenames only if your ZIP archives differ from the defaults filenames: - video_in_zip: left.mp4 # video filename inside each ZIP archive - video_tmp_suffix: .mp4 # suffix for the extraction temp file - zip_extension: .zip # extension used when resolving clip names - mask: mask.png # saved water mask - metadata: metadata.json # saved survey answers - frame: frame.png # middle frame snapshot - overlay: overlay.png # frame with mask blended in green - mask_vis: mask_vis.png # greyscale mask PNG (--extras only) - gif_original_hires: video_original_hires.gif - gif_original_lowres: video_original_lowres.gif - gif_overlay_hires: video_overlay_hires.gif - gif_overlay_lowres: video_overlay_lowres.gif + video_in_zip: left.mp4 + video_tmp_suffix: .mp4 + zip_extension: .zip ``` -Add, remove, or reorder questions directly in the YAML — the UI rebuilds automatically. `key` is what gets saved in `metadata.json`; `default` selects the pre-checked option (omit or set to `null` to leave unselected). +Output filenames (`mask.png`, `metadata.json`, etc.) have sensible defaults and can be overridden in the `filenames:` block — see [`config.py`](src/river_annotation_tool/config.py) for the full list. -### Optical flow segmentation (optional) +### Survey questions -Set `optical_flow_config_file` in `config.yaml` to point to a YAML file that enables the **Auto Segment** button. When pressed, the tool computes a river mask from the loaded frames and replaces the current mask (undoable). The segmentation combines two criteria: +Survey questions are defined in `config/questions.yaml` (committed to the repo). Add, remove, or reorder sections and items — the UI rebuilds automatically. `key` is what gets saved in `metadata.json`; `default` selects the pre-checked option (omit or set to `null` to leave unselected). + +### Optical flow segmentation + +`config/optical_flow_config.yaml` controls the **Auto Segment** button. When pressed, the tool computes a river mask from the loaded frames and replaces the current mask (undoable). The segmentation combines two criteria: - **Optical flow magnitude** — pixels where the temporal median of frame-to-frame flow (scaled by FPS) exceeds a fraction of the maximum are considered moving water. - **Brightness** — pixels outside a brightness window are excluded (removes sky, saturated glare, etc.). @@ -245,7 +235,7 @@ Polygons are drawn as overlays and do not affect the mask until you use **Fill** | Action | How | |---|---| | Load mask from previous clip | **Load Prev Mask** — copies the saved mask of the previous clip onto the current one; undoable | -| Optical flow first guess | **Auto Segment** — replaces the current mask with an automatic river segmentation; undoable. Only available when `optical_flow_config_file` is set in `config.yaml`. | +| Optical flow first guess | **Auto Segment** — replaces the current mask with an automatic river segmentation; undoable. Disabled when `enabled: false` in `config/optical_flow_config.yaml`. | ### Image display adjustments @@ -289,7 +279,7 @@ All output filenames can be overridden via the `filenames:` section in `config/c ### Survey answers (`metadata.json`) -Keys and values are determined by the `questions` section in `config/config.yaml`. With the default config: +Keys and values are determined by `config/questions.yaml`. With the default questions: ```json { @@ -332,7 +322,8 @@ config/ config.example.yaml # Example config to copy and edit clips.txt # Your clip list (git-ignored, copy from example) clips.example.txt # Example clip list - optical_flow_config.yaml # Optional optical flow parameters (enable via config.yaml) + questions.yaml # Survey question definitions + optical_flow_config.yaml # Optical flow parameters (set enabled: false to disable Auto Segment) src/river_annotation_tool/ annotation_script.py # Entry point — argument parsing and app launch annotator.py # Main QMainWindow — orchestrates all components diff --git a/config/config.example.yaml b/config/config.example.yaml index b27316b..d8febbc 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -1,66 +1,21 @@ -# For local storage, set data_dir and out_dir to file-system paths: -storage: local # 'local' (default) or 's3' -data_dir: data/filtered_data -out_dir: data/annotation_results -# For S3 storage, set storage: s3 and use bucket/prefix paths: -# storage: s3 -# data_dir: my-bucket/clips -# out_dir: my-bucket/annotation_results -# Credentials are read from env vars (copy .env.example to .env): +storage: local # 'local' or 's3' + +# Required: set these to your actual paths (local path or bucket/prefix for S3) +data_dir: +out_dir: +# For S3 credentials, copy .env.example to .env and fill in: # S3_ACCESS_KEY, S3_SECRET_ACCESS_KEY, S3_ENDPOINT_URL + clips_file: config/clips.txt optical_flow_config_file: config/optical_flow_config.yaml +questions_config_file: config/questions.yaml display_max: 720 fps_fallback: 25 max_frames: 100 -questions: - - section: River - items: - - key: flow - label: Flow Regime - options: [Turbulent, Laminar, Uncertain] - default: Laminar - - key: shadows - label: Strong Shadows - options: [Yes, No, Uncertain] - default: No - - key: artifacts - label: Artifacts on River - options: [Yes, No, Uncertain] - default: No - - section: Scene - items: - - key: lighting - label: Lighting - options: [Day, Night, Uncertain] - default: Day - - key: exposure - label: Exposure - options: [Overexposed, Underexposed, Both, Normal, Uncertain] - default: Normal - - section: Weather - items: - - key: snowing - label: Snowing - options: [Yes, No, Uncertain] - default: No - - key: snow_on_ground - label: Snow on Ground - options: [Yes, No, Uncertain] - default: No - +# Input filenames (override if your ZIP archives differ) filenames: video_in_zip: left.mp4 video_tmp_suffix: .mp4 zip_extension: .zip - mask: mask.png - metadata: metadata.json - frame: frame.png - overlay: overlay.png - mask_vis: mask_vis.png - gif_original_hires: video_original_hires.gif - gif_original_lowres: video_original_lowres.gif - gif_overlay_hires: video_overlay_hires.gif - gif_overlay_lowres: video_overlay_lowres.gif diff --git a/config/questions.yaml b/config/questions.yaml new file mode 100644 index 0000000..4a5740c --- /dev/null +++ b/config/questions.yaml @@ -0,0 +1,34 @@ +- section: River + items: + - key: flow + label: Flow Regime + options: [Turbulent, Laminar, Uncertain] + default: Laminar + - key: shadows + label: Strong Shadows + options: [Yes, No, Uncertain] + default: No + - key: artifacts + label: Artifacts on River + options: [Yes, No, Uncertain] + default: No +- section: Scene + items: + - key: lighting + label: Lighting + options: [Day, Night, Uncertain] + default: Day + - key: exposure + label: Exposure + options: [Overexposed, Underexposed, Both, Normal, Uncertain] + default: Normal +- section: Weather + items: + - key: snowing + label: Snowing + options: [Yes, No, Uncertain] + default: No + - key: snow_on_ground + label: Snow on Ground + options: [Yes, No, Uncertain] + default: No diff --git a/src/river_annotation_tool/annotator.py b/src/river_annotation_tool/annotator.py index d678e8f..3dbadc7 100644 --- a/src/river_annotation_tool/annotator.py +++ b/src/river_annotation_tool/annotator.py @@ -43,11 +43,7 @@ class Annotator(QMainWindow): self.fs = fs self.out_dir = config.out_dir self.extras = extras - self.of_cfg = ( - load_optical_flow_config(Path(config.optical_flow_config_file)) - if config.optical_flow_config_file - else None - ) + self.of_cfg = load_optical_flow_config(Path(config.optical_flow_config_file)) self.selector = ClipSelector( data_dir=config.data_dir, @@ -171,7 +167,7 @@ class Annotator(QMainWindow): btn_redo = QPushButton("Redo") btn_load_prev_mask = QPushButton("Load Prev Mask") btn_auto_segment = QPushButton("Auto Segment") - btn_auto_segment.setEnabled(self.of_cfg is not None and self.of_cfg.enabled) + btn_auto_segment.setEnabled(self.of_cfg.enabled) row1 = QHBoxLayout() for b in [ diff --git a/src/river_annotation_tool/config.py b/src/river_annotation_tool/config.py index 230bd9e..7c4ce5c 100644 --- a/src/river_annotation_tool/config.py +++ b/src/river_annotation_tool/config.py @@ -22,16 +22,17 @@ class FilenameConfig: @dataclass class AppConfig: - storage: str # required: 'local' or 's3' + storage: str + data_dir: str + out_dir: str + optical_flow_config_file: str + questions_config_file: str display_max: int = 480 fps_fallback: int = 25 max_frames: int = 100 - data_dir: str = "data/clips" - out_dir: str = "data/annotation_results" clips_file: str = "config/clips.txt" - optical_flow_config_file: str = "" - questions: list = field(default_factory=list) filenames: FilenameConfig = field(default_factory=FilenameConfig) + questions: list = field(default_factory=list, init=False) def get_questions(self): return [ @@ -69,14 +70,25 @@ def load_optical_flow_config(path: Path) -> OpticalFlowConfig: return OpticalFlowConfig(**data) +def load_questions_config(path: Path) -> list: + with open(path) as f: + return yaml.safe_load(f) + + def load_config(path: Path) -> AppConfig: with open(path) as f: data = yaml.safe_load(f) - if "storage" not in data: - raise ValueError( - f"{path}: missing required field 'storage'. Set it to 'local' or 's3'." - ) + for required in ( + "storage", + "data_dir", + "out_dir", + "optical_flow_config_file", + "questions_config_file", + ): + if not data.get(required): + raise ValueError(f"{path}: missing required field '{required}'.") fn_data = data.pop("filenames", {}) cfg = AppConfig(**data) cfg.filenames = FilenameConfig(**fn_data) + cfg.questions = load_questions_config(Path(cfg.questions_config_file)) return cfg From 4d0673ecb4f613ed24b11b13bdac5e28641f7360 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 16:56:11 +0200 Subject: [PATCH 19/23] Add per-annotator clip lists and document multi-annotator setup 7 annotators, 5 days each, 11 of 24 days double-covered for inter-annotator agreement. Removed annotator_summary.txt in favour of the README table. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 26 ++++++++ config/annotator_A.txt | 94 ++++++++++++++++++++++++++ config/annotator_B.txt | 128 ++++++++++++++++++++++++++++++++++++ config/annotator_C.txt | 146 +++++++++++++++++++++++++++++++++++++++++ config/annotator_D.txt | 102 ++++++++++++++++++++++++++++ config/annotator_E.txt | 80 ++++++++++++++++++++++ config/annotator_F.txt | 93 ++++++++++++++++++++++++++ config/annotator_G.txt | 110 +++++++++++++++++++++++++++++++ 8 files changed, 779 insertions(+) create mode 100644 config/annotator_A.txt create mode 100644 config/annotator_B.txt create mode 100644 config/annotator_C.txt create mode 100644 config/annotator_D.txt create mode 100644 config/annotator_E.txt create mode 100644 config/annotator_F.txt create mode 100644 config/annotator_G.txt diff --git a/README.md b/README.md index 7f52768..65f343e 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,32 @@ left_20230502T120000.zip Copy `config/clips.example.txt` as a starting point. +## Multi-annotator setup + +Pre-made clip lists for 7 annotators are included in `config/annotator_A.txt` through `config/annotator_G.txt`. Each annotator is assigned exactly 5 recording days (non-consecutive where possible), covering all 24 available days across the dataset. + +To run the tool for a specific annotator, pass their file via `--clips`: + +```sh +uv run python -m river_annotation_tool.annotation_script --clips config/annotator_A.txt +``` + +### Assignment + +11 of the 24 days are reviewed by two annotators (the theoretical maximum given 7 × 5 = 35 slots and 24 days), giving 11 days with double coverage for inter-annotator agreement checks. + +| Annotator | Days | Clips | +|---|---|---| +| A | 2025-11-17 · 2025-12-03 · 2026-01-01 · 2026-01-09 · 2026-02-11 | 94 | +| B | 2025-11-18 · 2025-12-05 · 2026-01-06 · 2026-02-12 · 2026-03-02 | 128 | +| C | 2025-11-22 · 2025-12-12 · 2026-01-07 · 2026-02-16 · 2026-03-03 | 146 | +| D | 2025-11-18 · 2025-11-24 · 2025-12-16 · 2026-01-08 · 2026-03-02 | 102 | +| E | 2025-11-25 · 2025-12-03 · 2026-01-09 · 2026-01-12 · 2026-03-03 | 80 | +| F | 2025-11-25 · 2025-12-16 · 2026-01-10 · 2026-01-13 · 2026-03-11 | 93 | +| G | 2025-11-22 · 2025-12-05 · 2026-01-12 · 2026-02-11 · 2026-03-12 | 110 | + +Days covered by two annotators: 2025-11-18 (B, D) · 2025-11-22 (C, G) · 2025-11-25 (E, F) · 2025-12-03 (A, E) · 2025-12-05 (B, G) · 2025-12-16 (D, F) · 2026-01-09 (A, E) · 2026-01-12 (E, G) · 2026-02-11 (A, G) · 2026-03-02 (B, D) · 2026-03-03 (C, E) + ## Controls The window is split into two panels: the **video canvas** on the left (~70% of the width) and the **survey panel** on the right. The video auto-plays as a looping preview. Drawing tools and mask controls are arranged above and beside the canvas; navigation buttons (**Previous / Next / Skip**) sit at the top. diff --git a/config/annotator_A.txt b/config/annotator_A.txt new file mode 100644 index 0000000..984b0a3 --- /dev/null +++ b/config/annotator_A.txt @@ -0,0 +1,94 @@ +GRAMMONT_2025-11-17T11:31:38.546953+00:00.zip +GRAMMONT_2025-11-17T12:31:39.650554+00:00.zip +GRAMMONT_2025-11-17T15:32:07.184007+00:00.zip +GRAMMONT_2025-11-17T15:47:10.070449+00:00.zip +GRAMMONT_2025-11-17T16:02:09.881377+00:00.zip +GRAMMONT_2025-11-17T16:17:07.937820+00:00.zip +GRAMMONT_2025-11-17T16:32:06.019806+00:00.zip +GRAMMONT_2025-11-17T16:47:05.241264+00:00.zip +GRAMMONT_2025-11-17T17:02:05.056396+00:00.zip +GRAMMONT_2025-11-17T17:17:05.186394+00:00.zip +GRAMMONT_2025-11-17T19:47:09.762766+00:00.zip +GRAMMONT_2025-11-17T20:02:05.552868+00:00.zip +GRAMMONT_2025-11-17T23:02:09.394251+00:00.zip +GRAMMONT_2025-12-03T09:32:01.515556+00:00.zip +GRAMMONT_2025-12-03T11:17:03.118822+00:00.zip +GRAMMONT_2025-12-03T15:17:03.043807+00:00.zip +GRAMMONT_2025-12-03T15:32:04.085232+00:00.zip +GRAMMONT_2025-12-03T15:47:04.864411+00:00.zip +GRAMMONT_2025-12-03T16:02:09.554901+00:00.zip +GRAMMONT_2025-12-03T16:17:07.984380+00:00.zip +GRAMMONT_2025-12-03T16:32:06.042132+00:00.zip +GRAMMONT_2025-12-03T16:47:05.425453+00:00.zip +GRAMMONT_2025-12-03T17:02:05.867456+00:00.zip +GRAMMONT_2025-12-03T19:17:08.208705+00:00.zip +GRAMMONT_2025-12-03T19:47:07.924056+00:00.zip +GRAMMONT_2025-12-03T20:17:04.802945+00:00.zip +GRAMMONT_2025-12-03T23:17:04.368047+00:00.zip +GRAMMONT_2026-01-01T00:01:38.617204+00:00.zip +GRAMMONT_2026-01-01T01:01:28.406165+00:00.zip +GRAMMONT_2026-01-01T01:16:27.492561+00:00.zip +GRAMMONT_2026-01-01T04:31:27.576814+00:00.zip +GRAMMONT_2026-01-01T06:01:28.565510+00:00.zip +GRAMMONT_2026-01-01T06:16:27.160500+00:00.zip +GRAMMONT_2026-01-01T06:31:27.071465+00:00.zip +GRAMMONT_2026-01-01T06:46:26.490815+00:00.zip +GRAMMONT_2026-01-01T07:01:27.248968+00:00.zip +GRAMMONT_2026-01-01T07:16:24.975576+00:00.zip +GRAMMONT_2026-01-01T07:31:22.112561+00:00.zip +GRAMMONT_2026-01-01T07:46:21.433475+00:00.zip +GRAMMONT_2026-01-01T08:01:18.720307+00:00.zip +GRAMMONT_2026-01-01T09:31:19.111375+00:00.zip +GRAMMONT_2026-01-01T09:46:17.316758+00:00.zip +GRAMMONT_2026-01-01T10:01:17.837711+00:00.zip +GRAMMONT_2026-01-01T10:16:18.968668+00:00.zip +GRAMMONT_2026-01-01T11:16:20.885047+00:00.zip +GRAMMONT_2026-01-01T11:31:19.000253+00:00.zip +GRAMMONT_2026-01-01T15:31:21.006934+00:00.zip +GRAMMONT_2026-01-01T15:46:23.298079+00:00.zip +GRAMMONT_2026-01-01T16:01:26.959862+00:00.zip +GRAMMONT_2026-01-01T16:16:26.753510+00:00.zip +GRAMMONT_2026-01-01T16:31:27.166783+00:00.zip +GRAMMONT_2026-01-01T16:46:27.263665+00:00.zip +GRAMMONT_2026-01-01T17:01:27.154872+00:00.zip +GRAMMONT_2026-01-01T17:46:27.751280+00:00.zip +GRAMMONT_2026-01-01T21:31:28.388140+00:00.zip +GRAMMONT_2026-01-09T00:01:37.412545+00:00.zip +GRAMMONT_2026-01-09T05:31:25.188144+00:00.zip +GRAMMONT_2026-01-09T07:31:19.934715+00:00.zip +GRAMMONT_2026-01-09T07:46:17.007279+00:00.zip +GRAMMONT_2026-01-09T08:16:17.338747+00:00.zip +GRAMMONT_2026-01-09T10:31:15.867851+00:00.zip +GRAMMONT_2026-01-09T18:16:25.202124+00:00.zip +GRAMMONT_2026-01-09T19:16:26.688542+00:00.zip +GRAMMONT_2026-01-09T20:01:22.748919+00:00.zip +GRAMMONT_2026-01-09T23:31:26.962588+00:00.zip +GRAMMONT_2026-02-11T00:01:38.048234+00:00.zip +GRAMMONT_2026-02-11T01:01:25.496751+00:00.zip +GRAMMONT_2026-02-11T04:31:25.749592+00:00.zip +GRAMMONT_2026-02-11T04:46:26.124855+00:00.zip +GRAMMONT_2026-02-11T05:31:27.076000+00:00.zip +GRAMMONT_2026-02-11T05:46:26.426312+00:00.zip +GRAMMONT_2026-02-11T06:01:26.452689+00:00.zip +GRAMMONT_2026-02-11T06:16:26.767560+00:00.zip +GRAMMONT_2026-02-11T06:31:24.624797+00:00.zip +GRAMMONT_2026-02-11T06:46:23.455019+00:00.zip +GRAMMONT_2026-02-11T07:01:21.080045+00:00.zip +GRAMMONT_2026-02-11T07:16:20.533988+00:00.zip +GRAMMONT_2026-02-11T07:31:19.697932+00:00.zip +GRAMMONT_2026-02-11T08:01:19.085138+00:00.zip +GRAMMONT_2026-02-11T09:16:17.805079+00:00.zip +GRAMMONT_2026-02-11T09:31:17.747859+00:00.zip +GRAMMONT_2026-02-11T12:31:20.771768+00:00.zip +GRAMMONT_2026-02-11T15:46:19.819366+00:00.zip +GRAMMONT_2026-02-11T16:31:20.659090+00:00.zip +GRAMMONT_2026-02-11T16:46:19.780502+00:00.zip +GRAMMONT_2026-02-11T17:01:25.098410+00:00.zip +GRAMMONT_2026-02-11T17:16:25.979320+00:00.zip +GRAMMONT_2026-02-11T17:31:27.522773+00:00.zip +GRAMMONT_2026-02-11T17:46:26.400124+00:00.zip +GRAMMONT_2026-02-11T18:01:25.365899+00:00.zip +GRAMMONT_2026-02-11T18:16:25.442042+00:00.zip +GRAMMONT_2026-02-11T21:01:25.956306+00:00.zip +GRAMMONT_2026-02-11T21:31:26.462852+00:00.zip +GRAMMONT_2026-02-11T22:31:26.629631+00:00.zip diff --git a/config/annotator_B.txt b/config/annotator_B.txt new file mode 100644 index 0000000..e073659 --- /dev/null +++ b/config/annotator_B.txt @@ -0,0 +1,128 @@ +GRAMMONT_2025-11-18T00:02:16.622295+00:00.zip +GRAMMONT_2025-11-18T01:02:05.553357+00:00.zip +GRAMMONT_2025-11-18T01:17:10.391972+00:00.zip +GRAMMONT_2025-11-18T04:02:09.847002+00:00.zip +GRAMMONT_2025-11-18T05:47:07.350141+00:00.zip +GRAMMONT_2025-11-18T06:02:06.960352+00:00.zip +GRAMMONT_2025-11-18T06:17:11.438801+00:00.zip +GRAMMONT_2025-11-18T06:32:10.134718+00:00.zip +GRAMMONT_2025-11-18T06:47:05.176210+00:00.zip +GRAMMONT_2025-11-18T07:02:05.016401+00:00.zip +GRAMMONT_2025-11-18T07:17:04.505510+00:00.zip +GRAMMONT_2025-11-18T07:32:02.052621+00:00.zip +GRAMMONT_2025-11-18T10:47:04.410566+00:00.zip +GRAMMONT_2025-11-18T14:47:02.368668+00:00.zip +GRAMMONT_2025-11-18T15:32:07.687051+00:00.zip +GRAMMONT_2025-11-18T15:47:04.708858+00:00.zip +GRAMMONT_2025-11-18T16:02:08.642632+00:00.zip +GRAMMONT_2025-11-18T16:17:08.155540+00:00.zip +GRAMMONT_2025-11-18T16:32:08.782723+00:00.zip +GRAMMONT_2025-11-18T16:47:09.340365+00:00.zip +GRAMMONT_2025-11-18T17:02:04.224910+00:00.zip +GRAMMONT_2025-11-18T17:17:06.866509+00:00.zip +GRAMMONT_2025-11-18T18:32:07.533041+00:00.zip +GRAMMONT_2025-12-05T00:02:14.666551+00:00.zip +GRAMMONT_2025-12-05T01:02:05.794759+00:00.zip +GRAMMONT_2025-12-05T01:17:05.892742+00:00.zip +GRAMMONT_2025-12-05T04:02:05.727427+00:00.zip +GRAMMONT_2025-12-05T06:17:08.164092+00:00.zip +GRAMMONT_2025-12-05T06:32:04.336644+00:00.zip +GRAMMONT_2025-12-05T06:47:08.004420+00:00.zip +GRAMMONT_2025-12-05T07:02:05.120746+00:00.zip +GRAMMONT_2025-12-05T07:17:05.647473+00:00.zip +GRAMMONT_2025-12-05T07:32:01.392138+00:00.zip +GRAMMONT_2025-12-05T07:47:01.666820+00:00.zip +GRAMMONT_2025-12-05T08:02:03.470785+00:00.zip +GRAMMONT_2025-12-05T08:31:58.765660+00:00.zip +GRAMMONT_2025-12-05T10:17:01.321795+00:00.zip +GRAMMONT_2025-12-05T12:01:57.048658+00:00.zip +GRAMMONT_2025-12-05T15:17:05.187833+00:00.zip +GRAMMONT_2025-12-05T15:32:03.494960+00:00.zip +GRAMMONT_2025-12-05T15:47:03.966183+00:00.zip +GRAMMONT_2025-12-05T16:02:08.393449+00:00.zip +GRAMMONT_2025-12-05T16:17:04.961643+00:00.zip +GRAMMONT_2025-12-05T16:32:07.906681+00:00.zip +GRAMMONT_2025-12-05T16:47:06.887564+00:00.zip +GRAMMONT_2025-12-05T17:02:06.990948+00:00.zip +GRAMMONT_2025-12-05T18:02:04.071706+00:00.zip +GRAMMONT_2025-12-05T18:32:05.013636+00:00.zip +GRAMMONT_2026-01-06T00:01:37.202493+00:00.zip +GRAMMONT_2026-01-06T01:01:27.022847+00:00.zip +GRAMMONT_2026-01-06T01:16:27.094115+00:00.zip +GRAMMONT_2026-01-06T04:31:26.976344+00:00.zip +GRAMMONT_2026-01-06T06:16:26.327723+00:00.zip +GRAMMONT_2026-01-06T06:31:24.587899+00:00.zip +GRAMMONT_2026-01-06T06:46:27.517903+00:00.zip +GRAMMONT_2026-01-06T07:01:26.908449+00:00.zip +GRAMMONT_2026-01-06T07:16:24.967227+00:00.zip +GRAMMONT_2026-01-06T07:31:21.683307+00:00.zip +GRAMMONT_2026-01-06T07:46:19.343980+00:00.zip +GRAMMONT_2026-01-06T08:01:19.342405+00:00.zip +GRAMMONT_2026-01-06T08:16:18.724209+00:00.zip +GRAMMONT_2026-01-06T09:31:17.414283+00:00.zip +GRAMMONT_2026-01-06T09:46:18.170203+00:00.zip +GRAMMONT_2026-01-06T10:01:18.539805+00:00.zip +GRAMMONT_2026-01-06T10:16:18.307980+00:00.zip +GRAMMONT_2026-01-06T10:31:19.164363+00:00.zip +GRAMMONT_2026-01-06T10:46:19.104227+00:00.zip +GRAMMONT_2026-01-06T11:16:18.209654+00:00.zip +GRAMMONT_2026-01-06T11:31:16.324483+00:00.zip +GRAMMONT_2026-01-06T11:46:17.515591+00:00.zip +GRAMMONT_2026-01-06T15:46:18.998119+00:00.zip +GRAMMONT_2026-01-06T16:01:21.931831+00:00.zip +GRAMMONT_2026-01-06T16:16:24.878420+00:00.zip +GRAMMONT_2026-01-06T16:31:28.169775+00:00.zip +GRAMMONT_2026-01-06T16:46:26.848151+00:00.zip +GRAMMONT_2026-01-06T17:01:27.977881+00:00.zip +GRAMMONT_2026-01-06T17:16:26.719117+00:00.zip +GRAMMONT_2026-01-06T17:31:26.921538+00:00.zip +GRAMMONT_2026-01-06T17:46:26.350830+00:00.zip +GRAMMONT_2026-01-06T21:31:27.184279+00:00.zip +GRAMMONT_2026-02-12T00:01:39.500750+00:00.zip +GRAMMONT_2026-02-12T04:46:27.068804+00:00.zip +GRAMMONT_2026-02-12T05:31:26.143011+00:00.zip +GRAMMONT_2026-02-12T05:46:25.853849+00:00.zip +GRAMMONT_2026-02-12T06:01:25.057912+00:00.zip +GRAMMONT_2026-02-12T06:16:25.744052+00:00.zip +GRAMMONT_2026-02-12T06:31:24.481263+00:00.zip +GRAMMONT_2026-02-12T06:46:24.932963+00:00.zip +GRAMMONT_2026-02-12T07:01:21.846587+00:00.zip +GRAMMONT_2026-02-12T07:16:21.265797+00:00.zip +GRAMMONT_2026-02-12T07:31:18.457424+00:00.zip +GRAMMONT_2026-02-12T07:46:20.250231+00:00.zip +GRAMMONT_2026-02-12T12:31:19.009888+00:00.zip +GRAMMONT_2026-02-12T15:01:19.953447+00:00.zip +GRAMMONT_2026-02-12T15:46:19.735032+00:00.zip +GRAMMONT_2026-02-12T16:31:19.052209+00:00.zip +GRAMMONT_2026-02-12T16:46:21.632499+00:00.zip +GRAMMONT_2026-02-12T17:01:25.223457+00:00.zip +GRAMMONT_2026-02-12T17:16:26.740464+00:00.zip +GRAMMONT_2026-02-12T17:31:25.908204+00:00.zip +GRAMMONT_2026-02-12T17:46:26.495323+00:00.zip +GRAMMONT_2026-02-12T18:01:26.530360+00:00.zip +GRAMMONT_2026-02-12T21:01:27.495845+00:00.zip +GRAMMONT_2026-02-12T22:31:26.172360+00:00.zip +GRAMMONT_2026-03-02T00:02:00.235433+00:00.zip +GRAMMONT_2026-03-02T01:16:49.553391+00:00.zip +GRAMMONT_2026-03-02T03:16:48.829809+00:00.zip +GRAMMONT_2026-03-02T05:01:49.656932+00:00.zip +GRAMMONT_2026-03-02T05:16:48.603914+00:00.zip +GRAMMONT_2026-03-02T05:31:48.072998+00:00.zip +GRAMMONT_2026-03-02T05:46:48.743481+00:00.zip +GRAMMONT_2026-03-02T06:01:46.583618+00:00.zip +GRAMMONT_2026-03-02T06:16:45.405628+00:00.zip +GRAMMONT_2026-03-02T06:31:43.415579+00:00.zip +GRAMMONT_2026-03-02T06:46:41.697932+00:00.zip +GRAMMONT_2026-03-02T07:01:41.696734+00:00.zip +GRAMMONT_2026-03-02T12:16:42.701851+00:00.zip +GRAMMONT_2026-03-02T15:31:41.098916+00:00.zip +GRAMMONT_2026-03-02T16:01:39.517501+00:00.zip +GRAMMONT_2026-03-02T16:46:42.561834+00:00.zip +GRAMMONT_2026-03-02T17:01:41.958941+00:00.zip +GRAMMONT_2026-03-02T17:16:46.511805+00:00.zip +GRAMMONT_2026-03-02T17:31:48.499316+00:00.zip +GRAMMONT_2026-03-02T17:46:48.055562+00:00.zip +GRAMMONT_2026-03-02T18:01:47.851974+00:00.zip +GRAMMONT_2026-03-02T18:16:48.002798+00:00.zip +GRAMMONT_2026-03-02T18:31:48.973226+00:00.zip +GRAMMONT_2026-03-02T21:16:48.406731+00:00.zip diff --git a/config/annotator_C.txt b/config/annotator_C.txt new file mode 100644 index 0000000..e0ffda5 --- /dev/null +++ b/config/annotator_C.txt @@ -0,0 +1,146 @@ +GRAMMONT_2025-11-22T00:02:20.203920+00:00.zip +GRAMMONT_2025-11-22T01:02:09.028313+00:00.zip +GRAMMONT_2025-11-22T01:17:07.434190+00:00.zip +GRAMMONT_2025-11-22T04:02:07.766345+00:00.zip +GRAMMONT_2025-11-22T05:32:06.253178+00:00.zip +GRAMMONT_2025-11-22T05:47:05.799143+00:00.zip +GRAMMONT_2025-11-22T06:02:04.909260+00:00.zip +GRAMMONT_2025-11-22T06:17:06.901461+00:00.zip +GRAMMONT_2025-11-22T06:31:15.263893+00:00.zip +GRAMMONT_2025-11-22T06:47:02.531210+00:00.zip +GRAMMONT_2025-11-22T07:02:01.143506+00:00.zip +GRAMMONT_2025-11-22T07:16:59.906656+00:00.zip +GRAMMONT_2025-11-22T10:47:02.705611+00:00.zip +GRAMMONT_2025-11-22T14:47:00.096714+00:00.zip +GRAMMONT_2025-11-22T15:32:01.015469+00:00.zip +GRAMMONT_2025-11-22T15:47:02.337459+00:00.zip +GRAMMONT_2025-11-22T16:02:04.420357+00:00.zip +GRAMMONT_2025-11-22T16:17:04.468696+00:00.zip +GRAMMONT_2025-11-22T16:32:07.616206+00:00.zip +GRAMMONT_2025-11-22T16:47:04.224377+00:00.zip +GRAMMONT_2025-11-22T17:02:08.264697+00:00.zip +GRAMMONT_2025-11-22T17:17:06.243128+00:00.zip +GRAMMONT_2025-11-22T18:32:05.405485+00:00.zip +GRAMMONT_2025-12-12T00:02:16.601750+00:00.zip +GRAMMONT_2025-12-12T01:02:06.959165+00:00.zip +GRAMMONT_2025-12-12T01:17:05.555142+00:00.zip +GRAMMONT_2025-12-12T04:02:05.356836+00:00.zip +GRAMMONT_2025-12-12T06:02:09.414012+00:00.zip +GRAMMONT_2025-12-12T06:17:06.397863+00:00.zip +GRAMMONT_2025-12-12T06:32:06.114230+00:00.zip +GRAMMONT_2025-12-12T06:47:07.413290+00:00.zip +GRAMMONT_2025-12-12T07:02:05.817199+00:00.zip +GRAMMONT_2025-12-12T07:17:05.572966+00:00.zip +GRAMMONT_2025-12-12T07:32:07.077808+00:00.zip +GRAMMONT_2025-12-12T07:47:02.952666+00:00.zip +GRAMMONT_2025-12-12T08:02:01.468729+00:00.zip +GRAMMONT_2025-12-12T09:32:00.091785+00:00.zip +GRAMMONT_2025-12-12T10:47:03.611372+00:00.zip +GRAMMONT_2025-12-12T15:01:24.078486+00:00.zip +GRAMMONT_2025-12-12T15:16:25.132035+00:00.zip +GRAMMONT_2025-12-12T15:31:27.076379+00:00.zip +GRAMMONT_2025-12-12T15:46:29.057871+00:00.zip +GRAMMONT_2025-12-12T16:01:29.207153+00:00.zip +GRAMMONT_2025-12-12T16:16:28.383856+00:00.zip +GRAMMONT_2025-12-12T16:31:29.989159+00:00.zip +GRAMMONT_2025-12-12T16:46:27.655685+00:00.zip +GRAMMONT_2025-12-12T17:01:26.768672+00:00.zip +GRAMMONT_2025-12-12T18:01:28.364073+00:00.zip +GRAMMONT_2025-12-12T18:31:28.885962+00:00.zip +GRAMMONT_2026-01-07T00:01:36.564462+00:00.zip +GRAMMONT_2026-01-07T01:01:25.835453+00:00.zip +GRAMMONT_2026-01-07T05:31:25.879211+00:00.zip +GRAMMONT_2026-01-07T06:01:27.118806+00:00.zip +GRAMMONT_2026-01-07T06:16:26.590606+00:00.zip +GRAMMONT_2026-01-07T06:31:11.284211+00:00.zip +GRAMMONT_2026-01-07T06:46:28.122544+00:00.zip +GRAMMONT_2026-01-07T07:01:26.812905+00:00.zip +GRAMMONT_2026-01-07T07:16:21.745008+00:00.zip +GRAMMONT_2026-01-07T07:31:20.991098+00:00.zip +GRAMMONT_2026-01-07T07:46:18.360296+00:00.zip +GRAMMONT_2026-01-07T08:01:18.000617+00:00.zip +GRAMMONT_2026-01-07T09:31:17.177515+00:00.zip +GRAMMONT_2026-01-07T09:46:16.591998+00:00.zip +GRAMMONT_2026-01-07T10:01:17.593665+00:00.zip +GRAMMONT_2026-01-07T10:16:17.565584+00:00.zip +GRAMMONT_2026-01-07T10:46:18.688218+00:00.zip +GRAMMONT_2026-01-07T15:46:20.163005+00:00.zip +GRAMMONT_2026-01-07T16:01:19.900623+00:00.zip +GRAMMONT_2026-01-07T16:16:25.483667+00:00.zip +GRAMMONT_2026-01-07T16:31:26.102407+00:00.zip +GRAMMONT_2026-01-07T16:46:27.893362+00:00.zip +GRAMMONT_2026-01-07T17:01:26.885062+00:00.zip +GRAMMONT_2026-01-07T17:16:26.349277+00:00.zip +GRAMMONT_2026-01-07T18:31:27.062101+00:00.zip +GRAMMONT_2026-01-07T18:46:27.409224+00:00.zip +GRAMMONT_2026-01-07T19:16:27.442506+00:00.zip +GRAMMONT_2026-01-07T19:31:27.148138+00:00.zip +GRAMMONT_2026-01-07T19:46:27.178939+00:00.zip +GRAMMONT_2026-01-07T20:01:26.118404+00:00.zip +GRAMMONT_2026-01-07T20:16:26.814555+00:00.zip +GRAMMONT_2026-01-07T20:31:26.777464+00:00.zip +GRAMMONT_2026-01-07T21:01:28.269021+00:00.zip +GRAMMONT_2026-01-07T21:16:26.280773+00:00.zip +GRAMMONT_2026-01-07T22:01:25.272933+00:00.zip +GRAMMONT_2026-01-07T22:46:25.828656+00:00.zip +GRAMMONT_2026-02-16T00:01:49.506731+00:00.zip +GRAMMONT_2026-02-16T02:01:38.207676+00:00.zip +GRAMMONT_2026-02-16T05:31:38.138921+00:00.zip +GRAMMONT_2026-02-16T05:46:39.421285+00:00.zip +GRAMMONT_2026-02-16T06:01:41.482751+00:00.zip +GRAMMONT_2026-02-16T06:16:38.604849+00:00.zip +GRAMMONT_2026-02-16T06:31:38.007478+00:00.zip +GRAMMONT_2026-02-16T06:46:38.680991+00:00.zip +GRAMMONT_2026-02-16T07:01:36.215933+00:00.zip +GRAMMONT_2026-02-16T07:16:32.902087+00:00.zip +GRAMMONT_2026-02-16T07:31:32.860647+00:00.zip +GRAMMONT_2026-02-16T07:46:32.874607+00:00.zip +GRAMMONT_2026-02-16T08:31:30.794666+00:00.zip +GRAMMONT_2026-02-16T09:16:29.781662+00:00.zip +GRAMMONT_2026-02-16T09:31:32.218357+00:00.zip +GRAMMONT_2026-02-16T09:46:28.532516+00:00.zip +GRAMMONT_2026-02-16T10:01:29.027530+00:00.zip +GRAMMONT_2026-02-16T10:16:30.952271+00:00.zip +GRAMMONT_2026-02-16T10:31:30.118460+00:00.zip +GRAMMONT_2026-02-16T10:46:30.728758+00:00.zip +GRAMMONT_2026-02-16T11:01:29.613915+00:00.zip +GRAMMONT_2026-02-16T11:16:27.770965+00:00.zip +GRAMMONT_2026-02-16T11:31:28.625626+00:00.zip +GRAMMONT_2026-02-16T11:46:27.599574+00:00.zip +GRAMMONT_2026-02-16T12:01:28.213671+00:00.zip +GRAMMONT_2026-02-16T12:16:28.302803+00:00.zip +GRAMMONT_2026-02-16T12:31:28.683376+00:00.zip +GRAMMONT_2026-02-16T12:46:29.904051+00:00.zip +GRAMMONT_2026-02-16T13:01:28.903040+00:00.zip +GRAMMONT_2026-02-16T13:16:31.554237+00:00.zip +GRAMMONT_2026-02-16T14:01:29.428221+00:00.zip +GRAMMONT_2026-02-16T14:16:28.134941+00:00.zip +GRAMMONT_2026-02-16T14:31:28.642884+00:00.zip +GRAMMONT_2026-02-16T14:46:31.050308+00:00.zip +GRAMMONT_2026-02-16T15:01:36.440533+00:00.zip +GRAMMONT_2026-02-16T18:31:37.721767+00:00.zip +GRAMMONT_2026-03-03T00:02:00.115993+00:00.zip +GRAMMONT_2026-03-03T01:16:47.545369+00:00.zip +GRAMMONT_2026-03-03T03:16:47.017876+00:00.zip +GRAMMONT_2026-03-03T05:01:48.172478+00:00.zip +GRAMMONT_2026-03-03T05:16:48.425996+00:00.zip +GRAMMONT_2026-03-03T05:31:47.535047+00:00.zip +GRAMMONT_2026-03-03T05:46:49.230361+00:00.zip +GRAMMONT_2026-03-03T06:01:48.032458+00:00.zip +GRAMMONT_2026-03-03T06:16:43.444057+00:00.zip +GRAMMONT_2026-03-03T06:31:43.572406+00:00.zip +GRAMMONT_2026-03-03T06:46:42.094886+00:00.zip +GRAMMONT_2026-03-03T07:01:40.412911+00:00.zip +GRAMMONT_2026-03-03T10:46:41.016664+00:00.zip +GRAMMONT_2026-03-03T12:16:43.885012+00:00.zip +GRAMMONT_2026-03-03T15:46:40.689643+00:00.zip +GRAMMONT_2026-03-03T16:16:40.142917+00:00.zip +GRAMMONT_2026-03-03T16:46:42.950814+00:00.zip +GRAMMONT_2026-03-03T17:01:42.774269+00:00.zip +GRAMMONT_2026-03-03T17:16:45.482827+00:00.zip +GRAMMONT_2026-03-03T17:31:48.702675+00:00.zip +GRAMMONT_2026-03-03T17:46:48.793191+00:00.zip +GRAMMONT_2026-03-03T18:01:48.301590+00:00.zip +GRAMMONT_2026-03-03T18:16:47.700676+00:00.zip +GRAMMONT_2026-03-03T18:31:48.737829+00:00.zip +GRAMMONT_2026-03-03T21:16:48.709909+00:00.zip diff --git a/config/annotator_D.txt b/config/annotator_D.txt new file mode 100644 index 0000000..093c4af --- /dev/null +++ b/config/annotator_D.txt @@ -0,0 +1,102 @@ +GRAMMONT_2025-11-18T00:02:16.622295+00:00.zip +GRAMMONT_2025-11-18T01:02:05.553357+00:00.zip +GRAMMONT_2025-11-18T01:17:10.391972+00:00.zip +GRAMMONT_2025-11-18T04:02:09.847002+00:00.zip +GRAMMONT_2025-11-18T05:47:07.350141+00:00.zip +GRAMMONT_2025-11-18T06:02:06.960352+00:00.zip +GRAMMONT_2025-11-18T06:17:11.438801+00:00.zip +GRAMMONT_2025-11-18T06:32:10.134718+00:00.zip +GRAMMONT_2025-11-18T06:47:05.176210+00:00.zip +GRAMMONT_2025-11-18T07:02:05.016401+00:00.zip +GRAMMONT_2025-11-18T07:17:04.505510+00:00.zip +GRAMMONT_2025-11-18T07:32:02.052621+00:00.zip +GRAMMONT_2025-11-18T10:47:04.410566+00:00.zip +GRAMMONT_2025-11-18T14:47:02.368668+00:00.zip +GRAMMONT_2025-11-18T15:32:07.687051+00:00.zip +GRAMMONT_2025-11-18T15:47:04.708858+00:00.zip +GRAMMONT_2025-11-18T16:02:08.642632+00:00.zip +GRAMMONT_2025-11-18T16:17:08.155540+00:00.zip +GRAMMONT_2025-11-18T16:32:08.782723+00:00.zip +GRAMMONT_2025-11-18T16:47:09.340365+00:00.zip +GRAMMONT_2025-11-18T17:02:04.224910+00:00.zip +GRAMMONT_2025-11-18T17:17:06.866509+00:00.zip +GRAMMONT_2025-11-18T18:32:07.533041+00:00.zip +GRAMMONT_2025-11-24T00:02:17.220289+00:00.zip +GRAMMONT_2025-11-24T01:17:06.672762+00:00.zip +GRAMMONT_2025-11-24T05:32:04.261510+00:00.zip +GRAMMONT_2025-11-24T05:47:09.391030+00:00.zip +GRAMMONT_2025-11-24T06:02:08.824582+00:00.zip +GRAMMONT_2025-11-24T06:17:07.446905+00:00.zip +GRAMMONT_2025-11-24T06:32:00.494092+00:00.zip +GRAMMONT_2025-11-24T06:47:07.344360+00:00.zip +GRAMMONT_2025-11-24T07:02:04.529073+00:00.zip +GRAMMONT_2025-11-24T07:17:05.544853+00:00.zip +GRAMMONT_2025-11-24T07:32:01.891046+00:00.zip +GRAMMONT_2025-11-24T07:47:00.405018+00:00.zip +GRAMMONT_2025-11-24T10:01:59.266718+00:00.zip +GRAMMONT_2025-11-24T14:47:01.734141+00:00.zip +GRAMMONT_2025-11-24T15:02:01.452351+00:00.zip +GRAMMONT_2025-11-24T15:17:01.907373+00:00.zip +GRAMMONT_2025-11-24T15:32:04.746603+00:00.zip +GRAMMONT_2025-11-24T15:47:05.257663+00:00.zip +GRAMMONT_2025-11-24T16:02:03.851786+00:00.zip +GRAMMONT_2025-11-24T16:17:08.370715+00:00.zip +GRAMMONT_2025-11-24T16:32:06.312360+00:00.zip +GRAMMONT_2025-11-24T18:02:05.411421+00:00.zip +GRAMMONT_2025-11-24T19:02:04.658144+00:00.zip +GRAMMONT_2025-12-16T00:01:41.461226+00:00.zip +GRAMMONT_2025-12-16T01:16:28.567684+00:00.zip +GRAMMONT_2025-12-16T06:31:29.022557+00:00.zip +GRAMMONT_2025-12-16T06:46:29.500063+00:00.zip +GRAMMONT_2025-12-16T07:01:31.526934+00:00.zip +GRAMMONT_2025-12-16T07:16:30.177120+00:00.zip +GRAMMONT_2025-12-16T07:31:27.010355+00:00.zip +GRAMMONT_2025-12-16T07:46:26.162426+00:00.zip +GRAMMONT_2025-12-16T08:16:25.350003+00:00.zip +GRAMMONT_2025-12-16T10:46:24.286299+00:00.zip +GRAMMONT_2025-12-16T11:16:24.959519+00:00.zip +GRAMMONT_2025-12-16T15:01:24.622331+00:00.zip +GRAMMONT_2025-12-16T15:16:25.305782+00:00.zip +GRAMMONT_2025-12-16T15:31:26.923661+00:00.zip +GRAMMONT_2025-12-16T15:46:30.091662+00:00.zip +GRAMMONT_2025-12-16T16:01:30.164684+00:00.zip +GRAMMONT_2025-12-16T16:16:26.912740+00:00.zip +GRAMMONT_2025-12-16T16:31:27.805273+00:00.zip +GRAMMONT_2025-12-16T16:46:28.631751+00:00.zip +GRAMMONT_2025-12-16T20:16:27.452196+00:00.zip +GRAMMONT_2025-12-16T21:01:28.663624+00:00.zip +GRAMMONT_2025-12-16T23:01:30.036410+00:00.zip +GRAMMONT_2026-01-08T00:01:38.571453+00:00.zip +GRAMMONT_2026-01-08T01:01:25.664393+00:00.zip +GRAMMONT_2026-01-08T02:31:25.943832+00:00.zip +GRAMMONT_2026-01-08T08:16:17.392212+00:00.zip +GRAMMONT_2026-01-08T08:46:16.720207+00:00.zip +GRAMMONT_2026-01-08T09:31:15.801732+00:00.zip +GRAMMONT_2026-01-08T18:16:24.859999+00:00.zip +GRAMMONT_2026-01-08T19:16:25.609118+00:00.zip +GRAMMONT_2026-01-08T20:01:24.809889+00:00.zip +GRAMMONT_2026-01-08T23:31:24.989446+00:00.zip +GRAMMONT_2026-03-02T00:02:00.235433+00:00.zip +GRAMMONT_2026-03-02T01:16:49.553391+00:00.zip +GRAMMONT_2026-03-02T03:16:48.829809+00:00.zip +GRAMMONT_2026-03-02T05:01:49.656932+00:00.zip +GRAMMONT_2026-03-02T05:16:48.603914+00:00.zip +GRAMMONT_2026-03-02T05:31:48.072998+00:00.zip +GRAMMONT_2026-03-02T05:46:48.743481+00:00.zip +GRAMMONT_2026-03-02T06:01:46.583618+00:00.zip +GRAMMONT_2026-03-02T06:16:45.405628+00:00.zip +GRAMMONT_2026-03-02T06:31:43.415579+00:00.zip +GRAMMONT_2026-03-02T06:46:41.697932+00:00.zip +GRAMMONT_2026-03-02T07:01:41.696734+00:00.zip +GRAMMONT_2026-03-02T12:16:42.701851+00:00.zip +GRAMMONT_2026-03-02T15:31:41.098916+00:00.zip +GRAMMONT_2026-03-02T16:01:39.517501+00:00.zip +GRAMMONT_2026-03-02T16:46:42.561834+00:00.zip +GRAMMONT_2026-03-02T17:01:41.958941+00:00.zip +GRAMMONT_2026-03-02T17:16:46.511805+00:00.zip +GRAMMONT_2026-03-02T17:31:48.499316+00:00.zip +GRAMMONT_2026-03-02T17:46:48.055562+00:00.zip +GRAMMONT_2026-03-02T18:01:47.851974+00:00.zip +GRAMMONT_2026-03-02T18:16:48.002798+00:00.zip +GRAMMONT_2026-03-02T18:31:48.973226+00:00.zip +GRAMMONT_2026-03-02T21:16:48.406731+00:00.zip diff --git a/config/annotator_E.txt b/config/annotator_E.txt new file mode 100644 index 0000000..00a66ab --- /dev/null +++ b/config/annotator_E.txt @@ -0,0 +1,80 @@ +GRAMMONT_2025-11-25T00:02:17.876729+00:00.zip +GRAMMONT_2025-11-25T01:02:05.646001+00:00.zip +GRAMMONT_2025-11-25T01:17:09.894834+00:00.zip +GRAMMONT_2025-11-25T04:02:04.433980+00:00.zip +GRAMMONT_2025-11-25T05:47:07.666696+00:00.zip +GRAMMONT_2025-11-25T06:02:11.511745+00:00.zip +GRAMMONT_2025-11-25T06:17:04.055068+00:00.zip +GRAMMONT_2025-11-25T06:32:09.079202+00:00.zip +GRAMMONT_2025-11-25T06:46:29.266484+00:00.zip +GRAMMONT_2025-11-25T07:02:05.006324+00:00.zip +GRAMMONT_2025-11-25T07:17:04.431043+00:00.zip +GRAMMONT_2025-11-25T07:32:00.875367+00:00.zip +GRAMMONT_2025-11-25T08:32:01.921526+00:00.zip +GRAMMONT_2025-11-25T14:02:00.055854+00:00.zip +GRAMMONT_2025-11-25T15:02:00.674240+00:00.zip +GRAMMONT_2025-11-25T15:32:04.667759+00:00.zip +GRAMMONT_2025-11-25T15:47:03.201793+00:00.zip +GRAMMONT_2025-11-25T16:02:06.850816+00:00.zip +GRAMMONT_2025-11-25T16:17:04.360406+00:00.zip +GRAMMONT_2025-11-25T16:32:09.562770+00:00.zip +GRAMMONT_2025-11-25T16:47:04.872758+00:00.zip +GRAMMONT_2025-11-25T17:02:08.046657+00:00.zip +GRAMMONT_2025-12-03T09:32:01.515556+00:00.zip +GRAMMONT_2025-12-03T11:17:03.118822+00:00.zip +GRAMMONT_2025-12-03T15:17:03.043807+00:00.zip +GRAMMONT_2025-12-03T15:32:04.085232+00:00.zip +GRAMMONT_2025-12-03T15:47:04.864411+00:00.zip +GRAMMONT_2025-12-03T16:02:09.554901+00:00.zip +GRAMMONT_2025-12-03T16:17:07.984380+00:00.zip +GRAMMONT_2025-12-03T16:32:06.042132+00:00.zip +GRAMMONT_2025-12-03T16:47:05.425453+00:00.zip +GRAMMONT_2025-12-03T17:02:05.867456+00:00.zip +GRAMMONT_2025-12-03T19:17:08.208705+00:00.zip +GRAMMONT_2025-12-03T19:47:07.924056+00:00.zip +GRAMMONT_2025-12-03T20:17:04.802945+00:00.zip +GRAMMONT_2025-12-03T23:17:04.368047+00:00.zip +GRAMMONT_2026-01-09T00:01:37.412545+00:00.zip +GRAMMONT_2026-01-09T05:31:25.188144+00:00.zip +GRAMMONT_2026-01-09T07:31:19.934715+00:00.zip +GRAMMONT_2026-01-09T07:46:17.007279+00:00.zip +GRAMMONT_2026-01-09T08:16:17.338747+00:00.zip +GRAMMONT_2026-01-09T10:31:15.867851+00:00.zip +GRAMMONT_2026-01-09T18:16:25.202124+00:00.zip +GRAMMONT_2026-01-09T19:16:26.688542+00:00.zip +GRAMMONT_2026-01-09T20:01:22.748919+00:00.zip +GRAMMONT_2026-01-09T23:31:26.962588+00:00.zip +GRAMMONT_2026-01-12T01:01:26.080207+00:00.zip +GRAMMONT_2026-01-12T04:01:26.663453+00:00.zip +GRAMMONT_2026-01-12T08:16:16.249789+00:00.zip +GRAMMONT_2026-01-12T09:31:16.196442+00:00.zip +GRAMMONT_2026-01-12T18:16:26.514693+00:00.zip +GRAMMONT_2026-01-12T18:31:25.430227+00:00.zip +GRAMMONT_2026-01-12T19:16:26.610193+00:00.zip +GRAMMONT_2026-01-12T20:01:26.503739+00:00.zip +GRAMMONT_2026-01-12T23:31:26.591525+00:00.zip +GRAMMONT_2026-03-03T00:02:00.115993+00:00.zip +GRAMMONT_2026-03-03T01:16:47.545369+00:00.zip +GRAMMONT_2026-03-03T03:16:47.017876+00:00.zip +GRAMMONT_2026-03-03T05:01:48.172478+00:00.zip +GRAMMONT_2026-03-03T05:16:48.425996+00:00.zip +GRAMMONT_2026-03-03T05:31:47.535047+00:00.zip +GRAMMONT_2026-03-03T05:46:49.230361+00:00.zip +GRAMMONT_2026-03-03T06:01:48.032458+00:00.zip +GRAMMONT_2026-03-03T06:16:43.444057+00:00.zip +GRAMMONT_2026-03-03T06:31:43.572406+00:00.zip +GRAMMONT_2026-03-03T06:46:42.094886+00:00.zip +GRAMMONT_2026-03-03T07:01:40.412911+00:00.zip +GRAMMONT_2026-03-03T10:46:41.016664+00:00.zip +GRAMMONT_2026-03-03T12:16:43.885012+00:00.zip +GRAMMONT_2026-03-03T15:46:40.689643+00:00.zip +GRAMMONT_2026-03-03T16:16:40.142917+00:00.zip +GRAMMONT_2026-03-03T16:46:42.950814+00:00.zip +GRAMMONT_2026-03-03T17:01:42.774269+00:00.zip +GRAMMONT_2026-03-03T17:16:45.482827+00:00.zip +GRAMMONT_2026-03-03T17:31:48.702675+00:00.zip +GRAMMONT_2026-03-03T17:46:48.793191+00:00.zip +GRAMMONT_2026-03-03T18:01:48.301590+00:00.zip +GRAMMONT_2026-03-03T18:16:47.700676+00:00.zip +GRAMMONT_2026-03-03T18:31:48.737829+00:00.zip +GRAMMONT_2026-03-03T21:16:48.709909+00:00.zip diff --git a/config/annotator_F.txt b/config/annotator_F.txt new file mode 100644 index 0000000..6423d0f --- /dev/null +++ b/config/annotator_F.txt @@ -0,0 +1,93 @@ +GRAMMONT_2025-11-25T00:02:17.876729+00:00.zip +GRAMMONT_2025-11-25T01:02:05.646001+00:00.zip +GRAMMONT_2025-11-25T01:17:09.894834+00:00.zip +GRAMMONT_2025-11-25T04:02:04.433980+00:00.zip +GRAMMONT_2025-11-25T05:47:07.666696+00:00.zip +GRAMMONT_2025-11-25T06:02:11.511745+00:00.zip +GRAMMONT_2025-11-25T06:17:04.055068+00:00.zip +GRAMMONT_2025-11-25T06:32:09.079202+00:00.zip +GRAMMONT_2025-11-25T06:46:29.266484+00:00.zip +GRAMMONT_2025-11-25T07:02:05.006324+00:00.zip +GRAMMONT_2025-11-25T07:17:04.431043+00:00.zip +GRAMMONT_2025-11-25T07:32:00.875367+00:00.zip +GRAMMONT_2025-11-25T08:32:01.921526+00:00.zip +GRAMMONT_2025-11-25T14:02:00.055854+00:00.zip +GRAMMONT_2025-11-25T15:02:00.674240+00:00.zip +GRAMMONT_2025-11-25T15:32:04.667759+00:00.zip +GRAMMONT_2025-11-25T15:47:03.201793+00:00.zip +GRAMMONT_2025-11-25T16:02:06.850816+00:00.zip +GRAMMONT_2025-11-25T16:17:04.360406+00:00.zip +GRAMMONT_2025-11-25T16:32:09.562770+00:00.zip +GRAMMONT_2025-11-25T16:47:04.872758+00:00.zip +GRAMMONT_2025-11-25T17:02:08.046657+00:00.zip +GRAMMONT_2025-12-16T00:01:41.461226+00:00.zip +GRAMMONT_2025-12-16T01:16:28.567684+00:00.zip +GRAMMONT_2025-12-16T06:31:29.022557+00:00.zip +GRAMMONT_2025-12-16T06:46:29.500063+00:00.zip +GRAMMONT_2025-12-16T07:01:31.526934+00:00.zip +GRAMMONT_2025-12-16T07:16:30.177120+00:00.zip +GRAMMONT_2025-12-16T07:31:27.010355+00:00.zip +GRAMMONT_2025-12-16T07:46:26.162426+00:00.zip +GRAMMONT_2025-12-16T08:16:25.350003+00:00.zip +GRAMMONT_2025-12-16T10:46:24.286299+00:00.zip +GRAMMONT_2025-12-16T11:16:24.959519+00:00.zip +GRAMMONT_2025-12-16T15:01:24.622331+00:00.zip +GRAMMONT_2025-12-16T15:16:25.305782+00:00.zip +GRAMMONT_2025-12-16T15:31:26.923661+00:00.zip +GRAMMONT_2025-12-16T15:46:30.091662+00:00.zip +GRAMMONT_2025-12-16T16:01:30.164684+00:00.zip +GRAMMONT_2025-12-16T16:16:26.912740+00:00.zip +GRAMMONT_2025-12-16T16:31:27.805273+00:00.zip +GRAMMONT_2025-12-16T16:46:28.631751+00:00.zip +GRAMMONT_2025-12-16T20:16:27.452196+00:00.zip +GRAMMONT_2025-12-16T21:01:28.663624+00:00.zip +GRAMMONT_2025-12-16T23:01:30.036410+00:00.zip +GRAMMONT_2026-01-10T00:01:36.641915+00:00.zip +GRAMMONT_2026-01-10T01:01:28.284170+00:00.zip +GRAMMONT_2026-01-10T04:31:29.222060+00:00.zip +GRAMMONT_2026-01-10T08:16:15.986472+00:00.zip +GRAMMONT_2026-01-10T09:31:17.407612+00:00.zip +GRAMMONT_2026-01-10T18:16:28.897049+00:00.zip +GRAMMONT_2026-01-10T19:16:30.647866+00:00.zip +GRAMMONT_2026-01-10T20:01:30.348871+00:00.zip +GRAMMONT_2026-01-10T21:31:29.784010+00:00.zip +GRAMMONT_2026-01-10T23:31:28.564388+00:00.zip +GRAMMONT_2026-01-13T00:01:38.910412+00:00.zip +GRAMMONT_2026-01-13T01:16:26.259757+00:00.zip +GRAMMONT_2026-01-13T13:31:19.055589+00:00.zip +GRAMMONT_2026-01-13T15:01:18.349499+00:00.zip +GRAMMONT_2026-01-13T15:46:18.863149+00:00.zip +GRAMMONT_2026-01-13T16:01:19.328714+00:00.zip +GRAMMONT_2026-01-13T16:16:24.076563+00:00.zip +GRAMMONT_2026-01-13T16:31:26.963254+00:00.zip +GRAMMONT_2026-01-13T16:46:27.052033+00:00.zip +GRAMMONT_2026-01-13T17:01:27.425383+00:00.zip +GRAMMONT_2026-01-13T17:16:27.033257+00:00.zip +GRAMMONT_2026-01-13T17:31:26.566384+00:00.zip +GRAMMONT_2026-01-13T20:16:26.283507+00:00.zip +GRAMMONT_2026-01-13T23:16:26.953217+00:00.zip +GRAMMONT_2026-03-11T00:02:00.572803+00:00.zip +GRAMMONT_2026-03-11T01:16:49.764464+00:00.zip +GRAMMONT_2026-03-11T03:16:48.638128+00:00.zip +GRAMMONT_2026-03-11T04:46:48.636321+00:00.zip +GRAMMONT_2026-03-11T05:01:50.264589+00:00.zip +GRAMMONT_2026-03-11T05:16:49.061370+00:00.zip +GRAMMONT_2026-03-11T05:31:50.354757+00:00.zip +GRAMMONT_2026-03-11T05:46:47.683406+00:00.zip +GRAMMONT_2026-03-11T06:01:45.221459+00:00.zip +GRAMMONT_2026-03-11T06:16:44.072001+00:00.zip +GRAMMONT_2026-03-11T06:31:42.765049+00:00.zip +GRAMMONT_2026-03-11T06:46:42.012177+00:00.zip +GRAMMONT_2026-03-11T11:16:40.882550+00:00.zip +GRAMMONT_2026-03-11T12:01:41.385581+00:00.zip +GRAMMONT_2026-03-11T14:16:41.864888+00:00.zip +GRAMMONT_2026-03-11T15:46:40.760608+00:00.zip +GRAMMONT_2026-03-11T16:46:44.235006+00:00.zip +GRAMMONT_2026-03-11T17:01:44.107636+00:00.zip +GRAMMONT_2026-03-11T17:16:47.593339+00:00.zip +GRAMMONT_2026-03-11T17:31:49.616627+00:00.zip +GRAMMONT_2026-03-11T17:46:49.957863+00:00.zip +GRAMMONT_2026-03-11T18:01:48.458727+00:00.zip +GRAMMONT_2026-03-11T18:16:49.530207+00:00.zip +GRAMMONT_2026-03-11T18:31:48.654295+00:00.zip +GRAMMONT_2026-03-11T23:01:49.104435+00:00.zip diff --git a/config/annotator_G.txt b/config/annotator_G.txt new file mode 100644 index 0000000..b164101 --- /dev/null +++ b/config/annotator_G.txt @@ -0,0 +1,110 @@ +GRAMMONT_2025-11-22T00:02:20.203920+00:00.zip +GRAMMONT_2025-11-22T01:02:09.028313+00:00.zip +GRAMMONT_2025-11-22T01:17:07.434190+00:00.zip +GRAMMONT_2025-11-22T04:02:07.766345+00:00.zip +GRAMMONT_2025-11-22T05:32:06.253178+00:00.zip +GRAMMONT_2025-11-22T05:47:05.799143+00:00.zip +GRAMMONT_2025-11-22T06:02:04.909260+00:00.zip +GRAMMONT_2025-11-22T06:17:06.901461+00:00.zip +GRAMMONT_2025-11-22T06:31:15.263893+00:00.zip +GRAMMONT_2025-11-22T06:47:02.531210+00:00.zip +GRAMMONT_2025-11-22T07:02:01.143506+00:00.zip +GRAMMONT_2025-11-22T07:16:59.906656+00:00.zip +GRAMMONT_2025-11-22T10:47:02.705611+00:00.zip +GRAMMONT_2025-11-22T14:47:00.096714+00:00.zip +GRAMMONT_2025-11-22T15:32:01.015469+00:00.zip +GRAMMONT_2025-11-22T15:47:02.337459+00:00.zip +GRAMMONT_2025-11-22T16:02:04.420357+00:00.zip +GRAMMONT_2025-11-22T16:17:04.468696+00:00.zip +GRAMMONT_2025-11-22T16:32:07.616206+00:00.zip +GRAMMONT_2025-11-22T16:47:04.224377+00:00.zip +GRAMMONT_2025-11-22T17:02:08.264697+00:00.zip +GRAMMONT_2025-11-22T17:17:06.243128+00:00.zip +GRAMMONT_2025-11-22T18:32:05.405485+00:00.zip +GRAMMONT_2025-12-05T00:02:14.666551+00:00.zip +GRAMMONT_2025-12-05T01:02:05.794759+00:00.zip +GRAMMONT_2025-12-05T01:17:05.892742+00:00.zip +GRAMMONT_2025-12-05T04:02:05.727427+00:00.zip +GRAMMONT_2025-12-05T06:17:08.164092+00:00.zip +GRAMMONT_2025-12-05T06:32:04.336644+00:00.zip +GRAMMONT_2025-12-05T06:47:08.004420+00:00.zip +GRAMMONT_2025-12-05T07:02:05.120746+00:00.zip +GRAMMONT_2025-12-05T07:17:05.647473+00:00.zip +GRAMMONT_2025-12-05T07:32:01.392138+00:00.zip +GRAMMONT_2025-12-05T07:47:01.666820+00:00.zip +GRAMMONT_2025-12-05T08:02:03.470785+00:00.zip +GRAMMONT_2025-12-05T08:31:58.765660+00:00.zip +GRAMMONT_2025-12-05T10:17:01.321795+00:00.zip +GRAMMONT_2025-12-05T12:01:57.048658+00:00.zip +GRAMMONT_2025-12-05T15:17:05.187833+00:00.zip +GRAMMONT_2025-12-05T15:32:03.494960+00:00.zip +GRAMMONT_2025-12-05T15:47:03.966183+00:00.zip +GRAMMONT_2025-12-05T16:02:08.393449+00:00.zip +GRAMMONT_2025-12-05T16:17:04.961643+00:00.zip +GRAMMONT_2025-12-05T16:32:07.906681+00:00.zip +GRAMMONT_2025-12-05T16:47:06.887564+00:00.zip +GRAMMONT_2025-12-05T17:02:06.990948+00:00.zip +GRAMMONT_2025-12-05T18:02:04.071706+00:00.zip +GRAMMONT_2025-12-05T18:32:05.013636+00:00.zip +GRAMMONT_2026-01-12T01:01:26.080207+00:00.zip +GRAMMONT_2026-01-12T04:01:26.663453+00:00.zip +GRAMMONT_2026-01-12T08:16:16.249789+00:00.zip +GRAMMONT_2026-01-12T09:31:16.196442+00:00.zip +GRAMMONT_2026-01-12T18:16:26.514693+00:00.zip +GRAMMONT_2026-01-12T18:31:25.430227+00:00.zip +GRAMMONT_2026-01-12T19:16:26.610193+00:00.zip +GRAMMONT_2026-01-12T20:01:26.503739+00:00.zip +GRAMMONT_2026-01-12T23:31:26.591525+00:00.zip +GRAMMONT_2026-02-11T00:01:38.048234+00:00.zip +GRAMMONT_2026-02-11T01:01:25.496751+00:00.zip +GRAMMONT_2026-02-11T04:31:25.749592+00:00.zip +GRAMMONT_2026-02-11T04:46:26.124855+00:00.zip +GRAMMONT_2026-02-11T05:31:27.076000+00:00.zip +GRAMMONT_2026-02-11T05:46:26.426312+00:00.zip +GRAMMONT_2026-02-11T06:01:26.452689+00:00.zip +GRAMMONT_2026-02-11T06:16:26.767560+00:00.zip +GRAMMONT_2026-02-11T06:31:24.624797+00:00.zip +GRAMMONT_2026-02-11T06:46:23.455019+00:00.zip +GRAMMONT_2026-02-11T07:01:21.080045+00:00.zip +GRAMMONT_2026-02-11T07:16:20.533988+00:00.zip +GRAMMONT_2026-02-11T07:31:19.697932+00:00.zip +GRAMMONT_2026-02-11T08:01:19.085138+00:00.zip +GRAMMONT_2026-02-11T09:16:17.805079+00:00.zip +GRAMMONT_2026-02-11T09:31:17.747859+00:00.zip +GRAMMONT_2026-02-11T12:31:20.771768+00:00.zip +GRAMMONT_2026-02-11T15:46:19.819366+00:00.zip +GRAMMONT_2026-02-11T16:31:20.659090+00:00.zip +GRAMMONT_2026-02-11T16:46:19.780502+00:00.zip +GRAMMONT_2026-02-11T17:01:25.098410+00:00.zip +GRAMMONT_2026-02-11T17:16:25.979320+00:00.zip +GRAMMONT_2026-02-11T17:31:27.522773+00:00.zip +GRAMMONT_2026-02-11T17:46:26.400124+00:00.zip +GRAMMONT_2026-02-11T18:01:25.365899+00:00.zip +GRAMMONT_2026-02-11T18:16:25.442042+00:00.zip +GRAMMONT_2026-02-11T21:01:25.956306+00:00.zip +GRAMMONT_2026-02-11T21:31:26.462852+00:00.zip +GRAMMONT_2026-02-11T22:31:26.629631+00:00.zip +GRAMMONT_2026-03-12T00:02:00.756259+00:00.zip +GRAMMONT_2026-03-12T01:16:49.289447+00:00.zip +GRAMMONT_2026-03-12T05:01:48.035876+00:00.zip +GRAMMONT_2026-03-12T05:16:48.883482+00:00.zip +GRAMMONT_2026-03-12T05:31:49.793476+00:00.zip +GRAMMONT_2026-03-12T05:46:49.641480+00:00.zip +GRAMMONT_2026-03-12T06:01:46.587244+00:00.zip +GRAMMONT_2026-03-12T06:16:45.028717+00:00.zip +GRAMMONT_2026-03-12T06:31:43.245102+00:00.zip +GRAMMONT_2026-03-12T06:46:42.052574+00:00.zip +GRAMMONT_2026-03-12T10:46:43.935297+00:00.zip +GRAMMONT_2026-03-12T12:31:42.025147+00:00.zip +GRAMMONT_2026-03-12T15:46:41.830340+00:00.zip +GRAMMONT_2026-03-12T16:16:40.453007+00:00.zip +GRAMMONT_2026-03-12T16:46:42.917700+00:00.zip +GRAMMONT_2026-03-12T17:01:43.627300+00:00.zip +GRAMMONT_2026-03-12T17:16:45.055691+00:00.zip +GRAMMONT_2026-03-12T17:31:48.034432+00:00.zip +GRAMMONT_2026-03-12T17:46:31.239038+00:00.zip +GRAMMONT_2026-03-12T18:01:49.569079+00:00.zip +GRAMMONT_2026-03-12T18:16:47.537836+00:00.zip +GRAMMONT_2026-03-12T18:31:49.125861+00:00.zip +GRAMMONT_2026-03-12T18:46:48.535395+00:00.zip +GRAMMONT_2026-03-12T22:01:49.336660+00:00.zip From 88c8f33dfc844d9bda82eeba22695260c56f2f88 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 17:10:02 +0200 Subject: [PATCH 20/23] update readme link to github --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 65f343e..574b405 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A desktop GUI application for manually annotating river video clips as part of t ```sh # 1. Clone and install -git clone https://github.com/HydroScan/river-annotation-tool +git clone https://gitlab.datascience.ch/industry/aimsight/river-annotation-tool cd river-annotation-tool uv sync From 23dbbc155588d66fb90761fb36ab35fb2d16ff35 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 17:39:49 +0200 Subject: [PATCH 21/23] Fix polygon canvas unzooming when mouse leaves axes Disable matplotlib autoscale after imshow so polygon plot() calls (rubber-band line, vertices) can't expand the view limits. Also reset xlim/ylim explicitly in load_clip for clips with different resolutions. Co-Authored-By: Claude Sonnet 4.6 --- src/river_annotation_tool/mask_canvas.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/river_annotation_tool/mask_canvas.py b/src/river_annotation_tool/mask_canvas.py index 43d4f3c..c52e167 100644 --- a/src/river_annotation_tool/mask_canvas.py +++ b/src/river_annotation_tool/mask_canvas.py @@ -51,6 +51,7 @@ class MaskCanvas: (0, 0), radius=5, fill=False, color="white", linewidth=1.5, visible=False ) self.ax.add_patch(self.brush_circle) + self.ax.autoscale(False) # prevent polygon plot() calls from expanding the view def _build_controls(self): self.btn_erase = QPushButton("Eraser") @@ -137,6 +138,8 @@ class MaskCanvas: self._current_frame = frames[0] self._clear_poly_state() self.img_artist.set_data(self._apply_image_adjustments(frames[0])) + self.ax.set_xlim(-0.5, dw - 0.5) + self.ax.set_ylim(dh - 0.5, -0.5) self.set_title(title) self.redraw() From 69eab7514f3751bd948db952eb158c1a410a80f3 Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 27 May 2026 10:02:20 +0200 Subject: [PATCH 22/23] Made project river-agnostic --- README.md | 70 ++++++---------- pyproject.toml | 4 +- requirements.txt | 18 ++-- .../__init__.py | 0 .../annotation_script.py | 0 .../annotator.py | 2 +- .../clip_selector.py | 0 .../compute_optical_flow.py | 0 .../config.py | 0 .../filesystem.py | 0 .../mask_canvas.py | 0 .../video_loader.py | 0 uv.lock | 84 +++++++++---------- 13 files changed, 81 insertions(+), 97 deletions(-) rename src/{river_annotation_tool => clip_annotator}/__init__.py (100%) rename src/{river_annotation_tool => clip_annotator}/annotation_script.py (100%) rename src/{river_annotation_tool => clip_annotator}/annotator.py (99%) rename src/{river_annotation_tool => clip_annotator}/clip_selector.py (100%) rename src/{river_annotation_tool => clip_annotator}/compute_optical_flow.py (100%) rename src/{river_annotation_tool => clip_annotator}/config.py (100%) rename src/{river_annotation_tool => clip_annotator}/filesystem.py (100%) rename src/{river_annotation_tool => clip_annotator}/mask_canvas.py (100%) rename src/{river_annotation_tool => clip_annotator}/video_loader.py (100%) diff --git a/README.md b/README.md index 574b405..2ddad84 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# River Annotation Tool +# Video Annotation Tool -A desktop GUI application for manually annotating river video clips as part of the [HydroScan](https://github.com/HydroScan) project. Annotators draw pixel-level water masks over river footage and answer structured survey questions about flow conditions, lighting, and scene quality. +A desktop GUI application for manually annotating video clips. Annotators draw pixel-level segmentation masks over footage and answer structured survey questions defined in a config file. ## Requirements @@ -24,7 +24,7 @@ cp config/clips.example.txt config/clips.txt # Edit config/questions.yaml to customise survey questions (optional) # 4. Run -uv run python -m river_annotation_tool.annotation_script +uv run python -m clip_annotator.annotation_script ``` ## Installation @@ -81,9 +81,9 @@ The `clips_file` (the list of clip filenames to annotate) is always read from th ## Usage ```sh -uv run python -m river_annotation_tool.annotation_script +uv run python -m clip_annotator.annotation_script # or, if you have the venv activated: -python -m river_annotation_tool.annotation_script +python -m clip_annotator.annotation_script ``` ### Arguments @@ -94,7 +94,7 @@ python -m river_annotation_tool.annotation_script | `--data` | *(from config)* | Override `data_dir` from config | | `--out` | *(from config)* | Override `out_dir` from config | | `--clips` | *(from config)* | Override `clips_file` from config | -| `--clip` | *(first unannotated in list)* | Open a specific clip by stem name | +| `--clip` | *(first unannotated in list)* | Open a specific clip by its stem name (filename without extension, e.g. `clip_20230501T120000`) | | `--extras` | off | Also save GIFs and extra PNGs (see Output section) | | `--no-skip` | off | Show already-annotated clips instead of skipping them | @@ -102,16 +102,16 @@ python -m river_annotation_tool.annotation_script ```sh # Annotate clips listed in config/clips.txt (default) -uv run python -m river_annotation_tool.annotation_script +uv run python -m clip_annotator.annotation_script # Use a different config file -uv run python -m river_annotation_tool.annotation_script --config config/my_config.yaml +uv run python -m clip_annotator.annotation_script --config config/my_config.yaml # Override paths from the command line -uv run python -m river_annotation_tool.annotation_script --data data/clips --out data/out +uv run python -m clip_annotator.annotation_script --data data/clips --out data/out # Annotate a single specific clip -uv run python -m river_annotation_tool.annotation_script --clip left_20230615T120000 +uv run python -m clip_annotator.annotation_script --clip clip_20230615T120000 ``` ## Configuration @@ -121,8 +121,8 @@ Main settings live in `config/config.yaml`. Copy `config/config.example.yaml` to ```yaml storage: local # required: 'local' or 's3' -data_dir: # required: directory containing ZIP archives (local path or bucket/prefix for S3) -out_dir: # required: where to write annotations +data_dir: # required: read-only source of ZIP archives (local path or bucket/prefix for S3) +out_dir: # required: write destination for annotations (can be same bucket as data_dir with a different prefix, or a separate location) clips_file: config/clips.txt optical_flow_config_file: config/optical_flow_config.yaml @@ -139,7 +139,7 @@ filenames: zip_extension: .zip ``` -Output filenames (`mask.png`, `metadata.json`, etc.) have sensible defaults and can be overridden in the `filenames:` block — see [`config.py`](src/river_annotation_tool/config.py) for the full list. +Output filenames (`mask.png`, `metadata.json`, etc.) have sensible defaults and can be overridden in the `filenames:` block — see [`config.py`](src/clip_annotator/config.py) for the full list. ### Survey questions @@ -147,9 +147,9 @@ Survey questions are defined in `config/questions.yaml` (committed to the repo). ### Optical flow segmentation -`config/optical_flow_config.yaml` controls the **Auto Segment** button. When pressed, the tool computes a river mask from the loaded frames and replaces the current mask (undoable). The segmentation combines two criteria: +`config/optical_flow_config.yaml` controls the **Auto Segment** button. When pressed, the tool computes a segmentation mask from the loaded frames and replaces the current mask (undoable). The segmentation combines two criteria: -- **Optical flow magnitude** — pixels where the temporal median of frame-to-frame flow (scaled by FPS) exceeds a fraction of the maximum are considered moving water. +- **Optical flow magnitude** — pixels where the temporal median of frame-to-frame flow (scaled by FPS) exceeds a fraction of the maximum are considered moving. - **Brightness** — pixels outside a brightness window are excluded (removes sky, saturated glare, etc.). ```yaml @@ -164,41 +164,25 @@ brightness_range: [2, 253] # [min, max] greyscale brightness to keep ## Clip list file -`config/clips.txt` lists the clip filenames to annotate, one per line. Lines starting with `#` are ignored. Clips are processed in order; already-annotated clips (those with an existing `mask.png`) are skipped automatically. Pass `--no-skip` to include them. When the last clip is reached, a dialog appears and the app exits. +`config/clips.txt` lists bare clip filenames (not full paths) to annotate, one per line — the tool prepends `data_dir` automatically. Each entry must be the exact filename of the ZIP archive as it appears in `data_dir` (e.g. `clip_20230501T120000.zip`). Lines starting with `#` are ignored. Clips are processed in order; a clip is considered already-annotated when `//mask.png` exists and is skipped automatically. Pass `--no-skip` to include already-annotated clips. When the last clip is reached, a dialog appears and the app exits. ``` # Example clips.txt -left_20230501T120000.zip -left_20230502T120000.zip +clip_20230501T120000.zip +clip_20230502T120000.zip ``` Copy `config/clips.example.txt` as a starting point. ## Multi-annotator setup -Pre-made clip lists for 7 annotators are included in `config/annotator_A.txt` through `config/annotator_G.txt`. Each annotator is assigned exactly 5 recording days (non-consecutive where possible), covering all 24 available days across the dataset. - -To run the tool for a specific annotator, pass their file via `--clips`: +To distribute clips across multiple annotators, create one clips file per annotator (e.g. `config/annotator_A.txt`) listing their assigned clip filenames, then pass it via `--clips`: ```sh -uv run python -m river_annotation_tool.annotation_script --clips config/annotator_A.txt +uv run python -m clip_annotator.annotation_script --clips config/annotator_A.txt ``` -### Assignment - -11 of the 24 days are reviewed by two annotators (the theoretical maximum given 7 × 5 = 35 slots and 24 days), giving 11 days with double coverage for inter-annotator agreement checks. - -| Annotator | Days | Clips | -|---|---|---| -| A | 2025-11-17 · 2025-12-03 · 2026-01-01 · 2026-01-09 · 2026-02-11 | 94 | -| B | 2025-11-18 · 2025-12-05 · 2026-01-06 · 2026-02-12 · 2026-03-02 | 128 | -| C | 2025-11-22 · 2025-12-12 · 2026-01-07 · 2026-02-16 · 2026-03-03 | 146 | -| D | 2025-11-18 · 2025-11-24 · 2025-12-16 · 2026-01-08 · 2026-03-02 | 102 | -| E | 2025-11-25 · 2025-12-03 · 2026-01-09 · 2026-01-12 · 2026-03-03 | 80 | -| F | 2025-11-25 · 2025-12-16 · 2026-01-10 · 2026-01-13 · 2026-03-11 | 93 | -| G | 2025-11-22 · 2025-12-05 · 2026-01-12 · 2026-02-11 · 2026-03-12 | 110 | - -Days covered by two annotators: 2025-11-18 (B, D) · 2025-11-22 (C, G) · 2025-11-25 (E, F) · 2025-12-03 (A, E) · 2025-12-05 (B, G) · 2025-12-16 (D, F) · 2026-01-09 (A, E) · 2026-01-12 (E, G) · 2026-02-11 (A, G) · 2026-03-02 (B, D) · 2026-03-03 (C, E) +Assigning non-overlapping clip lists lets each annotator work independently. Intentionally overlapping a subset of clips across annotators enables inter-annotator agreement checks. ## Controls @@ -218,7 +202,7 @@ Three drawing tools are available in the tool row. The active tool is highlighte | Action | How | |---|---| -| Draw water mask | Click and drag on the video | +| Draw mask | Click and drag on the video | | 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 (2–50 px, default 5); click **↺** to reset | @@ -261,7 +245,7 @@ Polygons are drawn as overlays and do not affect the mask until you use **Fill** | Action | How | |---|---| | Load mask from previous clip | **Load Prev Mask** — copies the saved mask of the previous clip onto the current one; undoable | -| Optical flow first guess | **Auto Segment** — replaces the current mask with an automatic river segmentation; undoable. Disabled when `enabled: false` in `config/optical_flow_config.yaml`. | +| Optical flow first guess | **Auto Segment** — replaces the current mask with an automatic segmentation based on motion and brightness; undoable. Disabled when `enabled: false` in `config/optical_flow_config.yaml`. | ### Image display adjustments @@ -288,7 +272,7 @@ Click **↺** below any slider to restore its default value. Each annotated clip produces a folder `//` with: ``` -mask.png # Binary water mask at full source resolution (always) +mask.png # Binary segmentation mask at full source resolution (always) metadata.json # Survey answers as JSON (always) frame.png # Middle frame of the clip (always) overlay.png # That frame with the mask blended in green (always) @@ -323,7 +307,7 @@ Keys and values are determined by `config/questions.yaml`. With the default ques ### Clip format -Each clip is a ZIP archive containing a video file (default `left.mp4`, configurable via `filenames.video_in_zip`). The filename encodes the recording timestamp (e.g. `left_20230615T120000.zip`). +Each clip is a ZIP archive containing a video file. The video inside the archive must be named `left.mp4` by default; if your archives use a different internal name, set `filenames.video_in_zip` in `config.yaml`. The ZIP filename becomes the output folder name (e.g. `clip_20230501T120000.zip` → `/clip_20230501T120000/`). ### Frame loading @@ -350,14 +334,14 @@ config/ clips.example.txt # Example clip list questions.yaml # Survey question definitions optical_flow_config.yaml # Optical flow parameters (set enabled: false to disable Auto Segment) -src/river_annotation_tool/ +src/clip_annotator/ annotation_script.py # Entry point — argument parsing and app launch annotator.py # Main QMainWindow — orchestrates all components clip_selector.py # Reads the clip list and picks the next clip filesystem.py # Storage backend — local passthrough or S3 via s3fs mask_canvas.py # Drawing widget — brush, undo, erase, mouse events video_loader.py # ZIP extraction and frame resizing - compute_optical_flow.py # Optical flow river segmentation (Auto Segment button) + compute_optical_flow.py # Optical flow segmentation (Auto Segment button) config.py # AppConfig dataclass and YAML loader __init__.py # Package version pyproject.toml # Project metadata and dependencies diff --git a/pyproject.toml b/pyproject.toml index f80a502..83d953d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61.0.0"] build-backend = "setuptools.build_meta" [project] -name = "river_annotation_tool" +name = "clip_annotator" authors = [ # TODO configure authors # { name = "Jane Smith", email = "jane.smith@example.com" }, @@ -33,7 +33,7 @@ dev = [ ] [tool.setuptools.dynamic] -version = {attr = "river_annotation_tool.__version__"} +version = {attr = "clip_annotator.__version__"} [tool.ruff] target-version = "py312" diff --git a/requirements.txt b/requirements.txt index de3dc9b..014f5d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -433,14 +433,14 @@ matplotlib==3.10.9 \ --hash=sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf \ --hash=sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1 \ --hash=sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358 - # via river-annotation-tool + # via clip-annotator matplotlib-inline==0.2.1 \ --hash=sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76 \ --hash=sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe # via + # clip-annotator # ipykernel # ipython - # river-annotation-tool mistune==3.2.0 \ --hash=sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a \ --hash=sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1 @@ -527,7 +527,7 @@ opencv-contrib-python-headless==4.12.0.88 \ --hash=sha256:b183e2322468c9d3bd9cac4ba44b272d828ec22842395bcfa51df31765224c0a \ --hash=sha256:c57e32812fea2a542bb220088fb3ce8a210fe114c9454d1c9e8cd162e1a1fde8 \ --hash=sha256:d60a12b915c55a50468c013fcd839e941b49ccc1f37b914b62543382c36bf81d - # via river-annotation-tool + # via clip-annotator packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 @@ -549,7 +549,7 @@ pandas==3.0.2 \ --hash=sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f \ --hash=sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043 \ --hash=sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab - # via river-annotation-tool + # via clip-annotator pandocfilters==1.5.1 \ --hash=sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e \ --hash=sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc @@ -576,8 +576,8 @@ pillow==12.2.0 \ --hash=sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421 \ --hash=sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5 # via + # clip-annotator # matplotlib - # river-annotation-tool platformdirs==4.9.6 \ --hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \ --hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917 @@ -663,7 +663,7 @@ pyside6==6.11.0 \ --hash=sha256:9092cb002ca43c64006afb2e0d0f6f51aef17aa737c33a45e502326a081ddcbc \ --hash=sha256:b15f39acc2b8f46251a630acad0d97f9a0a0461f2baffcd66d7adfada8eb641e \ --hash=sha256:c642e2d25704ca746fd37f56feacf25c5aecc4cd40bef23d18eec81f87d9dc00 - # via river-annotation-tool + # via clip-annotator pyside6-addons==6.11.0 \ --hash=sha256:413e6121c24f5ffdce376298059eddecff74aa6d638e94e0f6015b33d29b889e \ --hash=sha256:8ffb40222456078930816ebcac2f2511716d2acbc11716dd5acc5c365179a753 \ @@ -697,7 +697,7 @@ python-discovery==1.2.2 \ python-dotenv==1.2.2 \ --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 - # via river-annotation-tool + # via clip-annotator python-json-logger==4.1.0 \ --hash=sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2 \ --hash=sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195 @@ -723,9 +723,9 @@ pyyaml==6.0.3 \ --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 # via + # clip-annotator # jupyter-events # pre-commit - # river-annotation-tool pyzmq==27.1.0 \ --hash=sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28 \ --hash=sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113 \ @@ -811,7 +811,7 @@ ruff==0.15.0 \ s3fs==2026.4.0 \ --hash=sha256:5bdce0abb00b0435ee150807a45fea727451dbc22de4cbc116464f8504ab9d37 \ --hash=sha256:de0d2a1f33cdf03831fd2382d278c6e4e31fe57c3bf2f703c61f8aec6b703e2a - # via river-annotation-tool + # via clip-annotator send2trash==2.1.0 \ --hash=sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c \ --hash=sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459 diff --git a/src/river_annotation_tool/__init__.py b/src/clip_annotator/__init__.py similarity index 100% rename from src/river_annotation_tool/__init__.py rename to src/clip_annotator/__init__.py diff --git a/src/river_annotation_tool/annotation_script.py b/src/clip_annotator/annotation_script.py similarity index 100% rename from src/river_annotation_tool/annotation_script.py rename to src/clip_annotator/annotation_script.py diff --git a/src/river_annotation_tool/annotator.py b/src/clip_annotator/annotator.py similarity index 99% rename from src/river_annotation_tool/annotator.py rename to src/clip_annotator/annotator.py index 3dbadc7..be7e992 100644 --- a/src/river_annotation_tool/annotator.py +++ b/src/clip_annotator/annotator.py @@ -58,7 +58,7 @@ class Annotator(QMainWindow): self.history: list[str] = [] self.history_pos: int = -1 - self.setWindowTitle("River Annotator") + self.setWindowTitle("Clip Annotator") self._load_clip(specific=clip) self._history_push() self._init_ui() diff --git a/src/river_annotation_tool/clip_selector.py b/src/clip_annotator/clip_selector.py similarity index 100% rename from src/river_annotation_tool/clip_selector.py rename to src/clip_annotator/clip_selector.py diff --git a/src/river_annotation_tool/compute_optical_flow.py b/src/clip_annotator/compute_optical_flow.py similarity index 100% rename from src/river_annotation_tool/compute_optical_flow.py rename to src/clip_annotator/compute_optical_flow.py diff --git a/src/river_annotation_tool/config.py b/src/clip_annotator/config.py similarity index 100% rename from src/river_annotation_tool/config.py rename to src/clip_annotator/config.py diff --git a/src/river_annotation_tool/filesystem.py b/src/clip_annotator/filesystem.py similarity index 100% rename from src/river_annotation_tool/filesystem.py rename to src/clip_annotator/filesystem.py diff --git a/src/river_annotation_tool/mask_canvas.py b/src/clip_annotator/mask_canvas.py similarity index 100% rename from src/river_annotation_tool/mask_canvas.py rename to src/clip_annotator/mask_canvas.py diff --git a/src/river_annotation_tool/video_loader.py b/src/clip_annotator/video_loader.py similarity index 100% rename from src/river_annotation_tool/video_loader.py rename to src/clip_annotator/video_loader.py diff --git a/uv.lock b/uv.lock index bb4a696..ff8edcc 100644 --- a/uv.lock +++ b/uv.lock @@ -304,6 +304,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] +[[package]] +name = "clip-annotator" +source = { editable = "." } +dependencies = [ + { name = "matplotlib" }, + { name = "matplotlib-inline" }, + { name = "opencv-contrib-python-headless" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "pyside6" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "s3fs" }, +] + +[package.dev-dependencies] +dev = [ + { name = "notebook" }, + { name = "pre-commit" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "matplotlib", specifier = ">=3.10.8" }, + { name = "matplotlib-inline", specifier = ">=0.2.1" }, + { name = "opencv-contrib-python-headless", specifier = "==4.12.0.88" }, + { name = "pandas", specifier = ">=2.3.3" }, + { name = "pillow", specifier = ">=12.2.0" }, + { name = "pyside6", specifier = ">=6.11.0" }, + { name = "python-dotenv", specifier = ">=1.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "s3fs", specifier = ">=2024.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "notebook", specifier = "~=7.5" }, + { name = "pre-commit", specifier = "~=4.5" }, + { name = "ruff", specifier = "==0.15.0" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -1528,48 +1570,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, ] -[[package]] -name = "river-annotation-tool" -source = { editable = "." } -dependencies = [ - { name = "matplotlib" }, - { name = "matplotlib-inline" }, - { name = "opencv-contrib-python-headless" }, - { name = "pandas" }, - { name = "pillow" }, - { name = "pyside6" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "s3fs" }, -] - -[package.dev-dependencies] -dev = [ - { name = "notebook" }, - { name = "pre-commit" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "matplotlib", specifier = ">=3.10.8" }, - { name = "matplotlib-inline", specifier = ">=0.2.1" }, - { name = "opencv-contrib-python-headless", specifier = "==4.12.0.88" }, - { name = "pandas", specifier = ">=2.3.3" }, - { name = "pillow", specifier = ">=12.2.0" }, - { name = "pyside6", specifier = ">=6.11.0" }, - { name = "python-dotenv", specifier = ">=1.0" }, - { name = "pyyaml", specifier = ">=6.0" }, - { name = "s3fs", specifier = ">=2024.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "notebook", specifier = "~=7.5" }, - { name = "pre-commit", specifier = "~=4.5" }, - { name = "ruff", specifier = "==0.15.0" }, -] - [[package]] name = "rpds-py" version = "0.30.0" From dc7bb8e7eb124ba71a604af827f199573d913969 Mon Sep 17 00:00:00 2001 From: asreva Date: Tue, 2 Jun 2026 11:51:37 +0200 Subject: [PATCH 23/23] Address PR review comments: rename to __main__.py, improve config examples, document save freeze --- README.md | 22 +++++++++---------- config/config.example.yaml | 4 ++-- .../{annotation_script.py => __main__.py} | 0 src/clip_annotator/annotator.py | 22 ++++++++++++++----- 4 files changed, 29 insertions(+), 19 deletions(-) rename src/clip_annotator/{annotation_script.py => __main__.py} (100%) diff --git a/README.md b/README.md index 2ddad84..b6bff51 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ cp config/clips.example.txt config/clips.txt # Edit config/questions.yaml to customise survey questions (optional) # 4. Run -uv run python -m clip_annotator.annotation_script +uv run python -m clip_annotator ``` ## Installation @@ -81,9 +81,9 @@ The `clips_file` (the list of clip filenames to annotate) is always read from th ## Usage ```sh -uv run python -m clip_annotator.annotation_script +uv run python -m clip_annotator # or, if you have the venv activated: -python -m clip_annotator.annotation_script +python -m clip_annotator ``` ### Arguments @@ -95,23 +95,23 @@ python -m clip_annotator.annotation_script | `--out` | *(from config)* | Override `out_dir` from config | | `--clips` | *(from config)* | Override `clips_file` from config | | `--clip` | *(first unannotated in list)* | Open a specific clip by its stem name (filename without extension, e.g. `clip_20230501T120000`) | -| `--extras` | off | Also save GIFs and extra PNGs (see Output section) | +| `--extras` | off | Also save GIFs and extra PNGs (see Output section). **Note: GIF encoding is slow — saving will freeze the UI for several seconds per clip.** | | `--no-skip` | off | Show already-annotated clips instead of skipping them | ### Typical workflows ```sh # Annotate clips listed in config/clips.txt (default) -uv run python -m clip_annotator.annotation_script +uv run python -m clip_annotator # Use a different config file -uv run python -m clip_annotator.annotation_script --config config/my_config.yaml +uv run python -m clip_annotator --config config/my_config.yaml # Override paths from the command line -uv run python -m clip_annotator.annotation_script --data data/clips --out data/out +uv run python -m clip_annotator --data data/clips --out data/out # Annotate a single specific clip -uv run python -m clip_annotator.annotation_script --clip clip_20230615T120000 +uv run python -m clip_annotator --clip clip_20230615T120000 ``` ## Configuration @@ -179,7 +179,7 @@ Copy `config/clips.example.txt` as a starting point. To distribute clips across multiple annotators, create one clips file per annotator (e.g. `config/annotator_A.txt`) listing their assigned clip filenames, then pass it via `--clips`: ```sh -uv run python -m clip_annotator.annotation_script --clips config/annotator_A.txt +uv run python -m clip_annotator --clips config/annotator_A.txt ``` Assigning non-overlapping clip lists lets each annotator work independently. Intentionally overlapping a subset of clips across annotators enables inter-annotator agreement checks. @@ -263,7 +263,7 @@ Click **↺** below any slider to restore its default value. | 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. The UI freezes during saving; this is normal — wait for it to complete before clicking again. | | 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. | @@ -335,7 +335,7 @@ config/ questions.yaml # Survey question definitions optical_flow_config.yaml # Optical flow parameters (set enabled: false to disable Auto Segment) src/clip_annotator/ - annotation_script.py # Entry point — argument parsing and app launch + __main__.py # Entry point — argument parsing and app launch annotator.py # Main QMainWindow — orchestrates all components clip_selector.py # Reads the clip list and picks the next clip filesystem.py # Storage backend — local passthrough or S3 via s3fs diff --git a/config/config.example.yaml b/config/config.example.yaml index d8febbc..c197db8 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -1,8 +1,8 @@ storage: local # 'local' or 's3' # Required: set these to your actual paths (local path or bucket/prefix for S3) -data_dir: -out_dir: +data_dir: # e.g. /data/clips or for S3: hydroscan-data/GRAMMONT/clips +out_dir: # e.g. /data/out or for S3: hydroscan-data/annotations// # Put your name here # For S3 credentials, copy .env.example to .env and fill in: # S3_ACCESS_KEY, S3_SECRET_ACCESS_KEY, S3_ENDPOINT_URL diff --git a/src/clip_annotator/annotation_script.py b/src/clip_annotator/__main__.py similarity index 100% rename from src/clip_annotator/annotation_script.py rename to src/clip_annotator/__main__.py diff --git a/src/clip_annotator/annotator.py b/src/clip_annotator/annotator.py index be7e992..c6adcee 100644 --- a/src/clip_annotator/annotator.py +++ b/src/clip_annotator/annotator.py @@ -159,7 +159,7 @@ class Annotator(QMainWindow): self.btn_prev = QPushButton("Previous") self.btn_prev.setEnabled(False) - btn_next = QPushButton("Next") + self.btn_next = QPushButton("Next") btn_skip = QPushButton("Skip") btn_clear = QPushButton("Clear") btn_undo = QPushButton("Undo") @@ -172,7 +172,7 @@ class Annotator(QMainWindow): row1 = QHBoxLayout() for b in [ self.btn_prev, - btn_next, + self.btn_next, btn_skip, btn_load_prev_mask, btn_auto_segment, @@ -251,7 +251,7 @@ class Annotator(QMainWindow): self.setCentralWidget(container) self.btn_prev.clicked.connect(self.prev_clip) - btn_next.clicked.connect(self.next_clip) + self.btn_next.clicked.connect(self.next_clip) btn_skip.clicked.connect(self.skip_clip) btn_clear.clicked.connect(self.mc.clear) btn_undo.clicked.connect(self.mc.undo) @@ -350,6 +350,16 @@ class Annotator(QMainWindow): self.fs.pipe(out_path, buf.getvalue()) # ── actions ──────────────────────────────────────────────────── + def _save_locked(self): + self.btn_next.setEnabled(False) + self.btn_prev.setEnabled(False) + QApplication.processEvents() + try: + self.save() + finally: + self.btn_next.setEnabled(True) + self.btn_prev.setEnabled(self.history_pos > 0) + def save(self): out = fsjoin(self.out_dir, fsstem(self.filename)) self._fs_makedirs(out) @@ -421,7 +431,7 @@ class Annotator(QMainWindow): def prev_clip(self): if self.history_pos <= 0: return - self.save() + self._save_locked() self.history_pos -= 1 self._load_clip(path=self.history[self.history_pos]) self._switch_ui_to_clip() @@ -446,13 +456,13 @@ class Annotator(QMainWindow): msg.exec() clicked = msg.clickedButton() if clicked == btn_replace: - self.save() + self._save_locked() self._advance_clip() elif clicked == btn_keep: self._advance_clip() # Cancel: do nothing else: - self.save() + self._save_locked() self._advance_clip() def skip_clip(self):