|
|
| 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 = 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]
|
|
|
|
|
| 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
|
|
|
|
|
| axial_slice = v[:, :, d // 2]
|
| coronal_slice = v[:, w // 2, :]
|
| sagittal_slice = v[h // 2, :, :].T
|
|
|
|
|
| axial_mip = v.max(axis=2)
|
| coronal_mip = v.max(axis=1)
|
| sagittal_mip = v.max(axis=0).T
|
|
|
|
|
| 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],
|
| )
|
|
|
|
|
| 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()
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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:
|
|
|
| pass
|
|
|
|
|
| if len(shp) == 3:
|
| png_path = tmpdir / "orthogonal_views.png"
|
| try:
|
|
|
| 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"
|
| )
|
|
|
| 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"
|
| )
|
|
|
|
|
| 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
|
|
|
|
|
| if len(shp) == 4:
|
| vol = np.asarray(data).mean(axis=-1)
|
| 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")
|
|
|
| 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
|
|
|
|
|
| if len(shp) == 2:
|
| out = tmpdir / "image_preview.png"
|
| arr2 = _norm_uint8(arr)
|
| _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")
|
|
|