"""PNG renderers for the Kakera response payload. All public renderers take NumPy arrays and return raw PNG bytes. The frontend receives base64-encoded strings (see ``to_b64``) ready to drop into ````. Color philosophy follows ``specs/03_FRONTEND.md``: paper-white background, aizome indigo for primary signal, vermilion as the sparing accent (saddle channel, instance contours), near-black for grayscale endpoints. Implementation uses only NumPy + OpenCV. No matplotlib / Pillow on purpose (keeps the Docker image lean). """ from __future__ import annotations import base64 import cv2 import numpy as np # Project palette - RGB tuples. Converted to BGR inside ``_encode_rgb``. PAPER_WHITE: tuple[int, int, int] = (245, 240, 232) INDIGO: tuple[int, int, int] = (38, 52, 110) VERMILION: tuple[int, int, int] = (213, 74, 56) NEAR_BLACK: tuple[int, int, int] = (23, 25, 31) # Pure-color tints for the R / G / B channel renderers. _PURE_R: tuple[int, int, int] = (255, 0, 0) _PURE_G: tuple[int, int, int] = (0, 255, 0) _PURE_B: tuple[int, int, int] = (0, 0, 255) # --------------------------------------------------------------------------- # Low-level helpers # --------------------------------------------------------------------------- def _encode_rgb(rgb_u8: np.ndarray) -> bytes: """Encode an HWC RGB uint8 array as PNG bytes.""" bgr = cv2.cvtColor(rgb_u8, cv2.COLOR_RGB2BGR) ok, buf = cv2.imencode(".png", bgr) if not ok: raise RuntimeError("cv2.imencode failed for RGB image.") return buf.tobytes() def _encode_rgba(rgba_u8: np.ndarray) -> bytes: """Encode an HWC RGBA uint8 array as PNG bytes (preserves alpha).""" bgra = cv2.cvtColor(rgba_u8, cv2.COLOR_RGBA2BGRA) ok, buf = cv2.imencode(".png", bgra) if not ok: raise RuntimeError("cv2.imencode failed for RGBA image.") return buf.tobytes() def _clip01(arr: np.ndarray) -> np.ndarray: """Clamp to [0, 1] as float32 (no copy when already inside).""" a = arr.astype(np.float32, copy=False) return np.clip(a, 0.0, 1.0) def _ramp(value_2d: np.ndarray, lo_rgb: tuple[int, int, int], hi_rgb: tuple[int, int, int]) -> np.ndarray: """Linear blend between two RGB endpoints driven by ``value_2d in [0,1]``. Returns an (H, W, 3) uint8 RGB image. """ v = _clip01(value_2d)[..., None] lo = np.array(lo_rgb, dtype=np.float32) hi = np.array(hi_rgb, dtype=np.float32) out = lo + v * (hi - lo) return np.clip(out, 0.0, 255.0).astype(np.uint8) def _diverging(value_2d: np.ndarray, lo_rgb: tuple[int, int, int], mid_rgb: tuple[int, int, int], hi_rgb: tuple[int, int, int]) -> np.ndarray: """Three-stop diverging ramp pivoted at value 0.5.""" v = _clip01(value_2d) low_mask = v < 0.5 t = np.where(low_mask, v * 2.0, (v - 0.5) * 2.0)[..., None] lo = np.array(lo_rgb, dtype=np.float32) mid = np.array(mid_rgb, dtype=np.float32) hi = np.array(hi_rgb, dtype=np.float32) low_part = lo + t * (mid - lo) high_part = mid + t * (hi - mid) out = np.where(low_mask[..., None], low_part, high_part) return np.clip(out, 0.0, 255.0).astype(np.uint8) def _tinted_grayscale(value_2d: np.ndarray, pure_rgb: tuple[int, int, int]) -> np.ndarray: """Multiply a [0,1] map by a pure RGB color; 0 -> black, 1 -> pure color.""" v = _clip01(value_2d)[..., None] pure = np.array(pure_rgb, dtype=np.float32) return np.clip(v * pure, 0.0, 255.0).astype(np.uint8) # --------------------------------------------------------------------------- # Public renderers # --------------------------------------------------------------------------- def render_rgb(rgb_u8: np.ndarray) -> bytes: """Encode an HWC uint8 RGB array as PNG bytes.""" if rgb_u8.dtype != np.uint8 or rgb_u8.ndim != 3 or rgb_u8.shape[2] != 3: raise ValueError( f"render_rgb expects (H, W, 3) uint8; got shape={rgb_u8.shape} dtype={rgb_u8.dtype}." ) return _encode_rgb(rgb_u8) def render_channel(channel_2d: np.ndarray, *, idx: int) -> bytes: """Render one of the 9 input-tensor channels as a colored PNG. See ``CHANNEL_NAMES`` in ``app/inference.py`` for the index-to-name map. Coloring rules mirror ``specs/02_BACKEND.md``. """ if channel_2d.ndim != 2: raise ValueError(f"render_channel expects 2D input; got shape {channel_2d.shape}.") if idx == 0: img = _tinted_grayscale(channel_2d, _PURE_R) elif idx == 1: img = _tinted_grayscale(channel_2d, _PURE_G) elif idx == 2: img = _tinted_grayscale(channel_2d, _PURE_B) elif idx == 3: img = _ramp(channel_2d, PAPER_WHITE, NEAR_BLACK) elif idx == 4: img = _ramp(channel_2d, PAPER_WHITE, INDIGO) elif idx == 5: img = _ramp(channel_2d, PAPER_WHITE, VERMILION) elif idx == 6: img = _diverging(channel_2d, VERMILION, PAPER_WHITE, INDIGO) elif idx == 7: img = _ramp(channel_2d, PAPER_WHITE, INDIGO) elif idx == 8: img = _ramp(channel_2d, PAPER_WHITE, INDIGO) else: raise ValueError(f"render_channel: idx must be in 0..8; got {idx}.") return _encode_rgb(img) def render_heatmap(prob_2d: np.ndarray) -> bytes: """Render a probability map (paper-white -> indigo).""" if prob_2d.ndim != 2: raise ValueError(f"render_heatmap expects 2D input; got shape {prob_2d.shape}.") return _encode_rgb(_ramp(prob_2d, PAPER_WHITE, INDIGO)) def render_binary(binary_2d: np.ndarray) -> bytes: """Render a binary mask as paper-white background with indigo foreground.""" if binary_2d.ndim != 2: raise ValueError(f"render_binary expects 2D input; got shape {binary_2d.shape}.") mask = (binary_2d > 0).astype(np.float32) return _encode_rgb(_ramp(mask, PAPER_WHITE, INDIGO)) def render_instances(label_2d: np.ndarray) -> bytes: """Render a labeled instance map with one random color per instance. Background (label 0) is paper-white. Colors are deterministic across requests (``np.random.default_rng(42)``). """ if label_2d.ndim != 2: raise ValueError( f"render_instances expects 2D input; got shape {label_2d.shape}." ) h, w = label_2d.shape img = np.full((h, w, 3), PAPER_WHITE, dtype=np.uint8) if label_2d.max() <= 0: return _encode_rgb(img) max_label = int(label_2d.max()) rng = np.random.default_rng(42) # One color per id 1..max_label (id 0 stays paper-white). palette = rng.integers(40, 230, size=(max_label + 1, 3), dtype=np.uint8) palette[0] = PAPER_WHITE img[:] = palette[label_2d] return _encode_rgb(img) def render_overlay( rgb_u8: np.ndarray, label_2d: np.ndarray, *, opacity: float, ) -> tuple[bytes, bytes]: """Composite vermilion instance contours on top of the H&E image. Returns: ``(overlay_png, contours_rgba_png)``: ``overlay_png`` is the RGB composite at the given opacity, ready for a static ```` tag. ``contours_rgba_png`` is the contours layer alone with a transparent background; the frontend re-blends it client-side as the user moves the opacity slider. """ if rgb_u8.dtype != np.uint8 or rgb_u8.ndim != 3 or rgb_u8.shape[2] != 3: raise ValueError( f"render_overlay expects (H, W, 3) uint8 RGB; got {rgb_u8.shape}/{rgb_u8.dtype}." ) if label_2d.ndim != 2 or label_2d.shape != rgb_u8.shape[:2]: raise ValueError( f"render_overlay: label shape {label_2d.shape} must match " f"RGB spatial dims {rgb_u8.shape[:2]}." ) opacity = float(max(0.0, min(1.0, opacity))) h, w = label_2d.shape contours_rgba = np.zeros((h, w, 4), dtype=np.uint8) label_i32 = label_2d.astype(np.int32, copy=False) unique_ids = np.unique(label_i32) unique_ids = unique_ids[unique_ids > 0] for iid in unique_ids: binary = (label_i32 == iid).astype(np.uint8) contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) if not contours: continue cv2.drawContours(contours_rgba, contours, -1, (VERMILION[0], VERMILION[1], VERMILION[2], 255), 1) # Composite: only stroke pixels are blended. overlay = rgb_u8.copy() stroke_mask = contours_rgba[..., 3] > 0 if stroke_mask.any() and opacity > 0.0: stroke_rgb = contours_rgba[..., :3].astype(np.float32) base_rgb = overlay.astype(np.float32) blended = (1.0 - opacity) * base_rgb + opacity * stroke_rgb overlay[stroke_mask] = np.clip(blended[stroke_mask], 0, 255).astype(np.uint8) return _encode_rgb(overlay), _encode_rgba(contours_rgba) def to_b64(png_bytes: bytes) -> str: """Wrap raw PNG bytes as a ``data:image/png;base64,...`` string.""" return "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii")