From bbbac182075f16c5505c9457a240c0aa371e41c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:35:54 +0000 Subject: [PATCH 1/2] chore: update lockfiles --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index db79fd2..8d5f442 100644 --- a/uv.lock +++ b/uv.lock @@ -2348,7 +2348,7 @@ wheels = [ [[package]] name = "winnow" -version = "0.3.3" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "croniter" }, From e67f2d9638d7fee6b81dda4d3b6019f2ab9996ed Mon Sep 17 00:00:00 2001 From: Holden Date: Sat, 13 Jun 2026 18:54:35 +0000 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20cleanup=20audit=20findings=20?= =?UTF-8?q?=E2=80=94=20dedup=20helpers,=20prune=20orphan=20scores,=20cache?= =?UTF-8?q?=20has=5Ffrigate=5Fscores?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - upload_tracker: extract _pick_mapped_file() private helper; get_lowest_quality_mapped_file and get_most_redundant_mapped_file are now one-liners over the same body - upload_tracker: remove_frigate_file now also prunes the corresponding frigate_scores entry, preventing unbounded accumulation of orphaned score entries across replacement cycles - frigate_api: get_frigate_face_counts delegates to get_all_frigate_person_files, eliminating the duplicated "name != 'train' and isinstance(files, list)" filter body - executor: cache has_frigate_scores(name) as person_has_fscores before the per-file loop; refresh it after each remove_frigate_file call and after each scored upload, eliminating two redundant disk reads per at-cap file iteration - executor: casefold() both sides of the recognize_face person-name comparison so a Frigate casing normalization or manual-registration casing mismatch does not silently suppress scoring Co-Authored-By: Claude Sonnet 4.6 --- winnow/executor.py | 11 ++++++--- winnow/frigate_api.py | 32 +++++++++++-------------- winnow/upload_tracker.py | 52 ++++++++++++++++------------------------ 3 files changed, 43 insertions(+), 52 deletions(-) diff --git a/winnow/executor.py b/winnow/executor.py index 4b01046..c54d10a 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -395,6 +395,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: actually_uploaded: list[tuple[str, str | None]] = [] failed_deletes: set[str] = set() min_quality_score_for_slot: float | None = None + person_has_fscores: bool = has_frigate_scores(name) for fname in person_files: fpath = os.path.join(person_dir, fname) @@ -427,9 +428,9 @@ def upload_to_frigate(jobs: list[dict]) -> None: # 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): + if not at_cap or person_has_fscores: _result = recognize_face(fpath) - if _result is not None and _result[0] == name: + if _result is not None and (_result[0] or "").casefold() == name.casefold(): pre_fscore = _result[1] # Ceiling check: skip if the existing training set already covers this @@ -450,7 +451,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: progress.advance(upload_task) continue - using_fscore = has_frigate_scores(name) and Config.ENABLE_FRIGATE_SCORES + using_fscore = person_has_fscores and Config.ENABLE_FRIGATE_SCORES if using_fscore: candidate_score = pre_fscore if candidate_score is None: @@ -476,6 +477,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: ) if delete_frigate_person_files(name, [target_frigate_file]): remove_frigate_file(name, target_frigate_file) + person_has_fscores = has_frigate_scores(name) effective_count -= 1 min_quality_score_for_slot = None # clear any blur-mode slot floor — Frigate uses a different score metric else: @@ -507,6 +509,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: ) if delete_frigate_person_files(name, [target_frigate_file]): remove_frigate_file(name, target_frigate_file) + person_has_fscores = has_frigate_scores(name) effective_count -= 1 min_quality_score_for_slot = score_map.get(fname) else: @@ -538,6 +541,8 @@ def upload_to_frigate(jobs: list[dict]) -> None: crop_dims=dims_map.get(fname), frigate_score=pre_fscore, ) + if pre_fscore is not None: + person_has_fscores = True actually_uploaded.append((fname, asset_id)) break diff --git a/winnow/frigate_api.py b/winnow/frigate_api.py index 0fc4f24..3d3b6fd 100644 --- a/winnow/frigate_api.py +++ b/winnow/frigate_api.py @@ -22,24 +22,6 @@ def _get_faces_data() -> dict | None: 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_all_frigate_person_files() -> dict[str, list[str]] | None: """Return {person_name: [filename, ...]} for every person in Frigate. @@ -49,6 +31,8 @@ def get_all_frigate_person_files() -> dict[str, list[str]] | None: 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: files for name, files in data.items() @@ -56,6 +40,18 @@ def get_all_frigate_person_files() -> dict[str, list[str]] | 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." + """ + all_files = get_all_frigate_person_files() + if all_files is None: + return None + return {name: len(files) for name, files in all_files.items()} + + 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 3213c3b..a6d84e3 100644 --- a/winnow/upload_tracker.py +++ b/winnow/upload_tracker.py @@ -167,7 +167,9 @@ def remove_frigate_file(person_name: str, frigate_filename: str) -> None: 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) + asset_id = entry["frigate_files"].pop(frigate_filename, None) + if asset_id: + entry["frigate_scores"].pop(asset_id, None) by_person[person_name] = entry _save(UPLOAD_TRACKER_FILE, data) logger.debug(f"Removed Frigate file mapping {frigate_filename} ({person_name})") @@ -204,6 +206,22 @@ def has_frigate_scores(person_name: str) -> bool: return any(asset_id in frigate_scores for asset_id in frigate_files.values()) +def _pick_mapped_file( + person_name: str, score_key: str, *, highest: bool, exclude: set[str] | None = None +) -> tuple[str, str, float] | None: + data = _load(UPLOAD_TRACKER_FILE) + entry = _migrate_entry(data.get("by_person", {}).get(person_name, {})) + scores = entry.get(score_key, {}) + candidates = [ + (ff, asset_id, scores[asset_id]) + for ff, asset_id in entry.get("frigate_files", {}).items() + if (exclude is None or ff not in exclude) and asset_id in scores + ] + if not candidates: + return None + return max(candidates, key=lambda x: x[2]) if highest else min(candidates, key=lambda x: x[2]) + + def get_lowest_quality_mapped_file( person_name: str, exclude: set[str] | None = None ) -> tuple[str, str, float] | None: @@ -213,21 +231,7 @@ def get_lowest_quality_mapped_file( 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", {}) - - 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) - and asset_id in blur_scores - ] - - if not candidates: - return None - return min(candidates, key=lambda x: x[2]) + return _pick_mapped_file(person_name, "scores", highest=False, exclude=exclude) def get_most_redundant_mapped_file( @@ -240,21 +244,7 @@ def get_most_redundant_mapped_file( = 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", {}) - - 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]) + return _pick_mapped_file(person_name, "frigate_scores", highest=True, exclude=exclude) def get_frigate_filename_for_asset(person_name: str, asset_id: str) -> str | None: