Spaces:
Sleeping
Sleeping
File size: 10,274 Bytes
377b913 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | #!/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")
|