Communicative_CRN / src /physics.py
Sanni Henry
Initial deploy: Gradio landmark detection demo
377b913
Raw
History Blame Contribute Delete
10.3 kB
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# File: physics.py
#
# Physics module for:
# "Physics-Informed Communicative Reinforcement Learning via Hamilton-Jacobi
# Optimal Control for Robust Anatomical Landmark Detection in Low-Res Brain MRI"
#
# WHAT THIS IS (and what the two source PDFs got wrong):
# -----------------------------------------------------------------------------
# The proposal calls the constraint ||grad V|| * F = 1 a "Hamilton-Jacobi-Bellman"
# equation and claims it is "the same principle that underlies RL". That is not
# quite right. This equation is the *stationary Hamilton-Jacobi (Eikonal)* PDE. It
# is the HJ equation for the minimum-arrival-time / geodesic problem, NOT the
# Hamilton-Jacobi-*Bellman* equation whose solution is the RL value function.
# They are cousins, not twins (see EVALUATION.md).
#
# The *correct and defensible* way to use it here is:
# 1. Precompute, ONCE per image (offline), the geodesic navigation potential V
# by solving the Eikonal equation from each landmark, with an edge-modulated
# speed field F. (Solving a PINN per RL step, as the PDF suggests, is both
# unnecessary and prohibitively expensive.)
# 2. Use -V as a *potential-based reward shaping* term (Ng, Harada & Russell,
# 1999). This provably leaves the optimal policy unchanged while giving the
# agent a dense, anatomically-aware learning signal. The PDF's ad-hoc
# R = -dd - lambda||grad V|| + gamma C is NOT policy-invariant and can bias
# the optimum.
# 3. Optionally expose V (and other derived maps) as extra input channels.
#
# This module implements 1-3 with pure numpy/scipy/skimage and is fully runnable
# and cache-backed. No autograd PINN is required.
# -----------------------------------------------------------------------------
import os
import hashlib
import numpy as np
from scipy import ndimage as ndi
try:
from skimage.graph import MCP_Geometric
_HAVE_SKIMAGE = True
except Exception: # pragma: no cover
_HAVE_SKIMAGE = False
# =============================================================================
# Low-level field operators
# =============================================================================
def _normalize(vol, lo_pct=1.0, hi_pct=99.0):
vol = vol.astype(np.float32)
lo = np.percentile(vol, lo_pct)
hi = np.percentile(vol, hi_pct)
return np.clip((vol - lo) / (hi - lo + 1e-6), 0.0, 1.0)
def edge_strength(vol, sigma=1.0):
"""G(x) = |grad I| of a lightly smoothed, normalized volume."""
im = ndi.gaussian_filter(_normalize(vol), sigma)
grads = np.gradient(im)
return np.sqrt(sum(g ** 2 for g in grads)).astype(np.float32)
def speed_field(vol, beta=8.0, sigma=1.0):
"""F(x) = 1 / (1 + beta |grad I|), in (0, 1].
Fast (F->1) in smooth tissue, slow (F->0) at anatomical boundaries. This is
the standard geodesic-active-contour speed and is bounded, unlike the PDF's
alternative F = exp(-alpha G) which is also fine but less numerically tame.
"""
g = edge_strength(vol, sigma)
g = g / (g.max() + 1e-6)
return (1.0 / (1.0 + beta * g)).astype(np.float32)
def hessian_vesselness(vol, sigma=1.5):
"""Cheap Frangi-like ridge/vesselness proxy from Hessian eigenvalues.
NOTE: for brain landmark detection this channel is of dubious value (it was
designed for tubular vessels); included for completeness / ablation only.
"""
from skimage.feature import hessian_matrix, hessian_matrix_eigvals
n = _normalize(vol)
H = hessian_matrix(n, sigma=sigma, use_gaussian_derivatives=False)
eigs = hessian_matrix_eigvals(H)
l_small = np.abs(eigs[-1])
l_large = np.abs(eigs[0]) + 1e-6
return np.clip(l_small / l_large, 0, 3).astype(np.float32)
# =============================================================================
# The Eikonal / stationary Hamilton-Jacobi solver
# =============================================================================
def geodesic_potential(vol, seed_xyz, beta=8.0, sigma=1.0):
"""Solve ||grad V|| * F = 1 from `seed_xyz` (a landmark).
Implemented as a grid minimum-cost distance with slowness cost = 1/F, which
is exactly the first-order discretization of the Eikonal equation (this is
what fast-marching computes). Returns the geodesic arrival-time field V.
Falls back to a plain distance transform if scikit-image is unavailable.
"""
seed = tuple(int(round(c)) for c in seed_xyz)
if not _HAVE_SKIMAGE:
m = np.ones(vol.shape, dtype=bool)
m[seed] = False
return ndi.distance_transform_edt(m).astype(np.float32)
F = speed_field(vol, beta, sigma)
cost = (1.0 / (F + 1e-6)).astype(np.float64)
mcp = MCP_Geometric(cost)
V, _ = mcp.find_costs([seed])
V = np.asarray(V, dtype=np.float32)
finite = np.isfinite(V)
V[~finite] = V[finite].max() if finite.any() else 0.0
return V
def euclidean_potential(shape, seed_xyz, spacing=(1, 1, 1)):
"""d(x): straight-line distance to the landmark.
This is what the PDF's L_Consistency = ||V - d||^2 pulls V toward. Because V
is *geodesic*, d != V wherever F varies, so that loss fights the Eikonal
constraint. Use `geodesic` targets for consistency instead (see below).
"""
m = np.ones(shape, dtype=bool)
m[tuple(int(round(c)) for c in seed_xyz)] = False
return ndi.distance_transform_edt(m, sampling=spacing).astype(np.float32)
# =============================================================================
# Per-image physics provider (cached) -- the object the environment uses
# =============================================================================
class PhysicsProvider:
"""Precomputes and caches per-(image, landmark) physics fields.
Designed to be created once per environment and queried each step. All heavy
computation happens on the first touch of a given (image, landmark) pair.
"""
def __init__(self, beta=8.0, sigma=1.0, cache_dir=None, channels=("mri", "grad", "potential", "dist")):
self.beta = beta
self.sigma = sigma
self.cache_dir = cache_dir
self.channels = tuple(channels)
self._mem = {} # in-process cache
if cache_dir:
os.makedirs(cache_dir, exist_ok=True)
# ---- keys / disk cache ------------------------------------------------
def _key(self, image_id, seed_xyz):
h = hashlib.md5(f"{image_id}|{seed_xyz}|{self.beta}|{self.sigma}".encode()).hexdigest()[:16]
return h
def _load_disk(self, key):
if not self.cache_dir:
return None
p = os.path.join(self.cache_dir, key + ".npz")
if os.path.exists(p):
z = np.load(p)
return {k: z[k] for k in z.files}
return None
def _save_disk(self, key, fields):
if not self.cache_dir:
return
np.savez_compressed(os.path.join(self.cache_dir, key + ".npz"), **fields)
# ---- main API ---------------------------------------------------------
def fields_for(self, volume, seed_xyz, image_id="img"):
"""Return dict with 'V' (geodesic potential), 'F' (speed), 'd' (euclid)."""
key = self._key(image_id, tuple(map(int, seed_xyz)))
if key in self._mem:
return self._mem[key]
disk = self._load_disk(key)
if disk is not None:
self._mem[key] = disk
return disk
F = speed_field(volume, self.beta, self.sigma)
V = geodesic_potential(volume, seed_xyz, self.beta, self.sigma)
d = euclidean_potential(volume.shape, seed_xyz)
fields = {"V": V, "F": F, "d": d}
self._save_disk(key, fields)
self._mem[key] = fields
return fields
def channel_stack(self, volume, seed_xyz, image_id="img"):
"""Build the multi-channel physics tensor requested in Step 1 of the PDF.
Returns an array of shape (C, X, Y, Z), normalized to [0,1] per channel.
Redundant channels (e.g. grad vs potential) are correlated ~0.9; keep the
set small for real training. Default: MRI, grad, potential, dist.
"""
f = self.fields_for(volume, seed_xyz, image_id)
maps = {
"mri": _normalize(volume),
"grad": _norm01(edge_strength(volume, self.sigma)),
"vesselness": _norm01(hessian_vesselness(volume)),
"potential": _norm01(f["V"]),
"dist": _norm01(f["d"]),
"speed": f["F"],
}
return np.stack([maps[c] for c in self.channels], axis=0).astype(np.float32)
# ---- reward shaping ----------------------------------------------------
def shaping_potential(self, volume, seed_xyz, loc, image_id="img"):
"""Phi(s) = -V(loc): the potential for Ng-1999 potential-based shaping.
The shaped reward is r' = r + gamma*Phi(s') - Phi(s),
which is guaranteed to preserve the optimal policy while densifying the
signal. Use this INSTEAD of adding raw ||grad V|| to the reward.
"""
V = self.fields_for(volume, seed_xyz, image_id)["V"]
i = tuple(np.clip(np.round(loc).astype(int), 0, np.array(V.shape) - 1))
return -float(V[i])
def _norm01(a):
a = a.astype(np.float32)
return (a - a.min()) / (a.max() - a.min() + 1e-6)
# =============================================================================
# Self-test (runs on any 3D numpy volume)
# =============================================================================
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
vol = np.load(sys.argv[1])
else:
# synthetic blob volume
z, y, x = np.mgrid[0:60, 0:60, 0:60]
vol = (np.sin(x / 5.0) * np.cos(y / 5.0) * 100 + 200).astype(np.float32)
if vol.ndim == 4:
vol = vol[..., 0]
seed = tuple(np.array(vol.shape) // 2)
pp = PhysicsProvider()
f = pp.fields_for(vol, seed)
print("V range", float(f["V"].min()), float(f["V"].max()))
print("F range", float(f["F"].min()), float(f["F"].max()))
stk = pp.channel_stack(vol, seed)
print("channel stack", stk.shape)
print("Phi at a random loc:", pp.shaping_potential(vol, seed, [10, 10, 10]))
print("physics.py self-test OK")