ai-agent / src /ai_agent /utils /previews.py
katospiegel's picture
Deploy develop: FastAPI+React frontend, multi-stage Docker (ai_agent serve)
07c2476 verified
Raw
History Blame Contribute Delete
15.7 kB
# utils/previews.py
from __future__ import annotations
from pathlib import Path
import json
import os
import numpy as np
import imageio.v3 as iio
import tempfile
import logging
import time
import tifffile as tiff
from typing import List, Optional, Tuple
from PIL import Image
from ai_agent.utils.image_meta import summarize_image_metadata
from ai_agent.utils.image_io import load_any
from ai_agent.utils.cache_db import get_cache_db
log = logging.getLogger("pipeline")
PREVIEW_CACHE_TTL_SECONDS = int(os.getenv("PREVIEW_CACHE_TTL_SECONDS", "1800"))
PREVIEW_CACHE_MAX_ENTRIES = int(os.getenv("PREVIEW_CACHE_MAX_ENTRIES", "64"))
PREVIEW_MAX_SIDE_PX = int(os.getenv("PREVIEW_MAX_SIDE_PX", "500"))
_PREVIEW_NS = "preview"
def _fingerprint_paths(paths: List[str]) -> tuple[str, ...]:
fps: list[str] = []
for p in paths:
pp = Path(p)
try:
st = pp.stat()
fps.append(f"{pp.resolve()}::{st.st_mtime_ns}::{st.st_size}")
except Exception:
fps.append(str(pp))
return tuple(fps)
def _clear_preview_cache_for_tests() -> None:
"""Test helper to avoid cache state leakage across test cases."""
get_cache_db().clear(_PREVIEW_NS)
def _preview_cache_get(key: tuple[str, ...]) -> Tuple[Optional[str], Optional[str]]:
if PREVIEW_CACHE_TTL_SECONDS <= 0:
return None, None
try:
db_key = json.dumps(key)
raw = get_cache_db().get(_PREVIEW_NS, db_key)
if raw is None:
return None, None
entry = json.loads(raw)
preview_path: str = entry["path"]
meta_text: Optional[str] = entry.get("meta")
if not Path(preview_path).exists():
try:
get_cache_db().delete(_PREVIEW_NS, db_key)
except Exception:
pass
return None, None
return preview_path, meta_text
except Exception:
log.warning("Preview cache get failed; skipping cache.", exc_info=True)
return None, None
def _preview_cache_set(
key: tuple[str, ...], preview_path: str, meta_text: Optional[str]
) -> None:
if PREVIEW_CACHE_TTL_SECONDS <= 0:
return
try:
db_key = json.dumps(key)
value = json.dumps({"path": preview_path, "meta": meta_text})
get_cache_db().set(
_PREVIEW_NS,
db_key,
value,
ttl_seconds=PREVIEW_CACHE_TTL_SECONDS,
max_entries=PREVIEW_CACHE_MAX_ENTRIES,
)
except Exception:
log.warning("Preview cache set failed; continuing without caching.", exc_info=True)
def _norm_uint8(a: np.ndarray) -> np.ndarray:
v = a.astype(np.float32)
v = v - np.nanmin(v)
vmax = np.nanpercentile(v, 99.5) if np.isfinite(v).any() else 1.0
vmax = vmax if vmax > 0 else (v.max() if v.max() > 0 else 1.0)
v = np.clip(v / vmax, 0, 1)
return (v * 255).astype(np.uint8)
def _is_rgb_like(shape: tuple[int, ...]) -> bool:
"""True for 2D color images shaped (H, W, 3/4)."""
return len(shape) == 3 and shape[-1] in (3, 4) and shape[0] >= 16 and shape[1] >= 16
def _to_uint8_image(arr: np.ndarray) -> np.ndarray:
"""Convert any numeric array to a uint8 image without changing shape."""
a = np.asarray(arr)
if a.dtype == np.uint8:
return a
if np.issubdtype(a.dtype, np.floating):
if np.nanmax(a) <= 1.0:
a = np.clip(a, 0.0, 1.0) * 255.0
else:
a = np.clip(a, 0.0, 255.0)
return a.astype(np.uint8)
return np.clip(a, 0, 255).astype(np.uint8)
def _resize_for_preview(img: Image.Image, max_side_px: int = PREVIEW_MAX_SIDE_PX) -> Image.Image:
"""Resize oversized previews while preserving aspect ratio."""
max_side_px = max(1, int(max_side_px))
if max(img.size) <= max_side_px:
return img
resized = img.copy()
resized.thumbnail((max_side_px, max_side_px), Image.Resampling.LANCZOS)
return resized
def mip_montage(vol3d: np.ndarray, out_png: str | Path) -> str:
vol3d = _norm_uint8(vol3d)
axial = vol3d.max(axis=2)
cor = vol3d.max(axis=1)
sag = vol3d.max(axis=0).T
h1 = np.hstack([axial, cor])
# pad to rectangle
pad = np.zeros_like(axial)
img = np.vstack([h1, np.hstack([sag, pad])])
_resize_for_preview(Image.fromarray(img)).save(str(out_png))
return str(out_png)
def slice_gif(
vol: np.ndarray, out_gif: str | Path, axis: int = 2, step: int = 1, fps: int = 10
) -> str:
v = _norm_uint8(vol)
idxs = list(range(0, v.shape[axis], step))
frames = [np.take(v, i, axis=axis) for i in idxs]
if frames:
h, w = frames[0].shape[:2]
max_side_px = max(1, PREVIEW_MAX_SIDE_PX)
if max(h, w) > max_side_px:
scale = max_side_px / float(max(h, w))
new_w = max(1, int(round(w * scale)))
new_h = max(1, int(round(h * scale)))
resized_frames = []
for frame in frames:
pil_frame = Image.fromarray(frame)
resized_frames.append(
np.asarray(
pil_frame.resize((new_w, new_h), Image.Resampling.LANCZOS)
)
)
frames = resized_frames
iio.imwrite(str(out_gif), frames, plugin="pillow", duration=int(1000 / fps), loop=0)
return str(out_gif)
def contact_sheet_slices(
vol3d: np.ndarray,
out_png: str | Path,
max_slices: int = 36,
grid_cols: int = 6,
) -> str:
v = _norm_uint8(vol3d)
depth = v.shape[2]
step = max(1, depth // max_slices)
frames = [v[:, :, i] for i in range(0, depth, step)]
frames = frames[:max_slices] # cap exactly
# pad to full grid
cols = grid_cols
rows = int(np.ceil(len(frames) / cols))
h, w = frames[0].shape
canvas = np.zeros((rows * h, cols * w), dtype=np.uint8)
for idx, frame in enumerate(frames):
r = idx // cols
c = idx % cols
canvas[r * h : (r + 1) * h, c * w : (c + 1) * w] = frame
_resize_for_preview(Image.fromarray(canvas)).save(str(out_png))
return str(out_png)
def create_orthogonal_views(vol3d: np.ndarray, out_png: str | Path) -> str:
"""
Create a comprehensive 3-view (axial, coronal, sagittal) visualization.
Each view shows both a middle slice and a MIP projection.
Args:
vol3d: 3D volume array
out_png: Output path for PNG
"""
v = _norm_uint8(vol3d)
h, w, d = v.shape
# Middle slices
axial_slice = v[:, :, d // 2]
coronal_slice = v[:, w // 2, :]
sagittal_slice = v[h // 2, :, :].T
# MIP projections
axial_mip = v.max(axis=2)
coronal_mip = v.max(axis=1)
sagittal_mip = v.max(axis=0).T
# Ensure all views have similar aspect ratios by padding
def pad_to_square(img: np.ndarray, target_size: int) -> np.ndarray:
h, w = img.shape
if h == w:
return img
pad_h = (target_size - h) // 2 if h < target_size else 0
pad_w = (target_size - w) // 2 if w < target_size else 0
return np.pad(
img,
((pad_h, target_size - h - pad_h), (pad_w, target_size - w - pad_w)),
mode="constant",
)
max_dim = max(
axial_slice.shape[0],
axial_slice.shape[1],
coronal_slice.shape[0],
coronal_slice.shape[1],
sagittal_slice.shape[0],
sagittal_slice.shape[1],
)
# Create 2x3 grid: MIPs on top row, slices on bottom row
top_row = np.hstack(
[
pad_to_square(axial_mip, max_dim),
pad_to_square(coronal_mip, max_dim),
pad_to_square(sagittal_mip, max_dim),
]
)
bottom_row = np.hstack(
[
pad_to_square(axial_slice, max_dim),
pad_to_square(coronal_slice, max_dim),
pad_to_square(sagittal_slice, max_dim),
]
)
composite = np.vstack([top_row, bottom_row])
_resize_for_preview(Image.fromarray(composite)).save(str(out_png))
return str(out_png)
def _build_preview_for_vlm(
image_paths: Optional[List[str]],
) -> Tuple[Optional[str], Optional[str]]:
"""
Build an enhanced preview image optimized for VLM analysis.
Strategy:
- 2D images: Convert and normalize when needed
- 3D volumes: Create orthogonal multi-view composite
- 4D data: Extract representative 3D volume, then multi-view
- Medical images: Ensure proper intensity windowing
Returns:
(preview_path, metadata_text)
"""
if not image_paths:
return None, None
cache_key = _fingerprint_paths(image_paths)
cached_preview, cached_meta = _preview_cache_get(cache_key)
if cached_preview:
log.info("Preview cache hit for %d file(s)", len(image_paths))
return cached_preview, cached_meta
meta_text = None
try:
meta_text = summarize_image_metadata(image_paths)
except Exception:
log.exception(
"Image metadata summarization failed; continuing without metadata."
)
try:
_cleanup_old_previews(hours=24)
except Exception:
pass
for p in image_paths:
try:
data, meta = load_any(p)
shp = getattr(meta, "shape", None) or meta.get("shape")
if shp is None:
shp = getattr(data, "shape", None)
if shp is None:
continue
tmpdir = Path(tempfile.mkdtemp(prefix="preview_"))
arr = np.asarray(data)
ext = Path(p).suffix.lower()
# Handle true color images (H, W, 3/4) safely
# For PNG/JPEG/WebP, (H,W,3/4) is almost certainly color.
if _is_rgb_like(arr.shape) and ext in {".png", ".jpg", ".jpeg", ".webp"}:
out = tmpdir / "image_preview.png"
img_uint8 = _to_uint8_image(arr)
_resize_for_preview(Image.fromarray(img_uint8)).save(str(out))
_preview_cache_set(cache_key, str(out), meta_text)
return str(out), meta_text
# For TIFF, (H,W,3) can be either RGB or a 3-slice stack.
# If tags say it's RGB, render as color; otherwise treat as stack (fall through).
if _is_rgb_like(arr.shape) and ext in {".tif", ".tiff"}:
try:
with tiff.TiffFile(p) as tf:
page = tf.pages[0]
spp = int(getattr(page, "samplesperpixel", 1))
photometric = str(getattr(page, "photometric", "")).upper()
if spp in (3, 4) and (
"RGB" in photometric or "YCBCR" in photometric
):
out = tmpdir / "image_preview.png"
img_uint8 = _to_uint8_image(arr)
_resize_for_preview(Image.fromarray(img_uint8)).save(str(out))
_preview_cache_set(cache_key, str(out), meta_text)
return str(out), meta_text
except Exception:
# If tags can't be read, prefer treating TIFF (H,W,3) as a stack
pass
# 3D volumes: Create enhanced multi-view composite
if len(shp) == 3:
png_path = tmpdir / "orthogonal_views.png"
try:
# Try orthogonal views first (best for VLM understanding)
create_orthogonal_views(arr, png_path)
if png_path.exists():
log.info(
f"Created orthogonal view composite for 3D volume {shp}"
)
_preview_cache_set(cache_key, str(png_path), meta_text)
return str(png_path), meta_text
except Exception as e:
log.warning(
f"Orthogonal views failed: {e}, falling back to contact sheet"
)
# Fallback to contact sheet
png_path = tmpdir / "slices_grid.png"
try:
contact_sheet_slices(arr, png_path, max_slices=36, grid_cols=6)
if png_path.exists():
_preview_cache_set(cache_key, str(png_path), meta_text)
return str(png_path), meta_text
except Exception as e:
log.warning(
f"Contact sheet preview failed: {e}, falling back to MIP montage"
)
# Final fallback: MIP montage
try:
mip_montage(arr, png_path)
if png_path.exists():
_preview_cache_set(cache_key, str(png_path), meta_text)
return str(png_path), meta_text
except Exception:
pass
# 4D data: Extract representative 3D volume (mean over time), then multi-view
if len(shp) == 4:
vol = np.asarray(data).mean(axis=-1) # Average over 4th dimension
out = tmpdir / "orthogonal_4d.png"
try:
create_orthogonal_views(vol, out)
if out.exists():
log.info(f"Created orthogonal view for 4D volume {shp}")
_preview_cache_set(cache_key, str(out), meta_text)
return str(out), meta_text
except Exception as e:
log.warning(f"4D orthogonal failed: {e}, trying gif")
# Fallback to animated GIF
out = tmpdir / "sweep.gif"
step = max(1, vol.shape[2] // 64)
slice_gif(vol, out, axis=2, step=step, fps=12)
_preview_cache_set(cache_key, str(out), meta_text)
return str(out), meta_text
# 2D images: Normalize and resize.
if len(shp) == 2:
out = tmpdir / "image_preview.png"
arr2 = _norm_uint8(arr) # Use consistent normalization
_resize_for_preview(Image.fromarray(arr2)).save(str(out))
_preview_cache_set(cache_key, str(out), meta_text)
return str(out), meta_text
except Exception as e:
log.warning(f"Preview generation failed for {p}: {e}")
continue
return None, meta_text
def _cleanup_old_previews(hours: int = 24) -> None:
"""
Delete preview_* folders older than `hours` from the system temp dir.
Best-effort; ignore errors.
"""
root = Path(tempfile.gettempdir())
cutoff = time.time() - hours * 3600
try:
for p in root.glob("preview_*"):
try:
if p.is_dir() and p.stat().st_mtime < cutoff:
for sub in p.glob("**/*"):
try:
if sub.is_file():
sub.unlink()
except Exception:
pass
p.rmdir()
except Exception:
pass
except Exception:
logging.getLogger("api").exception("Preview cleanup failed")