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,27 @@ 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
|
||||||
|
|
||||||
|
### 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
|
## [0.4.5] - 2026-06-14
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.4.5"
|
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"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from .config import Config, get_headers
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
MAX_PAGES = 1000 # Safety limit for pagination
|
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
|
@dataclass
|
||||||
@@ -95,10 +96,10 @@ def fetch_all_assets(person: dict) -> list[dict]:
|
|||||||
if not page_assets:
|
if not page_assets:
|
||||||
break
|
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)}")
|
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
|
break
|
||||||
|
|
||||||
except (requests.RequestException, ValueError) as e:
|
except (requests.RequestException, ValueError) as e:
|
||||||
|
|||||||
@@ -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