ai-agent / src /ai_agent /utils /image_meta.py
katospiegel's picture
Deploy develop: FastAPI+React frontend, multi-stage Docker (ai_agent serve)
07c2476 verified
Raw
History Blame Contribute Delete
18.2 kB
# utils/image_meta.py
from __future__ import annotations
from pathlib import Path
from typing import Optional, List
import json
import os
import nibabel as nib
import pydicom
from PIL import Image
import tifffile as tiff
from ai_agent.utils.cache_db import CacheDB, get_cache_db
# ---------------------------------------------------------------------------
# SQLite-backed metadata cache (keyed by resolved-path + mtime + size)
# Avoids re-reading large files (e.g. TIFF stacks) on every retrieval call.
#
# PRIVACY NOTE: DICOM-derived metadata can contain sensitive identifying
# fields (Study/Series descriptions, institution names, patient context).
# By default the metadata namespace uses an in-memory-only CacheDB so
# nothing is written to disk. Set IMAGE_META_CACHE_PERSIST=1 to opt into
# on-disk persistence (e.g. for long-running servers with large TIFF stacks).
# ---------------------------------------------------------------------------
_META_CACHE_MAX = int(os.getenv("IMAGE_META_CACHE_MAX", "128"))
_META_CACHE_PERSIST = os.getenv("IMAGE_META_CACHE_PERSIST", "0").strip() == "1"
_META_NS = "meta"
_meta_log = __import__("logging").getLogger("cache_db.meta")
# In-memory-only DB used when persistence is disabled (the default).
_meta_mem_db: CacheDB | None = None if _META_CACHE_PERSIST else CacheDB(":memory:")
def _get_meta_db() -> CacheDB:
"""Return the CacheDB instance to use for image metadata."""
return get_cache_db() if _META_CACHE_PERSIST else _meta_mem_db # type: ignore[return-value]
def _meta_cache_key(p: Path) -> str:
"""Stable cache key derived from resolved path, mtime, and size."""
try:
st = p.stat()
return json.dumps([str(p.resolve()), st.st_mtime_ns, st.st_size])
except Exception:
return json.dumps([str(p), 0, 0])
def _meta_cache_get(key: str) -> Optional[str]:
try:
return _get_meta_db().get(_META_NS, key)
except Exception:
_meta_log.warning("Metadata cache get failed; skipping cache.", exc_info=True)
return None
def _meta_cache_set(key: str, value: str) -> None:
try:
_get_meta_db().set(_META_NS, key, value, max_entries=_META_CACHE_MAX)
except Exception:
_meta_log.warning("Metadata cache set failed; continuing without caching.", exc_info=True)
# ---- small helpers -----------------------------------------------------------
def _filesize_str(p: Path) -> str:
try:
b = p.stat().st_size
for unit in ("B", "KB", "MB", "GB", "TB"):
if b < 1024.0:
return f"{b:.1f}{unit}"
b /= 1024.0
except Exception:
pass
return "?"
def _is_nifti_path(p: Path) -> bool:
s = p.name.lower()
return s.endswith(".nii") or s.endswith(".nii.gz")
def _is_dicom_file(p: Path) -> bool:
"""More robust DICOM detection"""
try:
# First try extension
if p.suffix.lower() == ".dcm":
return True
# Then try DICM magic number
with open(p, "rb") as f:
f.seek(128)
if f.read(4) == b"DICM":
return True
# Finally try loading with pydicom
try:
ds = pydicom.dcmread(str(p), stop_before_pixels=True, force=True)
return hasattr(ds, "SOPClassUID")
except:
pass
return False
except Exception:
return False
def _is_dicom_path(p: Path) -> bool:
if p.is_dir():
return True # heuristic: treat dirs as DICOM series candidates
return _is_dicom_file(p)
# ---- summarizers -------------------------------------------------------------
def _summarize_nifti(p: Path) -> Optional[str]:
try:
img = nib.load(str(p))
hdr = img.header
shape = tuple(int(x) for x in img.shape)
zooms = tuple(float(z) for z in hdr.get_zooms()[: len(shape)])
dtype = str(hdr.get_data_dtype())
return (
f"NIfTI {len(shape)}D {shape} @ "
+ (("×".join(f"{z:.2f}" for z in zooms[:3]) + " mm") if zooms else "?")
+ f" dtype={dtype} filename={p.name} size={_filesize_str(p)}"
)
except Exception:
# fall back to a minimal line
return f"NIfTI ?D ? filename={p.name} size={_filesize_str(p)}"
def _summarize_dicom(path: Path) -> Optional[str]:
"""
Summarize a DICOM series (dir) or single file without loading pixels.
XA/fluoro handling:
- Pixel spacing from Shared/Per-Frame Functional Groups if present.
- Fall back to PixelSpacing and ImagerPixelSpacing.
- Cine-style objects reported as 'frames'; add fps when possible.
- Treat empty strings like missing values.
"""
# ---------- helpers ----------
def _nz(v):
"""None or empty/whitespace -> None; else strip strings."""
if v is None:
return None
if isinstance(v, str):
s = v.strip()
return s if s else None
return v
def _first(*vals):
for v in vals:
v = _nz(v)
if v is not None:
return v
return None
def _safe_float(x):
try:
return float(x)
except Exception:
return None
def _code_meaning(seq):
try:
if seq and len(seq) > 0:
item = seq[0]
return _first(
getattr(item, "CodeMeaning", None), getattr(item, "CodeValue", None)
)
except Exception:
pass
return None
def _px_from_fgs(ds):
"""(sy, sx, sz) from functional groups if present."""
sy = sx = sz = None
try:
sfg = getattr(ds, "SharedFunctionalGroupsSequence", None)
if sfg and len(sfg) > 0:
pms = getattr(sfg[0], "PixelMeasuresSequence", None)
if pms and len(pms) > 0:
p = pms[0]
ps = getattr(p, "PixelSpacing", None)
if isinstance(ps, (list, tuple)) and len(ps) == 2:
sy, sx = _safe_float(ps[0]), _safe_float(ps[1])
sz = _first(
_safe_float(getattr(p, "SpacingBetweenSlices", None)),
_safe_float(getattr(p, "SliceThickness", None)),
)
if sy is None or sx is None:
pfg = getattr(ds, "PerFrameFunctionalGroupsSequence", None)
if pfg and len(pfg) > 0:
for it in pfg[:8]:
pms = getattr(it, "PixelMeasuresSequence", None)
if pms and len(pms) > 0:
p = pms[0]
ps = getattr(p, "PixelSpacing", None)
if isinstance(ps, (list, tuple)) and len(ps) == 2:
sy = _safe_float(ps[0]) if sy is None else sy
sx = _safe_float(ps[1]) if sx is None else sx
if sz is None:
sz = _first(
_safe_float(
getattr(p, "SpacingBetweenSlices", None)
),
_safe_float(getattr(p, "SliceThickness", None)),
)
if sy is not None and sx is not None:
break
except Exception:
pass
return sy, sx, sz
def _anatomy_from_fgs(ds):
try:
sfg = getattr(ds, "SharedFunctionalGroupsSequence", None)
if sfg and len(sfg) > 0:
fas = getattr(sfg[0], "FrameAnatomySequence", None)
if fas and len(fas) > 0:
return _code_meaning(
getattr(fas[0], "AnatomicRegionSequence", None)
)
pfg = getattr(ds, "PerFrameFunctionalGroupsSequence", None)
if pfg and len(pfg) > 0:
fas = getattr(pfg[0], "FrameAnatomySequence", None)
if fas and len(fas) > 0:
return _code_meaning(
getattr(fas[0], "AnatomicRegionSequence", None)
)
except Exception:
pass
return None
# ---------- gather a few headers ----------
try:
if path.is_dir():
files = [p for p in path.rglob("*") if p.is_file()]
files = files[:256]
else:
files = [path]
dsets = []
for fp in files:
try:
ds = pydicom.dcmread(str(fp), stop_before_pixels=True, force=True)
if (
getattr(ds, "SOPClassUID", None) is None
and "_is_dicom_file" in globals()
):
if not _is_dicom_file(fp):
continue
dsets.append(ds)
except Exception:
continue
if not dsets:
tag = "DIR" if path.is_dir() else "FILE"
return f"DICOM ({tag}) filename={path.name}"
ds0 = dsets[0]
modality = _first(getattr(ds0, "Modality", None), "?").upper()
rows = getattr(ds0, "Rows", None)
cols = getattr(ds0, "Columns", None)
size_txt = f"{cols}x{rows}" if rows and cols else "?"
# frames vs slices
try:
n_frames = int(_first(getattr(ds0, "NumberOfFrames", None), 0) or 0)
except Exception:
n_frames = 0
cine_like = (n_frames > 1) or modality in {"XA", "XRF", "US", "RF"}
n_items = n_frames if n_frames > 0 else len(dsets)
count_label = "frames" if cine_like else "slices"
# spacing (sy, sx, sz)
sy, sx, sz = _px_from_fgs(ds0)
if sy is None or sx is None:
px = getattr(ds0, "PixelSpacing", None)
if isinstance(px, (list, tuple)) and len(px) == 2:
sy, sx = _safe_float(px[0]), _safe_float(px[1])
if sy is None or sx is None:
ipx = getattr(ds0, "ImagerPixelSpacing", None) # common in XA
if isinstance(ipx, (list, tuple)) and len(ipx) == 2:
sy = _safe_float(ipx[0]) if sy is None else sy
sx = _safe_float(ipx[1]) if sx is None else sx
# Z spacing (rarely meaningful for XA cine)
if not cine_like and sz is None and len(dsets) > 1:
try:
zvals = []
for ds in dsets[:64]:
ipp = getattr(ds, "ImagePositionPatient", None)
if isinstance(ipp, (list, tuple)) and len(ipp) == 3:
z = _safe_float(ipp[2])
if z is not None:
zvals.append(z)
if len(zvals) >= 2:
zvals.sort()
diffs = [
abs(zvals[i + 1] - zvals[i]) for i in range(len(zvals) - 1)
]
if diffs:
sz = sum(diffs) / len(diffs)
except Exception:
pass
if not cine_like and sz is None:
sz = _first(
getattr(ds0, "SpacingBetweenSlices", None),
getattr(ds0, "SliceThickness", None),
)
sz = _safe_float(sz)
# body / series (with better fallbacks)
body = _first(
getattr(ds0, "BodyPartExamined", None),
_code_meaning(getattr(ds0, "AnatomicRegionSequence", None)),
_anatomy_from_fgs(ds0),
getattr(ds0, "RequestedProcedureDescription", None),
getattr(ds0, "StudyDescription", None),
"?",
)
series = _first(
getattr(ds0, "SeriesDescription", None),
getattr(ds0, "ProtocolName", None),
getattr(ds0, "PerformedProcedureStepDescription", None),
getattr(ds0, "StudyDescription", None),
"?",
)
# cine timing
fps = None
cine_rate = _safe_float(getattr(ds0, "CineRate", None))
if cine_rate and cine_rate > 0:
fps = cine_rate
else:
ft = _safe_float(getattr(ds0, "FrameTime", None)) # ms
if ft and ft > 0:
fps = 1000.0 / ft
else:
# FrameTimeVector fallback: average of first few
try:
ftv = getattr(ds0, "FrameTimeVector", None)
if ftv:
vals = [_safe_float(v) for v in list(ftv)[:16]]
vals = [v for v in vals if v and v > 0]
if vals:
fps = 1000.0 / (sum(vals) / len(vals))
except Exception:
pass
# spacing text
if sy is not None and sx is not None and (sz is not None and not cine_like):
sp_txt = f"{sy:.2f}×{sx:.2f}×{sz:.2f} mm"
elif sy is not None and sx is not None:
sp_txt = f"{sy:.2f}×{sx:.2f} mm"
else:
sp_txt = "N/A"
extras = []
if fps:
extras.append(f"fps≈{fps:.1f}")
extras_txt = (" " + " ".join(extras)) if extras else ""
scope = "DIR" if path.is_dir() else "FILE"
return (
f"DICOM {modality} {scope} "
f"{count_label}{n_items} size={size_txt} @ {sp_txt}{extras_txt} "
f"body={body} series='{series}' name={path.name}"
)
except Exception:
scope = "DIR" if path.is_dir() else "FILE"
return f"DICOM {scope} name={path.name}"
def _summarize_image(p: Path) -> Optional[str]:
"""
Summarize PNG/JPEG/TIFF (including TIFF stacks) via Pillow.
"""
try:
with Image.open(str(p)) as im:
n = getattr(im, "n_frames", 1)
size = getattr(im, "size", None)
mode = im.mode
fmt = p.suffix.upper().lstrip(".")
# Try to get dtype/compression for TIFF if tifffile is available
dtype_txt = comp_txt = ""
if p.suffix.lower() in (".tif", ".tiff"):
try:
with tiff.TiffFile(str(p)) as tf:
page = tf.pages[0]
arr = page.asarray()
dtype_txt = f" dtype={getattr(arr, 'dtype', '')}"
comp = getattr(page, "compression", None)
if comp:
comp_txt = (
f" compression={getattr(comp, 'name', str(comp))}"
)
except Exception:
pass
return f"{fmt} {'stack' if n>1 else 'image'} frames={n} size={size} mode={mode}{dtype_txt}{comp_txt} filename={p.name} size={_filesize_str(p)}"
except Exception:
return f"{p.suffix.upper().lstrip('.')} ? filename={p.name} size={_filesize_str(p)}"
# ---- public API --------------------------------------------------------------
def summarize_image_metadata(
paths: Optional[List[str]] | Optional[str],
) -> Optional[str]:
"""
Build a short, human-readable summary for one path or a list of paths.
- DICOM dir/file: uses pydicom (tags only) and estimates slices & spacing.
- NIfTI: uses nibabel to get shape/zooms/dtype.
- Images (PNG/JPEG/TIFF...): uses Pillow, recognizes TIFF stacks.
This function is robust: failures result in minimal per-item summaries.
Results are cached in-process by (path, mtime_ns, size) so repeated calls
within or across requests do not re-read the same file.
"""
if not paths:
return None
if isinstance(paths, str):
paths = [paths]
parts: List[str] = []
for s in paths:
try:
p = Path(s)
cache_key = _meta_cache_key(p)
cached = _meta_cache_get(cache_key)
if cached is not None:
parts.append(cached)
continue
if p.is_dir() or _is_dicom_path(p):
result = _summarize_dicom(p) or f"DICOM name={p.name}"
elif _is_nifti_path(p):
result = _summarize_nifti(p) or f"NIfTI name={p.name}"
else:
result = (
_summarize_image(p)
or f"{p.suffix.upper().lstrip('.')} name={p.name}"
)
_meta_cache_set(cache_key, result)
parts.append(result)
except Exception as e:
parts.append(f"Unreadable '{s}': {e.__class__.__name__}")
return " | ".join(parts)
def detect_ext_token(paths: Optional[List[str]] | Optional[str]) -> Optional[str]:
"""
Return a space-separated string of canonical format tokens among the inputs:
e.g., "DICOM NIfTI TIFF". Useful to bias retrieval.
"""
if not paths:
return None
if isinstance(paths, str):
paths = [paths]
tokens = set()
for s in paths:
p = Path(s)
if p.is_dir() or _is_dicom_path(p) or p.suffix.lower() == ".dcm":
tokens.add("DICOM")
continue
if _is_nifti_path(p):
tokens.add("NIfTI")
continue
ext = p.suffix.lower()
if ext in (".tif", ".tiff"):
tokens.add("TIFF")
elif ext in (".png",):
tokens.add("PNG")
elif ext in (".jpg", ".jpeg"):
tokens.add("JPEG")
elif ext in (".bmp",):
tokens.add("BMP")
elif ext in (".webp",):
tokens.add("WEBP")
return " ".join(sorted(tokens)) if tokens else None