Coverage for imcluster/llm.py: 100.00%
118 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"""LLM-assisted names for image clusters."""
3from collections.abc import Sequence
4from typing import Any
6import llmloader
7import numpy as np
8from langchain_core.messages import HumanMessage, SystemMessage
9from numpy.typing import ArrayLike
10from rich.console import Console
11from rich.markup import escape
13from .io import ImclusterIO
15console = Console()
17DEFAULT_LLM = "gpt-5.6-luna"
20def encode_message(text: str) -> dict[str, str]:
21 """Return a multimodal text-content block."""
22 return {"type": "text", "text": text}
25def encode_thumbnail_message(thumbnail: str) -> dict[str, Any]:
26 """Return a multimodal image block for a cached JPEG thumbnail."""
27 return {
28 "type": "image_url",
29 "image_url": {"url": f"data:image/jpeg;base64,{thumbnail}"},
30 }
33def name_cluster(
34 llm: Any,
35 in_group: Sequence[str],
36 out_group: Sequence[str] | None = None,
37) -> str:
38 """Generate a concise name from cached thumbnails in and outside a cluster.
40 Args:
41 llm: LangChain-compatible multimodal chat model.
42 in_group: JPEG thumbnails belonging to the cluster, encoded as base64.
43 out_group: Optional thumbnails from nearby images outside the cluster.
45 Returns:
46 A stripped, non-empty cluster name.
48 Raises:
49 ValueError: If no in-cluster thumbnails or no name is returned.
50 """
51 if not in_group:
52 raise ValueError("At least one in-cluster thumbnail is required")
53 if out_group:
54 opening = (
55 "Name the visual category shared by the images in this cluster. "
56 "Images outside the cluster are contrasting examples and should help "
57 "you distinguish the category."
58 )
59 group_heading = "Images inside the cluster:"
60 else:
61 opening = "Name the visual category shared by these images."
62 group_heading = "Images:"
63 content: list[str | dict[Any, Any]] = [
64 encode_message(opening),
65 encode_message(group_heading),
66 ]
67 content.extend(encode_thumbnail_message(thumbnail) for thumbnail in in_group)
68 if out_group:
69 content.append(encode_message("Nearby images outside the cluster:"))
70 content.extend(encode_thumbnail_message(thumbnail) for thumbnail in out_group)
71 content.append(
72 encode_message(
73 "Respond with only a short descriptive cluster name. Do not add quotes, "
74 "a prefix, a sentence, or punctuation."
75 )
76 )
77 response = llm.invoke(
78 [
79 SystemMessage(
80 content=("You assign concise, specific names to clusters of images.")
81 ),
82 HumanMessage(content=content),
83 ]
84 )
85 response_content = getattr(response, "content", response)
86 if isinstance(response_content, list):
87 response_content = "".join(
88 str(block.get("text", "")) if isinstance(block, dict) else str(block)
89 for block in response_content
90 )
91 name = str(response_content).strip().strip("\"'").strip()
92 if not name:
93 raise ValueError("The LLM returned an empty cluster name")
94 return name
97def _normalize_vectors(feature_vectors: ArrayLike, image_count: int) -> Any:
98 """Validate and cosine-normalize image feature vectors."""
99 vectors = np.asarray(feature_vectors, dtype=float)
100 if vectors.ndim != 2 or len(vectors) != image_count:
101 raise ValueError("feature_vectors must contain one row per image")
102 norms = np.linalg.norm(vectors, axis=1, keepdims=True)
103 return np.divide(vectors, norms, out=np.zeros_like(vectors), where=norms != 0)
106def _representative_positions(
107 positions: Any,
108 normalized: Any,
109 limit: int,
110) -> list[int]:
111 """Select a medoid followed by diverse images using farthest-first traversal."""
112 cluster_vectors = normalized[positions]
113 medoid_offset = int(np.argmax(cluster_vectors @ cluster_vectors.sum(axis=0)))
114 selected = [int(positions[medoid_offset])]
115 while len(selected) < min(limit, len(positions)):
116 similarities = normalized[positions] @ normalized[selected].T
117 nearest_selected = similarities.max(axis=1)
118 for selected_position in selected:
119 nearest_selected[positions == selected_position] = np.inf
120 selected.append(int(positions[np.argmin(nearest_selected)]))
121 return selected
124def _outside_positions(
125 cluster_positions: Any,
126 representatives: Sequence[int],
127 normalized: Any,
128 limit: int,
129) -> list[int]:
130 """Select outside images nearest to any representative image."""
131 if limit == 0:
132 return []
133 outside = np.setdiff1d(
134 np.arange(len(normalized)), cluster_positions, assume_unique=True
135 )
136 if not len(outside):
137 return []
138 similarities = normalized[outside] @ normalized[list(representatives)].T
139 scores = similarities.max(axis=1)
140 ranked = np.argsort(-scores, kind="stable")[:limit]
141 return [int(position) for position in outside[ranked]]
144def name_clusters(
145 imcluster_io: ImclusterIO,
146 feature_vectors: ArrayLike,
147 cluster_column: str,
148 llm: Any = DEFAULT_LLM,
149 temperature: float = 0.2,
150 api_key: str | None = None,
151 in_group_size: int = 10,
152 out_group_size: int = 0,
153 force: bool = False,
154) -> dict[object, str]:
155 """Name clusters from representative cached thumbnails and save the results.
157 Args:
158 imcluster_io: Image cache containing cluster labels and thumbnails.
159 feature_vectors: Original image embeddings used to choose prompt examples.
160 cluster_column: DataFrame column containing cluster assignments.
161 llm: Loaded multimodal model or an llmloader model identifier.
162 temperature: Sampling temperature used when loading a model identifier.
163 api_key: Optional provider API key passed to llmloader.
164 in_group_size: Maximum representative thumbnails from inside each cluster.
165 out_group_size: Maximum nearby contrasting thumbnails outside each cluster.
166 force: Regenerate names even when a complete name cache exists.
168 Returns:
169 Mapping from cluster labels to their generated display names.
171 Raises:
172 ValueError: If required columns, vectors, thumbnails, or sizes are invalid.
173 """
174 if cluster_column not in imcluster_io.df:
175 raise ValueError(f"Missing clustering results column: {cluster_column}")
176 if "thumbnail" not in imcluster_io.df:
177 raise ValueError("Cached thumbnails are required to name clusters")
178 if in_group_size < 1:
179 raise ValueError("in_group_size must be at least 1")
180 if out_group_size < 0:
181 raise ValueError("out_group_size must not be negative")
183 name_column = f"{cluster_column}_name"
184 labels = imcluster_io.df[cluster_column].to_numpy()
185 unique_labels = list(dict.fromkeys(labels.tolist()))
186 if imcluster_io.has_column(name_column) and not force:
187 cached_names: dict[object, str] = {}
188 complete = True
189 for label in unique_labels:
190 values = {
191 value.strip()
192 for value in imcluster_io.df.loc[labels == label, name_column].tolist()
193 if isinstance(value, str) and value.strip()
194 }
195 if len(values) != 1:
196 complete = False
197 break
198 cached_names[label] = next(iter(values))
199 if complete:
200 console.print(
201 f"[green]Using cached cluster names:[/green] loaded "
202 f"{len(cached_names)} names from '{imcluster_io.output}'."
203 )
204 return cached_names
206 normalized = _normalize_vectors(feature_vectors, len(imcluster_io.images))
207 thumbnails = imcluster_io.df["thumbnail"].tolist()
208 if any(not isinstance(thumbnail, str) or not thumbnail for thumbnail in thumbnails):
209 raise ValueError("Every image must have a cached thumbnail before naming")
210 if isinstance(llm, str):
211 load_kwargs: dict[str, Any] = {"temperature": temperature}
212 if api_key is not None:
213 load_kwargs["api_key"] = api_key
214 llm = llmloader.load(llm, **load_kwargs)
216 names: dict[object, str] = {}
217 output_names = np.empty(len(labels), dtype=object)
218 with console.status("[cyan]Generating descriptive cluster names...[/cyan]"):
219 for label in unique_labels:
220 cluster_positions = np.flatnonzero(labels == label)
221 if label == -1:
222 cluster_name = "Noise"
223 else:
224 representatives = _representative_positions(
225 cluster_positions, normalized, in_group_size
226 )
227 outside = _outside_positions(
228 cluster_positions, representatives, normalized, out_group_size
229 )
230 cluster_name = name_cluster(
231 llm,
232 [thumbnails[position] for position in representatives],
233 [thumbnails[position] for position in outside],
234 )
235 if label == -1:
236 display_label = "noise"
237 elif isinstance(label, (int, np.integer)):
238 display_label = f"cluster {int(label) + 1}"
239 else:
240 display_label = f"cluster {label}"
241 console.print(
242 f"[green]Named {escape(display_label)}:[/green] {escape(cluster_name)}"
243 )
244 names[label] = cluster_name
245 output_names[cluster_positions] = cluster_name
246 imcluster_io.save_column(name_column, output_names.tolist())
247 console.print(
248 f"[green]Wrote cluster names:[/green] saved {len(names)} names to "
249 f"'{imcluster_io.output}'."
250 )
251 return names