diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..5d23f5d --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +if git diff --cached --name-only | grep -q "^pyproject\.toml$"; then + uv lock + git add uv.lock +fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4df253..f757851 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,9 @@ jobs: - name: Set up Python run: uv python install 3.13 + - name: Check lockfile is up to date + run: uv lock --check + - name: Install dependencies run: uv sync --extra cpu diff --git a/.github/workflows/update-lockfile.yml b/.github/workflows/update-lockfile.yml deleted file mode 100644 index 46f983c..0000000 --- a/.github/workflows/update-lockfile.yml +++ /dev/null @@ -1,63 +0,0 @@ -# .github/workflows/update-lockfile.yml -name: Update lockfile - -on: - push: - branches: - - '**' - - '!main' - paths: - - 'pyproject.toml' - workflow_dispatch: - -jobs: - update-lockfile: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Install uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - - - name: Set up Python - run: uv python install 3.13 - - - name: Regenerate lockfile - run: uv lock - - - name: Check for changes - id: diff - run: | - if git diff --quiet uv.lock; then - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - - name: Commit and push updated lockfile - if: steps.diff.outputs.changed == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add uv.lock - git commit -m "chore: update lockfile" - - if [ "$GITHUB_REF_NAME" = "dev" ]; then - # dev is protected — open a PR rather than pushing directly - BRANCH="chore/lockfile-$(git rev-parse --short HEAD)" - git checkout -b "$BRANCH" - git push origin "$BRANCH" - gh pr create \ - --base dev \ - --head "$BRANCH" \ - --title "chore: update lockfile" \ - --body "Automated lockfile update triggered by a \`pyproject.toml\` change on \`dev\`." - else - git push - fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 6966e91..c7f8a5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.6] - 2026-06-18 + +### Changed + +- **`MAX_AUTO_IMAGES` default lowered from 20 to 5** — existing users who have not set this variable and already have more than 5 winnow-managed images in Frigate will find themselves at cap on the next run. With `QUALITY_REPLACEMENT=true` (the default), winnow will attempt to swap weaker images rather than uploading new ones. Set `MAX_AUTO_IMAGES=20` to restore the previous behaviour. + ## [0.6.5] - 2026-06-17 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 943f2c6..5b00940 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,11 @@ git clone https://github.com/sudolulo/winnow.git cd winnow git checkout dev uv sync +git config core.hooksPath .githooks ``` +The last line activates the project's git hooks. The pre-commit hook automatically runs `uv lock` and stages the result whenever `pyproject.toml` is part of a commit, keeping the lockfile in sync without any extra steps. + ## Running Tests and Lint ```bash diff --git a/README.md b/README.md index f4c0968..197cb09 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ In scheduled mode the process (and loaded models) stays resident between runs. T | Variable | Default | Description | | :--- | :--- | :--- | -| `MAX_AUTO_IMAGES` | `20` | Maximum training images per person in Frigate | +| `MAX_AUTO_IMAGES` | `5` | Maximum training images per person in Frigate | | `QUALITY_REPLACEMENT` | `true` | When at cap, swap a weaker tracked image for a better candidate. With Frigate scoring active, targets the most redundant image (highest pre-upload recognize score); otherwise uses blur score. Never touches manually added Frigate files. Set `false` to skip people at cap | #### Advanced Tuning *(calibrated — do not adjust)* diff --git a/compose.yml b/compose.yml index d1ce394..c1ce25e 100644 --- a/compose.yml +++ b/compose.yml @@ -31,7 +31,7 @@ services: # - USE_FULL_RESOLUTION=true # Use full-res images vs thumbnails (default: true) # - MIN_CONFIDENCE=0.7 # Minimum face detection confidence (default: 0.7) # - BLUR_THRESHOLD=100.0 # Laplacian blur threshold; lower = accept more blur (default: 100.0) - # - MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 20) + # - MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 5) # ── Caching & Models ────────────────────────────────────────────────── # - FORCE_CPU=true # Disable GPU, fall back to CPU diff --git a/pyproject.toml b/pyproject.toml index 27d8aff..86e849f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "winnow" -version = "0.6.5" +version = "0.6.6" description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition." license = "AGPL-3.0-or-later" requires-python = ">=3.13" diff --git a/scheduler.py b/scheduler.py index 99368d5..709868b 100644 --- a/scheduler.py +++ b/scheduler.py @@ -49,11 +49,12 @@ def _run_scheduler() -> None: try: main() print("winnow run complete", flush=True) - except KeyboardInterrupt: + except (KeyboardInterrupt, SystemExit): raise except Exception as e: logger.error("winnow run failed: %s", e, exc_info=True) print(f"winnow run failed: {e}", flush=True) + cron = croniter(schedule, time.time()) next_run = cron.get_next(float) print(f"Next run: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(next_run))}", flush=True) time.sleep(min(60, max(1, next_run - time.time()))) diff --git a/tests/test_config.py b/tests/test_config.py index 353a7c6..e093269 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -21,7 +21,7 @@ def test_config_loads_defaults(monkeypatch): assert cfg.MIN_FACE_COUNT == 3 assert cfg.BLUR_THRESHOLD == 120.0 assert cfg.MIN_CONFIDENCE == 0.7 - assert cfg.MAX_AUTO_IMAGES == 20 + assert cfg.MAX_AUTO_IMAGES == 5 assert cfg.QUALITY_REPLACEMENT is True assert cfg.FACE_MARGIN == 0.15 assert cfg.USE_FULL_RESOLUTION is True diff --git a/uv.lock b/uv.lock index 0c3f5ba..9e9377f 100644 --- a/uv.lock +++ b/uv.lock @@ -905,7 +905,7 @@ wheels = [ [[package]] name = "winnow" -version = "0.6.5" +version = "0.6.6" source = { editable = "." } dependencies = [ { name = "croniter" }, diff --git a/winnow/config.py b/winnow/config.py index 2a33680..57db3ad 100644 --- a/winnow/config.py +++ b/winnow/config.py @@ -127,12 +127,15 @@ def _load(self) -> None: self.API_KEY = os.getenv("API_KEY") self.OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./frigate_train") self.YEARS_FILTER = _getenv_int("YEARS_FILTER", 10) + if self.YEARS_FILTER < 0: + logging.warning("YEARS_FILTER=%s is negative — using default 10", self.YEARS_FILTER) + self.YEARS_FILTER = 10 self.MIN_FACE_WIDTH = _getenv_int("MIN_FACE_WIDTH", 90) self.MIN_FACE_COUNT = _getenv_int("MIN_FACE_COUNT", 3) self.MERGE_DUPLICATE_PEOPLE = _getenv_bool("MERGE_DUPLICATE_PEOPLE", False) self.BLUR_THRESHOLD = _getenv_float("BLUR_THRESHOLD", 120.0) self.MIN_CONFIDENCE = _getenv_float("MIN_CONFIDENCE", 0.7) - self.MAX_AUTO_IMAGES = _getenv_int("MAX_AUTO_IMAGES", 20) + self.MAX_AUTO_IMAGES = _getenv_int("MAX_AUTO_IMAGES", 5) self.QUALITY_REPLACEMENT = _getenv_bool("QUALITY_REPLACEMENT", True) self.FRIGATE_SCORE_CEILING = _getenv_optional_float("FRIGATE_SCORE_CEILING") self.ENABLE_FRIGATE_SCORES = _getenv_bool("ENABLE_FRIGATE_SCORES", True) diff --git a/winnow/embeddings.py b/winnow/embeddings.py index 7787da7..efd0264 100644 --- a/winnow/embeddings.py +++ b/winnow/embeddings.py @@ -107,7 +107,6 @@ def get_insightface_app(): global _insightface_app, _insightface_loaded if _insightface_loaded: return _insightface_app - _insightface_loaded = True ctx_id = -1 insightface_home = os.environ.get("INSIGHTFACE_HOME", os.path.expanduser("~/.insightface")) @@ -172,10 +171,12 @@ def get_insightface_app(): _insightface_app.prepare(ctx_id=ctx_id, det_size=(640, 640)) logger.info("InsightFace Buffalo_L: ready on %s (%.1fs)", device_str, time.time() - t0) + _insightface_loaded = True return _insightface_app except ImportError: logger.error("InsightFace not installed!") + _insightface_loaded = True return None except Exception as e: logger.error("Failed to load InsightFace: %s", e) @@ -193,9 +194,11 @@ def get_insightface_app(): ) _insightface_app.prepare(ctx_id=-1, det_size=(640, 640)) logger.info("InsightFace Buffalo_L: ready on CPU (fallback, %.1fs)", time.time() - t0) + _insightface_loaded = True return _insightface_app except Exception as ex: logger.error("InsightFace CPU fallback failed: %s", ex) + _insightface_loaded = True return None diff --git a/winnow/executor.py b/winnow/executor.py index b5a3822..d8c04e4 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -186,12 +186,11 @@ def execute_jobs(jobs: list[dict]) -> None: saved = process_face_mode( img, asset, person, person_dir, count, insightface_app=insightface_app ) - if saved: + if isinstance(saved, tuple): filename = f"{count}.jpg" asset_map[filename] = asset["id"] score_map[filename] = asset.get("quality_score") - if isinstance(saved, tuple): - dims_map[filename] = saved + dims_map[filename] = saved # Time-spread path: compute blur score from the downloaded # image. Capped at 1440px via blur_score_from_image() so the # scale matches the preview thumbnails the embedding path uses @@ -202,8 +201,9 @@ def execute_jobs(jobs: list[dict]) -> None: count += 1 else: + reason = saved if isinstance(saved, str) else "no usable face data" progress.console.print( - f"[yellow]Skipped {asset['id']} (no usable face data)[/yellow]" + f"[yellow]Skipped {asset['id']} ({reason})[/yellow]" ) except Exception as e: logger.error("Failed to process asset %s: %s", asset.get("id", ""), e) @@ -384,9 +384,9 @@ def upload_to_frigate(jobs: list[dict]) -> None: # freed slot isn't filled with something worse than what we removed. if min_quality_score_for_slot is not None: file_score = score_map.get(fname) - if file_score is not None and file_score <= min_quality_score_for_slot: + if file_score is not None and file_score < min_quality_score_for_slot: progress.console.print( - f" [dim]⏭ {fname}: score {file_score:.3f} ≤ freed slot floor" + f" [dim]⏭ {fname}: score {file_score:.3f} < freed slot floor" f" {min_quality_score_for_slot:.3f}, skipping[/dim]" ) progress.advance(upload_task) @@ -566,13 +566,14 @@ def upload_to_frigate(jobs: list[dict]) -> None: error_detail = resp.json().get("message", full_body[:100]) except Exception: error_detail = full_body[:100] - if resp.status_code == 400: + if resp.status_code in (400, 500): progress.console.print(f" [dim]{error_detail}[/dim]") else: logger.debug("%s HTTP %s: %s", fname, resp.status_code, error_detail) _is_permanent = ( (resp.status_code == 400 and "face" in full_body.lower()) or resp.status_code == 422 + or (resp.status_code == 500 and "could not process" in full_body.lower()) ) if _is_permanent: asset_id = asset_map.get(fname) diff --git a/winnow/frigate_api.py b/winnow/frigate_api.py index 3d15dd7..eba6939 100644 --- a/winnow/frigate_api.py +++ b/winnow/frigate_api.py @@ -146,8 +146,10 @@ def delete_frigate_person_files(person_name: str, filenames: list[str]) -> bool: Returns True on success, False if unreachable or the request fails. """ frigate_url = _get_frigate_url() - if not frigate_url or not filenames: + if not frigate_url: return False + if not filenames: + return True from urllib.parse import quote encoded_name = quote(person_name, safe="") try: diff --git a/winnow/image_processing.py b/winnow/image_processing.py index 23c3f0e..0d18d2b 100644 --- a/winnow/image_processing.py +++ b/winnow/image_processing.py @@ -48,7 +48,9 @@ def align_face(img: Image.Image, landmarks: list[list[float]] | np.ndarray) -> I if lm.shape != (5, 2): logger.debug("Invalid landmark shape: %s, expected (5, 2)", lm.shape) return None - aligned = norm_crop(img_np, lm) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message=".*estimate.*is deprecated", category=FutureWarning) + aligned = norm_crop(img_np, lm) return Image.fromarray(aligned) except ImportError: logger.debug("InsightFace not available for face alignment") @@ -66,10 +68,11 @@ def process_face_mode( count: int, min_width: int | None = None, insightface_app=None, -) -> tuple[int, int] | None: +) -> tuple[int, int] | str: """Crop face based on Immich metadata and save to output directory. - Returns (width, height) of the saved crop, or None if no crop was saved. + Returns (width, height) of the saved crop, or a skip-reason string if the + face was filtered out. When insightface_app is provided and ENABLE_FACE_ALIGNMENT is True, re-detects the face in the Immich bbox region using InsightFace to get precise landmarks for a proper 112x112 aligned crop. Falls back to @@ -89,7 +92,7 @@ def process_face_mode( if not face_info: logger.debug("No face info for %s in asset %s", person.get("name"), asset.get("id")) - return None + return "no face metadata" img_w, img_h = img.size meta_w = face_info.get("imageWidth") or 0 @@ -108,7 +111,7 @@ def process_face_mode( face_w, face_h = x2 - x1, y2 - y1 if face_w < min_width or face_h < min_width: logger.debug("Face too small (%.1fx%.1f)", face_w, face_h) - return None + return f"face too small ({face_w:.0f}x{face_h:.0f}px, min {min_width}px)" # Re-detect face with InsightFace for landmark-based alignment. # Immich's /api/faces endpoint does not include landmarks, so the diff --git a/winnow/immich_api.py b/winnow/immich_api.py index 71ade02..436cbc0 100644 --- a/winnow/immich_api.py +++ b/winnow/immich_api.py @@ -134,7 +134,7 @@ def fetch_all_assets(person: dict) -> tuple[list[dict], int]: # Immich ≥2.x returns {"assets": {"items": [...]}}; # earlier versions returned {"assets": [...]} directly. if isinstance(page_assets, dict): - page_assets = page_assets.get("items", []) + page_assets = page_assets.get("items") or [] page_count = len(page_assets) # raw count for termination check before filtering @@ -303,7 +303,7 @@ def filter_recent_assets(assets: list[dict], years: int | None = None) -> list[d recent.append(asset) else: skipped += 1 - except ValueError: + except (ValueError, TypeError): bad_timestamp += 1 continue diff --git a/winnow/jobs.py b/winnow/jobs.py index 656be22..71545f3 100644 --- a/winnow/jobs.py +++ b/winnow/jobs.py @@ -86,7 +86,11 @@ def _resolve_strategy(strategy: str, has_embedding: bool) -> tuple[int | str, st "standard": (30, "smart"), "broad": (100, "smart"), } - return strategy_map.get(strategy, ("auto", "smart")) + result = strategy_map.get(strategy) + if result is None: + logger.warning("Unrecognised STRATEGY=%r — falling back to auto", strategy) + return ("auto", "smart") + return result def _perform_selection( @@ -244,13 +248,13 @@ def auto_configure(people: list[dict]) -> list[dict]: return [] strategy = os.environ.get("STRATEGY", "auto") - skip = [s.strip() for s in os.environ.get("SKIP_PEOPLE", "").split(",") if s.strip()] - only = [s.strip() for s in os.environ.get("ONLY_PEOPLE", "").split(",") if s.strip()] + skip = {s.strip().casefold() for s in os.environ.get("SKIP_PEOPLE", "").split(",") if s.strip()} + only = {s.strip().casefold() for s in os.environ.get("ONLY_PEOPLE", "").split(",") if s.strip()} if only: - valid_people = [p for p in valid_people if p["name"] in only] + valid_people = [p for p in valid_people if p["name"].casefold() in only] if skip: - valid_people = [p for p in valid_people if p["name"] not in skip] + valid_people = [p for p in valid_people if p["name"].casefold() not in skip] min_face_count = Config.MIN_FACE_COUNT