Spatial prototypes: build it step by step¶
Prototypes let you embed structured micro-environments into an STpuppeteer canvas — tumour cores, immune infiltrates, stromal tracts — then fill remaining space with background cells.
This notebook is the companion to 02_cell_generation.ipynb and covers:
PrototypeSpec— blueprint: pattern, cell composition, morphologyPrototypeSpecInstance— places one blueprint at a canvas locationPrototypeScene— groups several blueprints sharing a common centre- Generation paths — how
initialize_cells()handles prototypes vs. pure background
| Class | Has position? | Purpose |
|---|---|---|
PrototypeSpec |
✗ | Blueprint — defines one component |
PrototypeSpecInstance |
✓ | Placed realisation of a PrototypeSpec |
PrototypeScene |
✗ | Groups several PrototypeSpecs at a shared centre |
PrototypeSceneInstance |
✓ | Placed realisation of a PrototypeScene |
import warnings
warnings.simplefilter(action='ignore', category=FutureWarning)
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.patches import Polygon as MplPoly
import geopandas as gpd
from STpuppeteer.simulation import SimulationConfig, SpotlessSimulator
from STpuppeteer.simulation.config import (
MorphologySpec, PrototypeSpec, PrototypeSpecInstance,
PrototypeScene, PrototypeSceneInstance,
)
from STpuppeteer.simulation import count_prototype_cells
CT_PALETTE = {'ct_0': '#1f77b4', 'ct_1': '#ff7f0e', 'ct_2': '#2ca02c'}
CANVAS = 300.0
N_CELLS = 800
SEED = 42
CT_PROP = {'ct_0': 0.4, 'ct_1': 0.3, 'ct_2': 0.3}
plt.rcParams.update({'figure.dpi': 110, 'axes.spines.top': False, 'axes.spines.right': False})
print('Imports OK')
Imports OK
2p.1 PrototypeSpec — The Blueprint¶
A PrototypeSpec describes what a prototype looks like: its spatial arrangement, cell-type composition, and nucleus morphology. It carries no position.
Parameters¶
| Field | Type | Default | Effect |
|---|---|---|---|
pattern |
str |
— | Spatial arrangement: "cluster", "ring", or "chain" |
pattern_params |
dict |
pattern defaults | Shape params for the chosen pattern (see tables below) |
cell_type_composition |
dict |
None |
Cell-type proportions, e.g. {"ct_0": 0.7, "ct_1": 0.3} (must sum to 1) |
morphology |
MorphologySpec |
global default | Nucleus shape; None fields inherit from SimulationConfig.default_morphology |
n_cells |
int |
30 |
Base cell count; scaled by PrototypeSpecInstance.scale at instantiation |
spatial_bias |
dict \| None |
None |
Per-cell-type positional offset within the pattern (advanced) |
Pattern-specific pattern_params¶
Cluster — elliptical blob
| Key | Default | Effect |
|---|---|---|
radius |
30 µm | Spatial footprint |
elongation |
1.0 | 1 = circular, >1 = elliptical |
orientation |
0 rad | Rotation of the ellipse |
density_profile |
"Gaussian" |
"Gaussian" = peaked centre, "Uniform" = flat disk |
Ring — annular shell
| Key | Default | Effect |
|---|---|---|
inner_radius |
10 µm | Inner boundary |
outer_radius |
30 µm | Outer boundary |
elongation |
1.0 | 1 = circular, >1 = elliptical |
orientation |
0 rad | Rotation of the ellipse axis |
Chain — elongated band
| Key | Default | Effect |
|---|---|---|
length |
80 µm | Total length of the band |
width |
12 µm | Width of the band |
orientation |
0 rad | Rotation angle of the band axis |
width_distribution |
"uniform" |
"uniform" = flat width, "gaussian" = tapered ends |
For visual parameter scans of each pattern, see
02s_cell_parameter_deep_dive.ipynb§2a.1.
# Three pattern primitives as PrototypeSpec blueprints
cluster_spec = PrototypeSpec(
pattern='cluster',
pattern_params={'radius': 35.0, 'density_profile': 'Gaussian'},
cell_type_composition={'ct_0': 0.80, 'ct_1': 0.20},
morphology=MorphologySpec(mean_area=30.0, elongation=1.2, orientation_mode='random'),
n_cells=60,
)
ring_spec = PrototypeSpec(
pattern='ring',
pattern_params={'inner_radius': 33.0, 'outer_radius': 48.0},
cell_type_composition={'ct_1': 0.70, 'ct_2': 0.30},
morphology=MorphologySpec(mean_area=17.0, elongation=1.8, orientation_mode='radial'),
n_cells=80,
)
chain_base_spec = PrototypeSpec(
pattern='chain',
pattern_params={'width': 20.0, 'length': 120.0, 'orientation': np.pi / 5},
cell_type_composition={'ct_2': 1.0},
morphology=MorphologySpec(mean_area=10.0, elongation=2.8,
orientation_mode='aligned', orientation_axis=np.pi / 5),
n_cells=50,
)
for name, spec in [('cluster', cluster_spec), ('ring', ring_spec), ('chain', chain_base_spec)]:
print(f'{name}: pattern={spec.pattern}, n_cells={spec.n_cells}, '
f'composition={spec.cell_type_composition}')
cluster: pattern=cluster, n_cells=60, composition={'ct_0': 0.8, 'ct_1': 0.2}
ring: pattern=ring, n_cells=80, composition={'ct_1': 0.7, 'ct_2': 0.3}
chain: pattern=chain, n_cells=50, composition={'ct_2': 1.0}
2p.2 PrototypeSpecInstance — Placing a Blueprint¶
A PrototypeSpec is a recipe with no position. Wrap it in a PrototypeSpecInstance to pin it to a specific canvas location.
Parameters¶
| Field | Type | Default | Effect |
|---|---|---|---|
spec |
PrototypeSpec |
— | The blueprint to instantiate |
center |
np.ndarray (2,) |
— | Canvas coordinates [x, y] in µm |
scale |
float |
1.0 |
Uniform scale; stretches spatial dimensions and scales n_cells to preserve density |
orientation_offset |
float |
0.0 |
Additional rotation (rad) added on top of any baked-in orientation |
prototype_id |
int |
1 |
Ground-truth label for all cells from this instance (0 = background) |
seed |
int \| None |
None |
Random seed for reproducible placement |
# Place a stromal tract at a specific canvas location
stroma_spec = PrototypeSpec(
pattern='chain',
pattern_params={'width': 40.0, 'length': 250.0, 'orientation': np.pi / 6},
cell_type_composition={'ct_2': 1},
morphology=MorphologySpec(mean_area=10.0, elongation=2.8,
orientation_mode='aligned', orientation_axis=np.pi / 6),
n_cells=70,
)
chain_instance = PrototypeSpecInstance(
spec=stroma_spec,
center=np.array([150.0, 200.0]),
scale=0.7,
orientation_offset=0.0,
prototype_id=2,
seed=20,
)
print(f'Prototype cells (scaled): {count_prototype_cells([chain_instance])}')
# Simulate
cfg_single = SimulationConfig(
seed=SEED, canvas_size=CANVAS,
n_cells=N_CELLS, n_celltype=3,
celltype_proportion=list(CT_PROP.values()),
continuity=0.2, fuzziness=0.05,
n_genes=50, n_markers=5,
)
sim_single = SpotlessSimulator(cfg_single)
sim_single.generate_gene_parameters()
sim_single.initialize_cells(prototype_instances=[chain_instance])
gdf_single = sim_single.cell_gdf
print(f'Total cells: {len(gdf_single)} | chain cells (id=2): {(gdf_single["prototype_id"] == 2).sum()}')
Prototype cells (scaled): 49 Total cells: 800 | chain cells (id=2): 49
_PROTO_COLORS = {0: '#cccccc', 2: '#54ebd1'}
_PROTO_LABELS = {0: 'background', 2: 'stroma chain'}
gdf_single['_ct'] = gdf_single['celltype'].map(CT_PALETTE)
gdf_single['_proto'] = gdf_single['prototype_id'].map(_PROTO_COLORS)
fig, axs = plt.subplots(1, 2, figsize=(13, 6))
for ax, col, title in [
(axs[0], '_ct', 'Cell type'),
(axs[1], '_proto', 'Prototype origin'),
]:
gdf_single.set_geometry('cell_geometry').plot(
ax=ax, color=gdf_single[col], alpha=0.25, edgecolor='k', linewidth=0.25)
gdf_single.set_geometry('nucleus_geometry').plot(
ax=ax, color=gdf_single[col], edgecolor='none', alpha=0.85)
ax.set_aspect('equal'); ax.set_title(title, fontsize=11)
ax.set_xlabel('x (µm)'); ax.set_ylabel('y (µm)')
axs[0].legend(
handles=[mpatches.Patch(facecolor=c, label=k) for k, c in CT_PALETTE.items()],
fontsize=8, frameon=False)
axs[1].legend(
handles=[mpatches.Patch(facecolor=v, label=_PROTO_LABELS[k])
for k, v in _PROTO_COLORS.items()],
fontsize=8, frameon=False)
fig.suptitle('Single prototype — stroma chain (scale=0.7, prototype_id=2)', fontsize=12)
fig.tight_layout()
plt.show()
2p.3 PrototypeScene — Multi-Component Environments¶
When multiple prototypes share a common centre — e.g. a tumour core surrounded by an immune ring — group them into a PrototypeScene. Placing the scene positions all components simultaneously.
Parameters¶
| Field | Type | Default | Effect |
|---|---|---|---|
name |
str |
— | Human-readable identifier |
specs |
list[PrototypeSpec] |
— | Component blueprints; all share the same centre at instantiation |
scale_range |
tuple[float, float] |
(0.5, 1.5) |
Range from which a random scale is drawn by sample_instance() |
orientation_range |
tuple[float, float] |
(0, 2π) |
Range from which a random rotation (rad) is drawn by sample_instance() |
Call scene.sample_instance(center=…, seed=…) to produce a PrototypeSceneInstance, then .to_spec_instances() to decompose into a list of PrototypeSpecInstances accepted by initialize_cells().
# Two-component tumour microenvironment: dense core + immune infiltrate ring
tumour_spec = PrototypeSpec(
pattern='cluster',
pattern_params={'radius': 35.0, 'density_profile': 'Gaussian'},
cell_type_composition={'ct_0': 0.80, 'ct_1': 0.20},
morphology=MorphologySpec(mean_area=30.0, elongation=1.2, orientation_mode='random'),
n_cells=60,
)
immune_ring_spec = PrototypeSpec(
pattern='ring',
pattern_params={'inner_radius': 33.0, 'outer_radius': 48.0},
cell_type_composition={'ct_1': 0.70, 'ct_2': 0.30},
morphology=MorphologySpec(mean_area=17.0, elongation=1.8, orientation_mode='radial'),
n_cells=90,
)
# Group them — they will share the same centre when placed
tme_scene = PrototypeScene(
name='tme',
specs=[tumour_spec, immune_ring_spec],
scale_range=(1.0, 1.0), # fixed scale for reproducibility
)
print('Scene components:', [s.pattern for s in tme_scene.specs])
Scene components: ['cluster', 'ring']
# Place the scene → decompose into PrototypeSpecInstances
scene_instance = tme_scene.sample_instance(center=np.array([130.0, 70.0]), seed=10)
spec_instances = scene_instance.to_spec_instances() # prototype_id=1 for all TME components
# Combine with the stroma chain from §2p.2
all_instances = spec_instances + [chain_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}')
# Run the full simulation
sim = SpotlessSimulator(cfg_single)
sim.generate_gene_parameters()
sim.initialize_cells(prototype_instances=all_instances)
cell_gdf = sim.cell_gdf
print(f'\nTotal cells: {len(cell_gdf)}')
print(cell_gdf.groupby(['prototype_id', 'celltype']).size()
.rename('n').reset_index().to_string(index=False))
pattern=cluster center=[130. 70.] n_cells=60 prototype_id=1
pattern=ring center=[130. 70.] n_cells=90 prototype_id=1
pattern=chain center=[150. 200.] n_cells=70 prototype_id=2
Total cells: 800
prototype_id celltype n
0 ct_0 240
0 ct_1 182
0 ct_2 179
1 ct_0 45
1 ct_1 80
1 ct_2 25
2 ct_2 49
PROTO_COLORS = {0: '#cccccc', 1: '#e377c2', 2: '#54ebd1'}
PROTO_LABELS = {0: 'background', 1: 'TME (core + ring)', 2: 'stroma (chain)'}
cell_gdf['_ct'] = cell_gdf['celltype'].map(CT_PALETTE)
cell_gdf['_proto'] = cell_gdf['prototype_id'].map(PROTO_COLORS)
fig, axs = plt.subplots(1, 2, figsize=(14, 7))
for ax, col, title in [
(axs[0], '_ct', 'Cell type'),
(axs[1], '_proto', 'Prototype origin'),
]:
cell_gdf.set_geometry('cell_geometry').plot(
ax=ax, color=cell_gdf[col], alpha=0.3, edgecolor='k', linewidth=0.25)
cell_gdf.set_geometry('nucleus_geometry').plot(
ax=ax, color=cell_gdf[col], edgecolor='none', alpha=0.8)
ax.set_aspect('equal'); ax.set_title(title, fontsize=11)
ax.set_xlabel('x (µm)'); ax.set_ylabel('y (µm)')
axs[0].legend(
handles=[mpatches.Patch(facecolor=c, label=k) for k, c in CT_PALETTE.items()],
title='Cell type', fontsize=8, frameon=False)
axs[1].legend(
handles=[mpatches.Patch(facecolor=PROTO_COLORS[k], label=v)
for k, v in PROTO_LABELS.items()],
title='Origin', fontsize=8, frameon=False)
fig.suptitle('Multi-prototype simulation: TME cluster + immune ring + stroma chain', fontsize=12)
fig.tight_layout()
plt.show()
Prototype class hierarchy — recap¶
| Class | What it is | Position? |
|---|---|---|
PrototypeSpec |
Blueprint — pattern, composition, morphology | ✗ |
PrototypeSpecInstance |
One placed realisation of a PrototypeSpec |
✓ |
PrototypeScene |
Groups several PrototypeSpecs sharing a centre |
✗ |
PrototypeSceneInstance |
One placed realisation of a PrototypeScene; decomposes into PrototypeSpecInstances |
✓ |
2p.4 Generation Paths in initialize_cells()¶
initialize_cells() is the single high-level entry point. Three paths are available, chosen automatically by what you pass:
| Path | Trigger | What it does |
|---|---|---|
| Path B (default) | nothing extra | Hex grid → cell-type assignment → nuclei → Voronoi |
| Path A | prototype_instances=[…] |
Prototype cells first; background fills the rest |
| Path C | input_cell_polygons=[…] |
Skip geometry; use your own segmentation polygons |
MorphologySpec fallback chain¶
None fields in any MorphologySpec are resolved via a four-level priority chain (highest first):
PrototypeSpec.morphology ← per-prototype override
↓ 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
Only specify the fields that differ from the global default — None propagates down the chain automatically.
_base_cfg = dict(
seed=42, n_genes=50, n_markers=5, canvas_size=250.0,
n_cells=400, n_celltype=3, celltype_proportion=[0.4, 0.3, 0.3],
)
# ── Path B: pure random background, all defaults ─────────────────────────────
cfg_B = SimulationConfig(**_base_cfg)
sim_B = SpotlessSimulator(cfg_B)
sim_B.generate_gene_parameters()
sim_B.initialize_cells()
print(f'Path B (default): {len(sim_B.cell_gdf)} cells, '
f'prototype_ids = {sorted(sim_B.cell_gdf["prototype_id"].unique())}')
# ── Path B + per-cell-type MorphologySpec override ───────────────────────────
cfg_B2 = SimulationConfig(
**_base_cfg,
default_morphology=MorphologySpec(mean_area=15.0),
celltype_morphology={
'ct_0': MorphologySpec(mean_area=40.0, elongation=2.0),
},
)
ct0_resolved = cfg_B2.celltype_morphology['ct_0'].resolve(cfg_B2.default_morphology)
print(f'\nct_0 resolved MorphologySpec:\n {ct0_resolved}')
sim_B2 = SpotlessSimulator(cfg_B2)
sim_B2.generate_gene_parameters()
sim_B2.initialize_cells()
# ── Path A: with a tumour cluster prototype ───────────────────────────────────
tumour_inst_A = PrototypeSpecInstance(
spec=tumour_spec, center=np.array([125.0, 125.0]),
scale=1.0, prototype_id=1, seed=10,
)
cfg_A = SimulationConfig(**_base_cfg)
sim_A = SpotlessSimulator(cfg_A)
sim_A.generate_gene_parameters()
sim_A.initialize_cells(prototype_instances=[tumour_inst_A])
print(f'\nPath A (prototype): {len(sim_A.cell_gdf)} cells, '
f'prototype_ids = {sorted(sim_A.cell_gdf["prototype_id"].unique())}')
Path B (default): 400 cells, prototype_ids = [np.int64(0)] ct_0 resolved MorphologySpec: MorphologySpec(mean_area=40.0, cv_area=0.5, elongation=2.0, boundary_noise_apt=0.1, n_vertices=12, orientation_mode='random', orientation_noise=0.1, orientation_axis=None, expansion_ratio=1.8) Path A (prototype): 400 cells, prototype_ids = [np.int64(0), np.int64(1)]
PROTO_A_COLORS = {0: '#cccccc', 1: '#e377c2'}
PROTO_A_LABELS = {0: 'background', 1: 'tumour cluster'}
fig, axs = plt.subplots(1, 3, figsize=(18, 6))
for ax, gdf, cmap, handles, title in [
(axs[0], sim_B.cell_gdf, 'celltype', CT_PALETTE, 'Path B — all defaults'),
(axs[1], sim_B2.cell_gdf, 'celltype', CT_PALETTE, 'Path B — ct_0 large + elongated'),
(axs[2], sim_A.cell_gdf, 'prototype_id', PROTO_A_COLORS, 'Path A — tumour cluster'),
]:
if cmap is CT_PALETTE:
colors = gdf['celltype'].map(CT_PALETTE)
else:
colors = gdf['prototype_id'].map(PROTO_A_COLORS)
gdf.set_geometry('cell_geometry').plot(
ax=ax, color=colors, alpha=0.25, edgecolor='k', linewidth=0.25)
gdf.set_geometry('nucleus_geometry').plot(
ax=ax, color=colors, edgecolor='none', alpha=0.85)
ax.set_aspect('equal'); ax.set_title(title, fontsize=10)
ax.set_xlabel('x (µm)'); ax.set_ylabel('y (µm)')
ct_h = [mpatches.Patch(facecolor=c, label=k) for k, c in CT_PALETTE.items()]
pr_h = [mpatches.Patch(facecolor=PROTO_A_COLORS[k], label=v) for k, v in PROTO_A_LABELS.items()]
axs[0].legend(handles=ct_h, fontsize=7, frameon=False)
axs[1].legend(handles=ct_h, fontsize=7, frameon=False)
axs[2].legend(handles=pr_h, fontsize=7, frameon=False)
fig.suptitle('initialize_cells() — Path B (default), Path B (morphology override), Path A (prototype)',
fontsize=12)
fig.tight_layout()
plt.show()
Summary¶
| Step | Class / Function | Key parameters |
|---|---|---|
| Define blueprint | PrototypeSpec |
pattern, pattern_params, cell_type_composition, morphology, n_cells |
| Place on canvas | PrototypeSpecInstance |
center, scale, orientation_offset, prototype_id |
| Group components | PrototypeScene + .sample_instance() |
specs, scale_range, orientation_range |
| Run simulation | initialize_cells(prototype_instances=[…]) |
Path A |
Related notebooks:
02_cell_generation.ipynb— overview of the full cell generation pipeline02s_cell_parameter_deep_dive.ipynb— spatial pattern and morphology parameter scans03_simulate_counts.ipynb— gene expression count simulation