Compare commits

..
26 Commits
Author SHA1 Message Date
flan d79e113c96 perf: write-through in-memory cache for tracker JSON reads
All _load() calls after the first return the cached dict instead of
re-reading disk. _save() updates both disk and cache atomically.
Drops per-person tracker reads from ~90 to ~1 in the upload loop.
Keyed by resolved file path so test isolation (unique tmp_path dirs)
is preserved with no fixture changes needed.
2026-06-14 04:08:28 +00:00
flan 7dce4a5c71 perf: eliminate O(K²) dedup allocs, vectorize kmedoids cost, batch tracker writes
- _dedup_embeddings: pre-allocated (Q,D) buffer replaces vstack-on-keep,
  dropping O(K²×D) copy overhead down to O(K×D) fill work
- _kmedoids: swap cost sum replaced with numpy fancy-index reduction,
  ~20-50x faster per swap evaluation
- _reconcile_frigate_mappings: O(L) load/save pairs collapsed to one
  batch write via record_frigate_files_batch
2026-06-14 04:03:55 +00:00
github-actions[bot] 56c802a245 chore: update lockfiles 2026-06-14 03:50:30 +00:00
flan cc092ec972 fix: cap fetch_all_assets at 5000 items; filter non-dict page entries
Fetching up to MAX_PAGES*page_size (1M) assets before the 3000-item
diversity pool cap was applied could exhaust memory on large Immich
libraries. Early-exit once 5000 items are collected — the pool cap
of 3000 makes anything beyond that wasteful. Also filter null/non-dict
items from page responses at fetch time.
2026-06-14 03:49:45 +00:00
github-actions[bot] d6e4582401 chore: update lockfiles 2026-06-14 03:42:28 +00:00
flan a85bc31da9 release: merge dev → main for 0.4.5 2026-06-14 03:41:35 +00:00
flan 96b71798ad docs: add disclaimer that winnow is not an approved Frigate training method 2026-06-14 03:25:38 +00:00
github-actions[bot] e05363f632 chore: update lockfiles 2026-06-14 03:23:07 +00:00
flan a3e54dea7a release: merge dev → main for 0.4.4 2026-06-14 03:22:38 +00:00
flan 44b717d615 release: merge dev → main for 0.4.3 2026-06-14 00:47:06 +00:00
flan 38fe4d6f0c ci: merge dev → main — drop arm64 from GPU build 2026-06-13 22:44:43 +00:00
flan 9c42da4d37 docs: merge dev → main — AI attribution disclosure 2026-06-13 22:31:26 +00:00
github-actions[bot] 68505aeb0b chore: update lockfiles 2026-06-13 22:19:49 +00:00
flanandClaude Sonnet 4.6 0f86c1054a chore: bump version to 0.4.2, update changelog
CUDA base image downgraded to 12.8.1 (driver 570 compatibility fix),
benchmark script added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 22:19:16 +00:00
flanandClaude Sonnet 4.6 634688fc93 Fix ruff lint errors in benchmark.py
Remove unused imports, fix unsorted imports, remove bare f-strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 22:19:16 +00:00
flanandClaude Sonnet 4.6 9f0a78522f Downgrade GPU base image to CUDA 12.8.1; add benchmark script
CUDA 13.3 requires driver >= 575 but the host only has 570 (error 804).
CUDA 12.8.1 is the highest version supported by driver 570 and works
correctly with the NVIDIA Container Toolkit.

Add scripts/benchmark.py to measure InsightFace + SigLIP latency and
throughput across GPU and CPU modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 22:19:16 +00:00
flanandClaude Sonnet 4.6 8d1f5da05a ci: consolidate Docker builds — eliminate duplicate builds on release
release.yml now calls docker-publish.yml via workflow_call instead of
re-running all four image builds independently. docker-publish.yml gains
workflow_call inputs (tag, version) for release context; branch trigger
is narrowed to dev only (main changes only land via tagged releases).

Each release previously built all four variants twice (~90 min) — once on
merge to main, once on tag push. Now it builds once.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 22:19:16 +00:00
github-actions[bot] a7257cf031 chore: update lockfiles 2026-06-13 19:59:04 +00:00
flanandClaude Sonnet 4.6 326fdbdf38 release: merge dev → main for 0.4.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 19:58:19 +00:00
flanandClaude Sonnet 4.6 91e0858aa6 refactor: merge dev → main — post-0.4.0 cleanup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 18:55:42 +00:00
github-actions[bot] 71df0e81de chore: update lockfiles 2026-06-13 18:36:36 +00:00
flanandClaude Sonnet 4.6 9bb0727807 release: merge dev → main for 0.4.0
Frigate pre-upload scoring, quality replacement inversion, bootstrap fix,
FRIGATE_SCORE_CEILING / ENABLE_FRIGATE_SCORES, removal of post-upload gate.
See CHANGELOG.md [0.4.0] for the full list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 18:36:02 +00:00
flanandClaude Sonnet 4.6 7c306a4423 chore: bump version to 0.3.3, update changelog and lockfile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:45:06 +00:00
flanandClaude Sonnet 4.6 c22857b912 fix: raise MIN_FACE_WIDTH default from 50 to 90px (8k pixel floor)
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 <noreply@anthropic.com>
2026-06-13 15:45:06 +00:00
github-actions[bot] c53172f5ff chore: update lockfiles 2026-06-13 15:33:27 +00:00
flanandClaude Sonnet 4.6 9598142997 release: v0.3.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:32:33 +00:00
6 changed files with 64 additions and 21 deletions
+14
View File
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.8] - 2026-06-14
### Changed
- **Tracker write-through cache**: `upload_tracker` now keeps an in-memory copy of each JSON file keyed by its resolved path. All reads after the first hit the cache instead of disk; writes go to both disk and cache atomically. Cuts per-person disk I/O in the upload loop from ~90 reads to ~1, with no API or behaviour changes.
## [0.4.7] - 2026-06-14
### Changed
- **`_dedup_embeddings` pre-allocated buffer**: replaced the grow-on-keep `np.vstack` pattern with a pre-allocated `(Q, D)` buffer filled row-by-row. Eliminates O(K²) copy work and the GC pressure from K intermediate heap allocations while keeping identical arithmetic for the similarity checks.
- **`_kmedoids` cost computation vectorized**: the Python-level `sum(dist_matrix[i, medoids[labels[i]]] for i in range(n))` generator (called once per swap evaluation) is replaced with `dist_matrix[np.arange(n), np.array(medoids)[labels]].sum()` — a single numpy fancy-index + reduction, ~20–50× faster in the swap loop.
- **`_reconcile_frigate_mappings` single-write batch**: previously called `record_frigate_file` once per uploaded file, each doing a full JSON load + save (O(L) disk round-trips per person). Now builds the full `{frigate_filename: asset_id}` mapping dict and writes it in one `record_frigate_files_batch` call (O(1) disk round-trip).
## [0.4.6] - 2026-06-14
### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "winnow"
version = "0.4.6"
version = "0.4.8"
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"
Generated
+1 -1
View File
@@ -2348,7 +2348,7 @@ wheels = [
[[package]]
name = "winnow"
version = "0.4.4"
version = "0.4.6"
source = { editable = "." }
dependencies = [
{ name = "croniter" },
+10 -7
View File
@@ -337,16 +337,19 @@ def _dedup_embeddings(
order = sorted(range(len(candidates)), key=lambda i: quality_scores[i], reverse=True)
kept_indices = []
kept_stack: np.ndarray | None = None # rebuilt only when a new item is kept (not every iteration)
# Pre-allocate a max-size buffer and fill row-by-row — eliminates the O(K²)
# copy overhead from vstack-on-keep while keeping identical arithmetic.
kept_buf = np.empty((len(order), emb_normed.shape[1]), dtype=emb_normed.dtype)
n_kept = 0
for i in order:
if kept_stack is not None:
sims = emb_normed[i] @ kept_stack.T
if n_kept > 0:
sims = emb_normed[i] @ kept_buf[:n_kept].T
if np.any(sims > 1 - _DEDUP_THRESHOLD):
continue
kept_buf[n_kept] = emb_normed[i]
n_kept += 1
kept_indices.append(i)
row = emb_normed[i : i + 1]
kept_stack = row if kept_stack is None else np.vstack([kept_stack, row])
dropped = len(embeddings) - len(kept_indices)
if dropped:
@@ -390,7 +393,7 @@ def _kmedoids(dist_matrix: np.ndarray, k: int, max_iter: int = 50) -> tuple[list
# Iterative swap step
medoids = list(medoids)
labels = np.argmin(dist_matrix[:, medoids], axis=1)
cost = sum(dist_matrix[i, medoids[labels[i]]] for i in range(n))
cost = dist_matrix[np.arange(n), np.array(medoids)[labels]].sum()
for _ in range(max_iter):
improved = False
@@ -405,7 +408,7 @@ def _kmedoids(dist_matrix: np.ndarray, k: int, max_iter: int = 50) -> tuple[list
new_medoids = medoids.copy()
new_medoids[m_idx] = cand
new_labels = np.argmin(dist_matrix[:, new_medoids], axis=1)
new_cost = sum(dist_matrix[i, new_medoids[new_labels[i]]] for i in range(n))
new_cost = dist_matrix[np.arange(n), np.array(new_medoids)[new_labels]].sum()
if new_cost < cost:
medoids = new_medoids
labels = new_labels
+7 -4
View File
@@ -32,6 +32,7 @@ from .upload_tracker import (
mark_rejected,
mark_uploaded,
record_frigate_file,
record_frigate_files_batch,
remove_frigate_file,
)
@@ -100,10 +101,12 @@ def _reconcile_frigate_mappings(
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)")
mappings = {
frigate_file: asset_id
for (_, asset_id), frigate_file in zip(uploaded, sorted(new_files, key=_ts))
if asset_id
}
record_frigate_files_batch(person_name, mappings)
elif len(new_files) > target:
logger.info(
f"{person_name}: {len(new_files)} new Frigate files for {target} uploads"
+27 -4
View File
@@ -39,6 +39,11 @@ logger = logging.getLogger(__name__)
UPLOAD_TRACKER_FILE = "frigate_uploaded_ids.json"
REJECT_TRACKER_FILE = "frigate_rejected_ids.json"
# Write-through in-memory cache keyed by the resolved file path.
# Reduces per-call JSON reads from O(calls) to O(1) after the first load.
# Keyed by full path so tests with isolated tmp dirs never share entries.
_cache: dict[str, dict] = {}
def _tracker_path(filename: str) -> Path:
try:
@@ -50,18 +55,23 @@ def _tracker_path(filename: str) -> Path:
def _load(filename: str) -> dict:
path = _tracker_path(filename)
if not path.exists():
return {}
key = str(path)
if key in _cache:
return _cache[key]
data: dict = {}
if path.exists():
try:
with open(path) as f:
return json.load(f)
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"Could not load tracker {filename}: {e}")
return {}
_cache[key] = data
return data
def _save(filename: str, data: dict) -> None:
path = _tracker_path(filename)
_cache[str(path)] = data # keep cache consistent with what we write
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
json.dump(data, f, indent=2)
@@ -161,6 +171,19 @@ def record_frigate_file(person_name: str, frigate_filename: str, asset_id: str)
logger.debug(f"Mapped Frigate file {frigate_filename} → {asset_id} ({person_name})")
def record_frigate_files_batch(person_name: str, mappings: dict[str, str]) -> None:
"""Record multiple Frigate filename → asset_id mappings in a single load/save."""
if not mappings:
return
data = _load(UPLOAD_TRACKER_FILE)
by_person = data.setdefault("by_person", {})
entry = _migrate_entry(by_person.get(person_name, {}))
entry["frigate_files"].update(mappings)
by_person[person_name] = entry
_save(UPLOAD_TRACKER_FILE, data)
logger.debug(f"Batch-mapped {len(mappings)} Frigate file(s) for {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.