"""Palettes, overlays, HTML panels, and taxonomy tree rendering.""" from __future__ import annotations import colorsys import functools from pathlib import Path import numpy as np import pandas as pd from numpy.typing import NDArray from PIL import Image, ImageDraw from mermaidseg.dataset_reconciliation.concepts import ( MORPHOLOGIC_CONCEPTS, NONCORAL_CONCEPTS, TAXONOMIC_CONCEPTS, parse_concept_rank, ) RANK_ORDER: tuple[str, ...] = tuple(TAXONOMIC_CONCEPTS) DISPLAY_SIZE = 720 # MERMAID classes for which the taxonomy ladder is hidden (non-biological / non-target). TAXONOMY_SKIP_CLASS_LABELS: frozenset[str] = frozenset( { "background", "sand", "bare substrate", "anthropogenic", "human", "dark", } ) ONEHOT_MODE_LABELS: dict[str, str] = { "classes": "MERMAID Classification", **{rank: rank.capitalize() for rank in RANK_ORDER}, } BACKGROUND_IDS: frozenset[int] = frozenset({0}) SAND_IDS: frozenset[int] = frozenset({58}) HARD_SUBSTRATE_IDS: frozenset[int] = frozenset({7, 55, 56, 57}) ALGAE_IDS: frozenset[int] = frozenset({9, 10, 12, 26, 34, 36, 47, 60, 67, 70}) SPONGE_IDS: frozenset[int] = frozenset({64}) CORAL_IDS: frozenset[int] = frozenset( { 1, 2, 3, 4, 5, 6, 8, 11, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 27, 28, 29, 30, 31, 32, 33, 35, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 48, 49, 50, 51, 52, 53, 54, 61, 62, 63, 65, 66, 68, 69, } ) SAND_RGB: tuple[int, int, int] = (237, 201, 145) OTHER_FALLBACK_RGB: tuple[int, int, int] = (80, 60, 100) _RANK_HUE_RANGES: dict[str, tuple[float, float]] = { "kingdom": (0, 360), "phylum": (30, 300), "class": (180, 540), "order": (60, 330), "family": (0, 720), "genus": (0, 1080), } def _hsv_ramp( n: int, hue_deg_range: tuple[float, float], s_range: tuple[float, float] = (0.55, 0.9), v_range: tuple[float, float] = (0.55, 0.95), ) -> NDArray[np.uint8]: if n == 0: return np.zeros((0, 3), dtype=np.uint8) out = np.zeros((n, 3), dtype=np.uint8) h0, h1 = hue_deg_range s_lo, s_hi = s_range v_lo, v_hi = v_range for i in range(n): t = i / max(n - 1, 1) hue = ((h0 + (h1 - h0) * t) % 360) / 360.0 sat = s_lo + (s_hi - s_lo) * (0.5 + 0.5 * np.sin(i * 1.7)) val = v_lo + (v_hi - v_lo) * (0.5 + 0.5 * np.cos(i * 2.3)) r, g, b = colorsys.hsv_to_rgb(hue, float(sat), float(val)) out[i] = (int(r * 255), int(g * 255), int(b * 255)) return out def _gray_ramp(n: int, v_range: tuple[int, int] = (80, 180)) -> NDArray[np.uint8]: if n == 0: return np.zeros((0, 3), dtype=np.uint8) lo, hi = v_range values = np.linspace(lo, hi, n, dtype=np.int32) return np.stack([values, values, values], axis=1).astype(np.uint8) def make_color_palette(num_classes: int) -> NDArray[np.uint8]: palette = np.zeros((num_classes, 3), dtype=np.uint8) def _assign(ids: frozenset[int], colors: NDArray[np.uint8]) -> None: present = sorted(i for i in ids if 0 <= i < num_classes) for idx, cid in enumerate(present): palette[cid] = colors[idx] _assign(HARD_SUBSTRATE_IDS, _gray_ramp(len(HARD_SUBSTRATE_IDS))) _assign( ALGAE_IDS, _hsv_ramp( len(ALGAE_IDS), hue_deg_range=(90, 135), s_range=(0.75, 0.95), v_range=(0.55, 0.9) ), ) _assign(SPONGE_IDS, _hsv_ramp(len(SPONGE_IDS), hue_deg_range=(180, 215))) _assign(CORAL_IDS, _hsv_ramp(len(CORAL_IDS), hue_deg_range=(285, 390))) for sid in SAND_IDS: if 0 <= sid < num_classes: palette[sid] = SAND_RGB assigned = BACKGROUND_IDS | SAND_IDS | HARD_SUBSTRATE_IDS | ALGAE_IDS | SPONGE_IDS | CORAL_IDS for cid in range(num_classes): if cid not in assigned and cid not in BACKGROUND_IDS: palette[cid] = OTHER_FALLBACK_RGB for bid in BACKGROUND_IDS: if 0 <= bid < num_classes: palette[bid] = (0, 0, 0) return palette def resize_for_display(image_rgb: NDArray[np.uint8]) -> NDArray[np.uint8]: pil = Image.fromarray(image_rgb).resize((DISPLAY_SIZE, DISPLAY_SIZE), Image.BILINEAR) return np.asarray(pil, dtype=np.uint8) def draw_click_marker( image_rgb: NDArray[np.uint8], xy: tuple[int, int] | None, ) -> NDArray[np.uint8]: if xy is None: return image_rgb x, y = int(xy[0]), int(xy[1]) pil = Image.fromarray(image_rgb, mode="RGB").convert("RGBA") overlay = Image.new("RGBA", pil.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) for radius, width in ((11, 2), (6, 2), (2, 1)): draw.ellipse( (x - radius, y - radius, x + radius, y + radius), outline=(255, 255, 255, 235), width=width, ) draw.ellipse((x - 2, y - 2, x + 2, y + 2), fill=(255, 60, 60, 255)) return np.asarray(Image.alpha_composite(pil, overlay).convert("RGB"), dtype=np.uint8) def build_rank_index(concept_names: list[str]) -> dict[str, list[tuple[int, str]]]: index: dict[str, list[tuple[int, str]]] = {rank: [] for rank in RANK_ORDER} for idx, name in enumerate(concept_names): rank, value = parse_concept_rank(name) if rank in index: index[rank].append((idx, value)) return index def find_concept_channel(concept_names: list[str], name: str) -> int | None: try: return concept_names.index(name) except ValueError: return None @functools.lru_cache(maxsize=4) def load_taxonomy_parents(csv_path: str | Path) -> dict[str, str]: df = pd.read_csv(csv_path) parents: dict[str, str] = {} placeholders = {"not_given", "none", "", None} for child_rank, parent_rank in zip(RANK_ORDER[1:], RANK_ORDER[:-1], strict=True): if child_rank not in df.columns or parent_rank not in df.columns: continue sub = df[[child_rank, parent_rank]].dropna() for child_val, parent_val in sub.itertuples(index=False, name=None): if child_val in placeholders or parent_val in placeholders: continue parents.setdefault(f"{child_rank}__{child_val}", f"{parent_rank}__{parent_val}") return parents def make_rank_palette(values: list[str], rank: str) -> dict[str, NDArray[np.uint8]]: sorted_values = sorted(values) hue_range = _RANK_HUE_RANGES.get(rank, (0, 360)) colors = _hsv_ramp(len(sorted_values), hue_deg_range=hue_range) return {value: colors[i] for i, value in enumerate(sorted_values)} def _blend( display_rgb: NDArray[np.uint8], color_per_pixel: NDArray[np.uint8], alpha: NDArray[np.float32], ) -> NDArray[np.uint8]: alpha = np.clip(alpha, 0.0, 1.0)[..., None] blended = ( display_rgb.astype(np.float32) * (1.0 - alpha) + color_per_pixel.astype(np.float32) * alpha ) return blended.astype(np.uint8) def compose_onehot_overlay( display_rgb: NDArray[np.uint8], class_probs: NDArray[np.float32] | None, concept_probs: NDArray[np.float32] | None, class_palette: NDArray[np.uint8], mode: str, rank_index: dict[str, list[tuple[int, str]]], rank_palettes: dict[str, dict[str, NDArray[np.uint8]]], opacity: float, ) -> NDArray[np.uint8]: opacity = float(np.clip(opacity, 0.0, 1.0)) if mode == "classes": if class_probs is None: return display_rgb argmax = class_probs.argmax(axis=0) alpha = np.take_along_axis(class_probs, argmax[None, ...], axis=0)[0] * opacity return _blend(display_rgb, class_palette[argmax], alpha) if concept_probs is None or mode not in RANK_ORDER: return display_rgb entries = rank_index.get(mode, []) palette = rank_palettes.get(mode, {}) if not entries or not palette: return display_rgb channel_idxs = np.asarray([idx for idx, _ in entries], dtype=np.int64) values = [val for _, val in entries] rank_probs = concept_probs[channel_idxs] argmax = rank_probs.argmax(axis=0) alpha = np.take_along_axis(rank_probs, argmax[None, ...], axis=0)[0] * opacity color_lut = np.zeros((len(values), 3), dtype=np.uint8) for i, value in enumerate(values): if value != "none" and value in palette: color_lut[i] = palette[value] return _blend(display_rgb, color_lut[argmax], alpha) def compose_multihot_overlay( display_rgb: NDArray[np.uint8], concept_probs: NDArray[np.float32] | None, channel_idx: int, opacity: float, cmap: str = "viridis", ) -> NDArray[np.uint8]: if concept_probs is None: return display_rgb import matplotlib opacity = float(np.clip(opacity, 0.0, 1.0)) prob = np.clip(concept_probs[channel_idx], 0.0, 1.0) rgba = matplotlib.colormaps[cmap](prob) color_per_pixel = (rgba[..., :3] * 255.0).astype(np.uint8) return _blend(display_rgb, color_per_pixel, prob * opacity) def build_morph_concept_choices(concept_names: list[str]) -> list[str]: name_set = set(concept_names) return [name for name in (*MORPHOLOGIC_CONCEPTS, *NONCORAL_CONCEPTS) if name in name_set] def overlay_legend_items( class_probs: NDArray[np.float32] | None, concept_probs: NDArray[np.float32] | None, mode: str, rank_index: dict[str, list[tuple[int, str]]], rank_palettes: dict[str, dict[str, NDArray[np.uint8]]], class_palette: NDArray[np.uint8], id2label: dict[int, str], top_n: int = 12, ) -> list[tuple[str, tuple[int, int, int], float]]: """Categories present in the one-hot overlay's argmax, sorted by pixel cover. Mirrors the argmax that ``compose_onehot_overlay`` draws so the legend matches the overlay exactly. Returns ``(label, (r, g, b), coverage_fraction)`` triples. """ items: list[tuple[str, tuple[int, int, int], float]] = [] if mode == "classes": if class_probs is None: return [] argmax = class_probs.argmax(axis=0) total = argmax.size ids, counts = np.unique(argmax, return_counts=True) for cid, cnt in zip(ids.tolist(), counts.tolist(), strict=True): r, g, b = class_palette[cid] label = id2label.get(int(cid), f"class_{cid}") items.append((label, (int(r), int(g), int(b)), cnt / total)) else: entries = rank_index.get(mode, []) palette = rank_palettes.get(mode, {}) if concept_probs is None or not entries or not palette: return [] channel_idxs = np.asarray([idx for idx, _ in entries], dtype=np.int64) values = [val for _, val in entries] argmax = concept_probs[channel_idxs].argmax(axis=0) total = argmax.size ids, counts = np.unique(argmax, return_counts=True) for i, cnt in zip(ids.tolist(), counts.tolist(), strict=True): value = values[int(i)] if value == "none" or value not in palette: continue r, g, b = palette[value] items.append((value, (int(r), int(g), int(b)), cnt / total)) items.sort(key=lambda t: t[2], reverse=True) return items[:top_n] def render_multihot_legend( concept_name: str | None, concept_probs: NDArray[np.float32] | None = None, _channel_idx: int | None = None, pixel_prob: float | None = None, title: str = "Overlay color key", cmap: str = "viridis", stops: int = 12, ) -> str: """Color ramp for the selected multi-hot concept, with optional clicked-pixel readout.""" import matplotlib colormap = matplotlib.colormaps[cmap] ramp = ", ".join( "rgb({},{},{})".format(*(int(c * 255) for c in colormap(i / (stops - 1))[:3])) for i in range(stops) ) title_html = f'