Cell appearance: morphology and spatial patterns¶
Before we arrange cells spatially, STpuppeteer offers extensive control for cell morphologies and allow cells to arrange in defined spatial patterns to form "prototypes" (i.e. repeating units in spatial transcriptomics data).
The three spatial pattern primitives
| Primitive | Function | Typical use |
|---|---|---|
| Cluster | Elliptical blob of cells | Tumour core, gland, lymphoid follicle |
| Ring | Annular shell of cells | Immune infiltrate around a mass, vessel wall |
| Chain | Elongated band of cells | Stromal tract, duct, blood vessel |
Each primitive has independent control over size, elongation, orientation, and a primitive-specific shape parameter.
Nucleus morphology parameters
| Parameter | Effect |
|---|---|
orientation_mode |
How nuclei are oriented: random, radial, tangential, aligned |
elongation |
Axis ratio — 1 = round, >1 = stretched |
mean_area |
Mean nucleus area (µm²) |
boundary_noise_apt |
Irregularity of the nucleus outline |
As cells shapes are defined as expansion of nucleus, the nucleus morphology also reflects cell morphology.
Each grid below scans one parameter while keeping the others fixed, so visual differences are directly attributable to the varied parameter.
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import Polygon as MplPoly
from STpuppeteer.simulation.spatial_patterns import (
place_cluster, place_ring, place_chain, compute_orientations,
)
from STpuppeteer.simulation.morphology import generate_nuc_polygons
from STpuppeteer.simulation.config import (
MorphologySpec, PrototypeSpec, PrototypeSpecInstance,
)
from STpuppeteer.simulation.prototypes import generate_spec_points, generate_spec_nuclei
CENTER = np.array([0.0, 0.0])
N = 35
SEED = 42
BASE_MORPH = MorphologySpec(
mean_area=50, cv_area=0.25, elongation=1.0,
n_vertices=8, boundary_noise_apt=0.15,
orientation_mode="random", orientation_noise=0.15,
)
_COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b"]
def get_ct_palette(n_ct: int) -> dict:
"""Return a cell-type label → hex colour dict for n_ct types."""
return {f"ct_{i}": _COLORS[i % len(_COLORS)] for i in range(n_ct)}
plt.rcParams.update({
"figure.dpi": 120, "font.size": 12, "axes.titlesize": 12,
"axes.spines.top": False, "axes.spines.right": False,
"axes.spines.left": False, "axes.spines.bottom": False,
})
def ring_params(radius_size, ring_width):
"""Convert midpoint radius + width to inner/outer radii."""
inner = max(1.0, radius_size - ring_width / 2)
outer = radius_size + ring_width / 2
return inner, outer
def make_nuclei(positions, morph=BASE_MORPH, center=None, seed=SEED):
ctr = center if center is not None else positions.mean(axis=0)
orients = compute_orientations(
positions, mode=morph.orientation_mode, center=ctr,
axis_angle=morph.orientation_axis,
orientation_noise=morph.orientation_noise, seed=seed,
)
return generate_nuc_polygons(
positions, mean_area=morph.mean_area, cv_area=morph.cv_area,
n_vertices=morph.n_vertices, boundary_noise_apt=morph.boundary_noise_apt,
elongation=morph.elongation, orientations=orients, seed=seed,
)
def draw(ax, positions, nuclei, color="#4477CC", title=None,
colors_per_cell=None, xlim=None, ylim=None):
"""Draw nucleus polygons on ax, optionally coloured per cell type."""
for i, poly in enumerate(nuclei):
if poly is None or poly.is_empty:
continue
c = colors_per_cell[i] if colors_per_cell is not None else color
ax.add_patch(MplPoly(np.array(poly.exterior.coords), closed=True,
fc=c, ec="#111", lw=0.35, alpha=0.82))
if xlim is not None and ylim is not None:
ax.set_xlim(*xlim); ax.set_ylim(*ylim)
else:
xs, ys = positions[:, 0], positions[:, 1]
spread = max(xs.max() - xs.min(), ys.max() - ys.min())
pad = spread * 0.25 + 10
ax.set_xlim(xs.min() - pad, xs.max() + pad)
ax.set_ylim(ys.min() - pad, ys.max() + pad)
ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([])
if title:
ax.set_title(title, fontsize=11, pad=4)
def scan_grid(rows_spec, fig_title, ncols=5, figsize=(16, 10)):
"""Parameter-scan grid. Panels in the same row share axis limits."""
nrows = len(rows_spec)
fig, axes = plt.subplots(nrows, ncols, figsize=figsize)
if nrows == 1:
axes = axes[np.newaxis, :]
for r, (row_label, color, panels) in enumerate(rows_spec):
all_pos = np.vstack([pos for pos, _, _ in panels])
cx = (all_pos[:, 0].max() + all_pos[:, 0].min()) / 2
cy = (all_pos[:, 1].max() + all_pos[:, 1].min()) / 2
half = max(all_pos[:, 0].max() - all_pos[:, 0].min(),
all_pos[:, 1].max() - all_pos[:, 1].min()) / 2
pad = half * 0.25 + 15
xlim = (cx - half - pad, cx + half + pad)
ylim = (cy - half - pad, cy + half + pad)
for c, (pos, nucs, title) in enumerate(panels):
draw(axes[r, c], pos, nucs, color=color, title=title, xlim=xlim, ylim=ylim)
axes[r, 0].set_ylabel(row_label, fontsize=13, labelpad=5)
fig.suptitle(fig_title, fontsize=17, fontweight="bold", y=1.01)
fig.tight_layout(pad=1.2)
plt.show()
print("Helpers ready.")
2a.1 Spatial pattern parameter controls¶
The current model allows the users to define special spatial arrangement of certain cell types. Those spatial arrangements exists in the format of prototypes there are 3 types of spatial patterns avaiable: cluster, ring and chain. Each spatial patterns comes with their own parameters for control. Here we visualize the effects of different parameters on the spatial structures.
Initating spatial pattern prototypes
To initiate any type of spatial prototype structure is easy, simply generate a PrototypeSpec and PrototypeInstance class with the specification in mind.
# Minimal cluster example — one PrototypeSpecInstance, placed at the origin
from STpuppeteer.simulation.config import PrototypeSpec, PrototypeSpecInstance, MorphologySpec
import numpy as np
pattern_list = ["cluster", "ring", "chain"]
all_instances = []
for i, ptn in enumerate(pattern_list):
cluster_spec = PrototypeSpec(pattern=ptn)
instance = PrototypeSpecInstance(
spec=cluster_spec,
center=np.array([0.0, 0.0]),
prototype_id=i
)
all_instances.append(instance)
for inst in all_instances:
print(f" pattern={inst.spec.pattern:8s} center={inst.center} "
f"n_cells={inst.spec.n_cells} prototype_id={inst.prototype_id}")
pattern=cluster center=[0. 0.] n_cells=30 prototype_id=0 pattern=ring center=[0. 0.] n_cells=30 prototype_id=1 pattern=chain center=[0. 0.] n_cells=30 prototype_id=2
fig, axs = plt.subplots(ncols=3, nrows=1, figsize=(15, 5))
pattern_titles = {
"cluster": "Cluster (default: radius=30, Gaussian)",
"ring": "Ring (default: inner=10, outer=30)",
"chain": "Chain (default: length=80, width=12)",
}
for i, instance in enumerate(all_instances):
ax = axs[i]
positions = generate_spec_points(instance)
nuclei = generate_spec_nuclei(instance, positions, seed=SEED)
for poly in nuclei:
if poly and not poly.is_empty:
ax.add_patch(MplPoly(np.array(poly.exterior.coords), closed=True,
fc="#4477CC", ec="#111", lw=0.35, alpha=0.82))
xs, ys = positions[:, 0], positions[:, 1]
ax.set_xlim(xs.min() - 15, xs.max() + 15)
ax.set_ylim(ys.min() - 15, ys.max() + 15)
ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([])
ax.set_title(f"{pattern_titles[instance.spec.pattern]}\nn = {len(positions)}", fontsize=10)
fig.suptitle("Three spatial pattern primitives — default parameters", fontsize=14, fontweight="bold")
fig.tight_layout()
plt.show()
Cluster control¶
There are four essential parameters that controls the cluster profile:
| Parameter | What it controls |
|---|---|
radius |
Spatial footprint of the blob |
elongation |
1 = circular, >1 = elliptical |
orientation |
Rotation angle (radians) of the ellipse |
density_profile |
"Gaussian" = peaked centre, "Uniform" = flat disk |
# Row 1: radius
radii = [10, 20, 35, 55, 80]
row_r = [(place_cluster(CENTER, N,r, seed=SEED),
make_nuclei(place_cluster(CENTER, N,r, seed=SEED)),
f"radius = {r}") for r in radii]
# Row 2: elongation
elongs = [1.0, 1.5, 2.0, 3.0, 5.0]
row_e = [(place_cluster(CENTER, N,35, elongation=e, seed=SEED),
make_nuclei(place_cluster(CENTER, N,35, elongation=e, seed=SEED)),
f"elongation = {e}") for e in elongs]
# Row 3: orientation (elongation fixed at 3.5)
angles = [0, np.pi/8, np.pi/4, 3*np.pi/8, np.pi/2]
row_o = [(place_cluster(CENTER, N,35, elongation=3.5, orientation=a, seed=SEED),
make_nuclei(place_cluster(CENTER, N,35, elongation=3.5, orientation=a, seed=SEED)),
f"\u03b8 = {a/np.pi:.2f}\u03c0") for a in angles]
# Row 4: density_profile
profiles = [("Gaussian", SEED), ("Gaussian", SEED+5), ("Gaussian", SEED+10),
("Uniform", SEED), ("Uniform", SEED+5)]
row_d = [(place_cluster(CENTER, N,35, density_profile=dp, seed=s),
make_nuclei(place_cluster(CENTER, N,35, density_profile=dp, seed=s), seed=s),
f"{dp}\n(seed={s})") for dp, s in profiles]
scan_grid(
[("radius", "#4477CC", row_r), ("elongation", "#DD7733", row_e),
("orientation", "#449944", row_o), ("density_profile", "#AA44BB", row_d)],
fig_title="Cluster Pattern — Spatial Parameter Scan", figsize=(16, 10),
)
Ring¶
An annular shell of cells — ideal for immune infiltrates, vessel walls, or gland linings.
| Parameter | Effect |
|---|---|
inner_radius |
Inner boundary of the annulus (µm) |
outer_radius |
Outer boundary of the annulus (µm) |
elongation |
1 = circular ring, >1 = elliptical ring |
orientation |
Rotation angle (radians) of the ellipse axis |
ring_sizes = [12, 22, 35, 50, 70]
row_rs = []
for rs in ring_sizes:
ir, or_ = ring_params(rs, 12)
pos = place_ring(CENTER, N, ir, or_, seed=SEED)
row_rs.append((pos, make_nuclei(pos), f"radius_size = {rs}\n(in={ir:.0f}, out={or_:.0f})"))
ring_widths = [6, 14, 25, 38, 55]
row_rw = []
for rw in ring_widths:
ir, or_ = ring_params(30, rw)
pos = place_ring(CENTER, N, ir, or_, seed=SEED)
row_rw.append((pos, make_nuclei(pos), f"ring_width = {rw}\n(in={ir:.0f}, out={or_:.0f})"))
ring_el = [1.0, 1.5, 2.0, 3.0, 4.5]
row_re = [(place_ring(CENTER, N, 12, 30, elongation=e, seed=SEED),
make_nuclei(place_ring(CENTER, N, 12, 30, elongation=e, seed=SEED)),
f"elongation = {e}") for e in ring_el]
ring_ang = [0, np.pi/8, np.pi/4, 3*np.pi/8, np.pi/2]
row_rori = [(place_ring(CENTER, N, 12, 30, elongation=3.0, orientation=a, seed=SEED),
make_nuclei(place_ring(CENTER, N, 12, 30, elongation=3.0, orientation=a, seed=SEED)),
f"\u03b8 = {a/np.pi:.2f}\u03c0") for a in ring_ang]
scan_grid(
[("ring_size\n(width=12)", "#2288BB", row_rs),
("ring_width\n(size=30)", "#33AACC", row_rw),
("elongation", "#0055AA", row_re),
("orientation", "#4499DD", row_rori)],
fig_title="Ring Pattern — Spatial Parameter Scan", figsize=(16, 10),
)
Chain¶
An elongated band of cells — ideal for stromal tracts, ducts, or blood vessels.
| Parameter | Effect |
|---|---|
length |
Total length of the band (µm) |
width |
Width of the band (µm) |
orientation |
Rotation angle (radians) of the band axis |
width_distribution |
"uniform" = flat-width band, "gaussian" = tapered ends |
lengths = [20, 45, 75, 110, 160]
row_cl = [(place_chain(CENTER, N, 12, l, seed=SEED),
make_nuclei(place_chain(CENTER, N, 12, l, seed=SEED)),
f"length = {l}") for l in lengths]
ch_wids = [5, 12, 22, 35, 55]
row_cw = [(place_chain(CENTER, N, w, 80, seed=SEED),
make_nuclei(place_chain(CENTER, N, w, 80, seed=SEED)),
f"width = {w}") for w in ch_wids]
ch_angs = [0, np.pi/8, np.pi/4, 3*np.pi/8, np.pi/2]
row_co = [(place_chain(CENTER, N, 12, 70, orientation=a, seed=SEED),
make_nuclei(place_chain(CENTER, N, 12, 70, orientation=a, seed=SEED)),
f"\u03b8 = {a/np.pi:.2f}\u03c0") for a in ch_angs]
dists = [("uniform", SEED), ("uniform", SEED+3), ("uniform", SEED+6),
("gaussian", SEED), ("gaussian", SEED+3)]
row_cd = [(place_chain(CENTER, N, 22, 80, width_distribution=d, seed=s),
make_nuclei(place_chain(CENTER, N, 22, 80, width_distribution=d, seed=s), seed=s),
f"{d}\n(seed={s})") for d, s in dists]
scan_grid(
[("length", "#CC5533", row_cl), ("width", "#EE7744", row_cw),
("orientation", "#BB3311", row_co), ("width_distribution", "#FF9955", row_cd)],
fig_title="Chain Pattern — Spatial Parameter Scan", figsize=(16, 10),
)
2a.3 Nucleus Morphology — MorphologySpec in Detail¶
Every cell's nucleus is drawn as an irregular polygon whose shape is controlled by MorphologySpec. You only need to specify the fields you want to change — None fields are filled automatically from the fallback chain (see §2b.2).
Parameter reference¶
| Field | Default | Effect |
|---|---|---|
mean_area |
24 µm² | Mean nucleus area |
cv_area |
0.5 | Cell-to-cell area variation (coefficient of variation) |
elongation |
1.2 | Axis ratio: 1 = round, >1 = stretched |
orientation_mode |
"random" |
How nuclei are oriented: random, radial, tangential, aligned |
orientation_noise |
0.1 rad | Noise added on top of the orientation mode |
orientation_axis |
None |
Fixed angle (radians) used only when orientation_mode="aligned" |
boundary_noise_apt |
0.1 | Outline irregularity: 0 = smooth ellipse, ~0.6 = jagged |
n_vertices |
12 | Number of polygon vertices |
expansion_ratio |
1.8 | Nucleus radius × this = cell radius used in Voronoi expansion |
Usage patterns¶
# Full default spec — all hardcoded defaults:
morph = MorphologySpec.default()
# Partial spec — only override what you need; rest stays None (inherits at resolve time):
morph = MorphologySpec(mean_area=60.0, elongation=2.5, orientation_mode="radial")
# Resolve against a parent default to get a fully-populated spec:
resolved = morph.resolve(default=MorphologySpec.default())
The scan below isolates each morphology parameter on a fixed cluster layout.
BASE_POS = place_cluster(CENTER, 25, 25, seed=SEED)
def morph_row(vary_key, values, titles, **fixed_kw):
row = []
for val, title in zip(values, titles):
kw = dict(mean_area=50, cv_area=0.25, elongation=1.0, n_vertices=8,
boundary_noise_apt=0.15, orientation_mode="random",
orientation_noise=0.15, orientation_axis=None)
kw.update(fixed_kw); kw[vary_key] = val
m = MorphologySpec(**kw)
row.append((BASE_POS, make_nuclei(BASE_POS, m, center=CENTER), title))
return row
# Row 1: orientation_mode (elongation=2.5 to make orientation visible)
orient_5 = [
("random", None, "random"),
("aligned", 0.0, "aligned (0\u00b0)"),
("aligned", np.pi/4, "aligned (45\u00b0)"),
("radial", None, "radial"),
("tangential", None, "tangential"),
]
row_om = []
for mode, axis, label in orient_5:
m = MorphologySpec(mean_area=50, cv_area=0.25, elongation=2.5, n_vertices=8,
boundary_noise_apt=0.12, orientation_mode=mode,
orientation_noise=0.1, orientation_axis=axis)
row_om.append((BASE_POS, make_nuclei(BASE_POS, m, center=CENTER), label))
row_el = morph_row("elongation", [1.0, 1.5, 2.0, 3.5, 6.0],
["1.0", "1.5", "2.0", "3.5", "6.0"])
row_ma = morph_row("mean_area", [12, 30, 60, 110, 200],
["12 \u00b5m\u00b2", "30 \u00b5m\u00b2", "60 \u00b5m\u00b2",
"110 \u00b5m\u00b2", "200 \u00b5m\u00b2"])
row_bn = morph_row("boundary_noise_apt", [0.0, 0.08, 0.2, 0.4, 0.65],
["0.0 (smooth)", "0.08", "0.2", "0.4", "0.65 (jagged)"])
scan_grid(
[("orientation_mode\n(elongation=2.5)", "#5566CC", row_om),
("elongation", "#CC4477", row_el),
("mean_area", "#44AA66", row_ma),
("boundary_noise_apt", "#BB7711", row_bn)],
fig_title="Nucleus Morphology — Parameter Scan (fixed cluster layout)",
figsize=(16, 14),
)
2b.2 MorphologySpec Fallback Chain¶
When resolving the morphology for any cell, None fields are filled in the following priority order — highest priority first:
PrototypeSpec.morphology ← per-prototype override (highest)
↓ fill None fields from
SimulationConfig.celltype_morphology[ct] ← per-cell-type override
↓ fill None fields from
SimulationConfig.default_morphology ← global default
↓ fill None fields from
_MORPHOLOGY_HARDCODED_DEFAULTS ← built-in last resort (all fields defined)
This means you can be as coarse or fine as you like — only specify what differs from the global default.
Example — resolving ct_0 morphology:
from STpuppeteer.simulation.config import MorphologySpec, SimulationConfig
cfg = SimulationConfig(
default_morphology=MorphologySpec(mean_area=20.0), # global: only area
celltype_morphology={
"ct_0": MorphologySpec(mean_area=40.0, elongation=2.5), # override area + elongation
# ct_1, ct_2 → fall back to default_morphology entirely
},
)
# Inspect what ct_0 gets after the full fallback chain:
ct0_resolved = cfg.celltype_morphology["ct_0"].resolve(cfg.default_morphology)
print(ct0_resolved)
# → mean_area=40.0, elongation=2.5, cv_area=0.5, orientation_mode='random', …
2b.1 Spatial Cell-Type Assignment — continuity and fuzziness¶
Cell types are assigned via a Voronoi territory algorithm. Two parameters control the spatial structure:
| Parameter | Range | Effect |
|---|---|---|
continuity |
(0, 1] | Territory size — low → many small patches, high → large blobs. Sets the number of seed points ∝ 1 / continuity² |
fuzziness |
[0, 1) | Boundary mixing — 0 = crisp edges, approaching 1 = nearly random assignment |
The grids below scan each parameter independently to show its isolated effect.
import warnings
warnings.simplefilter("ignore")
from STpuppeteer.simulation.spatial_patterns import get_background_point_pattern
from STpuppeteer.simulation.cells import generate_celltypes
CANVAS_CF = 200.0
N_CF = 300
CT_PROP = {"ct_0": 0.4, "ct_1": 0.3, "ct_2": 0.3}
CT_PAL = get_ct_palette(3)
pts = get_background_point_pattern(n_points=N_CF, max_area=24.0,
canvas_size=CANVAS_CF, seed=SEED)
CONT_VALS = [0.05, 0.12, 0.25, 0.50, 1.00]
FUZZ_VALS = [0.00, 0.10, 0.25, 0.45, 0.70]
fig, axes = plt.subplots(len(FUZZ_VALS), len(CONT_VALS),
figsize=(14, 11), sharex=True, sharey=True)
for row, fuzz in enumerate(FUZZ_VALS):
for col, cont in enumerate(CONT_VALS):
ct = generate_celltypes(len(pts), CT_PROP, pts,
continuity=cont, fuzziness=fuzz, seed=SEED)
ax = axes[row, col]
for label, color in CT_PAL.items():
m = ct == label
ax.scatter(pts[m, 0], pts[m, 1], c=color, s=3, alpha=0.8)
ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([])
if row == 0:
ax.set_title(f"cont={cont}", fontsize=9)
if col == 0:
ax.set_ylabel(f"fuzz={fuzz}", fontsize=9)
handles = [mpatches.Patch(facecolor=c, label=k) for k, c in CT_PAL.items()]
fig.legend(handles=handles, loc="lower center", ncol=3,
bbox_to_anchor=(0.5, 0), fontsize=9, frameon=False)
fig.suptitle(
"Continuity \u00d7 Fuzziness \u2014 5 \u00d7 5 parameter grid\n"
"(columns = territory size rows = boundary mixing)",
fontsize=13, fontweight="bold",
)
fig.tight_layout()
fig.subplots_adjust(bottom=0.06)
plt.show()