IQA-Interpretation / analysis /viz /umap_plot.py
dvarfe's picture
sync with github version
0705c62
Raw
History Blame Contribute Delete
14 kB
from __future__ import annotations
import copy
import io
import base64
from typing import Any, List, Mapping, Tuple
import numpy as np
from PIL import Image
import plotly.graph_objects as go
from log_config import get_logger
logger = get_logger(__name__)
SELECTED_FEATURE_TRACE_NAME = "selected_feature"
NO_ACTIVE_FEATURES_ANNOTATION = "No active features on this image."
CATEGORY_PALETTE = [
'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#17becf', '#bcbd22',
'#8c564b', '#e377c2', '#7f7f7f', '#9467bd', '#aec7e8', '#ffbb78', '#98df8a',
]
def _img_to_base64(img: Image.Image, fmt: str = 'JPEG', quality: int = 80) -> str:
buf = io.BytesIO()
img.save(buf, format=fmt, quality=quality)
return base64.b64encode(buf.getvalue()).decode('utf-8')
def make_thumb_b64(img_idx: int, kadid_ds: Any, size: int = 140) -> str | None:
"""Return base64 thumbnail for image index using dataset object providing `images`.
`kadid_ds` is expected to expose sequence-like `.images` where each entry is a path.
"""
try:
p = str(kadid_ds.images[int(img_idx)])
img = Image.open(p).convert('RGB').resize((size, size), Image.LANCZOS)
return _img_to_base64(img)
except Exception as e:
logger.error('Error occurred while processing image %s: %s', img_idx, e)
return None
def _align_umap_series_length(
emb: np.ndarray,
labels: np.ndarray,
customdata: np.ndarray | list | None,
hovertext: list[str] | None,
marker_sizes: np.ndarray | None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray | list | None, list[str] | None, np.ndarray | None]:
n = int(emb.shape[0])
labels_arr = np.asarray(labels)
if labels_arr.shape[0] == n:
return emb, labels_arr, customdata, hovertext, marker_sizes
n = min(n, int(labels_arr.shape[0]))
emb = emb[:n]
labels_arr = labels_arr[:n]
if customdata is not None:
customdata = list(customdata)[:n]
if hovertext is not None:
hovertext = list(hovertext)[:n]
if marker_sizes is not None:
marker_sizes = np.asarray(marker_sizes)[:n]
return emb, labels_arr, customdata, hovertext, marker_sizes
def build_categorical_traces(
emb: np.ndarray,
labels: np.ndarray,
selected_category: str | None = None,
palette: List[str] | None = None,
customdata: np.ndarray | list | None = None,
hovertext: list[str] | None = None,
marker_sizes: np.ndarray | None = None,
legendonly_categories: frozenset[str] | None = None,
) -> Tuple[list[go.Scatter], list[str]]:
emb, labels, customdata, hovertext, marker_sizes = _align_umap_series_length(
emb, labels, customdata, hovertext, marker_sizes
)
palette = CATEGORY_PALETTE if palette is None else palette
legendonly_categories = legendonly_categories or frozenset()
unique_cats = sorted(str(x) for x in np.unique(labels))
selected_category = None if selected_category in {None, ''} else str(selected_category)
if selected_category not in unique_cats:
selected_category = None
traces: list[go.Scatter] = []
for i, cat in enumerate(unique_cats):
if selected_category is not None and cat != selected_category:
continue
mask = np.asarray([str(v) == cat for v in labels], dtype=bool)
if not np.any(mask):
continue
img_indices = np.flatnonzero(mask).astype(np.int32)
# Prepare customdata as list of [img_idx, label]
if customdata is None:
trace_customdata = [[int(idx), str(labels[int(idx)])] for idx in img_indices]
else:
trace_customdata = [customdata[int(idx)] for idx in img_indices]
trace_hovertext = None
if hovertext is not None:
trace_hovertext = [hovertext[int(idx)] for idx in img_indices]
trace_sizes = None
if marker_sizes is not None:
trace_sizes = [float(marker_sizes[int(idx)]) for idx in img_indices]
color = palette[i % len(palette)]
trace_visible: bool | str = 'legendonly' if cat in legendonly_categories else True
traces.append(
go.Scatter(
x=emb[mask, 0],
y=emb[mask, 1],
mode='markers',
name=cat,
legendgroup=cat,
marker=dict(size=trace_sizes if trace_sizes is not None else 7, color=color, opacity=0.85, line=dict(width=0)),
customdata=trace_customdata,
hoverinfo='none',
hovertext=trace_hovertext if trace_hovertext is not None else [f"image_idx={int(idx)} | category={cat}" for idx in img_indices],
showlegend=True,
visible=trace_visible,
)
)
return traces, unique_cats
def build_umap_figure(
embedding_2d: np.ndarray,
labels: np.ndarray,
*,
title: str,
selected_category: str | None = None,
palette: List[str] | None = None,
customdata: np.ndarray | list | None = None,
hovertext: list[str] | None = None,
marker_sizes: np.ndarray | None = None,
legendonly_categories: frozenset[str] | None = None,
) -> Tuple[go.Figure, list[str]]:
traces, unique_cats = build_categorical_traces(
embedding_2d,
labels,
selected_category=selected_category,
palette=palette,
customdata=customdata,
hovertext=hovertext,
marker_sizes=marker_sizes,
legendonly_categories=legendonly_categories,
)
fig = go.Figure(data=traces)
fig.update_layout(
title=dict(text=title, x=0.01, xanchor='left', y=0.97, yanchor='top', font=dict(size=16)),
xaxis_title='UMAP 1',
yaxis_title='UMAP 2',
legend=dict(
orientation='h',
yanchor='top',
y=-0.16,
xanchor='left',
x=0.0,
title_text='',
font=dict(size=11),
tracegroupgap=8,
),
margin=dict(l=40, r=24, t=96, b=92),
hovermode='closest',
autosize=True,
)
return fig, unique_cats
def _decode_plotly_array(values: Any) -> list[float]:
if values is None:
return []
if isinstance(values, (list, tuple, np.ndarray)):
return [float(v) for v in values]
if isinstance(values, Mapping) and "bdata" in values:
dtype_map = {"f8": np.float64, "f4": np.float32, "i4": np.int32, "i2": np.int16}
dtype = dtype_map.get(str(values.get("dtype")), np.float64)
raw = base64.b64decode(values["bdata"])
return np.frombuffer(raw, dtype=dtype).astype(float).tolist()
return [float(values)]
def _find_feature_point_in_figure(
traces: list[go.Scatter],
highlight_feature_id: int,
) -> tuple[float, float, list[Any]] | None:
for trace in traces:
if trace.name == SELECTED_FEATURE_TRACE_NAME:
continue
customdata = trace.customdata
if customdata is None:
continue
x_vals = _decode_plotly_array(trace.x)
y_vals = _decode_plotly_array(trace.y)
if not x_vals or not y_vals:
continue
for i, row in enumerate(customdata):
if not isinstance(row, (list, tuple, np.ndarray)) or len(row) < 1:
continue
try:
if int(row[0]) != highlight_feature_id:
continue
except (TypeError, ValueError):
continue
if i >= len(x_vals) or i >= len(y_vals):
continue
return x_vals[i], y_vals[i], list(row)
return None
def filter_umap_figure_to_feature_ids(
figure: go.Figure | dict[str, Any],
active_feature_ids: frozenset[int],
) -> dict[str, Any]:
"""Keep only UMAP points whose feature id (``customdata[0]``) is in ``active_feature_ids``."""
if isinstance(figure, go.Figure):
fig = copy.deepcopy(figure)
else:
fig = go.Figure(copy.deepcopy(figure))
active_ids = {int(fid) for fid in active_feature_ids}
filtered_traces: list[go.Scatter] = []
for trace in fig.data:
if trace.name == SELECTED_FEATURE_TRACE_NAME:
continue
customdata = trace.customdata
if customdata is None:
continue
x_vals = _decode_plotly_array(trace.x)
y_vals = _decode_plotly_array(trace.y)
if not x_vals or not y_vals:
continue
sizes = trace.marker.size if trace.marker else None
if isinstance(sizes, (list, tuple, np.ndarray)):
size_list = [float(s) for s in sizes]
else:
size_list = None
new_x: list[float] = []
new_y: list[float] = []
new_cd: list[Any] = []
new_sizes: list[float] = []
for i, row in enumerate(customdata):
if not isinstance(row, (list, tuple, np.ndarray)) or len(row) < 1:
continue
try:
feature_id = int(row[0])
except (TypeError, ValueError):
continue
if feature_id not in active_ids:
continue
if i >= len(x_vals) or i >= len(y_vals):
continue
new_x.append(x_vals[i])
new_y.append(y_vals[i])
new_cd.append(list(row))
if size_list is not None and i < len(size_list):
new_sizes.append(size_list[i])
if not new_x:
continue
if trace.marker is not None:
marker = trace.marker.to_plotly_json()
else:
marker = {"size": 7}
if new_sizes:
marker["size"] = new_sizes
filtered_traces.append(
go.Scatter(
x=new_x,
y=new_y,
mode=trace.mode,
name=trace.name,
legendgroup=trace.legendgroup,
marker=marker,
customdata=new_cd,
hoverinfo=trace.hoverinfo,
hovertext=trace.hovertext,
showlegend=trace.showlegend,
visible=trace.visible,
)
)
if isinstance(figure, go.Figure):
base_layout = copy.deepcopy(figure.layout)
else:
base_layout = copy.deepcopy(figure.get("layout", {}))
filtered_fig = go.Figure(data=filtered_traces, layout=base_layout)
if not filtered_traces:
filtered_fig.update_layout(
annotations=[
dict(
text=NO_ACTIVE_FEATURES_ANNOTATION,
xref="paper",
yref="paper",
x=0.5,
y=0.5,
showarrow=False,
font=dict(size=14),
)
]
)
else:
filtered_fig.update_layout(annotations=[])
return filtered_fig.to_plotly_json()
def apply_feature_umap_highlight(
figure: go.Figure | dict[str, Any],
highlight_feature_id: int | None,
) -> dict[str, Any]:
"""Add or remove a star marker for the selected feature on a feature UMAP figure."""
if isinstance(figure, go.Figure):
fig = copy.deepcopy(figure)
else:
fig = go.Figure(copy.deepcopy(figure))
fig.data = tuple(trace for trace in fig.data if trace.name != SELECTED_FEATURE_TRACE_NAME)
if highlight_feature_id is not None:
try:
feature_id = int(highlight_feature_id)
except (TypeError, ValueError):
feature_id = None
if feature_id is not None:
point = _find_feature_point_in_figure(list(fig.data), feature_id)
if point is not None:
x_val, y_val, row = point
fig.add_trace(
go.Scatter(
x=[x_val],
y=[y_val],
mode="markers",
name=SELECTED_FEATURE_TRACE_NAME,
legendgroup=SELECTED_FEATURE_TRACE_NAME,
showlegend=False,
marker=dict(
symbol="star",
size=18,
color="#f59e0b",
line=dict(width=2, color="#111827"),
),
customdata=[row],
hoverinfo="none",
)
)
return fig.to_plotly_json()
def plot_categorical_scatter_matplotlib(
emb: np.ndarray,
labels: np.ndarray,
title: str,
legend_title: str = 'category',
exclude_categories: frozenset[str] | None = None,
) -> None:
import matplotlib.pyplot as plt
def _label_str(value: object) -> str:
if value is None:
return 'unknown'
if isinstance(value, float) and np.isnan(value):
return 'unknown'
text = str(value).strip()
return 'unknown' if text.lower() in {'', 'nan', 'none'} else text
exclude = exclude_categories or frozenset()
if exclude:
keep_mask = np.array([_label_str(v) not in exclude for v in labels], dtype=bool)
emb = np.asarray(emb)[keep_mask]
labels = np.asarray(labels)[keep_mask]
unique_cats = sorted(dict.fromkeys(_label_str(x) for x in labels))
cmap = plt.get_cmap('tab10')
n = max(1, len(unique_cats))
colors = [cmap(i % cmap.N) for i in range(n)]
plt.figure(figsize=(10, 7))
for i, cat in enumerate(unique_cats):
mask = np.array([_label_str(v) == cat for v in labels])
if not np.any(mask):
continue
plt.scatter(
emb[mask, 0],
emb[mask, 1],
c=[colors[i]],
label=cat,
s=28,
alpha=0.85,
edgecolors='none',
)
plt.title(title)
plt.xlabel('UMAP 1')
plt.ylabel('UMAP 2')
plt.grid(True, alpha=0.3)
plt.legend(title=legend_title, bbox_to_anchor=(1.05, 1), loc='upper left')
plt.tight_layout()
plt.show()