Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79e113c96 | ||
|
|
7dce4a5c71 | ||
|
|
56c802a245 | ||
|
|
cc092ec972 | ||
|
|
d6e4582401 | ||
|
|
a85bc31da9 | ||
|
|
96b71798ad | ||
|
|
e05363f632 | ||
|
|
a3e54dea7a | ||
|
|
44b717d615 | ||
|
|
38fe4d6f0c | ||
|
|
9c42da4d37 | ||
|
|
68505aeb0b | ||
|
|
0f86c1054a | ||
|
|
634688fc93 | ||
|
|
9f0a78522f | ||
|
|
8d1f5da05a | ||
|
|
a7257cf031 | ||
|
|
326fdbdf38 | ||
|
|
91e0858aa6 | ||
|
|
71df0e81de | ||
|
|
9bb0727807 | ||
|
|
7c306a4423 | ||
|
|
c22857b912 | ||
|
|
c53172f5ff | ||
|
|
9598142997 |
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.4.6] - 2026-06-14
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "winnow"
|
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."
|
description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition and object classification."
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|||||||
@@ -2348,7 +2348,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.4.4"
|
version = "0.4.6"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "croniter" },
|
{ name = "croniter" },
|
||||||
|
|||||||
+10
-7
@@ -337,16 +337,19 @@ def _dedup_embeddings(
|
|||||||
order = sorted(range(len(candidates)), key=lambda i: quality_scores[i], reverse=True)
|
order = sorted(range(len(candidates)), key=lambda i: quality_scores[i], reverse=True)
|
||||||
|
|
||||||
kept_indices = []
|
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:
|
for i in order:
|
||||||
if kept_stack is not None:
|
if n_kept > 0:
|
||||||
sims = emb_normed[i] @ kept_stack.T
|
sims = emb_normed[i] @ kept_buf[:n_kept].T
|
||||||
if np.any(sims > 1 - _DEDUP_THRESHOLD):
|
if np.any(sims > 1 - _DEDUP_THRESHOLD):
|
||||||
continue
|
continue
|
||||||
|
kept_buf[n_kept] = emb_normed[i]
|
||||||
|
n_kept += 1
|
||||||
kept_indices.append(i)
|
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)
|
dropped = len(embeddings) - len(kept_indices)
|
||||||
if dropped:
|
if dropped:
|
||||||
@@ -390,7 +393,7 @@ def _kmedoids(dist_matrix: np.ndarray, k: int, max_iter: int = 50) -> tuple[list
|
|||||||
# Iterative swap step
|
# Iterative swap step
|
||||||
medoids = list(medoids)
|
medoids = list(medoids)
|
||||||
labels = np.argmin(dist_matrix[:, medoids], axis=1)
|
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):
|
for _ in range(max_iter):
|
||||||
improved = False
|
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 = medoids.copy()
|
||||||
new_medoids[m_idx] = cand
|
new_medoids[m_idx] = cand
|
||||||
new_labels = np.argmin(dist_matrix[:, new_medoids], axis=1)
|
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:
|
if new_cost < cost:
|
||||||
medoids = new_medoids
|
medoids = new_medoids
|
||||||
labels = new_labels
|
labels = new_labels
|
||||||
|
|||||||
+7
-4
@@ -32,6 +32,7 @@ from .upload_tracker import (
|
|||||||
mark_rejected,
|
mark_rejected,
|
||||||
mark_uploaded,
|
mark_uploaded,
|
||||||
record_frigate_file,
|
record_frigate_file,
|
||||||
|
record_frigate_files_batch,
|
||||||
remove_frigate_file,
|
remove_frigate_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,10 +101,12 @@ def _reconcile_frigate_mappings(
|
|||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
for (fname, asset_id), frigate_file in zip(uploaded, sorted(new_files, key=_ts)):
|
mappings = {
|
||||||
if asset_id:
|
frigate_file: asset_id
|
||||||
record_frigate_file(person_name, frigate_file, asset_id)
|
for (_, asset_id), frigate_file in zip(uploaded, sorted(new_files, key=_ts))
|
||||||
logger.debug(f"{person_name}: batch-mapped {target} Frigate file(s)")
|
if asset_id
|
||||||
|
}
|
||||||
|
record_frigate_files_batch(person_name, mappings)
|
||||||
elif len(new_files) > target:
|
elif len(new_files) > target:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{person_name}: {len(new_files)} new Frigate files for {target} uploads"
|
f"{person_name}: {len(new_files)} new Frigate files for {target} uploads"
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ logger = logging.getLogger(__name__)
|
|||||||
UPLOAD_TRACKER_FILE = "frigate_uploaded_ids.json"
|
UPLOAD_TRACKER_FILE = "frigate_uploaded_ids.json"
|
||||||
REJECT_TRACKER_FILE = "frigate_rejected_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:
|
def _tracker_path(filename: str) -> Path:
|
||||||
try:
|
try:
|
||||||
@@ -50,18 +55,23 @@ def _tracker_path(filename: str) -> Path:
|
|||||||
|
|
||||||
def _load(filename: str) -> dict:
|
def _load(filename: str) -> dict:
|
||||||
path = _tracker_path(filename)
|
path = _tracker_path(filename)
|
||||||
if not path.exists():
|
key = str(path)
|
||||||
return {}
|
if key in _cache:
|
||||||
|
return _cache[key]
|
||||||
|
data: dict = {}
|
||||||
|
if path.exists():
|
||||||
try:
|
try:
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
return json.load(f)
|
data = json.load(f)
|
||||||
except (json.JSONDecodeError, OSError) as e:
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
logger.warning(f"Could not load tracker {filename}: {e}")
|
logger.warning(f"Could not load tracker {filename}: {e}")
|
||||||
return {}
|
_cache[key] = data
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _save(filename: str, data: dict) -> None:
|
def _save(filename: str, data: dict) -> None:
|
||||||
path = _tracker_path(filename)
|
path = _tracker_path(filename)
|
||||||
|
_cache[str(path)] = data # keep cache consistent with what we write
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(path, "w") as f:
|
with open(path, "w") as f:
|
||||||
json.dump(data, f, indent=2)
|
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})")
|
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:
|
def remove_frigate_file(person_name: str, frigate_filename: str) -> None:
|
||||||
"""Remove a Frigate filename from the mapping after it has been deleted.
|
"""Remove a Frigate filename from the mapping after it has been deleted.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user