Coverage for imcluster/html.py: 100.00%
96 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-12 01:58 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-12 01:58 +0000
1"""HTML cluster-gallery generation."""
3import base64
4import json
5from collections import defaultdict
6from collections.abc import Mapping
7from datetime import datetime, timezone
8from importlib.resources import files
9from pathlib import Path
10from typing import Any
12import numpy as np
13from jinja2 import Environment, PackageLoader, select_autoescape
14from numpy.typing import ArrayLike
16from .io import ImclusterIO
19def representative_indices(
20 cluster_labels: ArrayLike,
21 feature_vectors: ArrayLike,
22) -> dict[object, int]:
23 """Return the cosine medoid position for each cluster."""
24 labels = np.asarray(cluster_labels)
25 vectors = np.asarray(feature_vectors, dtype=float)
26 if vectors.ndim != 2 or len(vectors) != len(labels):
27 raise ValueError("feature_vectors must contain one row per cluster label")
29 norms = np.linalg.norm(vectors, axis=1, keepdims=True)
30 normalized = np.divide(vectors, norms, out=np.zeros_like(vectors), where=norms != 0)
31 representatives: dict[object, int] = {}
32 for label in dict.fromkeys(labels.tolist()):
33 positions = np.flatnonzero(labels == label)
34 cluster_vectors = normalized[positions]
35 similarity_totals = cluster_vectors @ cluster_vectors.sum(axis=0)
36 representatives[label] = int(positions[np.argmax(similarity_totals)])
37 return representatives
40def similar_indices(
41 feature_vectors: ArrayLike,
42 limit: int = 30,
43) -> dict[int, list[int]]:
44 """Return nearest image positions ranked by cosine similarity.
46 Args:
47 feature_vectors: Feature matrix containing one row per image.
48 limit: Maximum number of neighbours returned for each image.
50 Returns:
51 Mapping from each image position to its most similar image positions.
53 Raises:
54 ValueError: If vectors are not a matrix or ``limit`` is negative.
55 """
56 vectors = np.asarray(feature_vectors, dtype=float)
57 if vectors.ndim != 2:
58 raise ValueError("feature_vectors must be a two-dimensional matrix")
59 if limit < 0:
60 raise ValueError("limit must not be negative")
61 norms = np.linalg.norm(vectors, axis=1, keepdims=True)
62 normalized = np.divide(vectors, norms, out=np.zeros_like(vectors), where=norms != 0)
63 result: dict[int, list[int]] = {}
64 for position, vector in enumerate(normalized):
65 scores = normalized @ vector
66 ranked = np.argsort(-scores, kind="stable")
67 result[position] = [
68 int(neighbour) for neighbour in ranked if neighbour != position
69 ][:limit]
70 return result
73def write_html(
74 imcluster_io: ImclusterIO,
75 output_html: str | Path | None = None,
76 cluster_column: str = "spectral_cluster",
77 metadata: Mapping[str, str] | None = None,
78 feature_vectors: ArrayLike | None = None,
79) -> None:
80 """Write an HTML gallery grouped by cached cluster labels.
82 Args:
83 imcluster_io: Image collection containing filenames and thumbnails.
84 output_html: Destination path. Defaults to the Parquet path with an
85 ``.html`` suffix.
86 cluster_column: DataFrame column containing cluster labels.
87 metadata: Optional report metadata displayed in the header.
88 feature_vectors: Embeddings used to select a representative image for
89 each cluster. Defaults to the first image when omitted.
91 Raises:
92 ValueError: If ``cluster_column`` is absent from the result table.
93 """
95 env = Environment(loader=PackageLoader(__package__), autoescape=select_autoescape())
97 template = env.get_template("clusters.html")
98 # template = env.get_template("vtab.html")
100 if not output_html:
101 output_html = imcluster_io.output.with_suffix(".html")
102 output_html = Path(output_html)
103 output_html.parent.mkdir(parents=True, exist_ok=True)
105 if cluster_column not in imcluster_io.df:
106 raise ValueError(f"Missing clustering results column: {cluster_column}")
108 data: defaultdict[object, list[dict[str, Any]]] = defaultdict(list)
109 df = imcluster_io.df.assign(_position=range(len(imcluster_io.df))).sort_values(
110 cluster_column
111 )
112 clusters = df[cluster_column]
113 thumbnails = df["thumbnail"]
114 filenames = df["filenames"]
115 paths = df["path"]
116 positions = df["_position"]
117 for filename, path, cluster, thumbnail, position in zip(
118 filenames,
119 paths,
120 clusters,
121 thumbnails,
122 positions,
123 strict=True,
124 ):
125 data[cluster].append(
126 {
127 "filename": filename,
128 "path": path,
129 "file_uri": Path(path).resolve().as_uri(),
130 "thumbnail": thumbnail,
131 "position": position,
132 }
133 )
135 cluster_name_column = f"{cluster_column}_name"
136 cluster_titles: dict[object, str] = {}
137 cluster_ids: dict[object, str] = {}
138 for index, (cluster, items) in enumerate(data.items(), start=1):
139 if cluster == -1:
140 default_title = "Noise"
141 cluster_ids[cluster] = "noise"
142 elif isinstance(cluster, (int, np.integer)):
143 default_title = f"Cluster {int(cluster) + 1}"
144 cluster_ids[cluster] = str(int(cluster) + 1)
145 else:
146 default_title = str(cluster)
147 cluster_ids[cluster] = str(index)
148 cluster_title = default_title
149 if imcluster_io.has_column(cluster_name_column):
150 position = int(items[0]["position"])
151 cached_title = imcluster_io.df.iloc[position][cluster_name_column]
152 if isinstance(cached_title, str) and cached_title.strip():
153 cluster_title = cached_title.strip()
154 cluster_titles[cluster] = cluster_title
156 similar_images: dict[int, list[int]]
157 if feature_vectors is None:
158 representatives = {key: items[0]["position"] for key, items in data.items()}
159 similar_images = {position: [] for position in range(len(imcluster_io.df))}
160 else:
161 medoids = representative_indices(
162 imcluster_io.df[cluster_column].to_numpy(), feature_vectors
163 )
164 representatives = medoids
165 similar_images = similar_indices(feature_vectors)
167 report_metadata = dict(metadata or {})
168 report_metadata["Generated"] = datetime.now(timezone.utc).strftime(
169 "%Y-%m-%d %H:%M UTC"
170 )
171 header = base64.b64encode(
172 files("imcluster").joinpath("assets/imcluster-header.png").read_bytes()
173 ).decode("ascii")
174 favicon = base64.b64encode(
175 files("imcluster").joinpath("assets/imcluster-logo.png").read_bytes()
176 ).decode("ascii")
177 bootstrap_css = files("imcluster").joinpath("assets/bootstrap.min.css").read_text()
178 bootstrap_js = (
179 files("imcluster").joinpath("assets/bootstrap.bundle.min.js").read_text()
180 )
181 copy_icon = files("imcluster").joinpath("assets/copy.svg").read_text()
182 search_icon = files("imcluster").joinpath("assets/search.svg").read_text()
183 previous_icon = files("imcluster").joinpath("assets/chevron-left.svg").read_text()
184 next_icon = files("imcluster").joinpath("assets/chevron-right.svg").read_text()
185 result = template.render(
186 data=data,
187 metadata=report_metadata,
188 header=header,
189 favicon=favicon,
190 representatives=representatives,
191 cluster_titles=cluster_titles,
192 cluster_ids=cluster_ids,
193 bootstrap_css=bootstrap_css,
194 bootstrap_js=bootstrap_js,
195 copy_icon=copy_icon,
196 search_icon=search_icon,
197 previous_icon=previous_icon,
198 next_icon=next_icon,
199 similar_images_json=json.dumps(similar_images),
200 )
202 with open(output_html, "w", encoding="utf-8") as f:
203 f.write(result)