# River 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. ## Requirements - Python 3.12 - [uv](https://docs.astral.sh/uv/) (recommended) or pip ## Quick start ```sh # 1. Clone and install git clone https://gitlab.datascience.ch/industry/aimsight/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) # Edit config/questions.yaml to customise survey questions (optional) # 4. Run uv run python -m river_annotation_tool.annotation_script ``` ## Installation ```sh # 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 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. 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) 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 uv run python -m river_annotation_tool.annotation_script # or, if you have the venv activated: python -m river_annotation_tool.annotation_script ``` ### Arguments | Argument | Default | Description | |---|---|---| | `--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 | | `--extras` | off | Also save GIFs and extra PNGs (see Output section) | | `--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 river_annotation_tool.annotation_script # Use a different config file uv run python -m river_annotation_tool.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 # Annotate a single specific clip uv run python -m river_annotation_tool.annotation_script --clip left_20230615T120000 ``` ## Configuration Main settings live in `config/config.yaml`. Copy `config/config.example.yaml` to get started. ```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 clips_file: config/clips.txt 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 # Override input filenames only if your ZIP archives differ from the defaults filenames: video_in_zip: left.mp4 video_tmp_suffix: .mp4 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. ### Survey questions 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.). ```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. ``` # Example clips.txt left_20230501T120000.zip 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. ### 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 (2–50 px, default 5); 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 | ### Mask editing | 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 | **Mask Alpha** slider (0–100%, default 15%); 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. Disabled when `enabled: false` in `config/optical_flow_config.yaml`. | ### 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. | ## Output Each annotated clip produces a folder `//` with: ``` 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 ``` All output filenames can be overridden via the `filenames:` section in `config/config.yaml`. ### Survey answers (`metadata.json`) Keys and values are determined by `config/questions.yaml`. With the default questions: ```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" } ``` ## Internals ### 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`). ### Frame loading 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 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. 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 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 ``` .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 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 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 ```sh # Install pre-commit hooks 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 ```