blindspot / eval /render.py
yashwrites's picture
Blindspot: initial Space deploy
54f0281 verified
Raw History Blame Contribute Delete
5.74 kB
"""Image rendering for eval prompts and galleries (grid overlay, learner marks, expert outlines, crops).
Rendered images go to the API (base64) or to data/eval_galleries/ — never to eval/reports/ (no patient images in
committed reports).
"""
from __future__ import annotations
import base64
from pathlib import Path
from typing import Any
import cv2
import numpy as np
from eval.common import sha256_bytes
from shared.contracts import Case
CYAN = (221, 201, 53) # BGR of #35C9DD (expert truth)
AMBER = (46, 169, 240) # BGR of #F0A92E (learner)
GRID = (0, 230, 255) # BGR bright yellow
BLACK = (0, 0, 0)
COLS = "ABCDEFGH"
def load_gray(root: Path, case: Case) -> np.ndarray:
img = cv2.imread(str(Path(root) / case.image_path), cv2.IMREAD_GRAYSCALE)
if img is None:
raise FileNotFoundError(Path(root) / case.image_path)
return img
def to_rgb(gray: np.ndarray) -> np.ndarray:
return cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) if gray.ndim == 2 else gray.copy()
def png_bytes(img_bgr: np.ndarray) -> bytes:
ok, buf = cv2.imencode(".png", img_bgr)
if not ok:
raise RuntimeError("png encode failed")
return buf.tobytes()
def image_block(img_bgr: np.ndarray) -> tuple[dict[str, Any], str]:
"""Anthropic image content block + sha256 of the PNG bytes."""
b = png_bytes(img_bgr)
return (
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": base64.standard_b64encode(b).decode("ascii"),
},
},
sha256_bytes(b),
)
def _text(img: np.ndarray, s: str, org: tuple[int, int], scale: float, color: tuple[int, int, int], th: int) -> None:
cv2.putText(img, s, org, cv2.FONT_HERSHEY_SIMPLEX, scale, BLACK, th + 2, cv2.LINE_AA)
cv2.putText(img, s, org, cv2.FONT_HERSHEY_SIMPLEX, scale, color, th, cv2.LINE_AA)
# ------------------------------------------------------------------------------------------------ grid (§12.1)
def cell_of(x: float, y: float, w: int, h: int, n: int = 8) -> str:
c = int(np.clip(np.floor(x / (w / n)), 0, n - 1))
r = int(np.clip(np.floor(y / (h / n)), 0, n - 1))
return f"{COLS[c]}{r + 1}"
def cell_bbox(cell: str, w: int, h: int, n: int = 8) -> tuple[int, int, int, int]:
c = COLS.index(cell[0].upper())
r = int(cell[1:]) - 1
if not (0 <= c < n and 0 <= r < n):
raise ValueError(cell)
return (int(round(c * w / n)), int(round(r * h / n)), int(round((c + 1) * w / n)), int(round((r + 1) * h / n)))
def all_cells(n: int = 8) -> list[str]:
return [f"{COLS[c]}{r + 1}" for r in range(n) for c in range(n)]
def grid_overlay(gray: np.ndarray, n: int = 8) -> np.ndarray:
"""Columns A–H left→right (image x), rows 1–8 top→bottom (image y); thin lines, edge labels."""
img = to_rgb(gray)
h, w = img.shape[:2]
th = max(1, int(round(w / 700)))
for i in range(1, n):
x = int(round(i * w / n))
y = int(round(i * h / n))
cv2.line(img, (x, 0), (x, h - 1), GRID, th, cv2.LINE_AA)
cv2.line(img, (0, y), (w - 1, y), GRID, th, cv2.LINE_AA)
scale = w / 1100
tth = max(1, int(round(w / 500)))
for c in range(n):
cx = int(round((c + 0.5) * w / n))
_text(img, COLS[c], (cx - int(10 * scale), int(28 * scale) + 4), scale, GRID, tth)
for r in range(n):
cy = int(round((r + 0.5) * h / n))
_text(img, str(r + 1), (4, cy + int(10 * scale)), scale, GRID, tth)
return img
# ------------------------------------------------------------------------------------------------ overlays
def draw_marks(img: np.ndarray, marks: list[Any]) -> np.ndarray:
h, w = img.shape[:2]
r = max(4, int(round(w / 80)))
th = max(1, int(round(w / 400)))
for m in marks:
x, y = int(round(m.x)), int(round(m.y))
cv2.circle(img, (x, y), r, AMBER, th, cv2.LINE_AA)
cv2.line(img, (x - r // 2, y), (x + r // 2, y), AMBER, th)
cv2.line(img, (x, y - r // 2), (x, y + r // 2), AMBER, th)
_text(img, m.mark_id, (x + r + 2, y - r), w / 1300, AMBER, th)
return img
def draw_outlines(img: np.ndarray, case: Case, repo: Any, findings: list[Any] | None = None) -> np.ndarray:
h, w = img.shape[:2]
th = max(1, int(round(w / 450)))
for f in findings if findings is not None else case.findings:
m = repo.mask(case.case_id, f.finding_id)
if m is not None:
cnts, _ = cv2.findContours(m.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cv2.drawContours(img, cnts, -1, CYAN, th, cv2.LINE_AA)
else:
x0, y0, x1, y1 = (int(round(v)) for v in f.geometry.bbox)
cv2.rectangle(img, (x0, y0), (x1, y1), CYAN, th)
x0, y0 = int(f.geometry.bbox[0]), int(f.geometry.bbox[1])
_text(img, f.short_id, (max(0, x0), max(12, y0 - 4)), w / 1300, CYAN, th)
return img
def crop(img: np.ndarray, cx: float, cy: float, size: int) -> np.ndarray:
h, w = img.shape[:2]
size = min(size, w, h)
x0 = int(np.clip(round(cx - size / 2), 0, w - size))
y0 = int(np.clip(round(cy - size / 2), 0, h - size))
out = img[y0 : y0 + size, x0 : x0 + size].copy()
if size < 512:
out = cv2.resize(out, (size * 2, size * 2), interpolation=cv2.INTER_CUBIC)
return out
def point(img: np.ndarray, x: float, y: float, color: tuple[int, int, int], label: str = "") -> None:
h, w = img.shape[:2]
r = max(3, int(round(w / 100)))
cv2.drawMarker(img, (int(round(x)), int(round(y))), color, cv2.MARKER_TILTED_CROSS, 2 * r, max(1, w // 400))
if label:
_text(img, label, (int(x) + r, int(y) + r), w / 1400, color, max(1, w // 500))