Merge dev into main: NOTICES + 0.2.13

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 05:55:59 +00:00
co-authored by Claude Sonnet 4.6
19 changed files with 450 additions and 73 deletions
+20 -20
View File
@@ -36,11 +36,11 @@ jobs:
steps:
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
echo "Disk space freed."
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL /usr/share/swift
sudo rm -rf "/usr/local/share/boost" "$AGENT_TOOLSDIRECTORY"
docker system prune -af
df -h
- name: Checkout repository
uses: actions/checkout@v6
@@ -148,11 +148,11 @@ jobs:
steps:
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
echo "Disk space freed."
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL /usr/share/swift
sudo rm -rf "/usr/local/share/boost" "$AGENT_TOOLSDIRECTORY"
docker system prune -af
df -h
- name: Checkout repository
uses: actions/checkout@v6
@@ -210,11 +210,11 @@ jobs:
steps:
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
echo "Disk space freed."
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL /usr/share/swift
sudo rm -rf "/usr/local/share/boost" "$AGENT_TOOLSDIRECTORY"
docker system prune -af
df -h
- name: Checkout repository
uses: actions/checkout@v6
@@ -272,11 +272,11 @@ jobs:
steps:
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
echo "Disk space freed."
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL /usr/share/swift
sudo rm -rf "/usr/local/share/boost" "$AGENT_TOOLSDIRECTORY"
docker system prune -af
df -h
- name: Checkout repository
uses: actions/checkout@v6
+9
View File
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.2.13] - 2026-06-13
### Added
- **Quality replacement**: when a person is at `MAX_AUTO_IMAGES`, winnow now checks each new candidate against the lowest-quality image already in Frigate and swaps it in if the new image scores higher. Only images winnow uploaded (tracked in `frigate_files`) are ever replaced — files added manually through Frigate's UI are left untouched permanently. Enabled by default; set `QUALITY_REPLACEMENT=false` to revert to the previous behaviour of skipping people at cap.
- **Frigate filename mapping**: each successful upload now records the mapping from Frigate's assigned filename to the originating Immich asset ID and face confidence score in the tracker (`frigate_files` field). This is the foundation for quality replacement and future management of the Frigate training set.
- **`QUALITY_REPLACEMENT` env var** (default `true`): controls whether at-cap people are eligible for quality replacement. When disabled, people at `MAX_AUTO_IMAGES` are skipped as before.
- **NOTICES file**: third-party attribution for if_curator (MIT, Copyright © 2026 Sebastian) added to satisfy upstream license requirements.
## [0.2.12] - 2026-06-13
### Added
+4 -3
View File
@@ -91,8 +91,9 @@ RUN if [ "$VARIANT" = "gpu" ]; then \
# Intel: install GPU compute runtime so OpenVINO EP can target Intel Arc / iGPU.
# onnxruntime-openvino bundles OpenVINO itself; only the userspace GPU driver
# (OpenCL ICD + Level Zero) is needed from the OS.
# libze-intel-gpu1 / intel-level-zero-gpu aren't in Ubuntu 22.04 main, so this
# block adds Intel's official graphics repo first, then installs.
# These packages aren't in Ubuntu 22.04 main, so this block adds Intel's
# official GPU repo first, then installs. libze-intel-gpu1 was renamed to
# level-zero in Intel's repo.
RUN if [ "$VARIANT" = "intel" ]; then \
apt-get update \
&& apt-get install -y --no-install-recommends curl gnupg \
@@ -103,7 +104,7 @@ https://repositories.intel.com/graphics/ubuntu jammy flex" \
> /etc/apt/sources.list.d/intel-graphics.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
intel-opencl-icd intel-level-zero-gpu libze-intel-gpu1 \
intel-opencl-icd intel-level-zero-gpu level-zero \
&& apt-get remove -y --autoremove curl gnupg \
&& rm -rf /var/lib/apt/lists/*; \
fi
+23
View File
@@ -0,0 +1,23 @@
winnow incorporates portions of if_curator (https://github.com/ds-sebastian/if_curator).
MIT License
Copyright (c) 2026 Sebastian
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+2 -1
View File
@@ -177,7 +177,8 @@ In scheduled mode the process (and loaded models) stays resident between runs. T
| `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 |
| `MAX_AUTO_IMAGES` | `80` | Maximum images selected in auto mode |
| `MAX_AUTO_IMAGES` | `80` | Maximum training images per person in Frigate |
| `QUALITY_REPLACEMENT` | `true` | When at cap, replace the lowest-quality mapped training image if a better candidate is found. Only affects images winnow uploaded — manually added Frigate training files are never touched. Set `false` to disable and skip people already at cap |
### GPU & Models
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "winnow"
version = "0.2.12"
version = "0.2.13"
description = "Immich to Frigate training sets"
license = "AGPL-3.0-or-later"
requires-python = ">=3.13"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "winnow"
version = "0.2.12"
version = "0.2.13"
description = "Immich to Frigate training sets"
license = "AGPL-3.0-or-later"
requires-python = ">=3.13"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "winnow"
version = "0.2.12"
version = "0.2.13"
description = "Immich to Frigate training sets"
license = "AGPL-3.0-or-later"
requires-python = ">=3.13"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "winnow"
version = "0.2.12"
version = "0.2.13"
description = "Immich to Frigate training sets"
license = "AGPL-3.0-or-later"
requires-python = ">=3.13"
+3
View File
@@ -22,6 +22,7 @@ def test_config_loads_defaults(monkeypatch):
assert cfg.BLUR_THRESHOLD == 100.0
assert cfg.MIN_CONFIDENCE == 0.7
assert cfg.MAX_AUTO_IMAGES == 80
assert cfg.QUALITY_REPLACEMENT is True
assert cfg.FACE_MARGIN == 0.15
assert cfg.USE_FULL_RESOLUTION is True
assert cfg.ENABLE_FACE_ALIGNMENT is True
@@ -39,6 +40,7 @@ def test_config_env_overrides(monkeypatch):
monkeypatch.setenv("BLUR_THRESHOLD", "50.0")
monkeypatch.setenv("MIN_CONFIDENCE", "0.9")
monkeypatch.setenv("MAX_AUTO_IMAGES", "40")
monkeypatch.setenv("QUALITY_REPLACEMENT", "false")
monkeypatch.setenv("FACE_MARGIN", "0.2")
monkeypatch.setenv("USE_FULL_RESOLUTION", "false")
monkeypatch.setenv("ENABLE_FACE_ALIGNMENT", "false")
@@ -54,6 +56,7 @@ def test_config_env_overrides(monkeypatch):
assert cfg.BLUR_THRESHOLD == 50.0
assert cfg.MIN_CONFIDENCE == 0.9
assert cfg.MAX_AUTO_IMAGES == 40
assert cfg.QUALITY_REPLACEMENT is False
assert cfg.FACE_MARGIN == 0.2
assert cfg.USE_FULL_RESOLUTION is False
assert cfg.ENABLE_FACE_ALIGNMENT is False
+10
View File
@@ -134,6 +134,16 @@ def test_assess_quality_passes_good_image():
img = _noisy_color_image()
result = assess_quality(img, face_bbox=(10, 10, 110, 110), confidence=0.9)
assert result.passed
assert result.blur_score is not None
assert result.blur_score > 0
def test_assess_quality_blur_score_is_low_for_flat_image():
from winnow.quality import assess_quality
flat = _rgb_image(128, 128, 128)
result = assess_quality(flat)
assert result.blur_score is not None
assert result.blur_score < 1.0
def test_assess_quality_collects_multiple_failures():
+87
View File
@@ -69,3 +69,90 @@ def test_duplicate_marks_are_idempotent():
mark_uploaded("dup", person_name="Alice")
mark_uploaded("dup", person_name="Alice")
assert filter_already_uploaded(["dup", "new"]) == ["new"]
# ── frigate_files mapping ─────────────────────────────────────────────────────
def test_record_and_remove_frigate_file():
from winnow.upload_tracker import get_person_summary, record_frigate_file, remove_frigate_file
record_frigate_file("Alice", "Alice-1000.webp", "asset-a1")
assert "Alice-1000.webp" in get_person_summary()["Alice"]["frigate_files"]
remove_frigate_file("Alice", "Alice-1000.webp")
assert "Alice-1000.webp" not in get_person_summary()["Alice"]["frigate_files"]
def test_remove_nonexistent_frigate_file_is_safe():
from winnow.upload_tracker import remove_frigate_file
# Should not raise even if the file was never recorded
remove_frigate_file("Alice", "Alice-ghost.webp")
def test_remove_frigate_file_does_not_unmark_asset():
"""Deleting a Frigate file should not re-expose the source asset for upload."""
from winnow.upload_tracker import (
filter_already_uploaded,
mark_uploaded,
record_frigate_file,
remove_frigate_file,
)
mark_uploaded("asset-a1", person_name="Alice")
record_frigate_file("Alice", "Alice-1000.webp", "asset-a1")
remove_frigate_file("Alice", "Alice-1000.webp")
# Asset must still be excluded — it was deliberately replaced, not lost
assert filter_already_uploaded(["asset-a1"]) == []
def test_get_tracked_frigate_file_count_zero_when_empty():
from winnow.upload_tracker import get_tracked_frigate_file_count
assert get_tracked_frigate_file_count("Alice") == 0
def test_get_tracked_frigate_file_count_counts_only_mapped():
"""Only files explicitly recorded via record_frigate_file count toward the cap."""
from winnow.upload_tracker import get_tracked_frigate_file_count, mark_uploaded, record_frigate_file
mark_uploaded("asset-a", person_name="Alice")
mark_uploaded("asset-b", person_name="Alice")
record_frigate_file("Alice", "Alice-1000.webp", "asset-a")
# asset-b is uploaded but not yet mapped — does not count
assert get_tracked_frigate_file_count("Alice") == 1
record_frigate_file("Alice", "Alice-1001.webp", "asset-b")
assert get_tracked_frigate_file_count("Alice") == 2
def test_get_lowest_quality_mapped_file_none_when_empty():
from winnow.upload_tracker import get_lowest_quality_mapped_file
assert get_lowest_quality_mapped_file("Alice") is None
def test_get_lowest_quality_mapped_file_returns_lowest():
from winnow.upload_tracker import (
get_lowest_quality_mapped_file,
mark_uploaded,
record_frigate_file,
)
mark_uploaded("asset-hi", person_name="Alice", score=0.95)
mark_uploaded("asset-lo", person_name="Alice", score=0.71)
record_frigate_file("Alice", "Alice-1000.webp", "asset-hi")
record_frigate_file("Alice", "Alice-1001.webp", "asset-lo")
result = get_lowest_quality_mapped_file("Alice")
assert result is not None
frigate_filename, asset_id, score = result
assert frigate_filename == "Alice-1001.webp"
assert asset_id == "asset-lo"
assert score == pytest.approx(0.71, abs=0.001)
def test_get_lowest_quality_mapped_file_skips_unscored():
"""Files mapped without a score should not be returned as candidates."""
from winnow.upload_tracker import (
get_lowest_quality_mapped_file,
mark_uploaded,
record_frigate_file,
)
mark_uploaded("asset-scored", person_name="Alice", score=0.85)
mark_uploaded("asset-noscr", person_name="Alice")
record_frigate_file("Alice", "Alice-1000.webp", "asset-scored")
record_frigate_file("Alice", "Alice-1001.webp", "asset-noscr")
result = get_lowest_quality_mapped_file("Alice")
assert result is not None
assert result[1] == "asset-scored" # only scored file is a candidate
+2
View File
@@ -30,6 +30,7 @@ class _Config:
BLUR_THRESHOLD: float = 100.0
MIN_CONFIDENCE: float = 0.7
MAX_AUTO_IMAGES: int = 80
QUALITY_REPLACEMENT: bool = True
# People filtering
MIN_FACE_COUNT: int = 0
@@ -60,6 +61,7 @@ class _Config:
self.BLUR_THRESHOLD = float(os.getenv("BLUR_THRESHOLD", "100.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")
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")
+1
View File
@@ -258,6 +258,7 @@ def _select_by_embedding(
logger.debug(f"Quality filtered {asset['id']}: {quality.reason}")
continue
asset["quality_score"] = quality.blur_score
face_crop = _crop_face_from_thumbnail(img, asset, person_id=person_id)
embed_img = face_crop if face_crop is not None else img
else:
+125 -3
View File
@@ -3,6 +3,7 @@
import logging
import os
import shutil
import time
from io import BytesIO
from urllib.parse import quote
@@ -12,14 +13,84 @@ 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 .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
from .upload_tracker import mark_rejected, mark_uploaded
from .upload_tracker import (
get_lowest_quality_mapped_file,
get_tracked_frigate_file_count,
mark_rejected,
mark_uploaded,
record_frigate_file,
remove_frigate_file,
)
logger = logging.getLogger(__name__)
def _reconcile_frigate_mappings(
person_name: str,
known_files_before: set[str],
uploaded: list[tuple[str, str | None]],
) -> None:
"""Map Frigate filenames to asset IDs after a batch of uploads.
Polls until all expected new files appear in the Frigate API, then maps
them to asset IDs by filename timestamp order (Frigate processes the
upload queue in FIFO order, so earlier uploads get earlier timestamps).
KNOWN LIMITATION — race condition with external uploads:
If another client uploads a face file for this person concurrently, the
count of new files will exceed `len(uploaded)` and we bail out entirely
(the "> target" branch). That's safe — we never record a wrong mapping —
but those uploads become permanently unmapped (they won't be eligible for
quality replacement). The right fix is a Frigate API that returns the
filename in the upload response, removing the need for any post-upload
diffing. Until then, the external-upload guard keeps mappings correct at
the cost of occasionally missing them when another client is active.
"""
target = len(uploaded)
current_files: set[str] = set()
for delay in (1, 2, 4, 8):
time.sleep(delay)
fresh = get_frigate_person_files(person_name)
if fresh is None:
logger.warning(
f"{person_name}: Frigate API unreachable during mapping reconciliation"
" — quality replacement won't target these files"
)
return
current_files = set(fresh)
if len(current_files - known_files_before) >= target:
break
new_files = current_files - known_files_before
if len(new_files) == target:
def _ts(fname: str) -> float:
try:
return float(fname.rsplit("_", 1)[-1].replace(".webp", ""))
except (ValueError, IndexError):
return 0.0
for (fname, asset_id), frigate_file in zip(uploaded, sorted(new_files, key=_ts)):
if asset_id:
record_frigate_file(person_name, frigate_file, asset_id)
logger.debug(f"{person_name}: batch-mapped {target} Frigate file(s)")
elif len(new_files) > target:
logger.info(
f"{person_name}: {len(new_files)} new Frigate files for {target} uploads"
" (external upload detected) — skipping file mapping"
)
else:
logger.warning(
f"{person_name}: only {len(new_files)} of {target} expected Frigate files"
" appeared after reconciliation — mapping skipped"
)
def _enrich_asset_with_face_data(asset: dict, person: dict) -> dict:
"""Enrich an asset dict with face bounding box data from the Immich faces API.
@@ -134,7 +205,7 @@ def execute_jobs(jobs: list[dict]) -> None:
# Record which asset produced which output file
filename = f"{count}.jpg"
asset_map[filename] = asset["id"]
score_map[filename] = asset.get("face_confidence")
score_map[filename] = asset.get("quality_score") or asset.get("face_confidence")
# Also record object-mode variant filenames
if mode == "object":
for f in sorted(os.listdir(person_dir)):
@@ -246,8 +317,54 @@ def upload_to_frigate(jobs: list[dict]) -> None:
person_uploaded = 0
person_failed = 0
# 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.
known_frigate_files_at_start: set[str] = set(get_frigate_person_files(name) or [])
effective_count = get_tracked_frigate_file_count(name)
quality_replacement = job.get("config", {}).get("quality_replacement", False)
actually_uploaded: list[tuple[str, str | None]] = []
for fname in person_files:
fpath = os.path.join(person_dir, fname)
at_cap = effective_count >= Config.MAX_AUTO_IMAGES
if at_cap:
if not quality_replacement:
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
worst = get_lowest_quality_mapped_file(name)
if worst is None or new_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" {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" replacing {worst_frigate_file}"
)
if delete_frigate_person_files(name, [worst_frigate_file]):
remove_frigate_file(name, worst_frigate_file)
effective_count -= 1
else:
logger.warning(f"Failed to delete {worst_frigate_file} for {name}, skipping replacement")
# Remove from tracker so the next candidate targets a different file.
# The file stays in Frigate (unmapped, like a manually-added file).
remove_frigate_file(name, worst_frigate_file)
progress.advance(upload_task)
continue
for attempt in range(1, max_retries + 1):
try:
with open(fpath, "rb") as f:
@@ -259,11 +376,12 @@ def upload_to_frigate(jobs: list[dict]) -> None:
if resp.status_code == 200:
uploaded += 1
person_uploaded += 1
effective_count += 1
# Mark this asset as uploaded so it's skipped on future runs
asset_id = asset_map.get(fname)
if asset_id:
mark_uploaded(asset_id, person_name=name, score=score_map.get(fname))
actually_uploaded.append((fname, asset_id))
break
else:
@@ -320,6 +438,10 @@ def upload_to_frigate(jobs: list[dict]) -> None:
progress.advance(upload_task)
# Batch-map Frigate filenames to asset IDs now that all uploads are done
if actually_uploaded:
_reconcile_frigate_mappings(name, known_frigate_files_at_start, actually_uploaded)
# Per-person summary
if person_failed == 0:
progress.console.print(
+62 -15
View File
@@ -8,26 +8,73 @@ import requests
logger = logging.getLogger(__name__)
def get_frigate_face_counts() -> dict[str, int] | None:
"""Return {person_name: training_image_count} from Frigate's train directory.
Returns None if FRIGATE_URL is not set or the API is unreachable, so callers
can distinguish "API unavailable" from "person has 0 images."
"""
def _get_faces_data() -> dict | None:
"""Fetch raw GET /api/faces response. Returns None if unavailable."""
frigate_url = os.environ.get("FRIGATE_URL", "").rstrip("/")
if not frigate_url:
return None
try:
resp = requests.get(f"{frigate_url}/api/faces", timeout=10)
resp.raise_for_status()
data = resp.json()
# Response: {person_name: [file, ...], "train": [...], ...}
# "train" is a flat pending list, not a person — skip it.
return {
name: len(files)
for name, files in data.items()
if name != "train" and isinstance(files, list)
}
return resp.json()
except Exception as e:
logger.warning(f"Could not query Frigate face counts: {e}")
logger.warning(f"Could not query Frigate faces API: {e}")
return None
def get_frigate_face_counts() -> dict[str, int] | None:
"""Return {person_name: training_image_count} from Frigate's train directory.
Returns None if FRIGATE_URL is not set or the API is unreachable, so callers
can distinguish "API unavailable" from "person has 0 images."
"""
data = _get_faces_data()
if data is None:
return None
# Response: {person_name: [file, ...], "train": [...], ...}
# "train" is a flat pending list, not a person — skip it.
return {
name: len(files)
for name, files in data.items()
if name != "train" and isinstance(files, list)
}
def get_frigate_person_files(person_name: str) -> list[str] | None:
"""Return the list of training filenames for a person in Frigate.
Returns None if the API is unreachable. Returns an empty list if the
person exists but has no training images yet.
"""
data = _get_faces_data()
if data is None:
return None
files = data.get(person_name)
return files if isinstance(files, list) else []
def delete_frigate_person_files(person_name: str, filenames: list[str]) -> bool:
"""Delete specific training files for a person from Frigate.
Uses POST /api/faces/{name}/delete with body {"ids": [filename, ...]}.
Returns True on success, False if unreachable or the request fails.
"""
frigate_url = os.environ.get("FRIGATE_URL", "").rstrip("/")
if not frigate_url or not filenames:
return False
from urllib.parse import quote
encoded = quote(person_name, safe="")
try:
resp = requests.post(
f"{frigate_url}/api/faces/{encoded}/delete",
json={"ids": filenames},
timeout=10,
)
if resp.ok:
logger.debug(f"Deleted {len(filenames)} Frigate file(s) for {person_name}")
return True
logger.warning(f"Frigate delete returned {resp.status_code} for {person_name}")
return False
except Exception as e:
logger.warning(f"Failed to delete Frigate files for {person_name}: {e}")
return False
+26 -19
View File
@@ -139,7 +139,7 @@ def _configure_person(person: dict, people: list[dict]) -> dict | None:
mode_choice = Prompt.ask("Choice", choices=["1", "2"], default="1")
entity_type = "face" if mode_choice == "1" else "object"
config = {"name": name, "mode": entity_type}
config = {"name": name, "mode": entity_type, "quality_replacement": Config.QUALITY_REPLACEMENT}
if entity_type == "object":
config["object_class"] = Prompt.ask("Enter Object Class (e.g. dog, cat, car)", default="dog")
@@ -283,34 +283,41 @@ def auto_configure(people: list[dict]) -> list[dict]:
rprint(f" [dim]Skipping {name} (0 new images after dedup).[/dim]")
continue
# Enforce MAX_AUTO_IMAGES as a lifetime cap per person.
# Priority: live Frigate count → last cached Frigate count → local uploaded count.
# Enforce MAX_AUTO_IMAGES against the tracked file count only.
# Manually-added Frigate files are invisible to this cap so users can
# curate their own files without shrinking winnow's managed quota.
person_summary = upload_summary.get(name, {})
if frigate_counts is not None:
already_uploaded = frigate_counts.get(name, 0)
else:
fc = person_summary.get("frigate_count")
already_uploaded = fc if fc is not None else person_summary.get("uploaded", 0)
already_uploaded = len(person_summary.get("frigate_files", {}))
capacity = Config.MAX_AUTO_IMAGES - already_uploaded
if capacity <= 0:
if not Config.QUALITY_REPLACEMENT:
rprint(
f" [dim]Skipping {name} (at cap:"
f" {already_uploaded}/{Config.MAX_AUTO_IMAGES}, quality replacement disabled).[/dim]"
)
continue
rprint(
f" [dim]Skipping {name} (at lifetime cap:"
f" {already_uploaded}/{Config.MAX_AUTO_IMAGES} trained).[/dim]"
f" [cyan]{name}: at cap ({already_uploaded}/{Config.MAX_AUTO_IMAGES}),"
f" checking for quality improvements...[/cyan]"
)
continue
quality_replacement_only = True
else:
quality_replacement_only = False
config["quality_replacement"] = quality_replacement_only or Config.QUALITY_REPLACEMENT
has_embedding = is_embedding_available(entity_type)
limit, selection_mode = _resolve_strategy(strategy, has_embedding)
# Cap selection to remaining capacity.
# For auto mode with partial training, keep "auto" so adaptive stopping
# still runs — just trim the result to the remaining capacity afterward.
# Cap selection to remaining capacity (no cap when replacement-only — executor
# decides per-image whether to swap; any candidate could be an improvement).
auto_cap = None
if limit == "auto":
if already_uploaded > 0:
auto_cap = capacity
else:
limit = min(limit, capacity)
if not quality_replacement_only:
if limit == "auto":
if already_uploaded > 0:
auto_cap = capacity
else:
limit = min(limit, capacity)
if selection_mode == "skip":
continue
+7 -3
View File
@@ -20,6 +20,7 @@ class QualityResult:
passed: bool
reasons: list[str] = field(default_factory=list)
blur_score: float | None = None
@property
def reason(self) -> str:
@@ -113,9 +114,12 @@ def assess_quality(
img_np = np.asarray(img)
reasons = []
# Run all checks, collect failures
# Compute laplacian variance once (used by check_blur and stored as blur_score)
gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) if img_np.ndim == 3 else img_np
blur_score = float(cv2.Laplacian(gray, cv2.CV_64F).var())
checks = [
check_blur(img_np, blur_threshold),
(blur_score >= blur_threshold, f"Blurry (laplacian={blur_score:.1f}, threshold={blur_threshold})" if blur_score < blur_threshold else ""),
check_grayscale(img_np),
check_exposure(img_np),
check_confidence(confidence, min_confidence),
@@ -129,5 +133,5 @@ def assess_quality(
if not passed:
reasons.append(reason)
return QualityResult(passed=len(reasons) == 0, reasons=reasons)
return QualityResult(passed=len(reasons) == 0, reasons=reasons, blur_score=blur_score)
+65 -5
View File
@@ -11,10 +11,14 @@ 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": 0.953}, # Immich face confidence 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": 0.953}, # Immich face confidence at upload time
"frigate_files": {"PersonName-123.webp": "immich-id-1"}, # Frigate filename → asset ID
"frigate_count": 42 # last known Frigate training image count
}
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.
"""
import json
@@ -72,9 +76,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": {}}
return {"asset_ids": sorted(entry), "scores": {}, "frigate_files": {}}
entry.setdefault("asset_ids", [])
entry.setdefault("scores", {})
entry.setdefault("frigate_files", {})
return entry
@@ -116,6 +121,60 @@ def mark_rejected(asset_id: str, person_name: str | None = None) -> None:
logger.debug(f"Marked {asset_id} as 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)
by_person = data.setdefault("by_person", {})
entry = _migrate_entry(by_person.get(person_name, {}))
entry["frigate_files"][frigate_filename] = asset_id
by_person[person_name] = entry
_save(UPLOAD_TRACKER_FILE, data)
logger.debug(f"Mapped Frigate file {frigate_filename} → {asset_id} ({person_name})")
def remove_frigate_file(person_name: str, frigate_filename: str) -> None:
"""Remove a Frigate filename from the mapping after it has been deleted.
Does NOT unmark the source asset_id — the deletion was deliberate and
we don't want to re-upload the inferior image on the next run.
"""
data = _load(UPLOAD_TRACKER_FILE)
by_person = data.get("by_person", {})
entry = _migrate_entry(by_person.get(person_name, {}))
entry["frigate_files"].pop(frigate_filename, None)
by_person[person_name] = entry
_save(UPLOAD_TRACKER_FILE, data)
logger.debug(f"Removed Frigate file mapping {frigate_filename} ({person_name})")
def get_tracked_frigate_file_count(person_name: str) -> int:
"""Return the number of Frigate training files winnow has mapped for this person.
Used as the cap baseline so that manually-added Frigate files do not
consume slots from winnow's managed quota.
"""
data = _load(UPLOAD_TRACKER_FILE)
entry = _migrate_entry(data.get("by_person", {}).get(person_name, {}))
return len(entry["frigate_files"])
def get_lowest_quality_mapped_file(person_name: str) -> tuple[str, str, float] | None:
"""Return (frigate_filename, asset_id, score) for the mapped file with the lowest
confidence score, or None if no mapped files with known scores exist."""
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
]
if not candidates:
return None
return min(candidates, key=lambda x: x[2])
def update_frigate_count(person_name: str, count: int) -> None:
"""Record Frigate's authoritative training image count for a person."""
data = _load(UPLOAD_TRACKER_FILE)
@@ -143,7 +202,7 @@ def reset_person(person_name: str) -> None:
def get_person_summary() -> dict[str, dict]:
"""Return {person_name: {uploaded, rejected, frigate_count, scores}} for display/capacity."""
"""Return {person_name: {uploaded, rejected, frigate_count, scores, frigate_files}} for display/capacity."""
uploaded_data = _load(UPLOAD_TRACKER_FILE).get("by_person", {})
rejected_data = _load(REJECT_TRACKER_FILE).get("by_person", {})
names = set(uploaded_data) | set(rejected_data)
@@ -156,6 +215,7 @@ def get_person_summary() -> dict[str, dict]:
"rejected": len(_get_ids(r_entry)),
"frigate_count": u_entry.get("frigate_count") if isinstance(u_entry, dict) else None,
"scores": u_entry.get("scores", {}) if isinstance(u_entry, dict) else {},
"frigate_files": u_entry.get("frigate_files", {}) if isinstance(u_entry, dict) else {},
}
return result