From eb3abe2ccaeba4bcdba00731eb84eb8e358f3a55 Mon Sep 17 00:00:00 2001 From: Holden Date: Sun, 14 Jun 2026 01:23:46 +0000 Subject: [PATCH 1/7] Fix misleading HTTP 500 detail and RuntimeWarning on single-image selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HTTP 500 errors from Frigate no longer echo the response body to the user (Frigate's generic message says 'Try restarting Frigate' which is wrong — 500s on upload are almost always image-specific, not a health issue). The detail is now logged at debug level. HTTP 400 detail is still shown since 'No face was detected' is genuinely useful. - np.median on empty upper triangle (n=1 after quality filtering) no longer emits RuntimeWarning; _compute_adaptive_threshold returns the floor (0.05) immediately when there are no pairwise distances to sample. - k-medoids cluster count floor raised to 1 (was 0 when n < 3), preventing k=0 being passed to _kmedoids. --- winnow/diversity.py | 4 +++- winnow/executor.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/winnow/diversity.py b/winnow/diversity.py index 711846c..8976d0d 100644 --- a/winnow/diversity.py +++ b/winnow/diversity.py @@ -373,6 +373,8 @@ def _compute_adaptive_threshold(emb_normed: np.ndarray, entity_type: str) -> flo # Compute pairwise cosine distances for the sample pairwise = 1 - sample @ sample.T upper_tri = pairwise[np.triu_indices(len(sample), k=1)] + if len(upper_tri) == 0: + return 0.05 median_dist = float(np.median(upper_tri)) # Faces: 20% of median (tighter — want fewer, more distinct images) @@ -421,7 +423,7 @@ def _cluster_aware_selection( target = Config.MAX_AUTO_IMAGES if limit == "auto" else limit # --- Stage 1: K-Medoids clustering --- - k = min(max(5, target // 4), n // 3, n) # e.g., 5-20 clusters + k = min(max(5, target // 4), max(1, n // 3), n) # e.g., 1-20 clusters logger.debug(f"Clustering {n} embeddings into {k} groups (K-Medoids)...") # Compute full cosine distance matrix diff --git a/winnow/executor.py b/winnow/executor.py index 2a56521..ab0402e 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -580,10 +580,12 @@ def upload_to_frigate(jobs: list[dict]) -> None: ) try: error_detail = resp.json().get("message", resp.text[:100]) - progress.console.print(f" [dim]{error_detail}[/dim]") except Exception: error_detail = resp.text[:100] + if resp.status_code == 400: progress.console.print(f" [dim]{error_detail}[/dim]") + else: + logger.debug(f"{fname} HTTP {resp.status_code}: {error_detail}") if resp.status_code == 400 and "face" in error_detail.lower(): asset_id = asset_map.get(fname) if asset_id: From 0a8a0c16ddc8af812bb24970e3190d8cf61c5e5b Mon Sep 17 00:00:00 2001 From: Holden Date: Sun, 14 Jun 2026 01:26:41 +0000 Subject: [PATCH 2/7] Deduplicate near-identical embeddings before diversity selection Burst shots produce embeddings that differ slightly (~0.01-0.05 cosine distance) due to JPEG noise and minor lighting variation, so FPS does not filter them. Add a greedy dedup pass after embedding collection: sort candidates by quality score descending, then drop any candidate within 0.10 cosine distance of an already-kept image. The best frame from each near-identical group survives; the rest are dropped before clustering. --- winnow/diversity.py | 64 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/winnow/diversity.py b/winnow/diversity.py index 8976d0d..2d026b9 100644 --- a/winnow/diversity.py +++ b/winnow/diversity.py @@ -281,7 +281,16 @@ def _select_by_embedding( logger.warning(f"Only {len(valid_candidates)} valid embeddings. Returning all.") return valid_candidates - # --- Phase 5: Cluster-aware selection --- + # --- Phase 5: Near-duplicate removal --- + # Burst shots and repeated near-identical photos produce embeddings that are + # close but not identical, so FPS doesn't filter them out on its own. + # Greedily drop any candidate within DEDUP_THRESHOLD cosine distance of a + # higher-quality image already in the kept set. + embeddings, valid_candidates, confidence_scores = _dedup_embeddings( + embeddings, valid_candidates, confidence_scores + ) + + # --- Phase 6: Cluster-aware selection --- return _cluster_aware_selection( embeddings, valid_candidates, @@ -291,6 +300,59 @@ def _select_by_embedding( ) +# ============================================================================= +# Near-Duplicate Removal +# ============================================================================= + +_DEDUP_THRESHOLD = 0.10 # cosine distance — burst shots are ~0.01-0.05 apart + + +def _dedup_embeddings( + embeddings: list, + candidates: list, + confidence_scores: list, +) -> tuple[list, list, list]: + """Greedy near-duplicate removal before clustering. + + Sorts by quality score descending (best first), then for each candidate + drops it if any already-kept embedding is within _DEDUP_THRESHOLD cosine + distance. This eliminates burst-shot near-duplicates while preserving the + highest-quality representative from each near-identical group. + """ + if len(embeddings) < 2: + return embeddings, candidates, confidence_scores + + emb_matrix = np.vstack(embeddings) + norms = np.linalg.norm(emb_matrix, axis=1, keepdims=True) + emb_normed = emb_matrix / np.maximum(norms, 1e-8) + + # Sort by quality descending so the best image in each near-duplicate group wins + quality_scores = [c.get("quality_score") or 0.0 for c in candidates] + order = sorted(range(len(candidates)), key=lambda i: quality_scores[i], reverse=True) + + kept_indices = [] + kept_normed = [] + + for i in order: + if kept_normed: + kept_stack = np.vstack(kept_normed) + sims = emb_normed[i] @ kept_stack.T + if np.any(sims > 1 - _DEDUP_THRESHOLD): + continue + kept_indices.append(i) + kept_normed.append(emb_normed[i]) + + dropped = len(embeddings) - len(kept_indices) + if dropped: + logger.info(f"Near-duplicate removal dropped {dropped} images (threshold {_DEDUP_THRESHOLD}).") + + return ( + [embeddings[i] for i in kept_indices], + [candidates[i] for i in kept_indices], + [confidence_scores[i] for i in kept_indices], + ) + + # ============================================================================= # K-Medoids (Lightweight Implementation) # ============================================================================= From 19f1a5e03befc9f359e7f5fcbfc0021c42f0c5ca Mon Sep 17 00:00:00 2001 From: Holden Date: Sun, 14 Jun 2026 01:46:28 +0000 Subject: [PATCH 3/7] Add RESET_PERSON=* to reset all tracked people; fix near-duplicate dedup - RESET_PERSON=* resets every tracked person (deletes their Frigate files and clears the tracker). Any other value resets that specific person by name, including someone literally named 'all'. - Near-duplicate removal pass added before diversity clustering: greedily drops candidates within 0.10 cosine distance of a higher-quality image, eliminating burst-shot duplicates that FPS would otherwise pass through. --- winnow/cli.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/winnow/cli.py b/winnow/cli.py index d7f1e5b..e2720e1 100644 --- a/winnow/cli.py +++ b/winnow/cli.py @@ -156,11 +156,22 @@ def main() -> None: rprint(f"Server: [dim]{Config.IMMICH_URL}[/dim]") rprint(f"Output: [dim]{Config.OUTPUT_DIR}[/dim]") - # Handle RESET_PERSON before anything else + # Handle RESET_PERSON before anything else. + # RESET_PERSON=* resets every tracked person; any other value resets + # that specific person by name. reset_person_name = os.environ.get("RESET_PERSON", "").strip() if reset_person_name: - reset_person(reset_person_name) - rprint(f"[bold yellow]Reset tracking data for: {reset_person_name}[/bold yellow]") + if reset_person_name == "*": + names = list(get_person_summary().keys()) + if names: + for name in names: + reset_person(name) + rprint(f"[bold yellow]Reset tracking data for all {len(names)} people.[/bold yellow]") + else: + rprint("[dim]No tracking data to reset.[/dim]") + else: + reset_person(reset_person_name) + rprint(f"[bold yellow]Reset tracking data for: {reset_person_name}[/bold yellow]") # Show per-person tracker summary if data exists summary = get_person_summary() From a9c1114b86417e5fc96f5b6a43277cc5b20aac11 Mon Sep 17 00:00:00 2001 From: Holden Date: Sun, 14 Jun 2026 01:46:46 +0000 Subject: [PATCH 4/7] Warn when a person named '*' exists during RESET_PERSON=* bulk reset --- winnow/cli.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/winnow/cli.py b/winnow/cli.py index e2720e1..169b49f 100644 --- a/winnow/cli.py +++ b/winnow/cli.py @@ -163,6 +163,11 @@ def main() -> None: if reset_person_name: if reset_person_name == "*": names = list(get_person_summary().keys()) + if "*" in names: + rprint( + "[yellow]Note: a person literally named '*' exists in the tracker " + "and will be reset along with everyone else.[/yellow]" + ) if names: for name in names: reset_person(name) From 6e34d41036823e1100d281a4118cd35c035775ff Mon Sep 17 00:00:00 2001 From: Holden Date: Sun, 14 Jun 2026 01:48:20 +0000 Subject: [PATCH 5/7] Guard person-name path traversal in output directory construction os.path.join silently discards the base when the second arg is absolute, and '../..' sequences escape the output tree. _safe_person_dir() resolves both paths with realpath and rejects any name that lands outside the output directory, logging an error and skipping the job rather than touching an unintended path. --- winnow/executor.py | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/winnow/executor.py b/winnow/executor.py index ab0402e..901b20f 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -38,6 +38,19 @@ from .upload_tracker import ( logger = logging.getLogger(__name__) +def _safe_person_dir(output_dir: str, person_name: str) -> str: + """Return the output subdirectory for a person, raising ValueError on path traversal. + + os.path.join silently discards output_dir when person_name is absolute, + and '../..' sequences resolve outside the tree. Both are rejected here. + """ + candidate = os.path.realpath(os.path.join(output_dir, person_name)) + base = os.path.realpath(output_dir) + if not candidate.startswith(base + os.sep) and candidate != base: + raise ValueError(f"Person name {person_name!r} escapes output directory — skipping") + return candidate + + def _reconcile_frigate_mappings( person_name: str, known_files_before: set[str], @@ -182,7 +195,11 @@ def execute_jobs(jobs: list[dict]) -> None: name, mode = person["name"], config.get("mode", "face") job_task = progress.add_task(f"Processing {name}...", total=len(assets)) - person_dir = os.path.join(Config.OUTPUT_DIR, name) + try: + person_dir = _safe_person_dir(Config.OUTPUT_DIR, name) + except ValueError as e: + logger.error(str(e)) + continue # Face crops are transient (uploaded then discarded); wipe before each run. # Object crops are the deliverable; preserve them across runs. if mode == "face" and os.path.isdir(person_dir): @@ -305,7 +322,11 @@ def upload_to_frigate(jobs: list[dict]) -> None: object_jobs = [j for j in jobs if j["config"].get("mode") == "object"] for job in object_jobs: name = job["person"]["name"] - person_dir = os.path.join(Config.OUTPUT_DIR, name) + try: + person_dir = _safe_person_dir(Config.OUTPUT_DIR, name) + except ValueError as e: + logger.error(str(e)) + continue rprint(f" [dim]📁 {name} (object): crops saved to {person_dir} — copy to Frigate manually[/dim]") frigate_url = os.environ.get("FRIGATE_URL", "") @@ -355,7 +376,11 @@ def upload_to_frigate(jobs: list[dict]) -> None: if " " in name: progress.console.print(f" â„šī¸ URL-encoded name for Frigate API: '{name}' → '{encoded_name}'") - person_dir = os.path.join(Config.OUTPUT_DIR, name) + try: + person_dir = _safe_person_dir(Config.OUTPUT_DIR, name) + except ValueError as e: + logger.error(str(e)) + continue if not os.path.isdir(person_dir): progress.console.print(f" [dim]â­ī¸ {name}: no output directory, skipping[/dim]") continue From 694f860b6de101c41b2717286e7a2582b7360c6d Mon Sep 17 00:00:00 2001 From: Holden Date: Sun, 14 Jun 2026 02:52:12 +0000 Subject: [PATCH 6/7] Raise near-duplicate dedup threshold from 0.10 to 0.20 0.10 only removed burst shots (distance 0.01-0.05). Same-event photos with similar pose and lighting sit at 0.10-0.20 and were passing through, producing visually similar training images especially for people with small datasets. 0.20 removes these while still preserving genuinely different poses, expressions, and lighting conditions. --- winnow/diversity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/winnow/diversity.py b/winnow/diversity.py index 2d026b9..6e766db 100644 --- a/winnow/diversity.py +++ b/winnow/diversity.py @@ -304,7 +304,7 @@ def _select_by_embedding( # Near-Duplicate Removal # ============================================================================= -_DEDUP_THRESHOLD = 0.10 # cosine distance — burst shots are ~0.01-0.05 apart +_DEDUP_THRESHOLD = 0.20 # cosine distance — burst shots ~0.01-0.05, same-event similar shots ~0.10-0.20 def _dedup_embeddings( From f9482eec4d80e2026229fcba393a41490a1e95f3 Mon Sep 17 00:00:00 2001 From: Holden Date: Sun, 14 Jun 2026 03:22:32 +0000 Subject: [PATCH 7/7] chore: bump version to 0.4.4, update changelog --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 152bf9e..0d3018c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.4] - 2026-06-14 + +### Added + +- **`RESET_PERSON=*` bulk reset**: resets every tracked person at once (deletes their Frigate training files and clears tracker data). Any other value still resets that specific person by name. If a person is literally named `*` they are reset as part of the bulk operation, and a warning is printed to clarify this. +- **Near-duplicate removal before diversity selection**: a greedy dedup pass now runs after embedding collection and before clustering. Candidates within 0.20 cosine distance of a higher-quality image are dropped, eliminating burst shots and same-event lookalike photos that produce redundant training images. The best-quality frame from each near-identical group is kept. Dropped count is logged per person. + +### Fixed + +- **HTTP 500 upload errors no longer show Frigate's misleading "Try restarting Frigate" message**: the response body is now logged at debug level only. HTTP 400 detail (e.g. "No face was detected") is still shown since it is actionable. +- **`RuntimeWarning: Mean of empty slice`** when a person has only one image after quality filtering: `_compute_adaptive_threshold` now returns the floor value immediately when there are no pairwise distances to sample, and the k-medoids cluster count is floored at 1 to prevent `k=0`. +- **Path traversal guard on output directory**: person names with `../` sequences or absolute paths (e.g. `/etc`) are now rejected before any filesystem operation, logging an error and skipping the job rather than writing outside the output tree. + ## [0.4.3] - 2026-06-14 ### Added diff --git a/pyproject.toml b/pyproject.toml index 300c470..6167b95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "winnow" -version = "0.4.3" +version = "0.4.4" 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"