- Add AI alignment score display to asset metadata - Implement image description fallback for non-workflow image assets - Improve AI captioning UI and UX - Optimize AI tagging suggestions with bootstrap generation - Refactor collections controller to support AI toggling - Enhance error handling and retry logic for asset indexing
205 lines
6.2 KiB
Python
205 lines
6.2 KiB
Python
"""
|
|
Auto-clustering utility — groups assets by visual similarity via K-Means.
|
|
|
|
Usage (CLI)::
|
|
|
|
python -m scripts.cluster_assets --db path/to/assets.sqlite --k 10
|
|
|
|
Or call programmatically::
|
|
|
|
from scripts.cluster_assets import cluster_assets
|
|
clusters = await cluster_assets(db, k=10)
|
|
|
|
The script reads all asset embeddings from the ``asset_embeddings`` table,
|
|
runs K-Means clustering (scikit-learn or Faiss), and returns a mapping from
|
|
cluster label to a list of ``asset_id`` values. The caller can then use
|
|
these clusters to suggest automatic "Collections".
|
|
|
|
Design notes
|
|
------------
|
|
* Pure read-only on the DB — clusters are returned, **not** written.
|
|
* Both ``scikit-learn`` and ``faiss-cpu`` K-Means are supported. The
|
|
script auto-selects whichever is installed (Faiss is preferred for large
|
|
datasets because of GPU/AVX2 acceleration).
|
|
* Cross-platform: no OS-specific calls.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Allow running as ``python -m scripts.cluster_assets`` from project root.
|
|
# ---------------------------------------------------------------------------
|
|
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
if str(_PROJECT_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_PROJECT_ROOT))
|
|
|
|
from mjr_am_backend.config import VECTOR_EMBEDDING_DIM # noqa: E402
|
|
from mjr_am_backend.features.index.vector_service import blob_to_vector # noqa: E402
|
|
from mjr_am_backend.shared import Result, get_logger # noqa: E402
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# K-Means implementations
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _kmeans_faiss(matrix, k: int, *, niter: int = 20, seed: int = 42):
|
|
"""Run K-Means via Faiss (fast, SIMD-accelerated)."""
|
|
import faiss
|
|
import numpy as np
|
|
|
|
dim = matrix.shape[1]
|
|
kmeans = faiss.Kmeans(dim, k, niter=niter, seed=seed, verbose=False)
|
|
kmeans.train(matrix.astype(np.float32))
|
|
_, labels = kmeans.index.search(matrix.astype(np.float32), 1)
|
|
return [int(lbl) for lbl in labels.flatten()]
|
|
|
|
|
|
def _kmeans_sklearn(matrix, k: int, *, seed: int = 42):
|
|
"""Run K-Means via scikit-learn (pure-Python fallback)."""
|
|
from sklearn.cluster import KMeans
|
|
|
|
model = KMeans(n_clusters=k, random_state=seed, n_init="auto")
|
|
return model.fit_predict(matrix)
|
|
|
|
|
|
def _run_kmeans(matrix, k: int) -> list[int]:
|
|
"""Auto-select the best available K-Means backend."""
|
|
try:
|
|
labels = _kmeans_faiss(matrix, k)
|
|
logger.info("K-Means via Faiss completed (%d clusters)", k)
|
|
return [int(lbl) for lbl in labels]
|
|
except ImportError:
|
|
pass
|
|
try:
|
|
labels = _kmeans_sklearn(matrix, k)
|
|
logger.info("K-Means via scikit-learn completed (%d clusters)", k)
|
|
return [int(lbl) for lbl in labels]
|
|
except ImportError:
|
|
raise RuntimeError("Neither faiss-cpu nor scikit-learn is installed") from None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def cluster_assets(
|
|
db,
|
|
*,
|
|
k: int = 10,
|
|
dim: int | None = None,
|
|
) -> Result[dict[int, list[int]]]:
|
|
"""Cluster all embedded assets into *k* groups.
|
|
|
|
Parameters
|
|
----------
|
|
db : Sqlite
|
|
Database adapter with ``aquery`` method.
|
|
k : int
|
|
Number of clusters.
|
|
dim : int, optional
|
|
Embedding vector dimensionality (defaults to config value).
|
|
|
|
Returns
|
|
-------
|
|
Result[dict[int, list[int]]]
|
|
Mapping ``cluster_label → [asset_id, ...]`` on success.
|
|
"""
|
|
dim = dim or VECTOR_EMBEDDING_DIM
|
|
|
|
rows_result = await db.aquery(
|
|
"SELECT asset_id, vector FROM vec.asset_embeddings WHERE vector IS NOT NULL"
|
|
)
|
|
if not rows_result.ok or not rows_result.data:
|
|
return Result.Err("NOT_FOUND", "No embeddings found in the database")
|
|
|
|
try:
|
|
import numpy as np
|
|
except ImportError:
|
|
return Result.Err("TOOL_MISSING", "numpy is required for clustering")
|
|
|
|
ids: list[int] = []
|
|
vectors: list[list[float]] = []
|
|
for row in rows_result.data:
|
|
try:
|
|
aid = int(row["asset_id"])
|
|
vec = blob_to_vector(row["vector"], dim)
|
|
ids.append(aid)
|
|
vectors.append(vec)
|
|
except Exception:
|
|
continue
|
|
|
|
if len(ids) < k:
|
|
return Result.Err(
|
|
"INVALID_INPUT",
|
|
f"Need at least {k} embeddings to form {k} clusters (found {len(ids)})",
|
|
)
|
|
|
|
matrix = np.array(vectors, dtype=np.float32)
|
|
|
|
try:
|
|
labels = await asyncio.to_thread(_run_kmeans, matrix, k)
|
|
except RuntimeError as exc:
|
|
return Result.Err("TOOL_MISSING", str(exc))
|
|
|
|
clusters: dict[int, list[int]] = {}
|
|
for aid, label in zip(ids, labels, strict=True):
|
|
clusters.setdefault(label, []).append(aid)
|
|
|
|
return Result.Ok(clusters)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI entry-point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _main(args: argparse.Namespace) -> None:
|
|
from mjr_am_backend.adapters.db.sqlite import Sqlite
|
|
|
|
db = Sqlite(args.db)
|
|
result = await cluster_assets(db, k=args.k)
|
|
|
|
if not result.ok:
|
|
print(f"Error: [{result.code}] {result.error}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
clusters = result.data
|
|
print(json.dumps({str(k): v for k, v in clusters.items()}, indent=2))
|
|
print(f"\n{len(clusters)} clusters formed from {sum(len(v) for v in clusters.values())} assets.")
|
|
|
|
await db.aclose()
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Cluster assets by visual similarity using K-Means on CLIP embeddings."
|
|
)
|
|
parser.add_argument(
|
|
"--db",
|
|
type=str,
|
|
required=True,
|
|
help="Path to the SQLite database (e.g. _mjr_index/assets.sqlite)",
|
|
)
|
|
parser.add_argument(
|
|
"--k",
|
|
type=int,
|
|
default=10,
|
|
help="Number of clusters (default: 10)",
|
|
)
|
|
args = parser.parse_args()
|
|
asyncio.run(_main(args))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|