minkowski — Differentiable Minkowski Functionals#
Module healpix_analyse.minkowski
Functions minkowski_functionals, minkowski_curves
Overview#
Minkowski functionals are morphological descriptors from integral geometry that capture the geometry and topology of a field at different intensity levels. In 2D, there are three:
Functional |
Symbol |
Physical meaning |
|---|---|---|
Area |
W0 |
Fraction of pixels above the threshold |
Perimeter |
W1 |
Normalised boundary length |
Euler characteristic |
W2 |
Connected components minus holes (χ) |
This module provides fully differentiable PyTorch implementations for
batched 2D images of shape [B, N, N]. Gradients flow back through
img and through threshold (when it is a tensor), making both
functions suitable as loss terms or feature extractors in end-to-end
deep learning pipelines.
Mathematical background#
For a 2D field \(f : \mathbb{R}^2 \to [0,1]\) and a threshold \(t\), the excursion set \(A_t = \{x \mid f(x) \geq t\}\) has three Minkowski functionals. The soft (differentiable) approximation replaces the indicator \(\mathbf{1}[f \geq t]\) with a sigmoid \(\sigma(\tau(f - t))\) (temperature \(\tau\)).
W0 — Area#
W1 — Perimeter#
Approximated by the \(L_1\) gradient norm (sum of absolute first-order finite differences):
For a hard binary image this equals the boundary length divided by \(N^2\).
W2 — Euler characteristic#
Computed via the pixel-complex formula (4-connectivity, Hadwiger 1957). Logical AND is approximated by the product of soft pixel values:
where:
Term |
Definition |
|---|---|
\(Q_1\) |
\(\sum_{i,j} f_{ij}\) (vertices) |
\(Q_h\) |
\(\sum_{i,j} f_{i,j} \cdot f_{i,j+1}\) (horizontal edge pairs) |
\(Q_v\) |
\(\sum_{i,j} f_{i,j} \cdot f_{i+1,j}\) (vertical edge pairs) |
\(Q_f\) |
\(\sum_{i,j} f_{i,j} \cdot f_{i,j+1} \cdot f_{i+1,j} \cdot f_{i+1,j+1}\) (2×2 faces) |
minkowski_functionals#
healpix_analyse.minkowski.minkowski_functionals(
img,
threshold = None,
temperature = 20.0,
)
Compute W0, W1, W2 at a single threshold level (or with no thresholding).
Parameters#
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
|
— |
Batch of square 2D images, values in [0, 1]. |
|
see below |
|
Soft threshold applied via sigmoid before computation. |
|
|
|
Sigmoid sharpness. Higher → closer to hard binary thresholding. |
Accepted shapes for threshold:
Shape |
Behaviour |
|---|---|
|
No thresholding |
|
Scalar — same threshold for the entire batch |
|
Scalar tensor (may carry |
|
One threshold per image in the batch |
|
Per-pixel spatial threshold (e.g. learned) |
Any shape broadcastable onto |
Accepted directly |
Returns#
dict[str, Tensor] with keys 'W0', 'W1', 'W2', each of shape [B].
All outputs are differentiable w.r.t. img and (when applicable) threshold.
Examples#
import torch
from healpix_analyse import minkowski_functionals
B, N = 8, 64
img = torch.rand(B, N, N, requires_grad=True)
# ── No threshold (continuous soft field) ──────────────────────────────────
mf = minkowski_functionals(img)
# mf['W0'].shape == mf['W1'].shape == mf['W2'].shape == [8]
# Backpropagate
mf['W2'].sum().backward()
# ── Scalar threshold ──────────────────────────────────────────────────────
mf = minkowski_functionals(img, threshold=0.5, temperature=30.0)
# ── Per-image threshold [B] ───────────────────────────────────────────────
t = torch.tensor([0.2, 0.3, 0.4, 0.5, 0.5, 0.6, 0.7, 0.8]) # [B]
mf = minkowski_functionals(img, threshold=t)
# ── Spatial threshold [B, N, N] (learned) ────────────────────────────────
t_spatial = torch.rand(B, N, N, requires_grad=True)
mf = minkowski_functionals(img, threshold=t_spatial)
mf['W0'].sum().backward()
print(t_spatial.grad is not None) # True — gradient through threshold
minkowski_curves#
healpix_analyse.minkowski.minkowski_curves(
img,
thresholds,
temperature = 20.0,
)
Compute W0, W1, W2 at multiple threshold levels, returning a curve for each functional. This is the standard form used in cosmological field analysis and topological texture descriptors.
Parameters#
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
|
— |
Batch of square 2D images. |
|
|
— |
Threshold levels. |
|
|
|
Sigmoid sharpness. |
Returns#
dict[str, Tensor] with keys 'W0', 'W1', 'W2', each of shape [B, T].
Fully differentiable w.r.t. img and thresholds.
Examples#
import torch
from healpix_analyse import minkowski_curves
B, N, T = 4, 64, 16
img = torch.rand(B, N, N, requires_grad=True)
# ── Shared threshold grid [T] ─────────────────────────────────────────────
thresholds = torch.linspace(0.05, 0.95, T)
curves = minkowski_curves(img, thresholds)
# curves['W0'].shape == [4, 16]
# Use as a feature vector in a loss
loss = curves['W2'].pow(2).mean()
loss.backward()
# ── Per-image threshold grid [B, T] ──────────────────────────────────────
thresholds_bt = torch.rand(B, T, requires_grad=True)
curves = minkowski_curves(img, thresholds_bt)
curves['W1'].sum().backward()
print(thresholds_bt.grad is not None) # True
Use as loss / feature in a training loop#
import torch
from healpix_analyse import minkowski_curves
def minkowski_loss(pred, target, T=16, temperature=25.0):
"""
MSE between the Minkowski curves of pred and target.
Encourages the network to match morphological structure,
not just pixel-wise intensities.
"""
thresholds = torch.linspace(0.05, 0.95, T,
device=pred.device, dtype=pred.dtype)
c_pred = minkowski_curves(pred, thresholds, temperature)
c_target = minkowski_curves(target, thresholds, temperature)
loss = sum(
(c_pred[k] - c_target[k]).pow(2).mean()
for k in ('W0', 'W1', 'W2')
)
return loss
# In your training loop:
pred = model(x) # [B, N, N]
target = y # [B, N, N]
loss = minkowski_loss(pred, target)
loss.backward()
Sanity checks (2D planar)#
Input |
Expected output |
|---|---|
All-ones image |
W0=1, W1=0, W2≈1/N² |
All-zeros image |
W0=0, W1=0, W2=0 |
Checkerboard |
W0=0.5, W1 near maximum, W2=0.5 |
Single square blob |
W0 ∝ blob area, W2 ≈ 1/N² |
HEALPix version#
For spherical HEALPix maps ([..., Npix]) the formulas are adapted to the
pixel graph: vertices are pixel centres, edges connect adjacent pixels (up to
8 neighbours each), and faces are the triangles formed by mutually adjacent
triples. The Euler characteristic formula then reads:
where the three terms count (soft) active vertices, edges, and triangular faces. For a full sphere with all pixels active this recovers \(\chi(\mathbb{S}^2) = 2 / N_{\text{pix}}\) (the Euler characteristic of the sphere, properly normalised).
The adjacency graph is precomputed once with build_healpix_adjacency
and then reused across all differentiable forward passes.
build_healpix_adjacency#
healpix_analyse.minkowski.build_healpix_adjacency(
nside,
cell_ids = None,
nest = True,
device = None,
)
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
|
— |
HEALPix resolution parameter. |
|
array-like or |
|
Global pixel indices of the map. |
|
|
|
Pixel ordering ( |
|
device or |
|
Target device for the returned tensors. |
Returns (edges, triangles):
Return |
Shape |
Description |
|---|---|---|
|
|
Unique adjacent local-index pairs |
|
|
Unique mutually adjacent triples |
minkowski_functionals_healpix#
healpix_analyse.minkowski.minkowski_functionals_healpix(
img,
edges,
triangles,
threshold = None,
temperature = 20.0,
)
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
|
— |
Batch of HEALPix maps, any leading dims. |
|
|
— |
From |
|
|
— |
From |
|
same cases as 2D |
|
Accepts |
|
|
|
Sigmoid sharpness. |
Returns dict with 'W0', 'W1', 'W2', each of shape [...].
minkowski_curves_healpix#
healpix_analyse.minkowski.minkowski_curves_healpix(
img,
edges,
triangles,
thresholds,
temperature = 20.0,
)
thresholds can be [T] (shared) or [B, T] (per-sample).
Returns dict with 'W0', 'W1', 'W2', each of shape [..., T].
Example#
import torch
import numpy as np
from healpix_analyse import (
build_healpix_adjacency,
minkowski_functionals_healpix,
minkowski_curves_healpix,
)
level = 6
nside = 2**level
B = 4
# ── One-time setup (geometry only, not differentiable) ───────────────────
edges, triangles = build_healpix_adjacency(level, nest=True, device="cpu")
print(f"Edges: {edges.shape}, Triangles: {triangles.shape}")
# ── Forward pass (fully differentiable) ──────────────────────────────────
Npix = 12 * nside**2
img = torch.rand(B, Npix, requires_grad=True)
mf = minkowski_functionals_healpix(img, edges, triangles, threshold=0.5)
# mf['W0'].shape == mf['W1'].shape == mf['W2'].shape == [4]
mf['W2'].sum().backward()
print(img.grad is not None) # True
# ── Minkowski curves ──────────────────────────────────────────────────────
T = 16
thresholds = torch.linspace(0.05, 0.95, T)
curves = minkowski_curves_healpix(img, edges, triangles, thresholds)
# curves['W0'].shape == [4, 16]
# ── Partial sky ───────────────────────────────────────────────────────────
from healpix_geo import nested
patch = nested.cone_coverage((0.0, 0.0), 20.0, level)
patch = np.asarray(getattr(patch, "data", patch), dtype=np.int64).reshape(-1)
edges_p, tri_p = build_healpix_adjacency(level, cell_ids=patch, nest=True)
img_patch = torch.rand(B, len(patch), requires_grad=True)
mf_patch = minkowski_functionals_healpix(img_patch, edges_p, tri_p)
Sanity checks (HEALPix)#
Input |
Expected output |
|---|---|
All-ones map |
W0=1, W1=0, W2≈2/Npix (Euler char. of sphere) |
All-zeros map |
W0=0, W1=0, W2=0 |
Half-sky step |
W0≈0.5, W1 large (great-circle boundary), W2≈0 |