Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
218706de3d | ||
|
|
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,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
- **OOM when Immich returns many pages per person**: `fetch_all_assets` now stops fetching once 5000 assets have been collected — the diversity selection pool is already capped at 3000 items, so fetching up to 1,000,000 was wasteful and could exhaust memory on large libraries. 5000 provides ample headroom for the pool cap while bounding per-person memory to ~2 MB.
|
||||
- **Non-dict items in Immich asset pages silently skipped**: a malformed or partially-null Immich response page could include `null` or non-object items in the assets array. These are now filtered at fetch time rather than causing `AttributeError` downstream.
|
||||
|
||||
## [0.4.5] - 2026-06-14
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "winnow"
|
||||
version = "0.4.5"
|
||||
version = "0.4.7"
|
||||
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"
|
||||
|
||||
@@ -2348,7 +2348,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "croniter" },
|
||||
|
||||
+10
-7
@@ -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
@@ -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"
|
||||
|
||||
@@ -14,6 +14,7 @@ from .config import Config, get_headers
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_PAGES = 1000 # Safety limit for pagination
|
||||
_MAX_ASSETS_PER_PERSON = 5000 # Stop fetching after this many — diversity pool is capped at 3000 anyway
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -95,10 +96,10 @@ def fetch_all_assets(person: dict) -> list[dict]:
|
||||
if not page_assets:
|
||||
break
|
||||
|
||||
assets.extend(page_assets)
|
||||
assets.extend(a for a in page_assets if isinstance(a, dict))
|
||||
logger.debug(f"Fetched page {page}, total: {len(assets)}")
|
||||
|
||||
if len(page_assets) < page_size:
|
||||
if len(page_assets) < page_size or len(assets) >= _MAX_ASSETS_PER_PERSON:
|
||||
break
|
||||
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
|
||||
@@ -161,6 +161,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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user