| """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 ``<img |
| src>``. |
| |
| 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 |
|
|
|
|
| |
| 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_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) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
| |
| 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 ``<img>`` 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) |
|
|
| |
| 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") |
|
|