Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32e4235384 | ||
|
|
85e499a677 | ||
|
|
ad4d212df0 | ||
|
|
31e7ca5af0 | ||
|
|
4f6967258f | ||
|
|
882af37d8e | ||
|
|
cad053e88f | ||
|
|
3db52c2141 | ||
|
|
af8bae1c45 | ||
|
|
ac9e9530f3 | ||
|
|
015db63d19 | ||
|
|
2804b21f9e | ||
|
|
981a86f28a | ||
|
|
ee585d4bae | ||
|
|
99185c2da9 | ||
|
|
196b0a5147 | ||
|
|
125ce54c7f | ||
|
|
f50d3fe1ab | ||
|
|
af92fe5fcc | ||
|
|
4cac3d28d2 | ||
|
|
21208f8331 |
+13
-5
@@ -4,7 +4,10 @@ API_KEY=your-immich-api-key
|
|||||||
FRIGATE_URL=http://192.168.1.10:5000
|
FRIGATE_URL=http://192.168.1.10:5000
|
||||||
|
|
||||||
# ── Mode & Strategy ───────────────────────────────────────────────────────────
|
# ── Mode & Strategy ───────────────────────────────────────────────────────────
|
||||||
AUTO_MODE=true
|
# Auto mode is active by default when no TTY is present (Docker/cron).
|
||||||
|
# Set AUTO_MODE=true to force auto mode even in an interactive terminal.
|
||||||
|
# AUTO_MODE=true
|
||||||
|
# VERBOSE=true # Enable DEBUG-level console output (log file is always DEBUG)
|
||||||
# TRAINING_MODE: face = upload to Frigate face recognition API
|
# TRAINING_MODE: face = upload to Frigate face recognition API
|
||||||
# object = save crops to output dir for manual Frigate placement
|
# object = save crops to output dir for manual Frigate placement
|
||||||
TRAINING_MODE=face
|
TRAINING_MODE=face
|
||||||
@@ -29,8 +32,8 @@ STRATEGY=auto
|
|||||||
# MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80)
|
# MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80)
|
||||||
|
|
||||||
# ── Caching & Models ──────────────────────────────────────────────────────────
|
# ── Caching & Models ──────────────────────────────────────────────────────────
|
||||||
FORCE_CPU=false
|
# FORCE_CPU=true # Disable GPU, fall back to CPU
|
||||||
ENABLE_CACHE=true
|
# ENABLE_CACHE=false # Disable embedding cache (default: true)
|
||||||
CACHE_DIR=/app/.if_cache
|
CACHE_DIR=/app/.if_cache
|
||||||
HF_HOME=/models/huggingface
|
HF_HOME=/models/huggingface
|
||||||
INSIGHTFACE_HOME=/models/.insightface
|
INSIGHTFACE_HOME=/models/.insightface
|
||||||
@@ -41,5 +44,10 @@ INSIGHTFACE_HOME=/models/.insightface
|
|||||||
# RESET_PERSON=John # Clear uploaded+rejected history for one person
|
# RESET_PERSON=John # Clear uploaded+rejected history for one person
|
||||||
|
|
||||||
# ── Scheduling ────────────────────────────────────────────────────────────────
|
# ── Scheduling ────────────────────────────────────────────────────────────────
|
||||||
# Cron expression (unset = run once and exit)
|
# CRON_SCHEDULE controls container lifetime:
|
||||||
CRON_SCHEDULE=0 3 * * 0 # Every Sunday at 3 AM
|
# unset — run once on startup, then exit
|
||||||
|
# empty string — stay alive, run nothing (trigger manually: docker exec -it winnow winnow)
|
||||||
|
# cron expression — run on startup, then on schedule
|
||||||
|
# CRON_SCHEDULE= # Manual mode (keep alive, no auto-run)
|
||||||
|
# CRON_SCHEDULE=0 3 * * 0 # Every Sunday at 3 AM
|
||||||
|
# CRON_SCHEDULE=0 3 1 * * # First of every month
|
||||||
|
|||||||
@@ -126,3 +126,58 @@ jobs:
|
|||||||
- name: Inspect image
|
- name: Inspect image
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ${{ steps.tags.outputs.tags }}
|
docker buildx imagetools inspect ${{ steps.tags.outputs.tags }}
|
||||||
|
|
||||||
|
build-cpu:
|
||||||
|
name: Build CPU-only (amd64)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Free up disk space
|
||||||
|
run: |
|
||||||
|
sudo rm -rf /usr/share/dotnet
|
||||||
|
sudo rm -rf /opt/ghc
|
||||||
|
sudo rm -rf "/usr/local/share/boost"
|
||||||
|
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||||
|
echo "Disk space freed."
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Determine CPU image tag
|
||||||
|
id: cpu-tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.ref_name }}" = "dev" ]; then
|
||||||
|
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev-cpu" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tag=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cpu" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build and push CPU image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: VARIANT=cpu
|
||||||
|
cache-from: type=gha,scope=linux/amd64-cpu
|
||||||
|
cache-to: type=gha,mode=max,scope=linux/amd64-cpu
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.cpu-tag.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Inspect CPU image
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ steps.cpu-tag.outputs.tag }}
|
||||||
|
|||||||
+20
-223
@@ -1,239 +1,36 @@
|
|||||||
# Custom
|
# Output and runtime artefacts
|
||||||
frigate_train/
|
frigate_train/
|
||||||
*.log
|
*.log
|
||||||
runs/
|
runs/
|
||||||
|
|
||||||
|
# Model and cache files
|
||||||
yolov9c.pt
|
yolov9c.pt
|
||||||
.insightface/
|
.insightface/
|
||||||
.huggingface/
|
.huggingface/
|
||||||
.cache/huggingface
|
|
||||||
.if_cache/
|
.if_cache/
|
||||||
|
|
||||||
.immich_config.json
|
.immich_config.json
|
||||||
# Python-generated files
|
|
||||||
__pycache__/
|
|
||||||
*.py[oc]
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
wheels/
|
|
||||||
*.egg-info
|
|
||||||
|
|
||||||
# Virtual environments
|
# Secrets
|
||||||
.venv
|
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
|
||||||
__pycache__/
|
|
||||||
*.py[codz]
|
|
||||||
*$py.class
|
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
MANIFEST
|
|
||||||
|
|
||||||
# PyInstaller
|
|
||||||
# Usually these files are written by a python script from a template
|
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
||||||
*.manifest
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Installer logs
|
|
||||||
pip-log.txt
|
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
|
||||||
# Unit test / coverage reports
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.nox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
*.py.cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
cover/
|
|
||||||
|
|
||||||
# Translations
|
|
||||||
*.mo
|
|
||||||
*.pot
|
|
||||||
|
|
||||||
# Django stuff:
|
|
||||||
*.log
|
|
||||||
local_settings.py
|
|
||||||
db.sqlite3
|
|
||||||
db.sqlite3-journal
|
|
||||||
|
|
||||||
# Flask stuff:
|
|
||||||
instance/
|
|
||||||
.webassets-cache
|
|
||||||
|
|
||||||
# Scrapy stuff:
|
|
||||||
.scrapy
|
|
||||||
|
|
||||||
# Sphinx documentation
|
|
||||||
docs/_build/
|
|
||||||
|
|
||||||
# PyBuilder
|
|
||||||
.pybuilder/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# Jupyter Notebook
|
|
||||||
.ipynb_checkpoints
|
|
||||||
|
|
||||||
# IPython
|
|
||||||
profile_default/
|
|
||||||
ipython_config.py
|
|
||||||
|
|
||||||
# pyenv
|
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
|
||||||
# .python-version
|
|
||||||
|
|
||||||
# pipenv
|
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
||||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
||||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
||||||
# install all needed dependencies.
|
|
||||||
# Pipfile.lock
|
|
||||||
|
|
||||||
# UV
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
|
||||||
# uv.lock
|
|
||||||
|
|
||||||
# poetry
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
|
||||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
||||||
# poetry.lock
|
|
||||||
# poetry.toml
|
|
||||||
|
|
||||||
# pdm
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
||||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
||||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
||||||
# pdm.lock
|
|
||||||
# pdm.toml
|
|
||||||
.pdm-python
|
|
||||||
.pdm-build/
|
|
||||||
|
|
||||||
# pixi
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
||||||
# pixi.lock
|
|
||||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
||||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
||||||
.pixi
|
|
||||||
|
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
||||||
__pypackages__/
|
|
||||||
|
|
||||||
# Celery stuff
|
|
||||||
celerybeat-schedule
|
|
||||||
celerybeat.pid
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
*.rdb
|
|
||||||
*.aof
|
|
||||||
*.pid
|
|
||||||
|
|
||||||
# RabbitMQ
|
|
||||||
mnesia/
|
|
||||||
rabbitmq/
|
|
||||||
rabbitmq-data/
|
|
||||||
|
|
||||||
# ActiveMQ
|
|
||||||
activemq-data/
|
|
||||||
|
|
||||||
# SageMath parsed files
|
|
||||||
*.sage.py
|
|
||||||
|
|
||||||
# Environments
|
|
||||||
.env
|
.env
|
||||||
.envrc
|
.envrc
|
||||||
.venv
|
|
||||||
env/
|
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
env.bak/
|
|
||||||
venv.bak/
|
|
||||||
|
|
||||||
# Spyder project settings
|
# Python
|
||||||
.spyderproject
|
__pycache__/
|
||||||
.spyproject
|
*.py[oc]
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
|
||||||
# Rope project settings
|
# Packaging
|
||||||
.ropeproject
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
wheels/
|
||||||
|
|
||||||
# mkdocs documentation
|
# Virtual environments
|
||||||
/site
|
.venv/
|
||||||
|
|
||||||
# mypy
|
# Tools
|
||||||
.mypy_cache/
|
|
||||||
.dmypy.json
|
|
||||||
dmypy.json
|
|
||||||
|
|
||||||
# Pyre type checker
|
|
||||||
.pyre/
|
|
||||||
|
|
||||||
# pytype static type analyzer
|
|
||||||
.pytype/
|
|
||||||
|
|
||||||
# Cython debug symbols
|
|
||||||
cython_debug/
|
|
||||||
|
|
||||||
# PyCharm
|
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
||||||
# .idea/
|
|
||||||
|
|
||||||
# Abstra
|
|
||||||
# Abstra is an AI-powered process automation framework.
|
|
||||||
# Ignore directories containing user credentials, local state, and settings.
|
|
||||||
# Learn more at https://abstra.io/docs
|
|
||||||
.abstra/
|
|
||||||
|
|
||||||
# Visual Studio Code
|
|
||||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
||||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
||||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
||||||
# you could uncomment the following to ignore the entire vscode folder
|
|
||||||
# .vscode/
|
|
||||||
|
|
||||||
# Ruff stuff:
|
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
# PyPI configuration file
|
.mypy_cache/
|
||||||
.pypirc
|
.python-version
|
||||||
|
|
||||||
# Marimo
|
|
||||||
marimo/_static/
|
|
||||||
marimo/_lsp/
|
|
||||||
__marimo__/
|
|
||||||
|
|
||||||
# Streamlit
|
|
||||||
.streamlit/secrets.toml
|
|
||||||
compose.override.yml
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
3.13
|
|
||||||
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.2.10] - 2026-06-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`VERBOSE` env var**: set `VERBOSE=true` to enable DEBUG-level console output. The log file always captures DEBUG; this flag controls what appears on the terminal. Useful when diagnosing issues without a full shell into the container.
|
||||||
|
- **`:cpu` Docker image tag**: a separate CPU-only image (`ghcr.io/sudolulo/winnow:cpu`) is now built and pushed alongside `:latest`. Uses `onnxruntime` instead of `onnxruntime-gpu`; ~2 GB smaller. Suitable for systems without an NVIDIA GPU.
|
||||||
|
- **Empty `CRON_SCHEDULE` keeps container alive**: setting `CRON_SCHEDULE=` (empty string) starts the container without running immediately and without exiting — useful for `docker exec` ad-hoc runs on a long-lived container. Previously, an empty value was treated the same as unset (run once, then exit).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **TTY auto-detection replaces `AUTO_MODE`**: winnow now detects whether a TTY is attached (`sys.stdin.isatty()`) and switches between interactive and auto mode automatically. `AUTO_MODE=true` becomes an explicit override for forcing auto mode in a terminal session. No config change needed for normal Docker deployments.
|
||||||
|
- **Logging levels audited**: internal algorithmic detail (clustering steps, asset fetch progress, per-page pagination) demoted from INFO to DEBUG. INFO now reflects meaningful pipeline milestones only (model ready, selection complete, quality filtered). Reduces noise in production logs without losing information.
|
||||||
|
- **Model load logging improved**: SigLIP and InsightFace loading now reports cache hit/miss, download size estimate, device used, and load time.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **GPU OOM crash loop (production)**: `CUDAExecutionProvider` was silently absent even with a GPU attached, causing InsightFace to run on CPU and exhaust RAM processing large person libraries. Root cause: CUDA/cuDNN libraries in nvidia pip packages were invisible to onnxruntime. Fixed by running `ldconfig` over all `nvidia-*/lib/` directories in the venv at image build time.
|
||||||
|
- **ldconfig path now Python-version-agnostic**: the `find` command used to register nvidia pip libraries hardcoded `python3.13`; replaced with `python3.*` glob so the path survives a Python upgrade without silently producing an empty ldconfig config.
|
||||||
|
- **`PYTHONPATH=/app` added to Dockerfile**: the entry point script sets `sys.path[0]` to the script directory, not `/app`. Since `uv sync` runs before `COPY winnow/`, the wheel has only dist-info in site-packages. `PYTHONPATH=/app` makes the `winnow` package importable without reverting to `python -m`.
|
||||||
|
- **CPU fallback retrying broken GPU provider**: InsightFace CPU fallback omitted `providers=["CPUExecutionProvider"]`, causing onnxruntime to retry `CUDAExecutionProvider` on every inference call. Now explicitly sets the CPU provider and suppresses C-extension noise via fd-level redirect.
|
||||||
|
- **`_suppress_output` stderr loss on fd exhaustion**: if the first `os.dup2` in the finally block raised `OSError`, the second call was skipped, permanently redirecting stderr to `/dev/null` for the process lifetime. Wrapped in nested `try/finally` so both restores are always attempted.
|
||||||
|
- **Frigate `/api/faces` response parsing**: the response is `{person_name: [files], "train": [...]}` — `"train"` is a flat pending list, not a person. Previous code called `.items()` on the `"train"` value (a list), crashing with `AttributeError`. Now skips the `"train"` key explicitly.
|
||||||
|
- **Immich 401 detection**: a stale or invalid API key now logs a clear error message (`Immich API key is invalid or expired (401 Unauthorized)`) instead of raising an unhandled exception.
|
||||||
|
- **Falsy-zero detection confidence**: `face.get("score") or face.get("confidence")` treated a valid `score=0.0` as falsy, falling through to the `confidence` field (often `None`). Replaced with an explicit `None` check. Affected both quality filtering and hard-example weighting in diversity selection.
|
||||||
|
- **Face crop using wrong person's image dimensions**: in multi-person assets, `_crop_face_from_thumbnail`'s scale-factor loop matched the first person with any face regardless of `person_id`, producing incorrectly scaled bounding box coordinates for the target person. Loop now applies the same `person_id` filter as `_get_face_bbox`.
|
||||||
|
- **Adaptive stopping bypassed for partially-trained people**: in auto mode with `already_uploaded > 0`, `limit` was converted from `"auto"` to an integer, disabling the FPS adaptive threshold and early-stop check. Now keeps `limit="auto"` through selection and trims the result to the remaining capacity afterward.
|
||||||
|
- **Embedding cache key mismatch**: HuggingFace cache path check hardcoded the model slug string; replaced with a derivation from `model_name` using `"models--" + model_name.replace("/", "--")` so the check stays correct if the model name changes.
|
||||||
|
- **Scheduler: sleep until next run**: the loop slept a fixed 60 seconds regardless of schedule interval, causing runs to fire up to 59 seconds late and waking the process unnecessarily on long schedules (e.g. weekly). Now sleeps exactly until `next_run`.
|
||||||
|
- **Scheduler swallowing `SystemExit`**: `except BaseException` in the run wrapper was replaced with `except Exception` (with `KeyboardInterrupt` re-raised above), so `sys.exit()` calls propagate correctly.
|
||||||
|
- **Log handler leak**: `setup_logging` now closes and removes existing handlers before adding new ones, preventing file handle accumulation across repeated calls.
|
||||||
|
- **`RETRY_REJECTED` silently applied in interactive mode**: the env var was applied unconditionally even in interactive sessions. Now used only as the default for the interactive prompt so users can override it per-run.
|
||||||
|
- **`compose.yml` comment inverted**: a comment stated `-it` forces non-interactive mode; corrected to reflect that `-it` allocates a TTY (interactive mode).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- API key is no longer stored in any config file. All authentication uses environment variables or `.env` only.
|
||||||
|
|
||||||
|
## [0.2.9] - 2026-06-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **arm64: `onnxruntime-gpu` has no arm64 wheels**: `onnxruntime-gpu` only publishes `manylinux_2_27_x86_64` and `manylinux_2_28_x86_64` wheels — `uv sync` on arm64 failed with exit code 2. Gated `onnxruntime-gpu` behind `sys_platform == 'linux' and platform_machine == 'x86_64'`; arm64 and non-Linux installs now get the CPU `onnxruntime` package instead.
|
||||||
|
- **uv lockfile now covers arm64**: Added `required-environments` to `[tool.uv]` so the lockfile is solved for both `linux/x86_64` and `linux/aarch64`, preventing silent resolution gaps for the non-build platform.
|
||||||
|
|
||||||
## [0.2.8] - 2026-06-12
|
## [0.2.8] - 2026-06-12
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+34
-18
@@ -1,20 +1,25 @@
|
|||||||
# ── Platform-conditional base ─────────────────────────────────────────────
|
# ── Base images ───────────────────────────────────────────────────────────────
|
||||||
# amd64: NVIDIA CUDA 13.3 (GPU acceleration when available, CPU fallback)
|
# amd64 + gpu: NVIDIA CUDA 13.3 + cuDNN (GPU acceleration when available)
|
||||||
# arm64: Ubuntu 24.04 (CPU-only; no CUDA on ARM)
|
# amd64 + cpu: Ubuntu 22.04 (CPU-only, ~2 GB smaller image)
|
||||||
|
# arm64: Ubuntu 24.04 (CPU-only; no CUDA wheels on ARM)
|
||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM nvidia/cuda:13.3.0-cudnn-runtime-ubuntu22.04 AS base-amd64
|
ARG VARIANT=gpu
|
||||||
FROM ubuntu:24.04 AS base-arm64
|
|
||||||
|
|
||||||
# ── Build stage ───────────────────────────────────────────────────────────
|
FROM --platform=$BUILDPLATFORM nvidia/cuda:13.3.0-cudnn-runtime-ubuntu22.04 AS base-amd64-gpu
|
||||||
|
FROM ubuntu:22.04 AS base-amd64-cpu
|
||||||
|
FROM ubuntu:24.04 AS base-arm64-gpu
|
||||||
|
FROM ubuntu:24.04 AS base-arm64-cpu
|
||||||
|
|
||||||
|
# ── Build stage ───────────────────────────────────────────────────────────────
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
FROM base-${TARGETARCH} AS build
|
FROM base-${TARGETARCH}-${VARIANT} AS build
|
||||||
|
|
||||||
|
ARG VARIANT=gpu
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
# Both bases (Ubuntu 22.04 CUDA / Ubuntu 24.04) need Python 3.13 from the
|
# Both Ubuntu 22.04 and 24.04 get Python 3.13 from the deadsnakes PPA.
|
||||||
# deadsnakes PPA. GNUPGHOME is isolated to a tmpdir so gpg never tries to
|
# GNUPGHOME is isolated so gpg never contacts an agent socket under QEMU.
|
||||||
# contact an agent socket, which fails silently under QEMU.
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates curl gnupg software-properties-common \
|
ca-certificates curl gnupg software-properties-common \
|
||||||
&& GNUPGHOME=$(mktemp -d) add-apt-repository ppa:deadsnakes/ppa -y \
|
&& GNUPGHOME=$(mktemp -d) add-apt-repository ppa:deadsnakes/ppa -y \
|
||||||
@@ -30,20 +35,26 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pyproject.toml uv.lock ./
|
# For cpu variant, swap in the CPU-only pyproject and lockfile before syncing.
|
||||||
RUN uv sync --frozen --no-dev \
|
COPY pyproject.toml uv.lock pyproject-cpu.toml uv-cpu.lock ./
|
||||||
|
RUN if [ "$VARIANT" = "cpu" ]; then \
|
||||||
|
cp pyproject-cpu.toml pyproject.toml && \
|
||||||
|
cp uv-cpu.lock uv.lock; \
|
||||||
|
fi && \
|
||||||
|
uv sync --frozen --no-dev \
|
||||||
&& uv cache clean
|
&& uv cache clean
|
||||||
|
|
||||||
COPY winnow/ winnow/
|
COPY winnow/ winnow/
|
||||||
COPY entrypoint.sh scheduler.py ./
|
COPY entrypoint.sh scheduler.py ./
|
||||||
RUN chmod +x /app/entrypoint.sh
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
# ── Runtime stage ─────────────────────────────────────────────────────────
|
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||||
# Starts fresh from the base image — excludes build tools (g++,
|
# Starts fresh from the base image — excludes build tools (g++,
|
||||||
# python3.13-dev, gnupg, software-properties-common) not needed at runtime.
|
# python3.13-dev, gnupg, software-properties-common) not needed at runtime.
|
||||||
|
|
||||||
FROM base-${TARGETARCH} AS runtime
|
FROM base-${TARGETARCH}-${VARIANT} AS runtime
|
||||||
|
|
||||||
|
ARG VARIANT=gpu
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
@@ -61,9 +72,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
COPY --from=build /app /app
|
COPY --from=build /app /app
|
||||||
COPY --from=build /usr/local/bin/uv /usr/local/bin/uv
|
COPY --from=build /usr/local/bin/uv /usr/local/bin/uv
|
||||||
|
|
||||||
# Expose CUDA/cuDNN libraries from pip packages so onnxruntime-gpu
|
# Register every nvidia pip-package lib/ directory with ldconfig so that
|
||||||
# can find libcublasLt.so.12 and libcudnn.so.9 at runtime (amd64 only)
|
# onnxruntime-gpu and torch can find libcudnn, libcublas, libcufft, etc.
|
||||||
ENV LD_LIBRARY_PATH="/app/.venv/lib/python3.13/site-packages/nvidia/cudnn/lib:/app/.venv/lib/python3.13/site-packages/nvidia/cuda_runtime/lib:${LD_LIBRARY_PATH}"
|
# without a hand-maintained LD_LIBRARY_PATH. Skipped silently on cpu builds.
|
||||||
|
RUN find /app/.venv/lib/python3.*/site-packages/nvidia -type d -name "lib" \
|
||||||
|
2>/dev/null > /etc/ld.so.conf.d/nvidia-pip.conf && ldconfig || true
|
||||||
|
|
||||||
RUN groupadd -g 568 apps && useradd -u 568 -g apps -m -s /bin/bash appuser \
|
RUN groupadd -g 568 apps && useradd -u 568 -g apps -m -s /bin/bash appuser \
|
||||||
&& mkdir -p /models/.insightface /models/huggingface \
|
&& mkdir -p /models/.insightface /models/huggingface \
|
||||||
@@ -71,7 +84,10 @@ RUN groupadd -g 568 apps && useradd -u 568 -g apps -m -s /bin/bash appuser \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
USER appuser
|
USER appuser
|
||||||
ENV HF_HOME=/models/huggingface INSIGHTFACE_HOME=/models
|
# PYTHONPATH=/app makes the winnow package importable from the entry point script.
|
||||||
|
# uv sync builds the wheel before winnow/ is COPY'd, so site-packages has only
|
||||||
|
# the dist-info. Explicitly adding /app lets Python find winnow/__init__.py there.
|
||||||
|
ENV HF_HOME=/models/huggingface INSIGHTFACE_HOME=/models/.insightface PYTHONPATH=/app
|
||||||
|
|
||||||
HEALTHCHECK CMD test -f /app/entrypoint.sh || exit 1
|
HEALTHCHECK CMD test -f /app/entrypoint.sh || exit 1
|
||||||
ENTRYPOINT ["tini", "--", "/app/entrypoint.sh"]
|
ENTRYPOINT ["tini", "--", "/app/entrypoint.sh"]
|
||||||
|
|||||||
@@ -111,17 +111,23 @@ If the embedding model is unavailable, the tool falls back to **time spread**: e
|
|||||||
|
|
||||||
## Running in Docker
|
## Running in Docker
|
||||||
|
|
||||||
|
### Image Tags
|
||||||
|
|
||||||
|
| Tag | Arch | GPU | Notes |
|
||||||
|
| :-- | :-- | :-- | :-- |
|
||||||
|
| `:latest` | amd64 + arm64 | CUDA 13.3 (amd64) | Requires NVIDIA Container Toolkit on amd64 |
|
||||||
|
| `:cpu` | amd64 | None | ~2 GB smaller; use if you have no NVIDIA GPU |
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
winnow:
|
winnow:
|
||||||
image: ghcr.io/sudolulo/winnow:latest
|
image: ghcr.io/sudolulo/winnow:latest # or :cpu for CPU-only amd64
|
||||||
environment:
|
environment:
|
||||||
- IMMICH_URL=http://192.168.1.10:2283
|
- IMMICH_URL=http://192.168.1.10:2283
|
||||||
- API_KEY=your-immich-api-key
|
- API_KEY=your-immich-api-key
|
||||||
- FRIGATE_URL=http://192.168.1.10:5000
|
- FRIGATE_URL=http://192.168.1.10:5000
|
||||||
- AUTO_MODE=true
|
|
||||||
- CRON_SCHEDULE=0 3 * * 0 # Every Sunday at 3 AM
|
- CRON_SCHEDULE=0 3 * * 0 # Every Sunday at 3 AM
|
||||||
volumes:
|
volumes:
|
||||||
- /path/to/models:/models
|
- /path/to/models:/models
|
||||||
@@ -136,11 +142,21 @@ services:
|
|||||||
capabilities: [gpu]
|
capabilities: [gpu]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **CPU users (`:cpu` tag):** remove the `deploy.resources` block — no NVIDIA runtime needed.
|
||||||
|
|
||||||
See [compose.yml](compose.yml) for the full annotated example.
|
See [compose.yml](compose.yml) for the full annotated example.
|
||||||
|
|
||||||
### Scheduling Behaviour
|
### Scheduling Behaviour
|
||||||
|
|
||||||
On startup the container always runs once immediately. If `CRON_SCHEDULE` is set, it then starts a scheduler that fires on the defined interval, keeping the process (and loaded models) alive between runs. Without `CRON_SCHEDULE` the container exits after the first run.
|
`CRON_SCHEDULE` controls container lifetime:
|
||||||
|
|
||||||
|
| `CRON_SCHEDULE` value | Behaviour |
|
||||||
|
| :-- | :-- |
|
||||||
|
| *(unset)* | Run once on startup, then exit |
|
||||||
|
| *(empty string)* | Stay alive, run nothing — trigger manually with `docker exec -it winnow winnow` |
|
||||||
|
| Cron expression | Run on startup, then repeat on schedule |
|
||||||
|
|
||||||
|
In scheduled mode the process (and loaded models) stays resident between runs. In manual mode the container idles indefinitely with `sleep infinity` — useful when you want to trigger runs interactively on demand without pulling a new container each time.
|
||||||
|
|
||||||
The first run after a fresh install downloads the embedding models (~1-2 GB). Subsequent runs use the cached models from the mounted volume and start immediately.
|
The first run after a fresh install downloads the embedding models (~1-2 GB). Subsequent runs use the cached models from the mounted volume and start immediately.
|
||||||
|
|
||||||
@@ -152,7 +168,8 @@ The first run after a fresh install downloads the embedding models (~1-2 GB). Su
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| `AUTO_MODE` | `false` | Run without interactive prompts — required for Docker/cron use |
|
| `AUTO_MODE` | *(auto)* | Force non-interactive mode even in a terminal; auto-detected otherwise (no TTY = auto) |
|
||||||
|
| `VERBOSE` | `false` | Set to `true` to enable DEBUG-level console output (the log file is always DEBUG) |
|
||||||
| `TRAINING_MODE` | `face` | `face` — upload crops to Frigate API; `object` — save crops to disk |
|
| `TRAINING_MODE` | `face` | `face` — upload crops to Frigate API; `object` — save crops to disk |
|
||||||
| `STRATEGY` | `auto` | `auto` (adaptive), `standard` (30 images), `broad` (100 images) |
|
| `STRATEGY` | `auto` | `auto` (adaptive), `standard` (30 images), `broad` (100 images) |
|
||||||
| `LIMIT` | *(unset)* | Exact image count — overrides `STRATEGY` |
|
| `LIMIT` | *(unset)* | Exact image count — overrides `STRATEGY` |
|
||||||
@@ -192,7 +209,7 @@ The first run after a fresh install downloads the embedding models (~1-2 GB). Su
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| `FORCE_CPU` | `false` | Disable GPU — fall back to CPU for embedding computation |
|
| `FORCE_CPU` | `false` | Disable GPU — fall back to CPU for embedding computation |
|
||||||
| `ENABLE_CACHE` | `false` | Cache computed embeddings to disk (speeds up re-runs on the same library) |
|
| `ENABLE_CACHE` | `true` | Cache computed embeddings to disk (speeds up re-runs on the same library) |
|
||||||
| `CACHE_DIR` | `.if_cache` | Path for embedding cache and upload tracker files |
|
| `CACHE_DIR` | `.if_cache` | Path for embedding cache and upload tracker files |
|
||||||
| `HF_HOME` | *(system)* | HuggingFace model cache location (SigLIP) |
|
| `HF_HOME` | *(system)* | HuggingFace model cache location (SigLIP) |
|
||||||
| `INSIGHTFACE_HOME` | *(system)* | InsightFace model cache location (Buffalo_L) |
|
| `INSIGHTFACE_HOME` | *(system)* | InsightFace model cache location (Buffalo_L) |
|
||||||
@@ -209,7 +226,7 @@ The first run after a fresh install downloads the embedding models (~1-2 GB). Su
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| `CRON_SCHEDULE` | *(unset)* | Cron expression for recurring runs — unset exits after first run |
|
| `CRON_SCHEDULE` | *(unset)* | Unset = run once and exit; empty = stay alive for manual `docker exec`; cron expression = scheduled |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -222,7 +239,7 @@ uv sync
|
|||||||
uv run winnow
|
uv run winnow
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Python 3.12+ and [uv](https://astral.sh/uv/). An NVIDIA GPU is strongly recommended — CPU mode works but embedding computation is significantly slower.
|
Requires Python 3.13+ and [uv](https://astral.sh/uv/). An NVIDIA GPU is strongly recommended — CPU mode works but embedding computation is significantly slower.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -231,7 +248,7 @@ Requires Python 3.12+ and [uv](https://astral.sh/uv/). An NVIDIA GPU is strongly
|
|||||||
- **Immich** v1.106+
|
- **Immich** v1.106+
|
||||||
- **Frigate** v0.16+ (face mode only — object mode has no Frigate API dependency)
|
- **Frigate** v0.16+ (face mode only — object mode has no Frigate API dependency)
|
||||||
- **NVIDIA GPU** recommended (CUDA 12.x)
|
- **NVIDIA GPU** recommended (CUDA 12.x)
|
||||||
- **Python 3.12+**
|
- **Python 3.13+**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+14
-10
@@ -9,7 +9,10 @@ services:
|
|||||||
- FRIGATE_URL=${FRIGATE_URL}
|
- FRIGATE_URL=${FRIGATE_URL}
|
||||||
|
|
||||||
# ── Mode & Strategy ───────────────────────────────────────────────────
|
# ── Mode & Strategy ───────────────────────────────────────────────────
|
||||||
- AUTO_MODE=true
|
# Auto mode is active by default when no TTY is present (Docker/cron).
|
||||||
|
# Set AUTO_MODE=true to force auto mode in an interactive terminal.
|
||||||
|
# To run interactively: docker exec -it winnow winnow
|
||||||
|
# - VERBOSE=true # Enable DEBUG-level console output
|
||||||
# TRAINING_MODE: face = upload to Frigate face recognition API
|
# TRAINING_MODE: face = upload to Frigate face recognition API
|
||||||
# object = save crops to output dir for manual Frigate placement
|
# object = save crops to output dir for manual Frigate placement
|
||||||
- TRAINING_MODE=face
|
- TRAINING_MODE=face
|
||||||
@@ -34,8 +37,8 @@ services:
|
|||||||
# - MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80)
|
# - MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80)
|
||||||
|
|
||||||
# ── Caching & Models ──────────────────────────────────────────────────
|
# ── Caching & Models ──────────────────────────────────────────────────
|
||||||
- FORCE_CPU=false
|
# - FORCE_CPU=true # Disable GPU, fall back to CPU
|
||||||
- ENABLE_CACHE=true
|
# - ENABLE_CACHE=false # Disable embedding cache (default: true)
|
||||||
- CACHE_DIR=/app/.if_cache
|
- CACHE_DIR=/app/.if_cache
|
||||||
- HF_HOME=/models/huggingface
|
- HF_HOME=/models/huggingface
|
||||||
- INSIGHTFACE_HOME=/models/.insightface
|
- INSIGHTFACE_HOME=/models/.insightface
|
||||||
@@ -46,18 +49,19 @@ services:
|
|||||||
# - RESET_PERSON=John # Clear uploaded+rejected history for one person
|
# - RESET_PERSON=John # Clear uploaded+rejected history for one person
|
||||||
|
|
||||||
# ── Scheduling ────────────────────────────────────────────────────────
|
# ── Scheduling ────────────────────────────────────────────────────────
|
||||||
# Cron expression (unset = run once and exit)
|
# CRON_SCHEDULE controls container lifetime:
|
||||||
# Every Sunday at 3 AM:
|
# unset — run once on startup, then exit
|
||||||
- CRON_SCHEDULE=0 3 * * 0
|
# empty string — stay alive, run nothing; trigger manually with:
|
||||||
# - CRON_SCHEDULE=0 3 1 * *
|
# docker exec -it winnow winnow
|
||||||
# - CRON_SCHEDULE=*/30 * * * *
|
# cron expression — run on startup, then on schedule
|
||||||
|
# - CRON_SCHEDULE= # Manual mode (keep alive, no auto-run)
|
||||||
|
# - CRON_SCHEDULE=0 3 * * 0 # Every Sunday at 3 AM
|
||||||
|
# - CRON_SCHEDULE=0 3 1 * * # First of every month
|
||||||
volumes:
|
volumes:
|
||||||
# Replace with absolute paths on your host, e.g. /opt/winnow/models
|
# Replace with absolute paths on your host, e.g. /opt/winnow/models
|
||||||
- /path/to/winnow/models:/models
|
- /path/to/winnow/models:/models
|
||||||
- /path/to/winnow/cache:/app/.if_cache
|
- /path/to/winnow/cache:/app/.if_cache
|
||||||
- /path/to/winnow/output:/app/frigate_train
|
- /path/to/winnow/output:/app/frigate_train
|
||||||
stdin_open: true
|
|
||||||
tty: true
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
+11
-4
@@ -2,11 +2,19 @@
|
|||||||
set -e
|
set -e
|
||||||
export PYTHONUNBUFFERED=1
|
export PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# 1. Run the job immediately on startup
|
# CRON_SCHEDULE controls container lifetime:
|
||||||
|
# unset — run once and exit
|
||||||
|
# empty string — stay alive, run nothing (use: docker exec -it winnow winnow)
|
||||||
|
# cron expression — run immediately, then on schedule
|
||||||
|
|
||||||
|
if [ "${CRON_SCHEDULE+isset}" = "isset" ] && [ -z "$CRON_SCHEDULE" ]; then
|
||||||
|
echo "▶ CRON_SCHEDULE is empty — manual mode. Use 'docker exec -it winnow winnow' to run."
|
||||||
|
exec sleep infinity
|
||||||
|
fi
|
||||||
|
|
||||||
echo "▶ Running on startup..."
|
echo "▶ Running on startup..."
|
||||||
/app/.venv/bin/python -m winnow.cli
|
/app/.venv/bin/winnow
|
||||||
|
|
||||||
# 2. If a schedule exists, start the scheduler
|
|
||||||
if [ -n "${CRON_SCHEDULE:-}" ]; then
|
if [ -n "${CRON_SCHEDULE:-}" ]; then
|
||||||
echo "▶ CRON_SCHEDULE set to: $CRON_SCHEDULE"
|
echo "▶ CRON_SCHEDULE set to: $CRON_SCHEDULE"
|
||||||
echo "▶ Switching to scheduled mode..."
|
echo "▶ Switching to scheduled mode..."
|
||||||
@@ -14,4 +22,3 @@ if [ -n "${CRON_SCHEDULE:-}" ]; then
|
|||||||
else
|
else
|
||||||
echo "▶ No schedule set, exiting."
|
echo "▶ No schedule set, exiting."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
[project]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.2.10"
|
||||||
|
description = "Immich to Frigate training sets"
|
||||||
|
license = "AGPL-3.0-or-later"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
authors = [{ name = "Holden Salomon", email = "holden@arch.fyi" }]
|
||||||
|
keywords = ["immich", "frigate", "face-recognition", "training-data", "arcface", "insightface"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Topic :: Scientific/Engineering :: Image Recognition",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"croniter>=5.0.2",
|
||||||
|
"insightface>=0.7.3",
|
||||||
|
"numpy>=2.2.6",
|
||||||
|
"onnxruntime>=1.23.2",
|
||||||
|
"opencv-python-headless>=4.12.0.88",
|
||||||
|
"pillow>=12.1.0",
|
||||||
|
"python-dotenv>=1.2.1",
|
||||||
|
"requests>=2.32.5",
|
||||||
|
"rich>=14.2.0",
|
||||||
|
"torch>=2.12.0",
|
||||||
|
"torchvision>=0.27.0",
|
||||||
|
"transformers>=4.57.6",
|
||||||
|
"ultralytics>=8.4.66",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
winnow = "winnow.cli:main"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://github.com/sudolulo/winnow"
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
required-environments = [
|
||||||
|
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
torch = [
|
||||||
|
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||||
|
]
|
||||||
|
torchvision = [
|
||||||
|
{ index = "pytorch-cpu", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[tool.uv.index]]
|
||||||
|
name = "pytorch-cpu"
|
||||||
|
url = "https://download.pytorch.org/whl/cpu"
|
||||||
|
explicit = true
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"ruff>=0.15.17",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["winnow"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
target-version = "py313"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I"]
|
||||||
|
|
||||||
|
[tool.deptry]
|
||||||
|
pep621_dev_dependency_groups = ["dev"]
|
||||||
|
|
||||||
|
[tool.deptry.package_module_name_map]
|
||||||
|
pillow = "PIL"
|
||||||
|
opencv-python-headless = "cv2"
|
||||||
|
python-dotenv = "dotenv"
|
||||||
|
insightface = "insightface"
|
||||||
|
numpy = "numpy"
|
||||||
|
onnxruntime = "onnxruntime"
|
||||||
|
requests = "requests"
|
||||||
|
rich = "rich"
|
||||||
|
torch = "torch"
|
||||||
|
transformers = "transformers"
|
||||||
|
ultralytics = "ultralytics"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
+9
-3
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.2.8"
|
version = "0.2.10"
|
||||||
description = "Immich to Frigate training sets"
|
description = "Immich to Frigate training sets"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
@@ -18,7 +18,9 @@ dependencies = [
|
|||||||
"insightface>=0.7.3",
|
"insightface>=0.7.3",
|
||||||
"nvidia-cudnn-cu12>=9.0.0",
|
"nvidia-cudnn-cu12>=9.0.0",
|
||||||
"numpy>=2.2.6",
|
"numpy>=2.2.6",
|
||||||
"onnxruntime-gpu>=1.23.2",
|
"onnxruntime-gpu>=1.23.2; sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||||
|
"onnxruntime>=1.23.2; sys_platform == 'linux' and platform_machine != 'x86_64'",
|
||||||
|
"onnxruntime>=1.23.2; sys_platform != 'linux'",
|
||||||
"opencv-python-headless>=4.12.0.88",
|
"opencv-python-headless>=4.12.0.88",
|
||||||
"pillow>=12.1.0",
|
"pillow>=12.1.0",
|
||||||
"python-dotenv>=1.2.1",
|
"python-dotenv>=1.2.1",
|
||||||
@@ -37,7 +39,11 @@ winnow = "winnow.cli:main"
|
|||||||
Repository = "https://github.com/sudolulo/winnow"
|
Repository = "https://github.com/sudolulo/winnow"
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
override-dependencies = ["onnxruntime-gpu>=1.23.2"]
|
override-dependencies = ["onnxruntime>=1.23.2"]
|
||||||
|
required-environments = [
|
||||||
|
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||||
|
"sys_platform == 'linux' and platform_machine == 'aarch64'",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
torch = [
|
torch = [
|
||||||
|
|||||||
+22
-28
@@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -9,33 +8,27 @@ from pathlib import Path
|
|||||||
try:
|
try:
|
||||||
from croniter import croniter
|
from croniter import croniter
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print("❌ croniter not installed. Run: uv add croniter")
|
print("croniter not installed. Run: uv add croniter")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Imported at module level so models loaded during the first run stay
|
||||||
|
# resident in memory across all subsequent scheduled runs.
|
||||||
|
from winnow.cli import main
|
||||||
|
|
||||||
SCHEDULE = os.environ["CRON_SCHEDULE"]
|
SCHEDULE = os.environ["CRON_SCHEDULE"]
|
||||||
MODELS_DIR = os.environ.get("HF_HOME", "/models/huggingface")
|
MODELS_DIR = os.environ.get("HF_HOME", "/models/huggingface")
|
||||||
INSIGHTFACE_BASE = os.environ.get("INSIGHTFACE_HOME", "/models")
|
INSIGHTFACE_HOME = os.environ.get("INSIGHTFACE_HOME", "/models/.insightface")
|
||||||
|
|
||||||
RUN_ENV = {**os.environ, "PYTHONUNBUFFERED": "1"}
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def check_models():
|
def check_models() -> None:
|
||||||
"""Log model status before each run."""
|
buffalo = Path(INSIGHTFACE_HOME) / "models" / "buffalo_l"
|
||||||
print("📦 Checking models...", flush=True)
|
|
||||||
buffalo = Path(INSIGHTFACE_BASE) / ".insightface" / "models" / "buffalo_l"
|
|
||||||
if buffalo.exists():
|
|
||||||
print(" ✅ InsightFace Buffalo_L: present", flush=True)
|
|
||||||
else:
|
|
||||||
print(" ⬇️ InsightFace Buffalo_L: not found — will download", flush=True)
|
|
||||||
|
|
||||||
hf_hub = Path(MODELS_DIR) / "hub"
|
hf_hub = Path(MODELS_DIR) / "hub"
|
||||||
if hf_hub.exists() and any(hf_hub.iterdir()):
|
if not buffalo.exists():
|
||||||
print(" ✅ HuggingFace models: present", flush=True)
|
print(" InsightFace Buffalo_L not found — will download on first run", flush=True)
|
||||||
else:
|
if not (hf_hub.exists() and any(hf_hub.iterdir())):
|
||||||
print(" ⬇️ HuggingFace models: not found — will download", flush=True)
|
print(" HuggingFace models not found — will download on first run", flush=True)
|
||||||
print("🚀 Starting winnow...", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
NOW = time.time()
|
NOW = time.time()
|
||||||
@@ -45,14 +38,15 @@ next_run = cron.get_next(float)
|
|||||||
while True:
|
while True:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now >= next_run:
|
if now >= next_run:
|
||||||
print(f"\n▶ [{time.strftime('%Y-%m-%d %H:%M:%S')}] Starting winnow...", flush=True)
|
print(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] Starting winnow run...", flush=True)
|
||||||
check_models()
|
check_models()
|
||||||
result = subprocess.run(["uv", "run", "winnow"], env=RUN_ENV)
|
try:
|
||||||
if result.returncode != 0:
|
main()
|
||||||
logger.error(f"winnow exited with code {result.returncode}")
|
print("winnow run complete", flush=True)
|
||||||
print(f"❌ winnow failed with exit code {result.returncode}", flush=True)
|
except KeyboardInterrupt:
|
||||||
else:
|
raise
|
||||||
print("✅ winnow completed successfully", flush=True)
|
except Exception as e:
|
||||||
|
logger.error(f"winnow run failed: {e}", exc_info=True)
|
||||||
|
print(f"winnow run failed: {e}", flush=True)
|
||||||
next_run = cron.get_next(float)
|
next_run = cron.get_next(float)
|
||||||
time.sleep(60)
|
time.sleep(max(1, next_run - time.time()))
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def test_config_loads_defaults(monkeypatch):
|
|||||||
assert cfg.FACE_MARGIN == 0.15
|
assert cfg.FACE_MARGIN == 0.15
|
||||||
assert cfg.USE_FULL_RESOLUTION is True
|
assert cfg.USE_FULL_RESOLUTION is True
|
||||||
assert cfg.ENABLE_FACE_ALIGNMENT is True
|
assert cfg.ENABLE_FACE_ALIGNMENT is True
|
||||||
assert cfg.ENABLE_CACHE is False
|
assert cfg.ENABLE_CACHE is True
|
||||||
|
|
||||||
_Config.reset()
|
_Config.reset()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Tests for image quality filtering functions."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb_image(r, g, b, size=(100, 100)) -> Image.Image:
|
||||||
|
arr = np.full((*size, 3), [r, g, b], dtype=np.uint8)
|
||||||
|
return Image.fromarray(arr, "RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def _noisy_color_image(size=(100, 100)) -> Image.Image:
|
||||||
|
"""Noisy image with a strong red channel so grayscale check passes."""
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
arr = rng.integers(0, 256, (*size, 3), dtype=np.uint8)
|
||||||
|
arr[:, :, 0] = np.clip(arr[:, :, 0].astype(int) + 80, 0, 255).astype(np.uint8)
|
||||||
|
arr[:, :, 2] = np.clip(arr[:, :, 2].astype(int) - 80, 0, 255).astype(np.uint8)
|
||||||
|
return Image.fromarray(arr, "RGB")
|
||||||
|
|
||||||
|
|
||||||
|
# ── check_blur ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_blur_rejects_flat_image():
|
||||||
|
from winnow.quality import check_blur
|
||||||
|
flat = np.full((100, 100, 3), 128, dtype=np.uint8)
|
||||||
|
passed, reason = check_blur(flat, threshold=100.0)
|
||||||
|
assert not passed
|
||||||
|
assert "Blurry" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_blur_passes_noisy_color_image():
|
||||||
|
from winnow.quality import check_blur
|
||||||
|
img = _noisy_color_image()
|
||||||
|
passed, _ = check_blur(np.asarray(img), threshold=100.0)
|
||||||
|
assert passed
|
||||||
|
|
||||||
|
|
||||||
|
# ── check_grayscale ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_grayscale_rejects_ir_image():
|
||||||
|
from winnow.quality import check_grayscale
|
||||||
|
gray = np.full((100, 100, 3), 128, dtype=np.uint8)
|
||||||
|
passed, reason = check_grayscale(gray)
|
||||||
|
assert not passed
|
||||||
|
assert "Grayscale" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_grayscale_passes_color_image():
|
||||||
|
from winnow.quality import check_grayscale
|
||||||
|
color = np.zeros((100, 100, 3), dtype=np.uint8)
|
||||||
|
color[:, :, 0] = 200 # strong red channel
|
||||||
|
passed, _ = check_grayscale(color)
|
||||||
|
assert passed
|
||||||
|
|
||||||
|
|
||||||
|
def test_grayscale_rejects_single_channel():
|
||||||
|
from winnow.quality import check_grayscale
|
||||||
|
single = np.full((100, 100, 1), 128, dtype=np.uint8)
|
||||||
|
passed, reason = check_grayscale(single)
|
||||||
|
assert not passed
|
||||||
|
|
||||||
|
|
||||||
|
# ── check_exposure ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_exposure_rejects_black_image():
|
||||||
|
from winnow.quality import check_exposure
|
||||||
|
black = np.zeros((100, 100, 3), dtype=np.uint8)
|
||||||
|
passed, reason = check_exposure(black)
|
||||||
|
assert not passed
|
||||||
|
assert "Underexposed" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_exposure_rejects_white_image():
|
||||||
|
from winnow.quality import check_exposure
|
||||||
|
white = np.full((100, 100, 3), 255, dtype=np.uint8)
|
||||||
|
passed, reason = check_exposure(white)
|
||||||
|
assert not passed
|
||||||
|
assert "Overexposed" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_exposure_passes_normal_image():
|
||||||
|
from winnow.quality import check_exposure
|
||||||
|
mid = np.full((100, 100, 3), 128, dtype=np.uint8)
|
||||||
|
passed, _ = check_exposure(mid)
|
||||||
|
assert passed
|
||||||
|
|
||||||
|
|
||||||
|
# ── check_face_size ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_face_size_rejects_small_face():
|
||||||
|
from winnow.quality import check_face_size
|
||||||
|
passed, reason = check_face_size(30, 30, min_px=50)
|
||||||
|
assert not passed
|
||||||
|
assert "small" in reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_face_size_passes_adequate_face():
|
||||||
|
from winnow.quality import check_face_size
|
||||||
|
passed, _ = check_face_size(100, 100, min_px=50)
|
||||||
|
assert passed
|
||||||
|
|
||||||
|
|
||||||
|
def test_face_size_rejects_if_either_dimension_small():
|
||||||
|
from winnow.quality import check_face_size
|
||||||
|
passed, _ = check_face_size(100, 30, min_px=50)
|
||||||
|
assert not passed
|
||||||
|
|
||||||
|
|
||||||
|
# ── check_confidence ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_confidence_rejects_low_score():
|
||||||
|
from winnow.quality import check_confidence
|
||||||
|
passed, reason = check_confidence(0.5, min_conf=0.7)
|
||||||
|
assert not passed
|
||||||
|
assert "confidence" in reason.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_passes_high_score():
|
||||||
|
from winnow.quality import check_confidence
|
||||||
|
passed, _ = check_confidence(0.95, min_conf=0.7)
|
||||||
|
assert passed
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_passes_none_score():
|
||||||
|
from winnow.quality import check_confidence
|
||||||
|
passed, _ = check_confidence(None, min_conf=0.7)
|
||||||
|
assert passed
|
||||||
|
|
||||||
|
|
||||||
|
# ── assess_quality (integration) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_assess_quality_passes_good_image():
|
||||||
|
from winnow.quality import assess_quality
|
||||||
|
img = _noisy_color_image()
|
||||||
|
result = assess_quality(img, face_bbox=(10, 10, 110, 110), confidence=0.9)
|
||||||
|
assert result.passed
|
||||||
|
|
||||||
|
|
||||||
|
def test_assess_quality_collects_multiple_failures():
|
||||||
|
from winnow.quality import assess_quality
|
||||||
|
black = _rgb_image(0, 0, 0)
|
||||||
|
result = assess_quality(black, face_bbox=(0, 0, 10, 10), confidence=0.3)
|
||||||
|
assert not result.passed
|
||||||
|
assert len(result.reasons) >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_assess_quality_skips_face_size_without_bbox():
|
||||||
|
from winnow.quality import assess_quality
|
||||||
|
img = _noisy_color_image()
|
||||||
|
result = assess_quality(img, face_bbox=None, confidence=0.9)
|
||||||
|
assert result.passed
|
||||||
+1884
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,13 @@ resolution-markers = [
|
|||||||
"platform_machine != 'aarch64' and platform_machine != 's390x' and platform_machine != 'x86_64' and sys_platform == 'linux'",
|
"platform_machine != 'aarch64' and platform_machine != 's390x' and platform_machine != 'x86_64' and sys_platform == 'linux'",
|
||||||
"platform_machine == 's390x' and sys_platform == 'linux'",
|
"platform_machine == 's390x' and sys_platform == 'linux'",
|
||||||
]
|
]
|
||||||
|
required-markers = [
|
||||||
|
"platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||||
|
"platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||||
|
]
|
||||||
|
|
||||||
[manifest]
|
[manifest]
|
||||||
overrides = [{ name = "onnxruntime-gpu", specifier = ">=1.23.2" }]
|
overrides = [{ name = "onnxruntime", specifier = ">=1.23.2" }]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-doc"
|
name = "annotated-doc"
|
||||||
@@ -1238,17 +1242,15 @@ name = "onnxruntime-gpu"
|
|||||||
version = "1.26.0"
|
version = "1.26.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "flatbuffers" },
|
{ name = "flatbuffers", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||||
{ name = "packaging" },
|
{ name = "packaging", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||||
{ name = "protobuf" },
|
{ name = "protobuf", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/97/fe8979f44b9275654b42f7bb556e30789b71a1b22998c83b540df2b1b774/onnxruntime_gpu-1.26.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfda2fad535595bfc3e570eb588092717711dcb2957656d814695e0c9ceb1508", size = 276974871, upload-time = "2026-05-08T19:15:58.052Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/97/fe8979f44b9275654b42f7bb556e30789b71a1b22998c83b540df2b1b774/onnxruntime_gpu-1.26.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfda2fad535595bfc3e570eb588092717711dcb2957656d814695e0c9ceb1508", size = 276974871, upload-time = "2026-05-08T19:15:58.052Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/3f/59f1777a394625ecc9a85636de57dc47c25dbb5f888da050f1463955a0ce/onnxruntime_gpu-1.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:6ab9f9c741d2e239b2e321ab0d389c04329d4ab7f11e3b92dd3aa7db1c59dee4", size = 226548083, upload-time = "2026-05-08T19:09:44.408Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/96/360328e3c463f7ea08e853c4239c397e83363dd0204de71a710dc1a544bd/onnxruntime_gpu-1.26.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcf6f347cad9f88a9a625c2b352cf9de927528aedb627ebbc089a201f1990b94", size = 276992052, upload-time = "2026-05-08T19:16:09.892Z" },
|
{ url = "https://files.pythonhosted.org/packages/89/96/360328e3c463f7ea08e853c4239c397e83363dd0204de71a710dc1a544bd/onnxruntime_gpu-1.26.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcf6f347cad9f88a9a625c2b352cf9de927528aedb627ebbc089a201f1990b94", size = 276992052, upload-time = "2026-05-08T19:16:09.892Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/c8/aa2dc0e79bba577f37d5448bcb32fea79977e07506684d8138c19a0f1077/onnxruntime_gpu-1.26.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e6e4fb1ec9ae1cf456534d9115f106ab2a1ae96fa513b4ed0f4795302b4a2c6", size = 276978254, upload-time = "2026-05-08T19:16:22.096Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/c8/aa2dc0e79bba577f37d5448bcb32fea79977e07506684d8138c19a0f1077/onnxruntime_gpu-1.26.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e6e4fb1ec9ae1cf456534d9115f106ab2a1ae96fa513b4ed0f4795302b4a2c6", size = 276978254, upload-time = "2026-05-08T19:16:22.096Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/e7/923298431e669567d7ccc2a4c898b6534a47641a051569fd97165fe6d9b8/onnxruntime_gpu-1.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e592439b0183d303c2374517b5b392599a3d50b2dc9de949b9b15731ac921c9", size = 229142768, upload-time = "2026-05-08T19:09:54.589Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/91/93ffe5431d154989f5e04864a25a97eea480997d771232bcbbc538188241/onnxruntime_gpu-1.26.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56dc7b73954ff4bdc71f5b8ab306b6f61be5d007881b6ef423a609e2b9cd088b", size = 276991545, upload-time = "2026-05-08T19:16:33.347Z" },
|
{ url = "https://files.pythonhosted.org/packages/97/91/93ffe5431d154989f5e04864a25a97eea480997d771232bcbbc538188241/onnxruntime_gpu-1.26.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56dc7b73954ff4bdc71f5b8ab306b6f61be5d007881b6ef423a609e2b9cd088b", size = 276991545, upload-time = "2026-05-08T19:16:33.347Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1859,15 +1861,16 @@ version = "2.12.0"
|
|||||||
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"platform_machine != 's390x' and sys_platform == 'darwin'",
|
"platform_machine != 's390x' and sys_platform == 'darwin'",
|
||||||
|
"platform_machine == 's390x' and sys_platform == 'darwin'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "filelock", marker = "platform_machine != 's390x' and sys_platform == 'darwin'" },
|
{ name = "filelock", marker = "sys_platform == 'darwin'" },
|
||||||
{ name = "fsspec", marker = "platform_machine != 's390x' and sys_platform == 'darwin'" },
|
{ name = "fsspec", marker = "sys_platform == 'darwin'" },
|
||||||
{ name = "jinja2", marker = "platform_machine != 's390x' and sys_platform == 'darwin'" },
|
{ name = "jinja2", marker = "sys_platform == 'darwin'" },
|
||||||
{ name = "networkx", marker = "platform_machine != 's390x' and sys_platform == 'darwin'" },
|
{ name = "networkx", marker = "sys_platform == 'darwin'" },
|
||||||
{ name = "setuptools", marker = "platform_machine != 's390x' and sys_platform == 'darwin'" },
|
{ name = "setuptools", marker = "sys_platform == 'darwin'" },
|
||||||
{ name = "sympy", marker = "platform_machine != 's390x' and sys_platform == 'darwin'" },
|
{ name = "sympy", marker = "sys_platform == 'darwin'" },
|
||||||
{ name = "typing-extensions", marker = "platform_machine != 's390x' and sys_platform == 'darwin'" },
|
{ name = "typing-extensions", marker = "sys_platform == 'darwin'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:90dd587a5f61bfe1307148b581e2084fc5bc4a06e2b90a20e9a36b81087ff16b", upload-time = "2026-05-12T16:20:17Z" },
|
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:90dd587a5f61bfe1307148b581e2084fc5bc4a06e2b90a20e9a36b81087ff16b", upload-time = "2026-05-12T16:20:17Z" },
|
||||||
@@ -1922,16 +1925,15 @@ resolution-markers = [
|
|||||||
"platform_machine == 's390x' and sys_platform == 'win32'",
|
"platform_machine == 's390x' and sys_platform == 'win32'",
|
||||||
"platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
|
"platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
|
||||||
"platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
|
"platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
|
||||||
"platform_machine == 's390x' and sys_platform == 'darwin'",
|
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "filelock", marker = "(platform_machine == 's390x' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "filelock", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "fsspec", marker = "(platform_machine == 's390x' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "fsspec", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "jinja2", marker = "(platform_machine == 's390x' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "jinja2", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "networkx", marker = "(platform_machine == 's390x' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "networkx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "setuptools", marker = "(platform_machine == 's390x' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "setuptools", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "sympy", marker = "(platform_machine == 's390x' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "sympy", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "typing-extensions", marker = "(platform_machine == 's390x' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "typing-extensions", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:68b7ddd4db4603a03e106e74c7098c8d8c8943d33c1e5ada009ca4cd885759c3", upload-time = "2026-05-12T23:17:12Z" },
|
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:68b7ddd4db4603a03e106e74c7098c8d8c8943d33c1e5ada009ca4cd885759c3", upload-time = "2026-05-12T23:17:12Z" },
|
||||||
@@ -1981,11 +1983,12 @@ version = "0.27.0"
|
|||||||
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"platform_machine != 's390x' and sys_platform == 'darwin'",
|
"platform_machine != 's390x' and sys_platform == 'darwin'",
|
||||||
|
"platform_machine == 's390x' and sys_platform == 'darwin'",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", marker = "platform_machine != 's390x' and sys_platform == 'darwin'" },
|
{ name = "numpy", marker = "sys_platform == 'darwin'" },
|
||||||
{ name = "pillow", marker = "platform_machine != 's390x' and sys_platform == 'darwin'" },
|
{ name = "pillow", marker = "sys_platform == 'darwin'" },
|
||||||
{ name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_machine != 's390x' and sys_platform == 'darwin'" },
|
{ name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:41d6dae73e1af09fa82ded597ae57f2a2314285acde54b25890a8f8e51b999d7", upload-time = "2026-05-12T16:20:37Z" },
|
{ url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:41d6dae73e1af09fa82ded597ae57f2a2314285acde54b25890a8f8e51b999d7", upload-time = "2026-05-12T16:20:37Z" },
|
||||||
@@ -2028,12 +2031,11 @@ resolution-markers = [
|
|||||||
"platform_machine == 's390x' and sys_platform == 'win32'",
|
"platform_machine == 's390x' and sys_platform == 'win32'",
|
||||||
"platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
|
"platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
|
||||||
"platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
|
"platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'",
|
||||||
"platform_machine == 's390x' and sys_platform == 'darwin'",
|
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy", marker = "(platform_machine == 's390x' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "numpy", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "pillow", marker = "(platform_machine == 's390x' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "pillow", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 's390x' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:69093b64b2762c43df17be2db2be163029963d90bc3f1801500fdeb723e54833", upload-time = "2026-05-12T16:20:36Z" },
|
{ url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:69093b64b2762c43df17be2db2be163029963d90bc3f1801500fdeb723e54833", upload-time = "2026-05-12T16:20:36Z" },
|
||||||
@@ -2151,13 +2153,13 @@ dependencies = [
|
|||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "scipy" },
|
{ name = "scipy" },
|
||||||
{ name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_machine != 's390x' and sys_platform == 'darwin'" },
|
{ name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" },
|
||||||
{ name = "torch", version = "2.12.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux'" },
|
{ name = "torch", version = "2.12.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux'" },
|
||||||
{ name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 's390x' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "torch", version = "2.12.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
{ name = "torch", version = "2.12.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||||
{ name = "torchvision", version = "0.27.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_machine != 's390x' and sys_platform == 'darwin'" },
|
{ name = "torchvision", version = "0.27.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" },
|
||||||
{ name = "torchvision", version = "0.27.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux'" },
|
{ name = "torchvision", version = "0.27.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux'" },
|
||||||
{ name = "torchvision", version = "0.27.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 's390x' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "torchvision", version = "0.27.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "torchvision", version = "0.27.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
{ name = "torchvision", version = "0.27.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||||
{ name = "ultralytics-thop" },
|
{ name = "ultralytics-thop" },
|
||||||
]
|
]
|
||||||
@@ -2172,9 +2174,9 @@ version = "2.0.20"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_machine != 's390x' and sys_platform == 'darwin'" },
|
{ name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" },
|
||||||
{ name = "torch", version = "2.12.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux'" },
|
{ name = "torch", version = "2.12.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux'" },
|
||||||
{ name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 's390x' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "torch", version = "2.12.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
{ name = "torch", version = "2.12.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/98/c6/d25cb53e141242f74950744179c21b8798bb09b9e7161465ecda4f577ddf/ultralytics_thop-2.0.20.tar.gz", hash = "sha256:f3595e0d8c6fd0b9f62fc2cd9be921755e2649a05c34f1fabaea0bff7295d641", size = 34682, upload-time = "2026-06-06T11:42:42.184Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/98/c6/d25cb53e141242f74950744179c21b8798bb09b9e7161465ecda4f577ddf/ultralytics_thop-2.0.20.tar.gz", hash = "sha256:f3595e0d8c6fd0b9f62fc2cd9be921755e2649a05c34f1fabaea0bff7295d641", size = 34682, upload-time = "2026-06-06T11:42:42.184Z" }
|
||||||
@@ -2193,7 +2195,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.2.8"
|
version = "0.2.9"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "croniter" },
|
{ name = "croniter" },
|
||||||
@@ -2201,19 +2203,20 @@ dependencies = [
|
|||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "nvidia-cudnn-cu12", version = "9.10.2.21", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
{ name = "nvidia-cudnn-cu12", version = "9.10.2.21", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||||
{ name = "nvidia-cudnn-cu12", version = "9.23.1.3", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'linux'" },
|
{ name = "nvidia-cudnn-cu12", version = "9.23.1.3", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'linux'" },
|
||||||
{ name = "onnxruntime-gpu" },
|
{ name = "onnxruntime" },
|
||||||
|
{ name = "onnxruntime-gpu", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||||
{ name = "opencv-python-headless" },
|
{ name = "opencv-python-headless" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_machine != 's390x' and sys_platform == 'darwin'" },
|
{ name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" },
|
||||||
{ name = "torch", version = "2.12.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux'" },
|
{ name = "torch", version = "2.12.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux'" },
|
||||||
{ name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 's390x' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "torch", version = "2.12.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
{ name = "torch", version = "2.12.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||||
{ name = "torchvision", version = "0.27.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_machine != 's390x' and sys_platform == 'darwin'" },
|
{ name = "torchvision", version = "0.27.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" },
|
||||||
{ name = "torchvision", version = "0.27.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux'" },
|
{ name = "torchvision", version = "0.27.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux'" },
|
||||||
{ name = "torchvision", version = "0.27.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 's390x' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "torchvision", version = "0.27.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "torchvision", version = "0.27.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
{ name = "torchvision", version = "0.27.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||||
{ name = "transformers" },
|
{ name = "transformers" },
|
||||||
{ name = "ultralytics" },
|
{ name = "ultralytics" },
|
||||||
@@ -2231,7 +2234,9 @@ requires-dist = [
|
|||||||
{ name = "insightface", specifier = ">=0.7.3" },
|
{ name = "insightface", specifier = ">=0.7.3" },
|
||||||
{ name = "numpy", specifier = ">=2.2.6" },
|
{ name = "numpy", specifier = ">=2.2.6" },
|
||||||
{ name = "nvidia-cudnn-cu12", specifier = ">=9.0.0" },
|
{ name = "nvidia-cudnn-cu12", specifier = ">=9.0.0" },
|
||||||
{ name = "onnxruntime-gpu", specifier = ">=1.23.2" },
|
{ name = "onnxruntime", marker = "sys_platform != 'linux'", specifier = ">=1.23.2" },
|
||||||
|
{ name = "onnxruntime", marker = "platform_machine != 'x86_64' and sys_platform == 'linux'", specifier = ">=1.23.2" },
|
||||||
|
{ name = "onnxruntime-gpu", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=1.23.2" },
|
||||||
{ name = "opencv-python-headless", specifier = ">=4.12.0.88" },
|
{ name = "opencv-python-headless", specifier = ">=4.12.0.88" },
|
||||||
{ name = "pillow", specifier = ">=12.1.0" },
|
{ name = "pillow", specifier = ">=12.1.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||||
|
|||||||
+6
-1
@@ -5,4 +5,9 @@ Immich library for Frigate's Face Recognition (ArcFace) and Object/State
|
|||||||
Classification models.
|
Classification models.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
|
||||||
|
try:
|
||||||
|
__version__ = version("winnow")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
__version__ = "unknown"
|
||||||
|
|||||||
+8
-5
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
from rich import print as rprint
|
from rich import print as rprint
|
||||||
from rich.prompt import Confirm
|
from rich.prompt import Confirm
|
||||||
@@ -10,7 +11,7 @@ from .config import Config, ConfigManager
|
|||||||
from .executor import execute_jobs, upload_to_frigate
|
from .executor import execute_jobs, upload_to_frigate
|
||||||
from .immich_api import get_people
|
from .immich_api import get_people
|
||||||
from .jobs import _show_preview, auto_configure, interactive_configure
|
from .jobs import _show_preview, auto_configure, interactive_configure
|
||||||
from .logging import console, setup_logging
|
from .log_config import console, setup_logging
|
||||||
from .upload_tracker import get_person_summary, reset_person
|
from .upload_tracker import get_person_summary, reset_person
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -19,7 +20,8 @@ logger = logging.getLogger(__name__)
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Entry point for winnow CLI."""
|
"""Entry point for winnow CLI."""
|
||||||
try:
|
try:
|
||||||
setup_logging(verbose=False)
|
verbose = os.environ.get("VERBOSE", "").lower() in ("true", "1", "yes")
|
||||||
|
setup_logging(verbose=verbose)
|
||||||
|
|
||||||
console.print(r"""
|
console.print(r"""
|
||||||
[bold blue]winnow[/bold blue]
|
[bold blue]winnow[/bold blue]
|
||||||
@@ -63,17 +65,18 @@ def main() -> None:
|
|||||||
rprint("[bold red]Could not fetch people from Immich. Check URL/Key.[/bold red]")
|
rprint("[bold red]Could not fetch people from Immich. Check URL/Key.[/bold red]")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check for non-interactive mode
|
# Auto mode when no TTY (Docker, cron, pipes) — the primary use case.
|
||||||
auto_mode = os.environ.get("AUTO_MODE", "false").lower() == "true"
|
# A TTY means local interactive use; AUTO_MODE=true overrides that for scripting.
|
||||||
|
auto_mode = not sys.stdin.isatty() or os.environ.get("AUTO_MODE", "").lower() in ("true", "1", "yes")
|
||||||
dry_run = os.environ.get("DRY_RUN", "false").lower() in ("true", "1", "yes")
|
dry_run = os.environ.get("DRY_RUN", "false").lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
rprint("[bold yellow]DRY RUN — no images will be downloaded or uploaded[/bold yellow]")
|
rprint("[bold yellow]DRY RUN — no images will be downloaded or uploaded[/bold yellow]")
|
||||||
|
|
||||||
if auto_mode:
|
if auto_mode:
|
||||||
rprint("[bold cyan]Running in AUTO mode (non-interactive)[/bold cyan]")
|
|
||||||
jobs = auto_configure(people)
|
jobs = auto_configure(people)
|
||||||
else:
|
else:
|
||||||
|
rprint("[bold cyan]Interactive mode — set AUTO_MODE=true to skip prompts[/bold cyan]")
|
||||||
jobs = interactive_configure(people)
|
jobs = interactive_configure(people)
|
||||||
|
|
||||||
if jobs:
|
if jobs:
|
||||||
|
|||||||
+8
-4
@@ -39,8 +39,7 @@ class _Config:
|
|||||||
USE_FULL_RESOLUTION: bool = True
|
USE_FULL_RESOLUTION: bool = True
|
||||||
ENABLE_FACE_ALIGNMENT: bool = True
|
ENABLE_FACE_ALIGNMENT: bool = True
|
||||||
|
|
||||||
# Caching (opt-in to avoid unexpected files)
|
ENABLE_CACHE: bool = True
|
||||||
ENABLE_CACHE: bool = False
|
|
||||||
CACHE_DIR: str = ".if_cache"
|
CACHE_DIR: str = ".if_cache"
|
||||||
|
|
||||||
def __new__(cls) -> "_Config":
|
def __new__(cls) -> "_Config":
|
||||||
@@ -64,7 +63,7 @@ class _Config:
|
|||||||
self.FACE_MARGIN = float(os.getenv("FACE_MARGIN", "0.15"))
|
self.FACE_MARGIN = float(os.getenv("FACE_MARGIN", "0.15"))
|
||||||
self.USE_FULL_RESOLUTION = os.getenv("USE_FULL_RESOLUTION", "true").lower() in ("true", "1", "yes")
|
self.USE_FULL_RESOLUTION = os.getenv("USE_FULL_RESOLUTION", "true").lower() in ("true", "1", "yes")
|
||||||
self.ENABLE_FACE_ALIGNMENT = os.getenv("ENABLE_FACE_ALIGNMENT", "true").lower() in ("true", "1", "yes")
|
self.ENABLE_FACE_ALIGNMENT = os.getenv("ENABLE_FACE_ALIGNMENT", "true").lower() in ("true", "1", "yes")
|
||||||
self.ENABLE_CACHE = os.getenv("ENABLE_CACHE", "false").lower() in ("true", "1", "yes")
|
self.ENABLE_CACHE = os.getenv("ENABLE_CACHE", "true").lower() in ("true", "1", "yes")
|
||||||
self.CACHE_DIR = os.getenv("CACHE_DIR", ".if_cache")
|
self.CACHE_DIR = os.getenv("CACHE_DIR", ".if_cache")
|
||||||
|
|
||||||
# Fall back to config file for non-sensitive values (API_KEY not stored here)
|
# Fall back to config file for non-sensitive values (API_KEY not stored here)
|
||||||
@@ -161,7 +160,12 @@ class _ConfigAccessor:
|
|||||||
|
|
||||||
|
|
||||||
Config = _ConfigAccessor()
|
Config = _ConfigAccessor()
|
||||||
ConfigManager = type("ConfigManager", (), {"get": staticmethod(lambda: _Config())})
|
|
||||||
|
|
||||||
|
class ConfigManager:
|
||||||
|
@staticmethod
|
||||||
|
def get() -> _Config:
|
||||||
|
return _Config()
|
||||||
|
|
||||||
|
|
||||||
def get_headers() -> dict[str, str]:
|
def get_headers() -> dict[str, str]:
|
||||||
|
|||||||
+28
-16
@@ -29,6 +29,7 @@ def select_diverse_assets(
|
|||||||
entity_name: str,
|
entity_name: str,
|
||||||
selection_mode: str = "smart",
|
selection_mode: str = "smart",
|
||||||
entity_type: str = "face",
|
entity_type: str = "face",
|
||||||
|
person_id: str | None = None,
|
||||||
progress_callback=None,
|
progress_callback=None,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""
|
"""
|
||||||
@@ -59,7 +60,7 @@ def select_diverse_assets(
|
|||||||
return _select_time_spread(assets, limit)
|
return _select_time_spread(assets, limit)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return _select_by_embedding(assets, limit, entity_type, progress_callback)
|
return _select_by_embedding(assets, limit, entity_type, person_id, progress_callback)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Smart Diversity failed: {e}. Falling back to time spread.")
|
logger.error(f"Smart Diversity failed: {e}. Falling back to time spread.")
|
||||||
return _select_time_spread(assets, limit)
|
return _select_time_spread(assets, limit)
|
||||||
@@ -80,9 +81,11 @@ def _fetch_thumbnail(asset_id: str, timeout: int = 10) -> Image.Image | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_face_bbox(asset: dict) -> tuple[float, float, float, float] | None:
|
def _get_face_bbox(asset: dict, person_id: str | None = None) -> tuple[float, float, float, float] | None:
|
||||||
"""Extract face bounding box from asset metadata if available."""
|
"""Extract face bounding box from asset metadata for the given person."""
|
||||||
for person in asset.get("people", []):
|
for person in asset.get("people", []):
|
||||||
|
if person_id and person.get("id") != person_id:
|
||||||
|
continue
|
||||||
faces = person.get("faces", [])
|
faces = person.get("faces", [])
|
||||||
if faces:
|
if faces:
|
||||||
f = faces[0]
|
f = faces[0]
|
||||||
@@ -95,12 +98,16 @@ def _get_face_bbox(asset: dict) -> tuple[float, float, float, float] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_face_confidence(asset: dict) -> float | None:
|
def _get_face_confidence(asset: dict, person_id: str | None = None) -> float | None:
|
||||||
"""Extract face detection confidence from asset metadata if available."""
|
"""Extract face detection confidence from asset metadata for the given person."""
|
||||||
for person in asset.get("people", []):
|
for person in asset.get("people", []):
|
||||||
|
if person_id and person.get("id") != person_id:
|
||||||
|
continue
|
||||||
faces = person.get("faces", [])
|
faces = person.get("faces", [])
|
||||||
if faces:
|
if faces:
|
||||||
return faces[0].get("score") or faces[0].get("confidence")
|
f = faces[0]
|
||||||
|
score = f.get("score")
|
||||||
|
return score if score is not None else f.get("confidence")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -108,6 +115,7 @@ def _crop_face_from_thumbnail(
|
|||||||
img: Image.Image,
|
img: Image.Image,
|
||||||
asset: dict,
|
asset: dict,
|
||||||
margin: float = 0.25,
|
margin: float = 0.25,
|
||||||
|
person_id: str | None = None,
|
||||||
) -> Image.Image | None:
|
) -> Image.Image | None:
|
||||||
"""Crop the face region from a thumbnail using Immich bbox metadata.
|
"""Crop the face region from a thumbnail using Immich bbox metadata.
|
||||||
|
|
||||||
@@ -118,19 +126,22 @@ def _crop_face_from_thumbnail(
|
|||||||
img: Full preview thumbnail
|
img: Full preview thumbnail
|
||||||
asset: Asset dict with people/faces metadata
|
asset: Asset dict with people/faces metadata
|
||||||
margin: Extra margin around the bbox (fraction, default 25%)
|
margin: Extra margin around the bbox (fraction, default 25%)
|
||||||
|
person_id: If provided, only crop from this person's face data.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Cropped face PIL image, or None if no face metadata available
|
Cropped face PIL image, or None if no face metadata available
|
||||||
"""
|
"""
|
||||||
bbox = _get_face_bbox(asset)
|
bbox = _get_face_bbox(asset, person_id=person_id)
|
||||||
if bbox is None:
|
if bbox is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
x1, y1, x2, y2 = bbox
|
x1, y1, x2, y2 = bbox
|
||||||
img_w, img_h = img.size
|
img_w, img_h = img.size
|
||||||
|
|
||||||
# Get metadata dimensions to scale bbox
|
# Get metadata dimensions to scale bbox — must match the same person as _get_face_bbox
|
||||||
for person in asset.get("people", []):
|
for person in asset.get("people", []):
|
||||||
|
if person_id and person.get("id") != person_id:
|
||||||
|
continue
|
||||||
faces = person.get("faces", [])
|
faces = person.get("faces", [])
|
||||||
if faces:
|
if faces:
|
||||||
meta_w = faces[0].get("imageWidth") or img_w
|
meta_w = faces[0].get("imageWidth") or img_w
|
||||||
@@ -169,6 +180,7 @@ def _select_by_embedding(
|
|||||||
assets: list,
|
assets: list,
|
||||||
limit: int | str,
|
limit: int | str,
|
||||||
entity_type: str,
|
entity_type: str,
|
||||||
|
person_id: str | None = None,
|
||||||
progress_callback=None,
|
progress_callback=None,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Select assets using embedding-based cluster-aware FPS.
|
"""Select assets using embedding-based cluster-aware FPS.
|
||||||
@@ -217,11 +229,11 @@ def _select_by_embedding(
|
|||||||
if img is None:
|
if img is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
confidence = _get_face_confidence(asset)
|
confidence = _get_face_confidence(asset, person_id=person_id)
|
||||||
|
|
||||||
# Quality gate: filter before expensive embedding computation
|
# Quality gate: filter before expensive embedding computation
|
||||||
if entity_type == "face":
|
if entity_type == "face":
|
||||||
face_bbox = _get_face_bbox(asset)
|
face_bbox = _get_face_bbox(asset, person_id=person_id)
|
||||||
quality = assess_quality(
|
quality = assess_quality(
|
||||||
img,
|
img,
|
||||||
face_bbox=face_bbox,
|
face_bbox=face_bbox,
|
||||||
@@ -236,7 +248,7 @@ def _select_by_embedding(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Crop the target person's face before embedding
|
# Crop the target person's face before embedding
|
||||||
face_crop = _crop_face_from_thumbnail(img, asset)
|
face_crop = _crop_face_from_thumbnail(img, asset, person_id=person_id)
|
||||||
embed_img = face_crop if face_crop is not None else img
|
embed_img = face_crop if face_crop is not None else img
|
||||||
else:
|
else:
|
||||||
embed_img = img
|
embed_img = img
|
||||||
@@ -360,7 +372,7 @@ def _compute_adaptive_threshold(emb_normed: np.ndarray, entity_type: str) -> flo
|
|||||||
fraction = 0.20 if entity_type == "face" else 0.10
|
fraction = 0.20 if entity_type == "face" else 0.10
|
||||||
threshold = max(0.05, median_dist * fraction)
|
threshold = max(0.05, median_dist * fraction)
|
||||||
|
|
||||||
logger.info(
|
logger.debug(
|
||||||
f"Adaptive threshold: {threshold:.4f} "
|
f"Adaptive threshold: {threshold:.4f} "
|
||||||
f"(median_dist={median_dist:.4f}, fraction={fraction}, type={entity_type})"
|
f"(median_dist={median_dist:.4f}, fraction={fraction}, type={entity_type})"
|
||||||
)
|
)
|
||||||
@@ -402,7 +414,7 @@ def _cluster_aware_selection(
|
|||||||
|
|
||||||
# --- Stage 1: K-Medoids clustering ---
|
# --- Stage 1: K-Medoids clustering ---
|
||||||
k = min(max(5, target // 4), n // 3, n) # e.g., 5-20 clusters
|
k = min(max(5, target // 4), n // 3, n) # e.g., 5-20 clusters
|
||||||
logger.info(f"Clustering {n} embeddings into {k} groups (K-Medoids)...")
|
logger.debug(f"Clustering {n} embeddings into {k} groups (K-Medoids)...")
|
||||||
|
|
||||||
# Compute full cosine distance matrix
|
# Compute full cosine distance matrix
|
||||||
dist_matrix = 1 - emb_normed @ emb_normed.T
|
dist_matrix = 1 - emb_normed @ emb_normed.T
|
||||||
@@ -411,7 +423,7 @@ def _cluster_aware_selection(
|
|||||||
selected = list(medoid_indices)
|
selected = list(medoid_indices)
|
||||||
selected_set = set(selected)
|
selected_set = set(selected)
|
||||||
|
|
||||||
logger.info(f"Selected {len(selected)} cluster medoids as initial picks.")
|
logger.debug(f"Selected {len(selected)} cluster medoids as initial picks.")
|
||||||
|
|
||||||
# --- Stage 2: FPS with hard example weighting ---
|
# --- Stage 2: FPS with hard example weighting ---
|
||||||
min_dists = np.full(n, np.inf)
|
min_dists = np.full(n, np.inf)
|
||||||
@@ -436,8 +448,8 @@ def _cluster_aware_selection(
|
|||||||
break # All points selected
|
break # All points selected
|
||||||
|
|
||||||
if limit == "auto" and best_dist < auto_threshold:
|
if limit == "auto" and best_dist < auto_threshold:
|
||||||
logger.info(
|
logger.debug(
|
||||||
f"Auto-stop: Next best image {best_dist:.3f} away " f"(adaptive threshold {auto_threshold:.4f})."
|
f"Auto-stop: next best image {best_dist:.3f} away (adaptive threshold {auto_threshold:.4f})."
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
+85
-32
@@ -6,11 +6,13 @@ Unified embedding interface for faces and objects.
|
|||||||
- Caching: Disk-based cache avoids recomputation on reruns
|
- Caching: Disk-based cache avoids recomputation on reruns
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import warnings
|
import warnings
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -20,6 +22,26 @@ from .cache import get_cache
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _suppress_output():
|
||||||
|
"""Suppress stdout/stderr at the file-descriptor level, silencing C extension noise."""
|
||||||
|
devnull_fd = os.open(os.devnull, os.O_WRONLY)
|
||||||
|
saved_out, saved_err = os.dup(1), os.dup(2)
|
||||||
|
try:
|
||||||
|
os.dup2(devnull_fd, 1)
|
||||||
|
os.dup2(devnull_fd, 2)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.dup2(saved_out, 1)
|
||||||
|
finally:
|
||||||
|
os.dup2(saved_err, 2)
|
||||||
|
os.close(devnull_fd)
|
||||||
|
os.close(saved_out)
|
||||||
|
os.close(saved_err)
|
||||||
|
|
||||||
|
|
||||||
# Lazy-loaded singletons
|
# Lazy-loaded singletons
|
||||||
_insightface_app = None
|
_insightface_app = None
|
||||||
_insightface_loaded = False
|
_insightface_loaded = False
|
||||||
@@ -37,18 +59,14 @@ def _preload_cuda_libs() -> None:
|
|||||||
"""Preload CUDA/cuDNN DLLs so onnxruntime-gpu registers CUDAExecutionProvider.
|
"""Preload CUDA/cuDNN DLLs so onnxruntime-gpu registers CUDAExecutionProvider.
|
||||||
|
|
||||||
Starting with onnxruntime-gpu 1.19+, CUDA/cuDNN libraries are no longer
|
Starting with onnxruntime-gpu 1.19+, CUDA/cuDNN libraries are no longer
|
||||||
bundled inside the ORT package. They must be loaded from the nvidia-*
|
bundled inside the ORT package — they come from the nvidia-* pip packages.
|
||||||
pip packages (nvidia-cuda-runtime-cu12, nvidia-cudnn-cu12) before any
|
preload_dlls() locates them automatically via site-packages discovery.
|
||||||
InferenceSession is created.
|
|
||||||
|
|
||||||
Calling preload_dlls() with directory="" searches NVIDIA site-packages
|
|
||||||
directories automatically.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import onnxruntime
|
import onnxruntime
|
||||||
if hasattr(onnxruntime, "preload_dlls"):
|
if hasattr(onnxruntime, "preload_dlls"):
|
||||||
onnxruntime.preload_dlls(cuda=True, cudnn=True, directory="")
|
onnxruntime.preload_dlls(cuda=True, cudnn=True)
|
||||||
logger.info("Preloaded CUDA/cuDNN DLLs for onnxruntime-gpu")
|
logger.debug("Preloaded CUDA/cuDNN DLLs for onnxruntime-gpu")
|
||||||
else:
|
else:
|
||||||
logger.debug("onnxruntime.preload_dlls() not available (ORT < 1.21)")
|
logger.debug("onnxruntime.preload_dlls() not available (ORT < 1.21)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -70,32 +88,48 @@ def get_insightface_app():
|
|||||||
# Preload CUDA/cuDNN DLLs BEFORE any ORT InferenceSession is created
|
# Preload CUDA/cuDNN DLLs BEFORE any ORT InferenceSession is created
|
||||||
_preload_cuda_libs()
|
_preload_cuda_libs()
|
||||||
|
|
||||||
|
ctx_id = -1
|
||||||
|
insightface_home = os.environ.get("INSIGHTFACE_HOME", os.path.expanduser("~/.insightface"))
|
||||||
try:
|
try:
|
||||||
import onnxruntime as ort
|
import onnxruntime as ort
|
||||||
from insightface.app import FaceAnalysis
|
from insightface.app import FaceAnalysis
|
||||||
|
|
||||||
|
# Disk cache check — lets the user know whether a download is coming
|
||||||
|
buffalo_path = Path(insightface_home) / "models" / "buffalo_l"
|
||||||
|
if buffalo_path.exists() and any(buffalo_path.iterdir()):
|
||||||
|
logger.info("InsightFace Buffalo_L: found in model cache")
|
||||||
|
else:
|
||||||
|
logger.info("InsightFace Buffalo_L: not cached — downloading now (~300 MB)")
|
||||||
|
|
||||||
# Get providers, excluding TensorRT to avoid noisy errors
|
# Get providers, excluding TensorRT to avoid noisy errors
|
||||||
providers = [p for p in ort.get_available_providers() if p != "TensorrtExecutionProvider"]
|
providers = [p for p in ort.get_available_providers() if p != "TensorrtExecutionProvider"]
|
||||||
logger.info(f"Available ONNX providers: {providers}")
|
logger.debug(f"ONNX providers available: {providers}")
|
||||||
|
|
||||||
# Determine device: 0 for GPU, -1 for CPU
|
|
||||||
gpu_providers = {
|
gpu_providers = {
|
||||||
"CUDAExecutionProvider",
|
"CUDAExecutionProvider",
|
||||||
"ROCmExecutionProvider",
|
"ROCmExecutionProvider",
|
||||||
"MPSExecutionProvider",
|
"MPSExecutionProvider",
|
||||||
"CoreMLExecutionProvider",
|
"CoreMLExecutionProvider",
|
||||||
}
|
}
|
||||||
ctx_id = -1 if _is_force_cpu() else (0 if gpu_providers & set(providers) else -1)
|
has_gpu_provider = bool(gpu_providers & set(providers))
|
||||||
|
ctx_id = -1 if _is_force_cpu() else (0 if has_gpu_provider else -1)
|
||||||
|
|
||||||
|
if not has_gpu_provider and not _is_force_cpu():
|
||||||
|
logger.warning(
|
||||||
|
"No GPU execution provider found — running InsightFace on CPU. "
|
||||||
|
"If you have an NVIDIA GPU, ensure the NVIDIA Container Toolkit is "
|
||||||
|
"installed and the container has GPU access (deploy.resources in compose)."
|
||||||
|
)
|
||||||
|
|
||||||
device_str = "GPU" if ctx_id >= 0 else "CPU"
|
device_str = "GPU" if ctx_id >= 0 else "CPU"
|
||||||
logger.info(f"Loading InsightFace Buffalo_L on {device_str} (ctx_id={ctx_id})...")
|
logger.info(f"InsightFace Buffalo_L: loading into memory on {device_str}...")
|
||||||
|
|
||||||
# Suppress C-level output during model loading
|
t0 = time.time()
|
||||||
with open(os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):
|
with _suppress_output():
|
||||||
insightface_home = os.environ.get("INSIGHTFACE_HOME", os.path.expanduser("~/.insightface"))
|
|
||||||
_insightface_app = FaceAnalysis(name="buffalo_l", root=insightface_home, providers=providers)
|
_insightface_app = FaceAnalysis(name="buffalo_l", root=insightface_home, providers=providers)
|
||||||
_insightface_app.prepare(ctx_id=ctx_id, det_size=(640, 640))
|
_insightface_app.prepare(ctx_id=ctx_id, det_size=(640, 640))
|
||||||
|
|
||||||
|
logger.info(f"InsightFace Buffalo_L: ready on {device_str} ({time.time() - t0:.1f}s)")
|
||||||
return _insightface_app
|
return _insightface_app
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -103,17 +137,23 @@ def get_insightface_app():
|
|||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load InsightFace: {e}")
|
logger.error(f"Failed to load InsightFace: {e}")
|
||||||
# Retry on CPU if GPU failed
|
|
||||||
if ctx_id == 0:
|
if ctx_id == 0:
|
||||||
logger.warning("Retrying InsightFace on CPU...")
|
logger.warning("InsightFace GPU load failed — retrying on CPU...")
|
||||||
try:
|
try:
|
||||||
from insightface.app import FaceAnalysis
|
from insightface.app import FaceAnalysis
|
||||||
|
|
||||||
_insightface_app = FaceAnalysis(name="buffalo_l", root=insightface_home)
|
t0 = time.time()
|
||||||
_insightface_app.prepare(ctx_id=-1, det_size=(640, 640))
|
with _suppress_output():
|
||||||
|
_insightface_app = FaceAnalysis(
|
||||||
|
name="buffalo_l",
|
||||||
|
root=insightface_home,
|
||||||
|
providers=["CPUExecutionProvider"],
|
||||||
|
)
|
||||||
|
_insightface_app.prepare(ctx_id=-1, det_size=(640, 640))
|
||||||
|
logger.info(f"InsightFace Buffalo_L: ready on CPU (fallback, {time.time() - t0:.1f}s)")
|
||||||
return _insightface_app
|
return _insightface_app
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.error(f"CPU fallback failed: {ex}")
|
logger.error(f"InsightFace CPU fallback failed: {ex}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -162,7 +202,18 @@ def get_siglip_model():
|
|||||||
from transformers import AutoImageProcessor, SiglipVisionModel
|
from transformers import AutoImageProcessor, SiglipVisionModel
|
||||||
|
|
||||||
model_name = "google/siglip-base-patch16-224"
|
model_name = "google/siglip-base-patch16-224"
|
||||||
logger.info(f"Loading SigLIP model ({model_name})...")
|
|
||||||
|
# Disk cache check — path derived from model_name using HuggingFace's slug convention
|
||||||
|
hf_home = os.environ.get("HF_HOME", os.path.join(os.path.expanduser("~"), ".cache", "huggingface"))
|
||||||
|
cache_slug = "models--" + model_name.replace("/", "--")
|
||||||
|
model_cache = Path(hf_home) / "hub" / cache_slug
|
||||||
|
if model_cache.exists() and any(model_cache.iterdir()):
|
||||||
|
logger.info(f"SigLIP {model_name}: found in model cache")
|
||||||
|
else:
|
||||||
|
logger.info(f"SigLIP {model_name}: not cached — downloading now (~380 MB)")
|
||||||
|
|
||||||
|
logger.info(f"SigLIP {model_name}: loading into memory...")
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.filterwarnings("ignore", category=FutureWarning)
|
warnings.filterwarnings("ignore", category=FutureWarning)
|
||||||
@@ -176,15 +227,16 @@ def get_siglip_model():
|
|||||||
if not _is_force_cpu():
|
if not _is_force_cpu():
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
_siglip_model = _siglip_model.cuda()
|
_siglip_model = _siglip_model.cuda()
|
||||||
logger.info("SigLIP running on CUDA GPU")
|
device_name = "CUDA GPU"
|
||||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||||
_siglip_model = _siglip_model.to("mps")
|
_siglip_model = _siglip_model.to("mps")
|
||||||
logger.info("SigLIP running on Apple MPS")
|
device_name = "Apple MPS"
|
||||||
else:
|
else:
|
||||||
logger.info("SigLIP running on CPU")
|
device_name = "CPU"
|
||||||
else:
|
else:
|
||||||
logger.info("FORCE_CPU set. SigLIP running on CPU")
|
device_name = "CPU (FORCE_CPU)"
|
||||||
|
|
||||||
|
logger.info(f"SigLIP {model_name}: ready on {device_name} ({time.time() - t0:.1f}s)")
|
||||||
return _siglip_model, _siglip_processor
|
return _siglip_model, _siglip_processor
|
||||||
|
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
@@ -267,30 +319,31 @@ def get_embedding(
|
|||||||
|
|
||||||
use_cache = Config.ENABLE_CACHE and asset_id is not None
|
use_cache = Config.ENABLE_CACHE and asset_id is not None
|
||||||
cache = get_cache(Config.CACHE_DIR) if use_cache else None
|
cache = get_cache(Config.CACHE_DIR) if use_cache else None
|
||||||
model_key = "immich" if entity_type == "face" else "siglip"
|
# Use a single consistent cache key per model so lookups and stores always match.
|
||||||
|
# "immich" was previously used as the face key on the lookup path but "insightface"
|
||||||
|
# on the store path — meaning the cache was never hit for locally-computed embeddings.
|
||||||
|
cache_key = "insightface" if entity_type == "face" else "siglip"
|
||||||
|
|
||||||
# 1. Use Immich embedding if provided
|
# 1. Use Immich embedding if provided
|
||||||
if immich_embedding is not None:
|
if immich_embedding is not None:
|
||||||
if cache:
|
if cache:
|
||||||
cache.put(asset_id, immich_embedding, model_key)
|
cache.put(asset_id, immich_embedding, cache_key)
|
||||||
return immich_embedding
|
return immich_embedding
|
||||||
|
|
||||||
# 2. Check disk cache
|
# 2. Check disk cache
|
||||||
if cache:
|
if cache:
|
||||||
cached = cache.get(asset_id, model_key)
|
cached = cache.get(asset_id, cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
# 3. Compute locally
|
# 3. Compute locally
|
||||||
if entity_type == "face":
|
if entity_type == "face":
|
||||||
emb = get_face_embedding(img_pil)
|
emb = get_face_embedding(img_pil)
|
||||||
model_key = "insightface"
|
|
||||||
else:
|
else:
|
||||||
emb = get_object_embedding(img_pil)
|
emb = get_object_embedding(img_pil)
|
||||||
|
|
||||||
# Cache the result
|
|
||||||
if emb is not None and cache:
|
if emb is not None and cache:
|
||||||
cache.put(asset_id, emb, model_key)
|
cache.put(asset_id, emb, cache_key)
|
||||||
|
|
||||||
return emb
|
return emb
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn
|
|||||||
from .config import Config, get_headers
|
from .config import Config, get_headers
|
||||||
from .image_processing import process_face_mode, process_full_mode, process_object_mode
|
from .image_processing import process_face_mode, process_full_mode, process_object_mode
|
||||||
from .immich_api import fetch_face_data, fetch_full_image
|
from .immich_api import fetch_face_data, fetch_full_image
|
||||||
from .logging import console
|
from .log_config import console
|
||||||
from .upload_tracker import mark_rejected, mark_uploaded
|
from .upload_tracker import mark_rejected, mark_uploaded
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -21,8 +21,13 @@ def get_frigate_face_counts() -> dict[str, int] | None:
|
|||||||
resp = requests.get(f"{frigate_url}/api/faces", timeout=10)
|
resp = requests.get(f"{frigate_url}/api/faces", timeout=10)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
train = data.get("train", {})
|
# Response: {person_name: [file, ...], "train": [...], ...}
|
||||||
return {name: len(files) for name, files in train.items() if isinstance(files, list)}
|
# "train" is a flat pending list, not a person — skip it.
|
||||||
|
return {
|
||||||
|
name: len(files)
|
||||||
|
for name, files in data.items()
|
||||||
|
if name != "train" and isinstance(files, list)
|
||||||
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not query Frigate face counts: {e}")
|
logger.warning(f"Could not query Frigate face counts: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -35,10 +35,13 @@ def get_people() -> list[dict]:
|
|||||||
headers=get_headers(),
|
headers=get_headers(),
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
logger.error("Immich API key is invalid or expired (401 Unauthorized). Update API_KEY.")
|
||||||
|
return []
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json().get("people", [])
|
return resp.json().get("people", [])
|
||||||
except (requests.RequestException, ValueError) as e:
|
except (requests.RequestException, ValueError) as e:
|
||||||
logger.error(f"Failed to fetch people: {e}")
|
logger.error(f"Failed to fetch people from Immich: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -49,7 +52,7 @@ def fetch_all_assets(person: dict) -> list[dict]:
|
|||||||
url = f"{Config.IMMICH_URL}/api/search/metadata"
|
url = f"{Config.IMMICH_URL}/api/search/metadata"
|
||||||
page_size = 1000
|
page_size = 1000
|
||||||
|
|
||||||
logger.info(f"Fetching assets for {name}...")
|
logger.debug(f"Fetching assets for {name}...")
|
||||||
|
|
||||||
assets = []
|
assets = []
|
||||||
for page in range(1, MAX_PAGES + 1):
|
for page in range(1, MAX_PAGES + 1):
|
||||||
@@ -137,10 +140,11 @@ def fetch_face_data(asset_id: str, person_id: str | None = None) -> FaceData | N
|
|||||||
face.get("boundingBoxY2", 0),
|
face.get("boundingBoxY2", 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
score = face.get("score")
|
||||||
return FaceData(
|
return FaceData(
|
||||||
embedding=embedding,
|
embedding=embedding,
|
||||||
bbox=bbox,
|
bbox=bbox,
|
||||||
confidence=face.get("score") or face.get("confidence"),
|
confidence=score if score is not None else face.get("confidence"),
|
||||||
image_width=face.get("imageWidth", 0),
|
image_width=face.get("imageWidth", 0),
|
||||||
image_height=face.get("imageHeight", 0),
|
image_height=face.get("imageHeight", 0),
|
||||||
)
|
)
|
||||||
@@ -212,6 +216,6 @@ def filter_recent_assets(assets: list[dict], years: int | None = None) -> list[d
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"Retained {len(recent)} assets (filtered {skipped} old assets).")
|
logger.debug(f"Retained {len(recent)} assets (filtered {skipped} old assets).")
|
||||||
return recent
|
return recent
|
||||||
|
|
||||||
|
|||||||
+28
-13
@@ -13,7 +13,7 @@ from .diversity import select_diverse_assets
|
|||||||
from .embeddings import is_embedding_available, load_embedding_model
|
from .embeddings import is_embedding_available, load_embedding_model
|
||||||
from .frigate_api import get_frigate_face_counts
|
from .frigate_api import get_frigate_face_counts
|
||||||
from .immich_api import fetch_all_assets, filter_recent_assets
|
from .immich_api import fetch_all_assets, filter_recent_assets
|
||||||
from .logging import console
|
from .log_config import console
|
||||||
from .upload_tracker import filter_already_uploaded, get_person_summary, update_frigate_count
|
from .upload_tracker import filter_already_uploaded, get_person_summary, update_frigate_count
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -81,7 +81,9 @@ def _resolve_strategy(strategy: str, has_embedding: bool) -> tuple[int | str, st
|
|||||||
return strategy_map.get(strategy, ("auto", "smart"))
|
return strategy_map.get(strategy, ("auto", "smart"))
|
||||||
|
|
||||||
|
|
||||||
def _perform_selection(assets: list, limit: int | str, name: str, selection_mode: str, entity_type: str) -> list:
|
def _perform_selection(
|
||||||
|
assets: list, limit: int | str, name: str, selection_mode: str, entity_type: str, person_id: str | None = None
|
||||||
|
) -> list:
|
||||||
"""Run diversity selection with progress display."""
|
"""Run diversity selection with progress display."""
|
||||||
if selection_mode == "smart":
|
if selection_mode == "smart":
|
||||||
model_display = "InsightFace (face embeddings)" if entity_type == "face" else "SigLIP (visual embeddings)"
|
model_display = "InsightFace (face embeddings)" if entity_type == "face" else "SigLIP (visual embeddings)"
|
||||||
@@ -104,6 +106,7 @@ def _perform_selection(assets: list, limit: int | str, name: str, selection_mode
|
|||||||
name,
|
name,
|
||||||
selection_mode=selection_mode,
|
selection_mode=selection_mode,
|
||||||
entity_type=entity_type,
|
entity_type=entity_type,
|
||||||
|
person_id=person_id,
|
||||||
progress_callback=lambda c, t: progress.update(task, completed=c, total=t),
|
progress_callback=lambda c, t: progress.update(task, completed=c, total=t),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -113,7 +116,9 @@ def _perform_selection(assets: list, limit: int | str, name: str, selection_mode
|
|||||||
|
|
||||||
rprint(f"\n[cyan]Using time-spread selection for {limit} images...[/cyan]")
|
rprint(f"\n[cyan]Using time-spread selection for {limit} images...[/cyan]")
|
||||||
with console.status(f"[bold]Selecting {limit} images evenly distributed over time...[/bold]"):
|
with console.status(f"[bold]Selecting {limit} images evenly distributed over time...[/bold]"):
|
||||||
selected = select_diverse_assets(assets, limit, name, selection_mode="time", entity_type=entity_type)
|
selected = select_diverse_assets(
|
||||||
|
assets, limit, name, selection_mode="time", entity_type=entity_type, person_id=person_id
|
||||||
|
)
|
||||||
rprint(f" [green]Selected {len(selected)} images using time spread.[/green]")
|
rprint(f" [green]Selected {len(selected)} images using time spread.[/green]")
|
||||||
return selected
|
return selected
|
||||||
|
|
||||||
@@ -145,8 +150,11 @@ def _configure_person(person: dict, people: list[dict]) -> dict | None:
|
|||||||
|
|
||||||
rprint(f" Found [bold]{len(all_assets)}[/bold] total, [bold]{len(recent_assets)}[/bold] in range ({years} years).")
|
rprint(f" Found [bold]{len(all_assets)}[/bold] total, [bold]{len(recent_assets)}[/bold] in range ({years} years).")
|
||||||
|
|
||||||
# Filter out assets already uploaded to Frigate
|
# Filter out assets already uploaded to Frigate.
|
||||||
retry_rejected = os.environ.get("RETRY_REJECTED", "false").lower() in ("true", "1", "yes")
|
# In interactive mode, ask — use the env var only as the default so it can
|
||||||
|
# still be pre-set (e.g. RETRY_REJECTED=true) without forcing the answer.
|
||||||
|
retry_env = os.environ.get("RETRY_REJECTED", "false").lower() in ("true", "1", "yes")
|
||||||
|
retry_rejected = Confirm.ask("Include previously rejected images?", default=retry_env)
|
||||||
before_dedup = len(recent_assets)
|
before_dedup = len(recent_assets)
|
||||||
new_asset_ids = set(filter_already_uploaded([a["id"] for a in recent_assets], retry_rejected=retry_rejected))
|
new_asset_ids = set(filter_already_uploaded([a["id"] for a in recent_assets], retry_rejected=retry_rejected))
|
||||||
recent_assets = [a for a in recent_assets if a["id"] in new_asset_ids]
|
recent_assets = [a for a in recent_assets if a["id"] in new_asset_ids]
|
||||||
@@ -167,7 +175,9 @@ def _configure_person(person: dict, people: list[dict]) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Perform selection
|
# Perform selection
|
||||||
selected_assets = _perform_selection(recent_assets, limit, name, selection_mode, entity_type)
|
selected_assets = _perform_selection(
|
||||||
|
recent_assets, limit, name, selection_mode, entity_type, person_id=person["id"]
|
||||||
|
)
|
||||||
|
|
||||||
rprint(f" [green]Queued {len(selected_assets)} images for {name}.[/green]")
|
rprint(f" [green]Queued {len(selected_assets)} images for {name}.[/green]")
|
||||||
return {"person": person, "assets": selected_assets, "limit": len(selected_assets), "config": config}
|
return {"person": person, "assets": selected_assets, "limit": len(selected_assets), "config": config}
|
||||||
@@ -276,10 +286,8 @@ def auto_configure(people: list[dict]) -> list[dict]:
|
|||||||
if frigate_counts is not None:
|
if frigate_counts is not None:
|
||||||
already_uploaded = frigate_counts.get(name, 0)
|
already_uploaded = frigate_counts.get(name, 0)
|
||||||
else:
|
else:
|
||||||
already_uploaded = (
|
fc = person_summary.get("frigate_count")
|
||||||
person_summary.get("frigate_count")
|
already_uploaded = fc if fc is not None else person_summary.get("uploaded", 0)
|
||||||
or person_summary.get("uploaded", 0)
|
|
||||||
)
|
|
||||||
capacity = Config.MAX_AUTO_IMAGES - already_uploaded
|
capacity = Config.MAX_AUTO_IMAGES - already_uploaded
|
||||||
if capacity <= 0:
|
if capacity <= 0:
|
||||||
rprint(
|
rprint(
|
||||||
@@ -291,17 +299,24 @@ def auto_configure(people: list[dict]) -> list[dict]:
|
|||||||
has_embedding = is_embedding_available(entity_type)
|
has_embedding = is_embedding_available(entity_type)
|
||||||
limit, selection_mode = _resolve_strategy(strategy, has_embedding)
|
limit, selection_mode = _resolve_strategy(strategy, has_embedding)
|
||||||
|
|
||||||
# Cap selection to remaining capacity
|
# Cap selection to remaining capacity.
|
||||||
|
# For auto mode with partial training, keep "auto" so adaptive stopping
|
||||||
|
# still runs — just trim the result to the remaining capacity afterward.
|
||||||
|
auto_cap = None
|
||||||
if limit == "auto":
|
if limit == "auto":
|
||||||
if already_uploaded > 0:
|
if already_uploaded > 0:
|
||||||
limit = capacity # partially filled — select exactly what remains
|
auto_cap = capacity
|
||||||
else:
|
else:
|
||||||
limit = min(limit, capacity)
|
limit = min(limit, capacity)
|
||||||
|
|
||||||
if selection_mode == "skip":
|
if selection_mode == "skip":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
selected_assets = _perform_selection(recent_assets, limit, name, selection_mode, entity_type)
|
selected_assets = _perform_selection(
|
||||||
|
recent_assets, limit, name, selection_mode, entity_type, person_id=person["id"]
|
||||||
|
)
|
||||||
|
if auto_cap is not None:
|
||||||
|
selected_assets = selected_assets[:auto_cap]
|
||||||
|
|
||||||
if selected_assets:
|
if selected_assets:
|
||||||
rprint(f" [green]Queued {len(selected_assets)} images for {name}.[/green]")
|
rprint(f" [green]Queued {len(selected_assets)} images for {name}.[/green]")
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ def setup_logging(verbose: bool = False) -> logging.Logger:
|
|||||||
"""Configure logging with Rich console and file output."""
|
"""Configure logging with Rich console and file output."""
|
||||||
level = logging.DEBUG if verbose else logging.INFO
|
level = logging.DEBUG if verbose else logging.INFO
|
||||||
|
|
||||||
# Configure root logger
|
# Configure root logger; close existing handlers before replacing them
|
||||||
root = logging.getLogger()
|
root = logging.getLogger()
|
||||||
root.setLevel(level)
|
root.setLevel(level)
|
||||||
root.handlers.clear()
|
for h in root.handlers[:]:
|
||||||
|
h.close()
|
||||||
|
root.removeHandler(h)
|
||||||
|
|
||||||
# Rich console handler - uses shared console to avoid breaking progress bars
|
# Rich console handler - uses shared console to avoid breaking progress bars
|
||||||
root.addHandler(RichHandler(rich_tracebacks=True, markup=True, console=console))
|
root.addHandler(RichHandler(rich_tracebacks=True, markup=True, console=console))
|
||||||
@@ -37,7 +39,7 @@ def setup_logging(verbose: bool = False) -> logging.Logger:
|
|||||||
# File handler (always debug level) — log file respects OUTPUT_DIR if set
|
# File handler (always debug level) — log file respects OUTPUT_DIR if set
|
||||||
log_dir = os.environ.get("OUTPUT_DIR", ".")
|
log_dir = os.environ.get("OUTPUT_DIR", ".")
|
||||||
os.makedirs(log_dir, exist_ok=True)
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
log_path = os.path.join(log_dir, "immich_export.log")
|
log_path = os.path.join(log_dir, "winnow.log")
|
||||||
file_handler = logging.FileHandler(log_path)
|
file_handler = logging.FileHandler(log_path)
|
||||||
file_handler.setLevel(logging.DEBUG)
|
file_handler.setLevel(logging.DEBUG)
|
||||||
file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
|
file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
|
||||||
Reference in New Issue
Block a user