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

1"""HTML cluster-gallery generation.""" 

2 

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 

11 

12import numpy as np 

13from jinja2 import Environment, PackageLoader, select_autoescape 

14from numpy.typing import ArrayLike 

15 

16from .io import ImclusterIO 

17 

18 

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") 

28 

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 

38 

39 

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. 

45 

46 Args: 

47 feature_vectors: Feature matrix containing one row per image. 

48 limit: Maximum number of neighbours returned for each image. 

49 

50 Returns: 

51 Mapping from each image position to its most similar image positions. 

52 

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 

71 

72 

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. 

81 

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. 

90 

91 Raises: 

92 ValueError: If ``cluster_column`` is absent from the result table. 

93 """ 

94 

95 env = Environment(loader=PackageLoader(__package__), autoescape=select_autoescape()) 

96 

97 template = env.get_template("clusters.html") 

98 # template = env.get_template("vtab.html") 

99 

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) 

104 

105 if cluster_column not in imcluster_io.df: 

106 raise ValueError(f"Missing clustering results column: {cluster_column}") 

107 

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 ) 

134 

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 

155 

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) 

166 

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 ) 

201 

202 with open(output_html, "w", encoding="utf-8") as f: 

203 f.write(result)