from collections import defaultdict from photoflow.core.models import Cluster from photoflow.immich import pipeline from app.clustering import cluster_assets, _epoch from app.coverage import coverage_members # A tag seeds only if its photos dominate the time window they span: among all # assets whose taken_at falls in [min, max] of the tag's photos, at least this # fraction must carry the tag. A name/event tag sprinkled among many untagged # photos in the same window fails this gate. SEED_DOMINANCE_MIN = 0.5 def _seed_tags(assets, tags_by_asset, max_span_days: int, dominance_min: float = SEED_DOMINANCE_MIN) -> set: epochs = {a.immich_id: _epoch(a.taken_at) for a in assets} tagged = defaultdict(list) # tag -> epochs of its assets for a in assets: for t in tags_by_asset.get(a.immich_id, []): if not pipeline.is_pipeline_tag(t): tagged[t].append(epochs[a.immich_id]) seeds = set() for tag, ts in tagged.items(): if len(ts) < 2: continue lo, hi = min(ts), max(ts) # Loose safety cap only — reject absurdly long spans, but keep genuine # long trips (this library has one > 60 days), so the default is high. if hi - lo > max_span_days * 86400: continue # Dominance / temporal contiguity gate. in_window = sum(1 for e in epochs.values() if lo <= e <= hi) if in_window and len(ts) / in_window >= dominance_min: seeds.add(tag) return seeds def run_cluster(store, *, gap_factor: float = 6.0, seed_max_span_days: int = 400) -> dict: assets = store.all_assets(include_processed=False) tags_by_asset = {a.immich_id: store.asset_tags(a.immich_id) for a in assets} seed_tags = _seed_tags(assets, tags_by_asset, seed_max_span_days) candidates = cluster_assets(assets, tags_by_asset, seed_tags, gap_factor=gap_factor) assets_by_id = {a.immich_id: a for a in assets} store.clear_clusters() n = 0 for cand in candidates: members = coverage_members(cand, assets_by_id) store.insert_cluster(Cluster( start_at=cand.start_at, end_at=cand.end_at, count=sum(1 for m in members if m.included), suggested_name=cand.suggested_name, confidence=cand.confidence, kind_guess=cand.kind_guess, status="pending"), members) n += 1 return {"clusters": n}