From c0c2d88941f20055cf1598c7caf9f22292fe0c0b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:33:25 +0000 Subject: [PATCH 01/14] chore: update lockfiles --- uv.lock | 64 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/uv.lock b/uv.lock index 2f69c7d..170881b 100644 --- a/uv.lock +++ b/uv.lock @@ -1970,15 +1970,16 @@ version = "2.12.0" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ "platform_machine != 's390x' and sys_platform == 'darwin'", + "platform_machine == 's390x' and sys_platform == 'darwin'", ] dependencies = [ - { name = "filelock", marker = "(platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 's390x' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { name = "fsspec", marker = "(platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 's390x' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { name = "jinja2", marker = "(platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 's390x' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { name = "networkx", marker = "(platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 's390x' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { name = "setuptools", marker = "(platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 's390x' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { name = "sympy", marker = "(platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 's390x' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { name = "typing-extensions", marker = "(platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 's390x' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "filelock", marker = "sys_platform == 'darwin' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "fsspec", marker = "sys_platform == 'darwin' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "jinja2", marker = "sys_platform == 'darwin' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "networkx", marker = "sys_platform == 'darwin' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "setuptools", marker = "sys_platform == 'darwin' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "sympy", marker = "sys_platform == 'darwin' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, ] 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" }, @@ -2041,16 +2042,15 @@ resolution-markers = [ "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'", ] 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "filelock", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "fsspec", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "jinja2", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "networkx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "setuptools", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "sympy", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "typing-extensions", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:5e0da19e1c3bfdc9b92638c552579eac678354485d61fc8921b0461fd6c40449", upload-time = "2026-05-12T23:17:05Z" }, @@ -2116,11 +2116,12 @@ version = "0.27.0" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ "platform_machine != 's390x' and sys_platform == 'darwin'", + "platform_machine == 's390x' and sys_platform == 'darwin'", ] dependencies = [ - { name = "numpy", marker = "(platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 's390x' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { name = "pillow", marker = "(platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 's390x' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 's390x' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "numpy", marker = "sys_platform == 'darwin' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "pillow", marker = "sys_platform == 'darwin' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, ] 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" }, @@ -2171,12 +2172,11 @@ resolution-markers = [ "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'", ] 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "numpy", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "pillow", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, ] 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" }, @@ -2306,13 +2306,13 @@ dependencies = [ { name = "pyyaml" }, { name = "requests" }, { name = "scipy" }, - { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 's390x' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, { 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') or (platform_machine == 'aarch64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (platform_machine == 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, { name = "torch", version = "2.12.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { name = "torchvision", version = "0.27.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 's390x' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "torchvision", version = "0.27.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, { 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') or (platform_machine == 'aarch64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (platform_machine == 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, { name = "torchvision", version = "0.27.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, { name = "ultralytics-thop" }, ] @@ -2327,9 +2327,9 @@ version = "2.0.20" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, - { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 's390x' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, { 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') or (platform_machine == 'aarch64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (platform_machine == 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, { name = "torch", version = "2.12.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, ] 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" } @@ -2363,13 +2363,13 @@ dependencies = [ { name = "python-dotenv" }, { name = "requests" }, { name = "rich" }, - { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 's390x' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, { 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') or (platform_machine == 'aarch64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (platform_machine == 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, { name = "torch", version = "2.12.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { name = "torchvision", version = "0.27.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 's390x' and sys_platform == 'darwin') or (platform_machine == 's390x' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { name = "torchvision", version = "0.27.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin' or (extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, { 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') or (platform_machine == 'aarch64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (platform_machine == 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, - { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, + { 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') or (sys_platform == 'darwin' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform == 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, { name = "torchvision", version = "0.27.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'x86_64' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu') or (sys_platform != 'linux' and extra == 'project-11-onnxruntime' and extra == 'project-15-onnxruntime-gpu')" }, { name = "transformers" }, { name = "ultralytics" }, From 4856a6d36f9ef2618d6dd22f207617e8ba6df01f Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 15:43:14 +0000 Subject: [PATCH 02/14] fix: raise MIN_FACE_WIDTH default from 50 to 90px (8k pixel floor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 50px crops produce ~2,500–4,225 total pixels — well below Frigate's own camera capture range of 16k–50k px. 90px guarantees ≥8,100 total pixels even when face margins are fully clipped by image edges. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 2 +- tests/test_config.py | 2 +- winnow/config.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 86702f7..480fa46 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,7 @@ STRATEGY=auto # YEARS_FILTER=10 # Only include images from the last N years (default: 10) # ── Image Quality ───────────────────────────────────────────────────────────── -# MIN_FACE_WIDTH=50 # Minimum face width in pixels (default: 50) +# MIN_FACE_WIDTH=90 # Minimum face width in pixels (default: 90, guarantees ≥8,100px crop) # FACE_MARGIN=0.15 # Padding around face crop as fraction (default: 0.15) # ENABLE_FACE_ALIGNMENT=true # Align face before cropping (default: true) # USE_FULL_RESOLUTION=true # Use full-res images vs thumbnails (default: true) diff --git a/tests/test_config.py b/tests/test_config.py index 6e247ed..e729763 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -17,7 +17,7 @@ def test_config_loads_defaults(monkeypatch): assert cfg.API_KEY == "test-key" assert cfg.OUTPUT_DIR == "./frigate_train" assert cfg.YEARS_FILTER == 10 - assert cfg.MIN_FACE_WIDTH == 50 + assert cfg.MIN_FACE_WIDTH == 90 assert cfg.MIN_FACE_COUNT == 0 assert cfg.BLUR_THRESHOLD == 100.0 assert cfg.MIN_CONFIDENCE == 0.7 diff --git a/winnow/config.py b/winnow/config.py index ad3be2f..30f5776 100644 --- a/winnow/config.py +++ b/winnow/config.py @@ -26,7 +26,7 @@ class _Config: YEARS_FILTER: int = 10 # Quality filtering - MIN_FACE_WIDTH: int = 50 + MIN_FACE_WIDTH: int = 90 BLUR_THRESHOLD: float = 100.0 MIN_CONFIDENCE: float = 0.7 MAX_AUTO_IMAGES: int = 80 @@ -56,7 +56,7 @@ class _Config: self.API_KEY = os.getenv("API_KEY") self.OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./frigate_train") self.YEARS_FILTER = int(os.getenv("YEARS_FILTER", "10")) - self.MIN_FACE_WIDTH = int(os.getenv("MIN_FACE_WIDTH", "50")) + self.MIN_FACE_WIDTH = int(os.getenv("MIN_FACE_WIDTH", "90")) self.MIN_FACE_COUNT = int(os.getenv("MIN_FACE_COUNT", "0")) self.BLUR_THRESHOLD = float(os.getenv("BLUR_THRESHOLD", "100.0")) self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7")) From 2b1d9e8b8a7b271abbd5987ed30ffa4fa2323914 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 15:44:51 +0000 Subject: [PATCH 03/14] chore: bump version to 0.3.3, update changelog and lockfile Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1516749..a23d354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.3] - 2026-06-13 + +### Fixed + +- **`MIN_FACE_WIDTH` default raised from 50 → 90px**: 50px crops produce 2,500–4,225 total pixels, well below Frigate's own camera capture range of 16k–50k px. 90px guarantees ≥8,100 total pixels even when face margins are fully clipped by image edges, keeping winnow training crops above the floor Frigate considers useful. + ## [0.3.2] - 2026-06-13 ### Added diff --git a/pyproject.toml b/pyproject.toml index f6537b8..fa16cc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "winnow" -version = "0.3.2" +version = "0.3.3" description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition and object classification." license = "AGPL-3.0-or-later" requires-python = ">=3.13" diff --git a/uv.lock b/uv.lock index 170881b..db79fd2 100644 --- a/uv.lock +++ b/uv.lock @@ -2348,7 +2348,7 @@ wheels = [ [[package]] name = "winnow" -version = "0.3.2" +version = "0.3.3" source = { editable = "." } dependencies = [ { name = "croniter" }, From ab641847b2c4c44ac12de68673827f210af59838 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 15:56:52 +0000 Subject: [PATCH 04/14] fix: raise BLUR_THRESHOLD default from 100 to 120 to match Frigate's floor Frigate classifies images with Laplacian variance < 120 as "very blurry" and its own docs recommend avoiding blurry training data. Winnow was accepting images in the 100-120 range that Frigate considers too blurry. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 2 +- tests/test_config.py | 2 +- winnow/config.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 480fa46..7389783 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,7 @@ STRATEGY=auto # ENABLE_FACE_ALIGNMENT=true # Align face before cropping (default: true) # USE_FULL_RESOLUTION=true # Use full-res images vs thumbnails (default: true) # MIN_CONFIDENCE=0.7 # Minimum face detection confidence (default: 0.7) -# BLUR_THRESHOLD=100.0 # Laplacian blur threshold; lower = accept more blur (default: 100.0) +# BLUR_THRESHOLD=120.0 # Laplacian blur threshold; lower = accept more blur (default: 120.0) # MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80) # ── Caching & Models ────────────────────────────────────────────────────────── diff --git a/tests/test_config.py b/tests/test_config.py index e729763..5d700d4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -19,7 +19,7 @@ def test_config_loads_defaults(monkeypatch): assert cfg.YEARS_FILTER == 10 assert cfg.MIN_FACE_WIDTH == 90 assert cfg.MIN_FACE_COUNT == 0 - assert cfg.BLUR_THRESHOLD == 100.0 + assert cfg.BLUR_THRESHOLD == 120.0 assert cfg.MIN_CONFIDENCE == 0.7 assert cfg.MAX_AUTO_IMAGES == 80 assert cfg.QUALITY_REPLACEMENT is True diff --git a/winnow/config.py b/winnow/config.py index 30f5776..ed5335f 100644 --- a/winnow/config.py +++ b/winnow/config.py @@ -27,7 +27,7 @@ class _Config: # Quality filtering MIN_FACE_WIDTH: int = 90 - BLUR_THRESHOLD: float = 100.0 + BLUR_THRESHOLD: float = 120.0 MIN_CONFIDENCE: float = 0.7 MAX_AUTO_IMAGES: int = 80 QUALITY_REPLACEMENT: bool = True @@ -58,7 +58,7 @@ class _Config: self.YEARS_FILTER = int(os.getenv("YEARS_FILTER", "10")) self.MIN_FACE_WIDTH = int(os.getenv("MIN_FACE_WIDTH", "90")) self.MIN_FACE_COUNT = int(os.getenv("MIN_FACE_COUNT", "0")) - self.BLUR_THRESHOLD = float(os.getenv("BLUR_THRESHOLD", "100.0")) + self.BLUR_THRESHOLD = float(os.getenv("BLUR_THRESHOLD", "120.0")) self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7")) self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "80")) self.QUALITY_REPLACEMENT = os.getenv("QUALITY_REPLACEMENT", "true").lower() in ("true", "1", "yes") From 03be6ce2cb5ddb86807a3a836c0d062048a70f8e Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 16:21:58 +0000 Subject: [PATCH 05/14] feat: store Frigate recognition scores and use them for quality replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After each successful upload, call POST /api/faces/recognize to get Frigate's own confidence score (0-1) for the uploaded crop. Store it in the tracker as frigate_scores alongside the existing blur score. When quality replacement activates and frigate_scores are present, use them for the replacement comparison instead of blur scores — an image Frigate recognizes poorly is a worse training image than one it recognizes well, regardless of sharpness. Falls back to blur scores on first run before any frigate_scores are populated. Also surfaces frigate_score in TRACE_CROP_SIZE output. Co-Authored-By: Claude Sonnet 4.6 --- winnow/cli.py | 2 ++ winnow/executor.py | 40 ++++++++++++++++------- winnow/frigate_api.py | 27 ++++++++++++++++ winnow/upload_tracker.py | 69 +++++++++++++++++++++++++++++++++------- 4 files changed, 115 insertions(+), 23 deletions(-) diff --git a/winnow/cli.py b/winnow/cli.py index 24e5b5c..007875e 100644 --- a/winnow/cli.py +++ b/winnow/cli.py @@ -41,6 +41,8 @@ def _handle_trace_crop(size_str: str) -> None: rprint(f" Immich URL: {immich_url}/photos/{m['asset_id']}") blur = m.get("blur_score") rprint(f" Blur score: {blur:.1f}" if blur is not None else " Blur score: unknown") + fscore = m.get("frigate_score") + rprint(f" Frigate score: {fscore:.2f}" if fscore is not None else " Frigate score: unknown") if m.get("frigate_filename"): rprint(f" Frigate file: {m['frigate_filename']}") else: diff --git a/winnow/executor.py b/winnow/executor.py index 2c2bdfe..05a3930 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -13,7 +13,7 @@ from rich import print as rprint from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn from .config import Config, get_headers -from .frigate_api import delete_frigate_person_files, get_frigate_person_files +from .frigate_api import delete_frigate_person_files, get_frigate_person_files, recognize_face from .image_processing import process_face_mode, process_full_mode, process_object_mode from .immich_api import fetch_face_data, fetch_full_image from .log_config import console @@ -22,6 +22,7 @@ from .upload_tracker import ( get_lowest_quality_mapped_file, get_tracked_frigate_file_count, get_tracked_frigate_filenames, + has_frigate_scores, mark_rejected, mark_uploaded, record_frigate_file, @@ -383,30 +384,45 @@ def upload_to_frigate(jobs: list[dict]) -> None: progress.console.print(f" [dim]⏭ {fname}: at cap, quality replacement disabled[/dim]") progress.advance(upload_task) continue - new_score = score_map.get(fname) - if new_score is None: - progress.console.print(f" [dim]⏭ {fname}: no confidence score, skipping replacement[/dim]") - progress.advance(upload_task) - continue + using_fscore = has_frigate_scores(name) + if using_fscore: + candidate_score = recognize_face(fpath) + if candidate_score is None: + progress.console.print( + f" [dim]⏭ {fname}: Frigate recognize unavailable, skipping replacement[/dim]" + ) + progress.advance(upload_task) + continue + score_label = "frigate" + else: + candidate_score = score_map.get(fname) + if candidate_score is None: + progress.console.print( + f" [dim]⏭ {fname}: no quality score, skipping replacement[/dim]" + ) + progress.advance(upload_task) + continue + score_label = "blur" worst = get_lowest_quality_mapped_file(name, exclude=failed_deletes) - if worst is None or new_score <= worst[2]: + if worst is None or candidate_score <= worst[2]: worst_score_str = f"{worst[2]:.3f}" if worst is not None else "N/A" progress.console.print( - f" [dim]⏭ {fname}: score {new_score:.3f} ≤ worst mapped" + f" [dim]⏭ {fname}: {score_label} {candidate_score:.3f} ≤ worst" f" {worst_score_str}, skipping[/dim]" ) progress.advance(upload_task) continue - # Delete the worst mapped file to make room for the better one worst_frigate_file, _worst_asset_id, worst_score = worst progress.console.print( - f" 🔄 {fname}: score {new_score:.3f} > {worst_score:.3f}," + f" 🔄 {fname}: {score_label} {candidate_score:.3f} > {worst_score:.3f}," f" replacing {worst_frigate_file}" ) if delete_frigate_person_files(name, [worst_frigate_file]): remove_frigate_file(name, worst_frigate_file) effective_count -= 1 - min_quality_score_for_slot = worst_score + # Slot floor guard uses blur scores only — frigate_score mode + # will re-evaluate the next candidate via recognize_face anyway. + min_quality_score_for_slot = score_map.get(fname) if not using_fscore else None else: logger.warning(f"Failed to delete {worst_frigate_file} for {name}, skipping replacement") failed_deletes.add(worst_frigate_file) @@ -429,11 +445,13 @@ def upload_to_frigate(jobs: list[dict]) -> None: asset_id = asset_map.get(fname) if asset_id: + post_fscore = recognize_face(fpath) mark_uploaded( asset_id, person_name=name, score=score_map.get(fname), crop_dims=dims_map.get(fname), + frigate_score=post_fscore, ) actually_uploaded.append((fname, asset_id)) diff --git a/winnow/frigate_api.py b/winnow/frigate_api.py index b6d4950..b3eef62 100644 --- a/winnow/frigate_api.py +++ b/winnow/frigate_api.py @@ -53,6 +53,33 @@ def get_frigate_person_files(person_name: str) -> list[str] | None: return files if isinstance(files, list) else [] +def recognize_face(file_path: str) -> float | None: + """Submit an image to Frigate's recognize endpoint and return the confidence score. + + Returns None if FRIGATE_URL is unset, the API is unreachable, no face is + detected, or face recognition is not enabled in Frigate. + """ + frigate_url = os.environ.get("FRIGATE_URL", "").rstrip("/") + if not frigate_url: + return None + try: + with open(file_path, "rb") as f: + resp = requests.post( + f"{frigate_url}/api/faces/recognize", + files={"file": (os.path.basename(file_path), f, "image/jpeg")}, + timeout=15, + ) + if not resp.ok: + return None + data = resp.json() + if data.get("success") and "score" in data: + return round(float(data["score"]), 4) + return None + except Exception as e: + logger.debug(f"Frigate recognize failed for {file_path}: {e}") + return None + + def delete_frigate_person_files(person_name: str, filenames: list[str]) -> bool: """Delete specific training files for a person from Frigate. diff --git a/winnow/upload_tracker.py b/winnow/upload_tracker.py index 79d128b..b3d4528 100644 --- a/winnow/upload_tracker.py +++ b/winnow/upload_tracker.py @@ -11,13 +11,19 @@ Both are excluded from future candidate pools. To reset: by_person schema (frigate_uploaded_ids.json): { - "asset_ids": ["immich-id-1", ...], # all assets we attempted to upload - "scores": {"immich-id-1": 450.3}, # Laplacian blur variance at upload time - "frigate_files": {"PersonName-123.webp": "immich-id-1"}, # Frigate filename → asset ID - "crop_dims": {"immich-id-1": [640, 480]}, # crop pixel dimensions at upload time - "frigate_count": 42 # last known Frigate training image count + "asset_ids": ["immich-id-1", ...], # all assets we attempted to upload + "scores": {"immich-id-1": 450.3}, # Laplacian blur variance at upload time + "frigate_scores": {"immich-id-1": 0.87}, # Frigate recognition confidence (0-1) post-upload + "frigate_files": {"PersonName-123.webp": "immich-id-1"}, # Frigate filename → asset ID + "crop_dims": {"immich-id-1": [640, 480]}, # crop pixel dimensions at upload time + "frigate_count": 42 # last known Frigate training image count } +frigate_scores uses the same 0-1 sigmoid-mapped cosine similarity that Frigate +displays in its UI. When available, quality replacement uses frigate_scores in +preference to blur scores — an image Frigate cannot recognize is a poor training +image regardless of sharpness. + frigate_files only contains files winnow uploaded — files added manually through Frigate's UI are never mapped here and are never touched by quality replacement. """ @@ -77,9 +83,10 @@ def _get_ids(entry: list | dict) -> list[str]: def _migrate_entry(entry: list | dict) -> dict: """Ensure by_person entry is in the current dict format.""" if isinstance(entry, list): - return {"asset_ids": sorted(entry), "scores": {}, "frigate_files": {}, "crop_dims": {}} + return {"asset_ids": sorted(entry), "scores": {}, "frigate_scores": {}, "frigate_files": {}, "crop_dims": {}} entry.setdefault("asset_ids", []) entry.setdefault("scores", {}) + entry.setdefault("frigate_scores", {}) entry.setdefault("frigate_files", {}) entry.setdefault("crop_dims", {}) return entry @@ -91,6 +98,7 @@ def _mark( person_name: str | None, score: float | None = None, crop_dims: tuple[int, int] | None = None, + frigate_score: float | None = None, ) -> None: data = _load(filename) flat_key = _flat_key(filename) @@ -107,6 +115,8 @@ def _mark( entry["scores"][asset_id] = round(score, 4) if crop_dims is not None: entry["crop_dims"][asset_id] = [crop_dims[0], crop_dims[1]] + if frigate_score is not None: + entry["frigate_scores"][asset_id] = round(frigate_score, 4) by_person[person_name] = entry _save(filename, data) @@ -126,8 +136,9 @@ def mark_uploaded( person_name: str | None = None, score: float | None = None, crop_dims: tuple[int, int] | None = None, + frigate_score: float | None = None, ) -> None: - _mark(UPLOAD_TRACKER_FILE, asset_id, person_name, score=score, crop_dims=crop_dims) + _mark(UPLOAD_TRACKER_FILE, asset_id, person_name, score=score, crop_dims=crop_dims, frigate_score=frigate_score) logger.debug(f"Marked {asset_id} as uploaded ({person_name})") @@ -184,24 +195,56 @@ def get_tracked_frigate_filenames(person_name: str) -> set[str]: return set(entry["frigate_files"].keys()) +def has_frigate_scores(person_name: str) -> bool: + """Return True if any mapped file for this person has a stored Frigate recognition score.""" + data = _load(UPLOAD_TRACKER_FILE) + entry = _migrate_entry(data.get("by_person", {}).get(person_name, {})) + frigate_files = entry.get("frigate_files", {}) + frigate_scores = entry.get("frigate_scores", {}) + return any(asset_id in frigate_scores for asset_id in frigate_files.values()) + + def get_lowest_quality_mapped_file( person_name: str, exclude: set[str] | None = None ) -> tuple[str, str, float] | None: """Return (frigate_filename, asset_id, score) for the mapped file with the lowest quality score, or None if no mapped files with known scores exist. + Uses Frigate recognition scores (0-1) when any are present for this person, + treating files without a Frigate score as 0.0. Falls back to Laplacian blur + scores when no Frigate scores exist yet. + Pass `exclude` to skip files that failed to delete this run without removing them from the tracker — they remain candidates on the next run. """ data = _load(UPLOAD_TRACKER_FILE) entry = _migrate_entry(data.get("by_person", {}).get(person_name, {})) frigate_files = entry.get("frigate_files", {}) - scores = entry.get("scores", {}) - candidates = [ - (frigate_filename, asset_id, scores[asset_id]) - for frigate_filename, asset_id in frigate_files.items() - if asset_id in scores and (exclude is None or frigate_filename not in exclude) + blur_scores = entry.get("scores", {}) + frigate_scores = entry.get("frigate_scores", {}) + + mapped = [ + (ff, asset_id) + for ff, asset_id in frigate_files.items() + if exclude is None or ff not in exclude ] + if not mapped: + return None + + use_frigate = any(asset_id in frigate_scores for _, asset_id in mapped) + + if use_frigate: + candidates = [ + (ff, asset_id, frigate_scores.get(asset_id, 0.0)) + for ff, asset_id in mapped + ] + else: + candidates = [ + (ff, asset_id, blur_scores[asset_id]) + for ff, asset_id in mapped + if asset_id in blur_scores + ] + if not candidates: return None return min(candidates, key=lambda x: x[2]) @@ -220,6 +263,7 @@ def find_by_crop_dimension(size: int) -> list[dict]: scores = entry.get("scores", {}) frigate_files = entry.get("frigate_files", {}) asset_to_frigate = {v: k for k, v in frigate_files.items()} + frigate_scores = entry.get("frigate_scores", {}) for asset_id, dims in entry.get("crop_dims", {}).items(): w, h = dims[0], dims[1] if w == size or h == size: @@ -229,6 +273,7 @@ def find_by_crop_dimension(size: int) -> list[dict]: "width": w, "height": h, "blur_score": scores.get(asset_id), + "frigate_score": frigate_scores.get(asset_id), "frigate_filename": asset_to_frigate.get(asset_id), }) return results From 26b598db98ebedce183d6ff532a34cf24afe1922 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 16:29:29 +0000 Subject: [PATCH 06/14] feat: post-upload quality gate via FRIGATE_SCORE_THRESHOLD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When FRIGATE_SCORE_THRESHOLD > 0, images that score below the threshold after upload are deleted from Frigate and removed from the tracker. Skipped when pre_run_count == 0 (cold start — no class mean to compare against yet). Deletion happens after reconciliation so the Frigate filename is known. Disabled by default (0.0). Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 1 + winnow/config.py | 2 ++ winnow/executor.py | 39 +++++++++++++++++++++++++++++++++++++++ winnow/upload_tracker.py | 10 ++++++++++ 4 files changed, 52 insertions(+) diff --git a/.env.example b/.env.example index 7389783..da0f54c 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,7 @@ STRATEGY=auto # MIN_CONFIDENCE=0.7 # Minimum face detection confidence (default: 0.7) # BLUR_THRESHOLD=120.0 # Laplacian blur threshold; lower = accept more blur (default: 120.0) # MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80) +# FRIGATE_SCORE_THRESHOLD=0.0 # Remove uploaded images scoring below this after upload (0 = disabled; skipped on cold start) # ── Caching & Models ────────────────────────────────────────────────────────── # FORCE_CPU=true # Disable GPU, fall back to CPU diff --git a/winnow/config.py b/winnow/config.py index ed5335f..6bd4dff 100644 --- a/winnow/config.py +++ b/winnow/config.py @@ -31,6 +31,7 @@ class _Config: MIN_CONFIDENCE: float = 0.7 MAX_AUTO_IMAGES: int = 80 QUALITY_REPLACEMENT: bool = True + FRIGATE_SCORE_THRESHOLD: float = 0.0 # People filtering MIN_FACE_COUNT: int = 0 @@ -62,6 +63,7 @@ class _Config: self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7")) self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "80")) self.QUALITY_REPLACEMENT = os.getenv("QUALITY_REPLACEMENT", "true").lower() in ("true", "1", "yes") + self.FRIGATE_SCORE_THRESHOLD = float(os.getenv("FRIGATE_SCORE_THRESHOLD", "0.0")) 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.ENABLE_FACE_ALIGNMENT = os.getenv("ENABLE_FACE_ALIGNMENT", "true").lower() in ("true", "1", "yes") diff --git a/winnow/executor.py b/winnow/executor.py index 05a3930..16c2982 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -19,6 +19,7 @@ from .immich_api import fetch_face_data, fetch_full_image from .log_config import console from .quality import assess_quality from .upload_tracker import ( + get_frigate_filename_for_asset, get_lowest_quality_mapped_file, get_tracked_frigate_file_count, get_tracked_frigate_filenames, @@ -356,9 +357,11 @@ def upload_to_frigate(jobs: list[dict]) -> None: else: known_frigate_files_at_start: set[str] = set(_snapshot) effective_count = get_tracked_frigate_file_count(name) + pre_run_count = effective_count quality_replacement = job.get("config", {}).get("quality_replacement", False) actually_uploaded: list[tuple[str, str | None]] = [] failed_deletes: set[str] = set() + quality_gate_failed: set[str] = set() min_quality_score_for_slot: float | None = None for fname in person_files: @@ -455,6 +458,22 @@ def upload_to_frigate(jobs: list[dict]) -> None: ) actually_uploaded.append((fname, asset_id)) + # Flag for post-reconcile removal if below threshold. + # We don't know the Frigate filename yet — reconcile maps + # it first, then we delete using the mapped name. + threshold = Config.FRIGATE_SCORE_THRESHOLD + if ( + threshold > 0 + and pre_run_count > 0 + and post_fscore is not None + and post_fscore < threshold + ): + quality_gate_failed.add(asset_id) + progress.console.print( + f" [yellow]⚠ {fname}: Frigate score {post_fscore:.2f}" + f" < threshold {threshold:.2f}, will remove after mapping[/yellow]" + ) + break else: if attempt < max_retries: @@ -520,6 +539,26 @@ def upload_to_frigate(jobs: list[dict]) -> None: if actually_uploaded: _reconcile_frigate_mappings(name, known_frigate_files_at_start, actually_uploaded) + # Post-reconcile quality gate: filenames are now mapped, so we can delete. + if quality_gate_failed: + removed = 0 + for asset_id in quality_gate_failed: + frigate_fn = get_frigate_filename_for_asset(name, asset_id) + if frigate_fn and delete_frigate_person_files(name, [frigate_fn]): + remove_frigate_file(name, frigate_fn) + effective_count -= 1 + removed += 1 + else: + logger.warning( + f"{name}: could not remove low-score file for {asset_id}" + " — no Frigate filename mapped (reconciliation race?)" + ) + if removed: + progress.console.print( + f" [yellow]🗑 {name}: removed {removed} image(s) below" + f" Frigate score threshold ({Config.FRIGATE_SCORE_THRESHOLD:.2f})[/yellow]" + ) + # Per-person summary if person_failed == 0: progress.console.print( diff --git a/winnow/upload_tracker.py b/winnow/upload_tracker.py index b3d4528..3f9f025 100644 --- a/winnow/upload_tracker.py +++ b/winnow/upload_tracker.py @@ -250,6 +250,16 @@ def get_lowest_quality_mapped_file( return min(candidates, key=lambda x: x[2]) +def get_frigate_filename_for_asset(person_name: str, asset_id: str) -> str | None: + """Return the Frigate training filename mapped to this asset ID, or None.""" + data = _load(UPLOAD_TRACKER_FILE) + entry = _migrate_entry(data.get("by_person", {}).get(person_name, {})) + for frigate_filename, aid in entry["frigate_files"].items(): + if aid == asset_id: + return frigate_filename + return None + + def find_by_crop_dimension(size: int) -> list[dict]: """Return all tracked crops whose width or height matches `size` pixels. From f322eba380fc114af9de8f8de809fbda1404cdbc Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 16:32:47 +0000 Subject: [PATCH 07/14] feat: dynamic Frigate score threshold from stored set minimum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At the start of each person's upload phase, compute the minimum stored Frigate recognition score across all currently mapped files. Use max(config_threshold, dynamic_min) as the effective gate threshold so new uploads must score at least as well as the weakest image already in the training set. Prevents overtraining well-recognised people: if all 80 images score ≥0.85, the dynamic threshold becomes ~0.85 and new additions that score below that are removed rather than diluting a good training set. Co-Authored-By: Claude Sonnet 4.6 --- winnow/executor.py | 19 ++++++++++++++----- winnow/upload_tracker.py | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/winnow/executor.py b/winnow/executor.py index 16c2982..626c242 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -21,6 +21,7 @@ from .quality import assess_quality from .upload_tracker import ( get_frigate_filename_for_asset, get_lowest_quality_mapped_file, + get_min_frigate_score, get_tracked_frigate_file_count, get_tracked_frigate_filenames, has_frigate_scores, @@ -359,6 +360,15 @@ def upload_to_frigate(jobs: list[dict]) -> None: effective_count = get_tracked_frigate_file_count(name) pre_run_count = effective_count quality_replacement = job.get("config", {}).get("quality_replacement", False) + # Dynamic threshold: at least as strict as the weakest image already stored. + # Takes whichever is higher — the configured floor or the current set minimum. + _dynamic = get_min_frigate_score(name) + effective_threshold = max( + Config.FRIGATE_SCORE_THRESHOLD, + _dynamic if _dynamic is not None else 0.0, + ) + if _dynamic is not None and _dynamic > Config.FRIGATE_SCORE_THRESHOLD: + logger.debug(f"{name}: dynamic Frigate score threshold {_dynamic:.3f}") actually_uploaded: list[tuple[str, str | None]] = [] failed_deletes: set[str] = set() quality_gate_failed: set[str] = set() @@ -461,17 +471,16 @@ def upload_to_frigate(jobs: list[dict]) -> None: # Flag for post-reconcile removal if below threshold. # We don't know the Frigate filename yet — reconcile maps # it first, then we delete using the mapped name. - threshold = Config.FRIGATE_SCORE_THRESHOLD if ( - threshold > 0 + effective_threshold > 0 and pre_run_count > 0 and post_fscore is not None - and post_fscore < threshold + and post_fscore < effective_threshold ): quality_gate_failed.add(asset_id) progress.console.print( f" [yellow]⚠ {fname}: Frigate score {post_fscore:.2f}" - f" < threshold {threshold:.2f}, will remove after mapping[/yellow]" + f" < threshold {effective_threshold:.2f}, will remove after mapping[/yellow]" ) break @@ -556,7 +565,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: if removed: progress.console.print( f" [yellow]🗑 {name}: removed {removed} image(s) below" - f" Frigate score threshold ({Config.FRIGATE_SCORE_THRESHOLD:.2f})[/yellow]" + f" Frigate score threshold ({effective_threshold:.2f})[/yellow]" ) # Per-person summary diff --git a/winnow/upload_tracker.py b/winnow/upload_tracker.py index 3f9f025..ffd252f 100644 --- a/winnow/upload_tracker.py +++ b/winnow/upload_tracker.py @@ -250,6 +250,21 @@ def get_lowest_quality_mapped_file( return min(candidates, key=lambda x: x[2]) +def get_min_frigate_score(person_name: str) -> float | None: + """Return the lowest stored Frigate recognition score for this person's mapped files. + + Returns None if no Frigate scores have been recorded yet (cold start or + feature not yet active). Used to derive a dynamic quality threshold so new + uploads must score at least as well as the weakest image already in the set. + """ + data = _load(UPLOAD_TRACKER_FILE) + entry = _migrate_entry(data.get("by_person", {}).get(person_name, {})) + frigate_files = entry.get("frigate_files", {}) + frigate_scores = entry.get("frigate_scores", {}) + scored = [frigate_scores[aid] for aid in frigate_files.values() if aid in frigate_scores] + return min(scored) if scored else None + + def get_frigate_filename_for_asset(person_name: str, asset_id: str) -> str | None: """Return the Frigate training filename mapped to this asset ID, or None.""" data = _load(UPLOAD_TRACKER_FILE) From 903d7f1054f33eab876fd1cbcb7d1ec64268e584 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 16:41:31 +0000 Subject: [PATCH 08/14] fix: quality gate disabled at threshold 0; batch deletions; summary shows net count - FRIGATE_SCORE_THRESHOLD=0.0 now fully disables the quality gate including the dynamic floor; a positive value is required to activate either - Post-reconcile gate deletions are batched into one API call per person instead of one call per file - Per-person summary reports gate removals and net uploaded count when the gate fires; grand summary includes total removed across all people - .env.example comment updated to match the corrected opt-in behaviour Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 2 +- winnow/executor.py | 61 +++++++++++++++++++++++++++++----------------- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index da0f54c..81e3aaf 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,7 @@ STRATEGY=auto # MIN_CONFIDENCE=0.7 # Minimum face detection confidence (default: 0.7) # BLUR_THRESHOLD=120.0 # Laplacian blur threshold; lower = accept more blur (default: 120.0) # MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80) -# FRIGATE_SCORE_THRESHOLD=0.0 # Remove uploaded images scoring below this after upload (0 = disabled; skipped on cold start) +# FRIGATE_SCORE_THRESHOLD=0.0 # Quality gate: remove images scoring below this after upload (0 = disabled; requires at least one prior run) # ── Caching & Models ────────────────────────────────────────────────────────── # FORCE_CPU=true # Disable GPU, fall back to CPU diff --git a/winnow/executor.py b/winnow/executor.py index 626c242..eeab846 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -305,7 +305,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: rprint(f" People: [bold]{len(face_jobs)}[/bold], Total images: [bold]{total_files}[/bold]") - uploaded, failed = 0, 0 + uploaded, failed, gate_total = 0, 0, 0 max_retries = 2 with Progress( @@ -360,15 +360,15 @@ def upload_to_frigate(jobs: list[dict]) -> None: effective_count = get_tracked_frigate_file_count(name) pre_run_count = effective_count quality_replacement = job.get("config", {}).get("quality_replacement", False) - # Dynamic threshold: at least as strict as the weakest image already stored. - # Takes whichever is higher — the configured floor or the current set minimum. - _dynamic = get_min_frigate_score(name) - effective_threshold = max( - Config.FRIGATE_SCORE_THRESHOLD, - _dynamic if _dynamic is not None else 0.0, - ) - if _dynamic is not None and _dynamic > Config.FRIGATE_SCORE_THRESHOLD: - logger.debug(f"{name}: dynamic Frigate score threshold {_dynamic:.3f}") + # Dynamic threshold: only active when FRIGATE_SCORE_THRESHOLD > 0. + # Zero means the gate is disabled — the dynamic floor does not activate. + if Config.FRIGATE_SCORE_THRESHOLD > 0: + _dynamic = get_min_frigate_score(name) + effective_threshold = max(Config.FRIGATE_SCORE_THRESHOLD, _dynamic or 0.0) + if _dynamic is not None and _dynamic > Config.FRIGATE_SCORE_THRESHOLD: + logger.debug(f"{name}: dynamic Frigate score threshold {_dynamic:.3f}") + else: + effective_threshold = 0.0 actually_uploaded: list[tuple[str, str | None]] = [] failed_deletes: set[str] = set() quality_gate_failed: set[str] = set() @@ -549,33 +549,46 @@ def upload_to_frigate(jobs: list[dict]) -> None: _reconcile_frigate_mappings(name, known_frigate_files_at_start, actually_uploaded) # Post-reconcile quality gate: filenames are now mapped, so we can delete. + gate_removed = 0 if quality_gate_failed: - removed = 0 + to_delete: list[tuple[str, str]] = [] # (frigate_fn, asset_id) for asset_id in quality_gate_failed: frigate_fn = get_frigate_filename_for_asset(name, asset_id) - if frigate_fn and delete_frigate_person_files(name, [frigate_fn]): - remove_frigate_file(name, frigate_fn) - effective_count -= 1 - removed += 1 + if frigate_fn: + to_delete.append((frigate_fn, asset_id)) else: logger.warning( f"{name}: could not remove low-score file for {asset_id}" " — no Frigate filename mapped (reconciliation race?)" ) - if removed: - progress.console.print( - f" [yellow]🗑 {name}: removed {removed} image(s) below" - f" Frigate score threshold ({effective_threshold:.2f})[/yellow]" - ) + if to_delete: + if delete_frigate_person_files(name, [fn for fn, _ in to_delete]): + for frigate_fn, _aid in to_delete: + remove_frigate_file(name, frigate_fn) + gate_removed = len(to_delete) + effective_count -= gate_removed + gate_total += gate_removed + else: + logger.warning( + f"{name}: batch delete of {len(to_delete)} low-score file(s) failed" + ) # Per-person summary - if person_failed == 0: + if person_failed == 0 and gate_removed == 0: progress.console.print( f" ✅ {name}: {person_uploaded}/{person_uploaded} uploaded" ) - else: + elif person_failed == 0: + net = person_uploaded - gate_removed progress.console.print( - f" ⚠️ {name}: {person_uploaded} succeeded, {person_failed} failed" + f" [yellow]✅ {name}: {person_uploaded} uploaded," + f" {gate_removed} removed by quality gate (score < {effective_threshold:.2f})" + f" → {net} net[/yellow]" + ) + else: + gate_note = f", {gate_removed} removed by quality gate" if gate_removed else "" + progress.console.print( + f" ⚠️ {name}: {person_uploaded} succeeded, {person_failed} failed{gate_note}" ) # Grand summary @@ -585,6 +598,8 @@ def upload_to_frigate(jobs: list[dict]) -> None: rprint(f" ❌ Failed: [red]{failed}[/red]") else: rprint(" ❌ Failed: 0") + if gate_total: + rprint(f" 🗑 Removed (quality gate): [yellow]{gate_total}[/yellow]") if failed > 0: rprint(" [yellow]Check logs above for per-file error details.[/yellow]") From ef5934d1af5bc310aca9e047b9885d39831fca77 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 16:51:44 +0000 Subject: [PATCH 09/14] fix: stale frigate mapping reconciliation, recognize opt-out, cold start notice - At upload start, diff tracker vs live Frigate file list and remove any mappings for files no longer present; corrects effective_count so manually deleted files don't permanently consume quota slots - Add ENABLE_FRIGATE_SCORES config (default true); when false, skips all recognize_face calls and falls back to blur scores for quality replacement - Print a dim notice when FRIGATE_SCORE_THRESHOLD is set but pre_run_count is zero, so users know the gate is deferred to the next run Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 1 + winnow/config.py | 2 ++ winnow/executor.py | 19 +++++++++++++++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 81e3aaf..016e580 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,7 @@ STRATEGY=auto # BLUR_THRESHOLD=120.0 # Laplacian blur threshold; lower = accept more blur (default: 120.0) # MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80) # FRIGATE_SCORE_THRESHOLD=0.0 # Quality gate: remove images scoring below this after upload (0 = disabled; requires at least one prior run) +# ENABLE_FRIGATE_SCORES=true # Call Frigate's recognize endpoint after each upload to store quality scores (default: true; adds ~200ms per upload) # ── Caching & Models ────────────────────────────────────────────────────────── # FORCE_CPU=true # Disable GPU, fall back to CPU diff --git a/winnow/config.py b/winnow/config.py index 6bd4dff..784b853 100644 --- a/winnow/config.py +++ b/winnow/config.py @@ -32,6 +32,7 @@ class _Config: MAX_AUTO_IMAGES: int = 80 QUALITY_REPLACEMENT: bool = True FRIGATE_SCORE_THRESHOLD: float = 0.0 + ENABLE_FRIGATE_SCORES: bool = True # People filtering MIN_FACE_COUNT: int = 0 @@ -64,6 +65,7 @@ class _Config: self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "80")) self.QUALITY_REPLACEMENT = os.getenv("QUALITY_REPLACEMENT", "true").lower() in ("true", "1", "yes") self.FRIGATE_SCORE_THRESHOLD = float(os.getenv("FRIGATE_SCORE_THRESHOLD", "0.0")) + self.ENABLE_FRIGATE_SCORES = os.getenv("ENABLE_FRIGATE_SCORES", "true").lower() in ("true", "1", "yes") 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.ENABLE_FACE_ALIGNMENT = os.getenv("ENABLE_FACE_ALIGNMENT", "true").lower() in ("true", "1", "yes") diff --git a/winnow/executor.py b/winnow/executor.py index eeab846..1a84183 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -357,6 +357,17 @@ def upload_to_frigate(jobs: list[dict]) -> None: known_frigate_files_at_start: set[str] = get_tracked_frigate_filenames(name) else: known_frigate_files_at_start: set[str] = set(_snapshot) + # Remove tracker mappings for files that no longer exist in Frigate + # (manually deleted, or cleaned up outside winnow). This corrects the + # effective_count so those slots are available for new uploads. + stale = get_tracked_frigate_filenames(name) - known_frigate_files_at_start + for stale_fn in stale: + remove_frigate_file(name, stale_fn) + if stale: + progress.console.print( + f" [dim]{name}: cleared {len(stale)} stale mapping(s)" + " (file(s) no longer in Frigate)[/dim]" + ) effective_count = get_tracked_frigate_file_count(name) pre_run_count = effective_count quality_replacement = job.get("config", {}).get("quality_replacement", False) @@ -367,6 +378,10 @@ def upload_to_frigate(jobs: list[dict]) -> None: effective_threshold = max(Config.FRIGATE_SCORE_THRESHOLD, _dynamic or 0.0) if _dynamic is not None and _dynamic > Config.FRIGATE_SCORE_THRESHOLD: logger.debug(f"{name}: dynamic Frigate score threshold {_dynamic:.3f}") + if pre_run_count == 0: + progress.console.print( + f" [dim]{name}: first run — quality gate will apply from the next run[/dim]" + ) else: effective_threshold = 0.0 actually_uploaded: list[tuple[str, str | None]] = [] @@ -397,7 +412,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: progress.console.print(f" [dim]⏭ {fname}: at cap, quality replacement disabled[/dim]") progress.advance(upload_task) continue - using_fscore = has_frigate_scores(name) + using_fscore = has_frigate_scores(name) and Config.ENABLE_FRIGATE_SCORES if using_fscore: candidate_score = recognize_face(fpath) if candidate_score is None: @@ -458,7 +473,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: asset_id = asset_map.get(fname) if asset_id: - post_fscore = recognize_face(fpath) + post_fscore = recognize_face(fpath) if Config.ENABLE_FRIGATE_SCORES else None mark_uploaded( asset_id, person_name=name, From e0a5d98df6a272dc78ce554293f9b4946e263f67 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 17:06:19 +0000 Subject: [PATCH 10/14] fix: surface dynamic gate floor in normal output When the dynamic threshold (min stored Frigate score) raises the effective gate floor above the configured FRIGATE_SCORE_THRESHOLD, print it as a dim info line rather than only logging at DEBUG/VERBOSE level. Co-Authored-By: Claude Sonnet 4.6 --- winnow/executor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/winnow/executor.py b/winnow/executor.py index 1a84183..d576a68 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -377,7 +377,10 @@ def upload_to_frigate(jobs: list[dict]) -> None: _dynamic = get_min_frigate_score(name) effective_threshold = max(Config.FRIGATE_SCORE_THRESHOLD, _dynamic or 0.0) if _dynamic is not None and _dynamic > Config.FRIGATE_SCORE_THRESHOLD: - logger.debug(f"{name}: dynamic Frigate score threshold {_dynamic:.3f}") + progress.console.print( + f" [dim]{name}: quality gate floor raised to {_dynamic:.2f}" + f" (min stored score, above configured {Config.FRIGATE_SCORE_THRESHOLD:.2f})[/dim]" + ) if pre_run_count == 0: progress.console.print( f" [dim]{name}: first run — quality gate will apply from the next run[/dim]" From 785c9d4a22375bf1466b1ea7c8d4dd52e97ed8cc Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 17:10:30 +0000 Subject: [PATCH 11/14] =?UTF-8?q?feat:=20quality=20gate=20on=20by=20defaul?= =?UTF-8?q?t;=20fix=20replacement/gate=20conflict;=20gate-failed=20?= =?UTF-8?q?=E2=86=92=20rejected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dynamic floor now always active once Frigate scores exist — new images must score at least as well as the weakest image already in the set, with no config required. FRIGATE_SCORE_THRESHOLD adds an explicit absolute floor on top. Gate active state is surfaced in normal output for both cases. Quality replacement now pre-checks the gate threshold before deleting the worst image. If the candidate would fail the gate, replacement is skipped entirely rather than creating a net slot loss. Gate-failed assets are reclassified as rejected (moved from uploaded_asset_ids to rejected_asset_ids) so they are excluded from future runs without wasting API calls on re-upload. RESET_PERSON still clears rejected records for a true full reset. RETRY_REJECTED can recover them if the threshold is later lowered. Co-Authored-By: Claude Sonnet 4.6 --- winnow/executor.py | 36 +++++++++++++++++++++++++----------- winnow/upload_tracker.py | 18 ++++++++++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/winnow/executor.py b/winnow/executor.py index d576a68..d9c90c3 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -27,6 +27,7 @@ from .upload_tracker import ( has_frigate_scores, mark_rejected, mark_uploaded, + reclassify_as_rejected, record_frigate_file, remove_frigate_file, ) @@ -371,22 +372,25 @@ def upload_to_frigate(jobs: list[dict]) -> None: effective_count = get_tracked_frigate_file_count(name) pre_run_count = effective_count quality_replacement = job.get("config", {}).get("quality_replacement", False) - # Dynamic threshold: only active when FRIGATE_SCORE_THRESHOLD > 0. - # Zero means the gate is disabled — the dynamic floor does not activate. - if Config.FRIGATE_SCORE_THRESHOLD > 0: - _dynamic = get_min_frigate_score(name) - effective_threshold = max(Config.FRIGATE_SCORE_THRESHOLD, _dynamic or 0.0) - if _dynamic is not None and _dynamic > Config.FRIGATE_SCORE_THRESHOLD: + # Dynamic floor is always active once scores exist — new images must score + # at least as well as the weakest image already in the set. + # FRIGATE_SCORE_THRESHOLD adds an explicit absolute minimum on top. + _dynamic = get_min_frigate_score(name) + effective_threshold = max(Config.FRIGATE_SCORE_THRESHOLD, _dynamic or 0.0) + if _dynamic is not None and pre_run_count > 0: + if Config.FRIGATE_SCORE_THRESHOLD > 0 and _dynamic > Config.FRIGATE_SCORE_THRESHOLD: progress.console.print( f" [dim]{name}: quality gate floor raised to {_dynamic:.2f}" f" (min stored score, above configured {Config.FRIGATE_SCORE_THRESHOLD:.2f})[/dim]" ) - if pre_run_count == 0: + elif Config.FRIGATE_SCORE_THRESHOLD == 0: progress.console.print( - f" [dim]{name}: first run — quality gate will apply from the next run[/dim]" + f" [dim]{name}: quality gate active at {_dynamic:.2f} (min stored score)[/dim]" ) - else: - effective_threshold = 0.0 + if Config.ENABLE_FRIGATE_SCORES and pre_run_count == 0: + progress.console.print( + f" [dim]{name}: first run — quality gate will apply from the next run[/dim]" + ) actually_uploaded: list[tuple[str, str | None]] = [] failed_deletes: set[str] = set() quality_gate_failed: set[str] = set() @@ -434,6 +438,15 @@ def upload_to_frigate(jobs: list[dict]) -> None: progress.advance(upload_task) continue score_label = "blur" + # Skip replacement if candidate would fail the quality gate — + # deleting the worst then gating the new one is a net slot loss. + if using_fscore and effective_threshold > 0 and pre_run_count > 0 and candidate_score < effective_threshold: + progress.console.print( + f" [dim]⏭ {fname}: frigate {candidate_score:.3f} below gate threshold" + f" {effective_threshold:.2f}, skipping replacement[/dim]" + ) + progress.advance(upload_task) + continue worst = get_lowest_quality_mapped_file(name, exclude=failed_deletes) if worst is None or candidate_score <= worst[2]: worst_score_str = f"{worst[2]:.3f}" if worst is not None else "N/A" @@ -581,8 +594,9 @@ def upload_to_frigate(jobs: list[dict]) -> None: ) if to_delete: if delete_frigate_person_files(name, [fn for fn, _ in to_delete]): - for frigate_fn, _aid in to_delete: + for frigate_fn, aid in to_delete: remove_frigate_file(name, frigate_fn) + reclassify_as_rejected(aid, name) gate_removed = len(to_delete) effective_count -= gate_removed gate_total += gate_removed diff --git a/winnow/upload_tracker.py b/winnow/upload_tracker.py index ffd252f..3a297fa 100644 --- a/winnow/upload_tracker.py +++ b/winnow/upload_tracker.py @@ -147,6 +147,24 @@ def mark_rejected(asset_id: str, person_name: str | None = None) -> None: logger.debug(f"Marked {asset_id} as rejected ({person_name})") +def reclassify_as_rejected(asset_id: str, person_name: str | None = None) -> None: + """Move a gate-failed asset from the uploaded flat set to rejected. + + Preserves by_person history in the uploaded tracker (scores, crop dims, + etc.) but removes the asset from uploaded_asset_ids so it is excluded + from future candidate pools via the rejected tracker instead. + RESET_PERSON clears both trackers, so a full reset still re-evaluates + gate-failed images. + """ + data = _load(UPLOAD_TRACKER_FILE) + flat = set(data.get("uploaded_asset_ids", [])) + flat.discard(asset_id) + data["uploaded_asset_ids"] = sorted(flat) + _save(UPLOAD_TRACKER_FILE, data) + _mark(REJECT_TRACKER_FILE, asset_id, person_name) + logger.debug(f"Reclassified {asset_id} as gate-failed rejected ({person_name})") + + def record_frigate_file(person_name: str, frigate_filename: str, asset_id: str) -> None: """Record the mapping from a Frigate training filename to an Immich asset ID.""" data = _load(UPLOAD_TRACKER_FILE) From 110a45f4674edaa292631f4dd11d6ebf1df3bc4a Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 17:22:08 +0000 Subject: [PATCH 12/14] perf: batch GET /api/faces; skip download on low confidence; batch gate tracker writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetch all Frigate training files once before the upload loop instead of once per person — for N people this reduces GET /api/faces calls from N to 1. Falls back to per-person calls if the pre-fetch fails. Check InsightFace detection confidence immediately after face enrichment, before fetching the full-resolution image. Assets that fail MIN_CONFIDENCE are skipped without downloading, saving potentially large image downloads. Collapse the gate removal tracker writes from 3×N file ops into 2 total via remove_and_reclassify_batch: one write to the uploaded tracker (remove file mappings + remove from flat set) and one write to the rejected tracker. Co-Authored-By: Claude Sonnet 4.6 --- winnow/executor.py | 27 ++++++++++++++++++++++----- winnow/frigate_api.py | 16 ++++++++++++++++ winnow/upload_tracker.py | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/winnow/executor.py b/winnow/executor.py index d9c90c3..f29c664 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -13,7 +13,7 @@ from rich import print as rprint from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn from .config import Config, get_headers -from .frigate_api import delete_frigate_person_files, get_frigate_person_files, recognize_face +from .frigate_api import delete_frigate_person_files, get_all_frigate_person_files, get_frigate_person_files, recognize_face from .image_processing import process_face_mode, process_full_mode, process_object_mode from .immich_api import fetch_face_data, fetch_full_image from .log_config import console @@ -29,6 +29,7 @@ from .upload_tracker import ( mark_uploaded, reclassify_as_rejected, record_frigate_file, + remove_and_reclassify_batch, remove_frigate_file, ) @@ -186,6 +187,17 @@ def execute_jobs(jobs: list[dict]) -> None: # from the Immich faces API (not included in search/metadata results) if mode == "face": asset = _enrich_asset_with_face_data(asset, person) + # Skip download if detection confidence already disqualifies + # the asset — avoids fetching a large image we'll discard. + conf = asset.get("face_confidence") + if conf is not None and conf < Config.MIN_CONFIDENCE: + progress.console.print( + f"[yellow]Skipped {asset['id']}" + f" (confidence {conf:.2f} < {Config.MIN_CONFIDENCE})[/yellow]" + ) + progress.advance(job_task) + progress.advance(overall_task) + continue # Use full-resolution for final output when configured if use_full_res: @@ -309,6 +321,10 @@ def upload_to_frigate(jobs: list[dict]) -> None: uploaded, failed, gate_total = 0, 0, 0 max_retries = 2 + # Fetch all Frigate training files once — avoids one GET /api/faces per person. + # Falls back to per-person calls inside the loop if this fetch fails. + all_frigate_files = get_all_frigate_person_files() + with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), @@ -346,7 +362,10 @@ def upload_to_frigate(jobs: list[dict]) -> None: # Snapshot live Frigate files for post-upload reconciliation diff only. # effective_count is sourced from the tracker (mapped files) so that # manually-added Frigate files don't consume winnow's managed quota. - _snapshot = get_frigate_person_files(name) + _snapshot = ( + all_frigate_files.get(name, []) if all_frigate_files is not None + else get_frigate_person_files(name) + ) if _snapshot is None: # Frigate GET is down; fall back to the tracker's mapped filenames # as the pre-upload baseline. reconciliation will still work unless @@ -594,9 +613,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: ) if to_delete: if delete_frigate_person_files(name, [fn for fn, _ in to_delete]): - for frigate_fn, aid in to_delete: - remove_frigate_file(name, frigate_fn) - reclassify_as_rejected(aid, name) + remove_and_reclassify_batch(name, to_delete) gate_removed = len(to_delete) effective_count -= gate_removed gate_total += gate_removed diff --git a/winnow/frigate_api.py b/winnow/frigate_api.py index b3eef62..5e7f30f 100644 --- a/winnow/frigate_api.py +++ b/winnow/frigate_api.py @@ -40,6 +40,22 @@ def get_frigate_face_counts() -> dict[str, int] | None: } +def get_all_frigate_person_files() -> dict[str, list[str]] | None: + """Return {person_name: [filename, ...]} for every person in Frigate. + + Single call used to build per-person snapshots before the upload loop, + avoiding one GET /api/faces per person. Returns None if unavailable. + """ + data = _get_faces_data() + if data is None: + return None + return { + name: files + for name, files in data.items() + if name != "train" and isinstance(files, list) + } + + def get_frigate_person_files(person_name: str) -> list[str] | None: """Return the list of training filenames for a person in Frigate. diff --git a/winnow/upload_tracker.py b/winnow/upload_tracker.py index 3a297fa..bc720eb 100644 --- a/winnow/upload_tracker.py +++ b/winnow/upload_tracker.py @@ -147,6 +147,45 @@ def mark_rejected(asset_id: str, person_name: str | None = None) -> None: logger.debug(f"Marked {asset_id} as rejected ({person_name})") +def remove_and_reclassify_batch( + person_name: str, frigate_files_and_assets: list[tuple[str, str]] +) -> None: + """Remove Frigate file mappings and reclassify asset IDs as rejected in one pass. + + Replaces individual remove_frigate_file + reclassify_as_rejected calls in the + quality gate batch — 2 file writes total instead of 3×N. + """ + frigate_fns = [fn for fn, _ in frigate_files_and_assets] + asset_ids = [aid for _, aid in frigate_files_and_assets] + + # Uploaded tracker: remove file mappings + remove from flat set + uploaded_data = _load(UPLOAD_TRACKER_FILE) + flat_up = set(uploaded_data.get("uploaded_asset_ids", [])) + for aid in asset_ids: + flat_up.discard(aid) + uploaded_data["uploaded_asset_ids"] = sorted(flat_up) + by_person = uploaded_data.setdefault("by_person", {}) + entry = _migrate_entry(by_person.get(person_name, {})) + for fn in frigate_fns: + entry["frigate_files"].pop(fn, None) + by_person[person_name] = entry + _save(UPLOAD_TRACKER_FILE, uploaded_data) + + # Rejected tracker: add to flat set + by_person + rejected_data = _load(REJECT_TRACKER_FILE) + flat_rej = set(rejected_data.get("rejected_asset_ids", [])) + flat_rej.update(asset_ids) + rejected_data["rejected_asset_ids"] = sorted(flat_rej) + rej_by_person = rejected_data.setdefault("by_person", {}) + rej_entry = _migrate_entry(rej_by_person.get(person_name, {})) + ids = set(rej_entry["asset_ids"]) + ids.update(asset_ids) + rej_entry["asset_ids"] = sorted(ids) + rej_by_person[person_name] = rej_entry + _save(REJECT_TRACKER_FILE, rejected_data) + logger.debug(f"Batch-reclassified {len(asset_ids)} asset(s) as rejected ({person_name})") + + def reclassify_as_rejected(asset_id: str, person_name: str | None = None) -> None: """Move a gate-failed asset from the uploaded flat set to rejected. From ed045f07dd442d264f59e883eef9d92628797ee4 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 17:22:58 +0000 Subject: [PATCH 13/14] fix: label InsightFace skip as "detection confidence" to distinguish from Frigate score Co-Authored-By: Claude Sonnet 4.6 --- winnow/executor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/winnow/executor.py b/winnow/executor.py index f29c664..6e40d5f 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -193,7 +193,7 @@ def execute_jobs(jobs: list[dict]) -> None: if conf is not None and conf < Config.MIN_CONFIDENCE: progress.console.print( f"[yellow]Skipped {asset['id']}" - f" (confidence {conf:.2f} < {Config.MIN_CONFIDENCE})[/yellow]" + f" (detection confidence {conf:.2f} < {Config.MIN_CONFIDENCE})[/yellow]" ) progress.advance(job_task) progress.advance(overall_task) From 6fcea587ff976ec05eb9a8d402d40eaa6fe34190 Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 18:35:11 +0000 Subject: [PATCH 14/14] chore: bump version to 0.4.0, update changelog and all docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finalizes the 0.4.0 release: - Version bumped to 0.4.0 in pyproject.toml - CHANGELOG.md: add [0.4.0] section covering Frigate pre-upload scoring, quality replacement inversion, bootstrap fix, FRIGATE_SCORE_CEILING, ENABLE_FRIGATE_SCORES, removal of post-upload quality gate, and all doc/default corrections - README.md: step 8 updated for dual-mode replacement, FRIGATE_SCORE_CEILING and ENABLE_FRIGATE_SCORES added to env var table, MIN_FACE_WIDTH and BLUR_THRESHOLD defaults corrected (50→90, 100→120) - .env.example: FRIGATE_SCORE_THRESHOLD replaced with FRIGATE_SCORE_CEILING; QUALITY_REPLACEMENT line added; comments updated to match current semantics - winnow/executor.py: bootstrap fix — recognize now called for all below-cap uploads when ENABLE_FRIGATE_SCORES=true (was gated on CEILING > 0) - winnow/upload_tracker.py: frigate_scores schema comment corrected to pre-upload; get_most_redundant_mapped_file() added - winnow/frigate_api.py: recognize_face returns (face_name, score)|None tuple so wrong-person scores never drive replacement or ceiling decisions - winnow/config.py: FRIGATE_SCORE_THRESHOLD renamed to FRIGATE_SCORE_CEILING; ENABLE_FRIGATE_SCORES added - tests/test_upload_tracker.py: 4 new tests for get_most_redundant_mapped_file Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 5 +- CHANGELOG.md | 31 ++++++ README.md | 19 ++-- pyproject.toml | 2 +- tests/test_upload_tracker.py | 42 ++++++++ winnow/config.py | 4 +- winnow/executor.py | 193 +++++++++++++++-------------------- winnow/frigate_api.py | 11 +- winnow/upload_tracker.py | 125 ++++++----------------- 9 files changed, 213 insertions(+), 219 deletions(-) diff --git a/.env.example b/.env.example index 016e580..9aa6d33 100644 --- a/.env.example +++ b/.env.example @@ -30,8 +30,9 @@ STRATEGY=auto # MIN_CONFIDENCE=0.7 # Minimum face detection confidence (default: 0.7) # BLUR_THRESHOLD=120.0 # Laplacian blur threshold; lower = accept more blur (default: 120.0) # MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 80) -# FRIGATE_SCORE_THRESHOLD=0.0 # Quality gate: remove images scoring below this after upload (0 = disabled; requires at least one prior run) -# ENABLE_FRIGATE_SCORES=true # Call Frigate's recognize endpoint after each upload to store quality scores (default: true; adds ~200ms per upload) +# QUALITY_REPLACEMENT=true # At cap, replace a weaker tracked image with a better candidate (default: true) +# FRIGATE_SCORE_CEILING=0.0 # Skip uploads already well-covered (pre-upload score > ceiling = redundant; 0 = disabled; requires at least one prior run) +# ENABLE_FRIGATE_SCORES=true # Call Frigate's recognize endpoint pre-upload to store diversity scores (default: true; adds ~200ms per upload) # ── Caching & Models ────────────────────────────────────────────────────────── # FORCE_CPU=true # Disable GPU, fall back to CPU diff --git a/CHANGELOG.md b/CHANGELOG.md index a23d354..484bcd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] - 2026-06-13 + +### Added + +- **Pre-upload Frigate recognition scores**: `recognize_face` is now called before each upload to measure how novel the candidate is relative to the existing training set. The score is stored in the tracker (`frigate_scores` field) and drives quality replacement in subsequent runs. Adds ~200 ms per upload. +- **`ENABLE_FRIGATE_SCORES`** (default `true`): controls all pre-upload Frigate recognize calls. Set `false` to use blur-score replacement only and skip the Frigate round-trip entirely. +- **`FRIGATE_SCORE_CEILING`** (default `0.0`): skip uploads whose pre-upload recognize score already exceeds this value — those face conditions are already well-covered by the training set. `0` disables (no ceiling); requires at least one prior run to have stored scores. +- **`get_most_redundant_mapped_file()`**: new upload-tracker function that returns the mapped file with the highest Frigate pre-upload score. High score = the training set already covers that face condition well = the best deletion target for quality replacement. +- **Cold-start notice**: first run (no existing Frigate model) now logs a clear message explaining why Frigate scores are unavailable and that they will populate on subsequent runs. +- **4 new tests** for `get_most_redundant_mapped_file` covering score ordering, ties, excludes, and no-score cases. + +### Changed + +- **Quality replacement now uses Frigate scores**: when Frigate scores are available, at-cap replacement targets the _most redundant_ mapped file (highest pre-upload score) and replaces it only when the candidate is _more novel_ (lower score). Falls back to blur-score comparison when no Frigate scores have been stored yet. +- **`recognize_face` returns `(face_name, score) | None`** instead of `float | None`: the caller now validates that the recognized person matches the expected person before using the score. Wrong-person scores no longer drive ceiling skips or replacement decisions. +- **Bootstrap fix**: recognize was previously called below-cap only when `FRIGATE_SCORE_CEILING > 0`, so `frigate_scores` was never populated with default settings and the Frigate replacement path never activated. Recognize is now called for all below-cap uploads when `ENABLE_FRIGATE_SCORES=true`, seeding scores for future at-cap runs regardless of ceiling setting. +- **Batch GET `/api/faces`**: Frigate file-count lookups are now batched to reduce round-trip overhead on runs with many people. +- **Skip candidate download on low Frigate confidence**: candidates where the Immich detection confidence is below threshold are now filtered before the full-resolution download, saving bandwidth. + +### Removed + +- **Post-upload quality gate (`FRIGATE_SCORE_THRESHOLD`)**: enforcement of a Frigate score threshold after upload has been removed. Post-upload scores are taken after the image is already in the training set, so the model has already retrained on it — deleting it at that point is wasteful and disrupts the model for the next Frigate run. Pre-upload scoring (`FRIGATE_SCORE_CEILING`) provides a cleaner signal at the right moment. + +### Fixed + +- **Frigate replacement path never activated with default settings**: with `FRIGATE_SCORE_CEILING=0.0` (default), the bootstrap call to `recognize_face` was gated behind `CEILING > 0`, so `frigate_scores` stayed empty, `has_frigate_scores` was always False, and the Frigate replacement branch was permanently unreachable. Removing the ceiling guard from the below-cap recognize call breaks the circular dependency. +- **Schema comment contradiction**: `upload_tracker.py` line-16 comment described `frigate_scores` as "post-upload" while the block comment on lines 22–24 said "pre-upload". Corrected to "pre-upload" throughout. +- **README default values**: `MIN_FACE_WIDTH` was documented as `50` (actual default: `90`); `BLUR_THRESHOLD` was documented as `100.0` (actual default: `120.0`). Both corrected. +- **README missing env vars**: `FRIGATE_SCORE_CEILING` and `ENABLE_FRIGATE_SCORES` were present in `config.py` and `.env.example` but absent from the README env var table. Both added. +- **README quality-replacement description**: Step 8 and the `QUALITY_REPLACEMENT` row now document the dual-mode behaviour (Frigate-score path and blur-score fallback) instead of describing only the original blur-score path. + ## [0.3.3] - 2026-06-13 ### Fixed diff --git a/README.md b/README.md index 5273dff..5641f39 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ [![Docker](https://github.com/sudolulo/winnow/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/sudolulo/winnow/actions/workflows/docker-publish.yml) [![Test](https://github.com/sudolulo/winnow/actions/workflows/test.yml/badge.svg)](https://github.com/sudolulo/winnow/actions/workflows/test.yml) [![GitHub release](https://img.shields.io/github/v/release/sudolulo/winnow)](https://github.com/sudolulo/winnow/releases/latest) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE) [![Immich](https://img.shields.io/badge/Immich-v1.106%2B-blueviolet)](https://immich.app) [![Frigate](https://img.shields.io/badge/Frigate-Ready-brightgreen)](https://frigate.video) +> **Early Development — Use With Caution** +> winnow is functional but still maturing. Features that modify your Frigate training data — quality replacement, stale mapping cleanup — can remove images from your dataset and are not yet battle-tested at scale. Review the logs after each run and keep backups of your Frigate face training directory until you are confident in the results. + **Docs:** [Setup](https://github.com/sudolulo/winnow/wiki/Setup) · [Troubleshooting](https://github.com/sudolulo/winnow/wiki/Troubleshooting) · [FAQ](https://github.com/sudolulo/winnow/wiki/FAQ) `winnow` pulls photos from your [Immich](https://immich.app) library, selects the most diverse and highest-quality subset using AI embeddings, and delivers them as training data for [Frigate](https://frigate.video)'s face recognition and object classification models. @@ -56,9 +59,11 @@ Immich library 8. Deliver • Face mode: upload crops to Frigate's face registration API ↳ below MAX_AUTO_IMAGES — upload freely - ↳ at cap + QUALITY_REPLACEMENT=true — swap the lowest-scoring tracked - image if the new candidate scores higher; manually added files are - never touched + ↳ at cap + QUALITY_REPLACEMENT=true — with Frigate scoring active, + swap the most redundant tracked image (highest pre-upload recognize + score) if the candidate is more novel (lower score); falling back to + blur-score comparison when no Frigate scores are available; manually + added files are never touched ↳ at cap + QUALITY_REPLACEMENT=false — skip this person • Object mode: save crops to disk → place into your Frigate data directory ``` @@ -183,14 +188,16 @@ In scheduled mode the process (and loaded models) stays resident between runs. T | Variable | Default | Description | | :--- | :--- | :--- | -| `MIN_FACE_WIDTH` | `50` | Minimum face crop width in pixels | +| `MIN_FACE_WIDTH` | `90` | Minimum face crop width in pixels | | `FACE_MARGIN` | `0.15` | Padding around bounding box crop (fraction of face size) | | `ENABLE_FACE_ALIGNMENT` | `true` | Align to ArcFace 112×112 format using facial landmarks | | `USE_FULL_RESOLUTION` | `true` | Download full-resolution originals rather than preview thumbnails | | `MIN_CONFIDENCE` | `0.7` | Minimum Immich face detection confidence | -| `BLUR_THRESHOLD` | `100.0` | Laplacian variance threshold — lower accepts more blur | +| `BLUR_THRESHOLD` | `120.0` | Laplacian variance threshold — lower accepts more blur | | `MAX_AUTO_IMAGES` | `80` | Maximum training images per person in Frigate | -| `QUALITY_REPLACEMENT` | `true` | When at cap, swap the lowest-scoring tracked image for a better candidate. Never touches manually added Frigate files. Set `false` to skip people already at cap | +| `QUALITY_REPLACEMENT` | `true` | When at cap, swap a weaker tracked image for a better candidate. With Frigate scoring active, targets the most redundant image (highest pre-upload recognize score); otherwise uses blur score. Never touches manually added Frigate files. Set `false` to skip people at cap | +| `FRIGATE_SCORE_CEILING` | `0.0` | Skip uploads whose pre-upload Frigate recognize score exceeds this value — they are already well-covered. `0` disables; requires at least one prior run to have scores | +| `ENABLE_FRIGATE_SCORES` | `true` | Call Frigate's recognize endpoint pre-upload to store diversity scores used for quality replacement. Adds ~200 ms per upload. Disable to use blur-score replacement only | ### GPU & Models diff --git a/pyproject.toml b/pyproject.toml index fa16cc8..e65e885 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "winnow" -version = "0.3.3" +version = "0.4.0" description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition and object classification." license = "AGPL-3.0-or-later" requires-python = ">=3.13" diff --git a/tests/test_upload_tracker.py b/tests/test_upload_tracker.py index fcdd37c..f5afb9c 100644 --- a/tests/test_upload_tracker.py +++ b/tests/test_upload_tracker.py @@ -218,3 +218,45 @@ def test_get_lowest_quality_exclude_all_returns_none(): mark_uploaded("asset-a", person_name="Alice", score=0.50) record_frigate_file("Alice", "Alice-a.webp", "asset-a") assert get_lowest_quality_mapped_file("Alice", exclude={"Alice-a.webp"}) is None + + +# ── get_most_redundant_mapped_file ──────────────────────────────────────────── + +def test_get_most_redundant_none_when_no_frigate_scores(): + from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_file + mark_uploaded("asset-a", person_name="Alice", score=0.80) + record_frigate_file("Alice", "Alice-a.webp", "asset-a") + # blur score only, no frigate_score → no candidates + assert get_most_redundant_mapped_file("Alice") is None + + +def test_get_most_redundant_returns_highest_frigate_score(): + from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_file + mark_uploaded("asset-novel", person_name="Alice", score=0.50, frigate_score=0.31) + mark_uploaded("asset-redundant", person_name="Alice", score=0.90, frigate_score=0.88) + record_frigate_file("Alice", "Alice-novel.webp", "asset-novel") + record_frigate_file("Alice", "Alice-redundant.webp", "asset-redundant") + result = get_most_redundant_mapped_file("Alice") + assert result is not None + frigate_filename, asset_id, score = result + assert frigate_filename == "Alice-redundant.webp" + assert asset_id == "asset-redundant" + assert score == pytest.approx(0.88, abs=0.001) + + +def test_get_most_redundant_exclude_skips_file(): + from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_file + mark_uploaded("asset-hi", person_name="Alice", score=0.9, frigate_score=0.85) + mark_uploaded("asset-lo", person_name="Alice", score=0.5, frigate_score=0.40) + record_frigate_file("Alice", "Alice-hi.webp", "asset-hi") + record_frigate_file("Alice", "Alice-lo.webp", "asset-lo") + result = get_most_redundant_mapped_file("Alice", exclude={"Alice-hi.webp"}) + assert result is not None + assert result[1] == "asset-lo" # hi excluded; lo is next highest + + +def test_get_most_redundant_exclude_all_returns_none(): + from winnow.upload_tracker import get_most_redundant_mapped_file, mark_uploaded, record_frigate_file + mark_uploaded("asset-a", person_name="Alice", score=0.5, frigate_score=0.70) + record_frigate_file("Alice", "Alice-a.webp", "asset-a") + assert get_most_redundant_mapped_file("Alice", exclude={"Alice-a.webp"}) is None diff --git a/winnow/config.py b/winnow/config.py index 784b853..198a030 100644 --- a/winnow/config.py +++ b/winnow/config.py @@ -31,7 +31,7 @@ class _Config: MIN_CONFIDENCE: float = 0.7 MAX_AUTO_IMAGES: int = 80 QUALITY_REPLACEMENT: bool = True - FRIGATE_SCORE_THRESHOLD: float = 0.0 + FRIGATE_SCORE_CEILING: float = 0.0 ENABLE_FRIGATE_SCORES: bool = True # People filtering @@ -64,7 +64,7 @@ class _Config: self.MIN_CONFIDENCE = float(os.getenv("MIN_CONFIDENCE", "0.7")) self.MAX_AUTO_IMAGES = int(os.getenv("MAX_AUTO_IMAGES", "80")) self.QUALITY_REPLACEMENT = os.getenv("QUALITY_REPLACEMENT", "true").lower() in ("true", "1", "yes") - self.FRIGATE_SCORE_THRESHOLD = float(os.getenv("FRIGATE_SCORE_THRESHOLD", "0.0")) + self.FRIGATE_SCORE_CEILING = float(os.getenv("FRIGATE_SCORE_CEILING", "0.0")) self.ENABLE_FRIGATE_SCORES = os.getenv("ENABLE_FRIGATE_SCORES", "true").lower() in ("true", "1", "yes") 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") diff --git a/winnow/executor.py b/winnow/executor.py index 6e40d5f..4b01046 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -19,17 +19,14 @@ from .immich_api import fetch_face_data, fetch_full_image from .log_config import console from .quality import assess_quality from .upload_tracker import ( - get_frigate_filename_for_asset, get_lowest_quality_mapped_file, - get_min_frigate_score, + get_most_redundant_mapped_file, get_tracked_frigate_file_count, get_tracked_frigate_filenames, has_frigate_scores, mark_rejected, mark_uploaded, - reclassify_as_rejected, record_frigate_file, - remove_and_reclassify_batch, remove_frigate_file, ) @@ -318,7 +315,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: rprint(f" People: [bold]{len(face_jobs)}[/bold], Total images: [bold]{total_files}[/bold]") - uploaded, failed, gate_total = 0, 0, 0 + uploaded, failed = 0, 0 max_retries = 2 # Fetch all Frigate training files once — avoids one GET /api/faces per person. @@ -391,28 +388,12 @@ def upload_to_frigate(jobs: list[dict]) -> None: effective_count = get_tracked_frigate_file_count(name) pre_run_count = effective_count quality_replacement = job.get("config", {}).get("quality_replacement", False) - # Dynamic floor is always active once scores exist — new images must score - # at least as well as the weakest image already in the set. - # FRIGATE_SCORE_THRESHOLD adds an explicit absolute minimum on top. - _dynamic = get_min_frigate_score(name) - effective_threshold = max(Config.FRIGATE_SCORE_THRESHOLD, _dynamic or 0.0) - if _dynamic is not None and pre_run_count > 0: - if Config.FRIGATE_SCORE_THRESHOLD > 0 and _dynamic > Config.FRIGATE_SCORE_THRESHOLD: - progress.console.print( - f" [dim]{name}: quality gate floor raised to {_dynamic:.2f}" - f" (min stored score, above configured {Config.FRIGATE_SCORE_THRESHOLD:.2f})[/dim]" - ) - elif Config.FRIGATE_SCORE_THRESHOLD == 0: - progress.console.print( - f" [dim]{name}: quality gate active at {_dynamic:.2f} (min stored score)[/dim]" - ) if Config.ENABLE_FRIGATE_SCORES and pre_run_count == 0: progress.console.print( - f" [dim]{name}: first run — quality gate will apply from the next run[/dim]" + f" [dim]{name}: first run — Frigate diversity scoring will apply from the next run[/dim]" ) actually_uploaded: list[tuple[str, str | None]] = [] failed_deletes: set[str] = set() - quality_gate_failed: set[str] = set() min_quality_score_for_slot: float | None = None for fname in person_files: @@ -433,21 +414,75 @@ def upload_to_frigate(jobs: list[dict]) -> None: continue at_cap = effective_count >= Config.MAX_AUTO_IMAGES + + # Pre-upload Frigate score — clean measurement (image not yet in training set). + # Called for all below-cap uploads (seeds frigate_scores for future at-cap + # replacement) and for at-cap uploads when scores already exist. Skipped on + # the first run (pre_run_count == 0) since Frigate has no model yet. + # recognize_face returns (face_name, score); we only use the score when the + # best match is for the correct person. Mismatches (or "unknown") are treated + # as None so a wrong-person score never drives a ceiling skip or replacement. + # Frigate rebuilds its model asynchronously after any delete (clear + background + # thread), so the first recognize call after a deletion returns None — our code + # handles this conservatively by skipping that candidate until the next run. + pre_fscore: float | None = None + if Config.ENABLE_FRIGATE_SCORES and pre_run_count > 0: + if not at_cap or has_frigate_scores(name): + _result = recognize_face(fpath) + if _result is not None and _result[0] == name: + pre_fscore = _result[1] + + # Ceiling check: skip if the existing training set already covers this + # face condition well. Applies below cap only — at cap, replacement logic + # drives the decision. + if not at_cap and Config.FRIGATE_SCORE_CEILING > 0 and pre_run_count > 0: + if pre_fscore is not None and pre_fscore > Config.FRIGATE_SCORE_CEILING: + progress.console.print( + f" [dim]⏭ {fname}: Frigate score {pre_fscore:.2f}" + f" > ceiling {Config.FRIGATE_SCORE_CEILING:.2f}, already covered[/dim]" + ) + progress.advance(upload_task) + continue + if at_cap: if not quality_replacement: progress.console.print(f" [dim]⏭ {fname}: at cap, quality replacement disabled[/dim]") progress.advance(upload_task) continue + using_fscore = has_frigate_scores(name) and Config.ENABLE_FRIGATE_SCORES if using_fscore: - candidate_score = recognize_face(fpath) + candidate_score = pre_fscore if candidate_score is None: progress.console.print( f" [dim]⏭ {fname}: Frigate recognize unavailable, skipping replacement[/dim]" ) progress.advance(upload_task) continue - score_label = "frigate" + # Low score = more novel than the most redundant mapped file = replace + target = get_most_redundant_mapped_file(name, exclude=failed_deletes) + if target is None or candidate_score >= target[2]: + target_score_str = f"{target[2]:.3f}" if target is not None else "N/A" + progress.console.print( + f" [dim]⏭ {fname}: frigate {candidate_score:.3f} ≥ most redundant" + f" {target_score_str}, not more novel[/dim]" + ) + progress.advance(upload_task) + continue + target_frigate_file, _target_asset_id, target_score = target + progress.console.print( + f" 🔄 {fname}: frigate {candidate_score:.3f} < {target_score:.3f}," + f" replacing {target_frigate_file} (more novel)" + ) + if delete_frigate_person_files(name, [target_frigate_file]): + remove_frigate_file(name, target_frigate_file) + effective_count -= 1 + min_quality_score_for_slot = None # clear any blur-mode slot floor — Frigate uses a different score metric + else: + logger.warning(f"Failed to delete {target_frigate_file} for {name}, skipping replacement") + failed_deletes.add(target_frigate_file) + progress.advance(upload_task) + continue else: candidate_score = score_map.get(fname) if candidate_score is None: @@ -456,41 +491,29 @@ def upload_to_frigate(jobs: list[dict]) -> None: ) progress.advance(upload_task) continue - score_label = "blur" - # Skip replacement if candidate would fail the quality gate — - # deleting the worst then gating the new one is a net slot loss. - if using_fscore and effective_threshold > 0 and pre_run_count > 0 and candidate_score < effective_threshold: + target = get_lowest_quality_mapped_file(name, exclude=failed_deletes) + if target is None or candidate_score <= target[2]: + target_score_str = f"{target[2]:.3f}" if target is not None else "N/A" + progress.console.print( + f" [dim]⏭ {fname}: blur {candidate_score:.3f} ≤ worst" + f" {target_score_str}, skipping[/dim]" + ) + progress.advance(upload_task) + continue + target_frigate_file, _target_asset_id, target_score = target progress.console.print( - f" [dim]⏭ {fname}: frigate {candidate_score:.3f} below gate threshold" - f" {effective_threshold:.2f}, skipping replacement[/dim]" + f" 🔄 {fname}: blur {candidate_score:.3f} > {target_score:.3f}," + f" replacing {target_frigate_file}" ) - progress.advance(upload_task) - continue - worst = get_lowest_quality_mapped_file(name, exclude=failed_deletes) - if worst is None or candidate_score <= worst[2]: - worst_score_str = f"{worst[2]:.3f}" if worst is not None else "N/A" - progress.console.print( - f" [dim]⏭ {fname}: {score_label} {candidate_score:.3f} ≤ worst" - f" {worst_score_str}, skipping[/dim]" - ) - progress.advance(upload_task) - continue - worst_frigate_file, _worst_asset_id, worst_score = worst - progress.console.print( - f" 🔄 {fname}: {score_label} {candidate_score:.3f} > {worst_score:.3f}," - f" replacing {worst_frigate_file}" - ) - if delete_frigate_person_files(name, [worst_frigate_file]): - remove_frigate_file(name, worst_frigate_file) - effective_count -= 1 - # Slot floor guard uses blur scores only — frigate_score mode - # will re-evaluate the next candidate via recognize_face anyway. - min_quality_score_for_slot = score_map.get(fname) if not using_fscore else None - else: - logger.warning(f"Failed to delete {worst_frigate_file} for {name}, skipping replacement") - failed_deletes.add(worst_frigate_file) - progress.advance(upload_task) - continue + if delete_frigate_person_files(name, [target_frigate_file]): + remove_frigate_file(name, target_frigate_file) + effective_count -= 1 + min_quality_score_for_slot = score_map.get(fname) + else: + logger.warning(f"Failed to delete {target_frigate_file} for {name}, skipping replacement") + failed_deletes.add(target_frigate_file) + progress.advance(upload_task) + continue for attempt in range(1, max_retries + 1): try: @@ -508,31 +531,15 @@ def upload_to_frigate(jobs: list[dict]) -> None: asset_id = asset_map.get(fname) if asset_id: - post_fscore = recognize_face(fpath) if Config.ENABLE_FRIGATE_SCORES else None mark_uploaded( asset_id, person_name=name, score=score_map.get(fname), crop_dims=dims_map.get(fname), - frigate_score=post_fscore, + frigate_score=pre_fscore, ) actually_uploaded.append((fname, asset_id)) - # Flag for post-reconcile removal if below threshold. - # We don't know the Frigate filename yet — reconcile maps - # it first, then we delete using the mapped name. - if ( - effective_threshold > 0 - and pre_run_count > 0 - and post_fscore is not None - and post_fscore < effective_threshold - ): - quality_gate_failed.add(asset_id) - progress.console.print( - f" [yellow]⚠ {fname}: Frigate score {post_fscore:.2f}" - f" < threshold {effective_threshold:.2f}, will remove after mapping[/yellow]" - ) - break else: if attempt < max_retries: @@ -598,46 +605,14 @@ def upload_to_frigate(jobs: list[dict]) -> None: if actually_uploaded: _reconcile_frigate_mappings(name, known_frigate_files_at_start, actually_uploaded) - # Post-reconcile quality gate: filenames are now mapped, so we can delete. - gate_removed = 0 - if quality_gate_failed: - to_delete: list[tuple[str, str]] = [] # (frigate_fn, asset_id) - for asset_id in quality_gate_failed: - frigate_fn = get_frigate_filename_for_asset(name, asset_id) - if frigate_fn: - to_delete.append((frigate_fn, asset_id)) - else: - logger.warning( - f"{name}: could not remove low-score file for {asset_id}" - " — no Frigate filename mapped (reconciliation race?)" - ) - if to_delete: - if delete_frigate_person_files(name, [fn for fn, _ in to_delete]): - remove_and_reclassify_batch(name, to_delete) - gate_removed = len(to_delete) - effective_count -= gate_removed - gate_total += gate_removed - else: - logger.warning( - f"{name}: batch delete of {len(to_delete)} low-score file(s) failed" - ) - # Per-person summary - if person_failed == 0 and gate_removed == 0: + if person_failed == 0: progress.console.print( f" ✅ {name}: {person_uploaded}/{person_uploaded} uploaded" ) - elif person_failed == 0: - net = person_uploaded - gate_removed - progress.console.print( - f" [yellow]✅ {name}: {person_uploaded} uploaded," - f" {gate_removed} removed by quality gate (score < {effective_threshold:.2f})" - f" → {net} net[/yellow]" - ) else: - gate_note = f", {gate_removed} removed by quality gate" if gate_removed else "" progress.console.print( - f" ⚠️ {name}: {person_uploaded} succeeded, {person_failed} failed{gate_note}" + f" ⚠️ {name}: {person_uploaded} succeeded, {person_failed} failed" ) # Grand summary @@ -647,8 +622,6 @@ def upload_to_frigate(jobs: list[dict]) -> None: rprint(f" ❌ Failed: [red]{failed}[/red]") else: rprint(" ❌ Failed: 0") - if gate_total: - rprint(f" 🗑 Removed (quality gate): [yellow]{gate_total}[/yellow]") if failed > 0: rprint(" [yellow]Check logs above for per-file error details.[/yellow]") diff --git a/winnow/frigate_api.py b/winnow/frigate_api.py index 5e7f30f..0fc4f24 100644 --- a/winnow/frigate_api.py +++ b/winnow/frigate_api.py @@ -69,8 +69,13 @@ def get_frigate_person_files(person_name: str) -> list[str] | None: return files if isinstance(files, list) else [] -def recognize_face(file_path: str) -> float | None: - """Submit an image to Frigate's recognize endpoint and return the confidence score. +def recognize_face(file_path: str) -> tuple[str | None, float] | None: + """Submit an image to Frigate's recognize endpoint. + + Returns (face_name, score) where face_name is the best-matching person + (may be "unknown" if below Frigate's confidence threshold) and score is + the sigmoid-mapped cosine similarity (0-1) against that person's mean + embedding. Returns None if FRIGATE_URL is unset, the API is unreachable, no face is detected, or face recognition is not enabled in Frigate. @@ -89,7 +94,7 @@ def recognize_face(file_path: str) -> float | None: return None data = resp.json() if data.get("success") and "score" in data: - return round(float(data["score"]), 4) + return (data.get("face_name"), round(float(data["score"]), 4)) return None except Exception as e: logger.debug(f"Frigate recognize failed for {file_path}: {e}") diff --git a/winnow/upload_tracker.py b/winnow/upload_tracker.py index bc720eb..3213c3b 100644 --- a/winnow/upload_tracker.py +++ b/winnow/upload_tracker.py @@ -13,16 +13,15 @@ by_person schema (frigate_uploaded_ids.json): { "asset_ids": ["immich-id-1", ...], # all assets we attempted to upload "scores": {"immich-id-1": 450.3}, # Laplacian blur variance at upload time - "frigate_scores": {"immich-id-1": 0.87}, # Frigate recognition confidence (0-1) post-upload + "frigate_scores": {"immich-id-1": 0.87}, # Frigate recognition confidence (0-1) pre-upload "frigate_files": {"PersonName-123.webp": "immich-id-1"}, # Frigate filename → asset ID "crop_dims": {"immich-id-1": [640, 480]}, # crop pixel dimensions at upload time "frigate_count": 42 # last known Frigate training image count } -frigate_scores uses the same 0-1 sigmoid-mapped cosine similarity that Frigate -displays in its UI. When available, quality replacement uses frigate_scores in -preference to blur scores — an image Frigate cannot recognize is a poor training -image regardless of sharpness. +frigate_scores stores pre-upload recognize scores (0-1 sigmoid-mapped cosine +similarity). High score = the existing training set already covers this face +condition well. Low score = a gap — novel/diverse for the training set. frigate_files only contains files winnow uploaded — files added manually through Frigate's UI are never mapped here and are never touched by quality replacement. @@ -147,62 +146,6 @@ def mark_rejected(asset_id: str, person_name: str | None = None) -> None: logger.debug(f"Marked {asset_id} as rejected ({person_name})") -def remove_and_reclassify_batch( - person_name: str, frigate_files_and_assets: list[tuple[str, str]] -) -> None: - """Remove Frigate file mappings and reclassify asset IDs as rejected in one pass. - - Replaces individual remove_frigate_file + reclassify_as_rejected calls in the - quality gate batch — 2 file writes total instead of 3×N. - """ - frigate_fns = [fn for fn, _ in frigate_files_and_assets] - asset_ids = [aid for _, aid in frigate_files_and_assets] - - # Uploaded tracker: remove file mappings + remove from flat set - uploaded_data = _load(UPLOAD_TRACKER_FILE) - flat_up = set(uploaded_data.get("uploaded_asset_ids", [])) - for aid in asset_ids: - flat_up.discard(aid) - uploaded_data["uploaded_asset_ids"] = sorted(flat_up) - by_person = uploaded_data.setdefault("by_person", {}) - entry = _migrate_entry(by_person.get(person_name, {})) - for fn in frigate_fns: - entry["frigate_files"].pop(fn, None) - by_person[person_name] = entry - _save(UPLOAD_TRACKER_FILE, uploaded_data) - - # Rejected tracker: add to flat set + by_person - rejected_data = _load(REJECT_TRACKER_FILE) - flat_rej = set(rejected_data.get("rejected_asset_ids", [])) - flat_rej.update(asset_ids) - rejected_data["rejected_asset_ids"] = sorted(flat_rej) - rej_by_person = rejected_data.setdefault("by_person", {}) - rej_entry = _migrate_entry(rej_by_person.get(person_name, {})) - ids = set(rej_entry["asset_ids"]) - ids.update(asset_ids) - rej_entry["asset_ids"] = sorted(ids) - rej_by_person[person_name] = rej_entry - _save(REJECT_TRACKER_FILE, rejected_data) - logger.debug(f"Batch-reclassified {len(asset_ids)} asset(s) as rejected ({person_name})") - - -def reclassify_as_rejected(asset_id: str, person_name: str | None = None) -> None: - """Move a gate-failed asset from the uploaded flat set to rejected. - - Preserves by_person history in the uploaded tracker (scores, crop dims, - etc.) but removes the asset from uploaded_asset_ids so it is excluded - from future candidate pools via the rejected tracker instead. - RESET_PERSON clears both trackers, so a full reset still re-evaluates - gate-failed images. - """ - data = _load(UPLOAD_TRACKER_FILE) - flat = set(data.get("uploaded_asset_ids", [])) - flat.discard(asset_id) - data["uploaded_asset_ids"] = sorted(flat) - _save(UPLOAD_TRACKER_FILE, data) - _mark(REJECT_TRACKER_FILE, asset_id, person_name) - logger.debug(f"Reclassified {asset_id} as gate-failed rejected ({person_name})") - def record_frigate_file(person_name: str, frigate_filename: str, asset_id: str) -> None: """Record the mapping from a Frigate training filename to an Immich asset ID.""" @@ -265,61 +208,53 @@ def get_lowest_quality_mapped_file( person_name: str, exclude: set[str] | None = None ) -> tuple[str, str, float] | None: """Return (frigate_filename, asset_id, score) for the mapped file with the lowest - quality score, or None if no mapped files with known scores exist. + blur score, or None if no mapped files with known scores exist. - Uses Frigate recognition scores (0-1) when any are present for this person, - treating files without a Frigate score as 0.0. Falls back to Laplacian blur - scores when no Frigate scores exist yet. - - Pass `exclude` to skip files that failed to delete this run without removing - them from the tracker — they remain candidates on the next run. + Used for quality replacement when no Frigate scores are available. + Pass `exclude` to skip files that failed to delete this run. """ data = _load(UPLOAD_TRACKER_FILE) entry = _migrate_entry(data.get("by_person", {}).get(person_name, {})) frigate_files = entry.get("frigate_files", {}) blur_scores = entry.get("scores", {}) - frigate_scores = entry.get("frigate_scores", {}) - mapped = [ - (ff, asset_id) + candidates = [ + (ff, asset_id, blur_scores[asset_id]) for ff, asset_id in frigate_files.items() - if exclude is None or ff not in exclude + if (exclude is None or ff not in exclude) + and asset_id in blur_scores ] - if not mapped: - return None - - use_frigate = any(asset_id in frigate_scores for _, asset_id in mapped) - - if use_frigate: - candidates = [ - (ff, asset_id, frigate_scores.get(asset_id, 0.0)) - for ff, asset_id in mapped - ] - else: - candidates = [ - (ff, asset_id, blur_scores[asset_id]) - for ff, asset_id in mapped - if asset_id in blur_scores - ] if not candidates: return None return min(candidates, key=lambda x: x[2]) -def get_min_frigate_score(person_name: str) -> float | None: - """Return the lowest stored Frigate recognition score for this person's mapped files. +def get_most_redundant_mapped_file( + person_name: str, exclude: set[str] | None = None +) -> tuple[str, str, float] | None: + """Return (frigate_filename, asset_id, score) for the mapped file with the highest + Frigate recognition score, or None if no mapped files with Frigate scores exist. - Returns None if no Frigate scores have been recorded yet (cold start or - feature not yet active). Used to derive a dynamic quality threshold so new - uploads must score at least as well as the weakest image already in the set. + High Frigate score = the training set already covers this face condition well + = the most redundant file and therefore the best replacement target. + Pass `exclude` to skip files that failed to delete this run. """ data = _load(UPLOAD_TRACKER_FILE) entry = _migrate_entry(data.get("by_person", {}).get(person_name, {})) frigate_files = entry.get("frigate_files", {}) frigate_scores = entry.get("frigate_scores", {}) - scored = [frigate_scores[aid] for aid in frigate_files.values() if aid in frigate_scores] - return min(scored) if scored else None + + candidates = [ + (ff, asset_id, frigate_scores[asset_id]) + for ff, asset_id in frigate_files.items() + if (exclude is None or ff not in exclude) + and asset_id in frigate_scores + ] + + if not candidates: + return None + return max(candidates, key=lambda x: x[2]) def get_frigate_filename_for_asset(person_name: str, asset_id: str) -> str | None: