u-space / src /ulens /simplex.py
loose-bits's picture
Deploy 7123e8e
d58cfe6 verified
Raw History Blame Contribute Delete
3.75 kB
"""The cone's composition as a point in a 3-simplex, and the tetrahedron that is the mark.
The four rectified components, normalised to sum to one, are barycentric coordinates in a
3-simplex: the positive orthant the cone lives in is exactly a regular tetrahedron whose four
vertices are the four axes, so a state's composition is drawn with nothing reduced or
projected away. Near a vertex, one axis carries the cone; at the barycentre all four
contribute equally. What the picture drops is the magnitude — how strongly the state pointed
— and only that.
Numpy only. The demo draws the view as inline SVG through `project`, and the same camera gives
the page its mark (`data/mark.svg`, the favicon and the eyebrow): four vertex dots in the axis
colours on six thin edges, nothing else.
"""
from __future__ import annotations
from importlib.resources import files
import numpy as np
#: a regular tetrahedron with its barycentre at the origin and circumradius 1. Row order is
#: the category order (`ambiguity, incompleteness, conflicting_evidence, general_uncertainty`).
VERT = np.array([[1, 1, 1], [1, -1, -1], [-1, 1, -1], [-1, -1, 1]], np.float32) / np.sqrt(3.0)
#: the paper's camera (its simplex-path figures): the eye, looking at the barycentre, z up.
EYE = (1.08, 1.87, 0.79)
#: the mark's file inside the package, so `pip install` ships it and `launch` can serve it
MARK_PATH = files("ulens.data") / "mark.svg"
def shares(C: np.ndarray) -> np.ndarray:
"""`[T, 4]` non-negative components as shares summing to one; a row of zeros is NaN."""
C = np.asarray(C, np.float64)
tot = C.sum(axis=-1, keepdims=True)
with np.errstate(invalid="ignore", divide="ignore"):
return np.where(tot > 0, C / tot, np.nan)
def bary(W: np.ndarray) -> np.ndarray:
"""`[T, 4]` shares to `[T, 3]` points inside the tetrahedron. Linear: a pure one-axis
state is that axis's vertex and equal shares are the origin."""
return np.asarray(W, np.float64) @ VERT.astype(np.float64)
def boundary_fraction(P: np.ndarray) -> np.ndarray:
"""How far out each `[T, 3]` point sits, as the fraction of the way from the barycentre
to the tetrahedron's surface along its own direction: 0 at the centre, 1 on a face.
The face opposite vertex `i` has outward normal `-VERT[i]` and sits at distance 1/3."""
P = np.asarray(P, np.float64)
d = -(P @ VERT.T.astype(np.float64)) # [T, 4] signed distance along each normal
return np.max(d, axis=-1) * 3.0
def project(P: np.ndarray, eye=EYE) -> np.ndarray:
"""`[T, 3]` to `[T, 2]` screen coordinates (y up) under a perspective camera at `eye`
looking at the origin with z up. The origin projects to (0, 0)."""
e = np.asarray(eye, np.float64)
d0 = np.linalg.norm(e)
f = -e / d0 # forward
r = np.cross(f, np.array([0.0, 0.0, 1.0]))
r /= np.linalg.norm(r) # right
u = np.cross(r, f) # up on screen
rel = np.asarray(P, np.float64) - e
depth = rel @ f
return np.column_stack([(rel @ r) * d0 / depth, (rel @ u) * d0 / depth])
def mark_points(size: float = 32.0, margin: float = 4.6) -> np.ndarray:
"""The four vertices on a `size`-square canvas (y down), the projected frame fitted
inside `margin`. This is where the dots of `data/mark.svg` are."""
xy = project(VERT)
lo, hi = xy.min(0), xy.max(0)
s = (size - 2 * margin) / float((hi - lo).max())
ctr = (lo + hi) / 2
out = (xy - ctr) * s
out[:, 1] *= -1
return out + size / 2
def mark_svg() -> str:
"""The mark's markup, for inline use; the file itself is the favicon."""
return MARK_PATH.read_text(encoding="utf-8").strip()