From 5468712a4a042d83342257ccab819f8c7f48629b Mon Sep 17 00:00:00 2001 From: asreva Date: Wed, 20 May 2026 13:42:48 +0200 Subject: [PATCH] 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]