Step 2 — Cell Generation¶
After defining gene expression parameters, STpuppeteer places cells on a canvas, assigns cell types, and generates realistic nucleus and cell-boundary polygons.
This notebook walks through each stage of the pipeline step by step — you will see the intermediate result at every stage before putting it all together at the end.
Pipeline
Step 1 │ Sample background positions get_background_point_pattern()
Step 2 │ Assign cell types generate_celltypes() [continuity + fuzziness]
Step 3 │ Generate nucleus polygons generate_nuclei_by_celltype() [MorphologySpec]
Step 4 │ Expand to cell polygons nuclei_to_cells_voronoi()
Step 5 │ (Optional) Insert prototypes initialize_cells(prototype_instances=[…])
Related deep-dives:
- Spatial pattern scans, morphology, continuity & fuzziness →
02s_cell_parameter_deep_dive.ipynb- Prototype class reference, scenes, and generation paths →
02p_prototype_deep_dive.ipynb
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 seaborn as sns
import geopandas as gpd
# High-level API
from STpuppeteer.simulation import SimulationConfig, SpotlessSimulator
from STpuppeteer.simulation.config import (
MorphologySpec, PrototypeSpec, PrototypeSpecInstance,
PrototypeScene, PrototypeSceneInstance,
)
# Low-level building blocks (used for step-by-step walkthrough)
from STpuppeteer.simulation.spatial_patterns import (
get_background_point_pattern, compute_orientations,
)
from STpuppeteer.simulation.cells import generate_celltypes
from STpuppeteer.simulation.morphology import (
generate_nuclei_by_celltype, get_expansion_radius, nuclei_to_cells_voronoi,
)
_COLORS = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b']
def get_ct_palette(n_ct: int) -> dict:
return {f'ct_{i}': _COLORS[i % len(_COLORS)] for i in range(n_ct)}
CT_PALETTE = {"ct_0": "#1f77b4", "ct_1": "#ff7f0e", "ct_2": "#2ca02c"}
PROTO_PALETTE = {0: "#cccccc", 1: "#9467bd", 2: "#17becf", 3: "#2affd1"}
plt.rcParams.update({"figure.dpi": 110, "axes.spines.top": False, "axes.spines.right": False})
# Shared canvas / cell settings used throughout the notebook
CANVAS = 200.0 # µm
N_CELLS = 500
SEED = 42
CT_PROP = {"ct_0": 0.4, "ct_1": 0.3, "ct_2": 0.3}
print("Imports OK")
Imports OK
Step 1 — Sample Background Cell Positions¶
The canvas is tiled with a jittered hexagonal grid: positions are placed on a regular hex lattice then randomly displaced so cells look organic. The use of hexagnoal grids ensures a minimial degree of separation between cells to allow for nucleus and cell area expansion.
In the workflow, get_background_point_pattern(n_points, max_area, canvas_size) handles this automatically:
n_points— approximate target; the actual count depends on how the hex grid tiles the canvasmax_area— mean nucleus area (µm²); sets grid spacing so nuclei don't overlapcanvas_size— side length of the square tissue canvas (µm)
# Step 1: sample background positions
centroids = get_background_point_pattern(
n_points=N_CELLS,
max_area=24.0, # default
canvas_size=CANVAS,
seed=SEED,
)
print(f"Requested: {N_CELLS} cells | Actual: {len(centroids)} positions")
fig, axs = plt.subplots(1, 2, figsize=(11, 5))
# Left: raw scatter
ax = axs[0]
ax.scatter(centroids[:, 0], centroids[:, 1], s=8, c="#444444", alpha=0.6)
ax.set_aspect("equal")
ax.set_title(f"Jittered hex-grid positions (n = {len(centroids)})")
ax.set_xlabel("x (µm)"); ax.set_ylabel("y (µm)")
# Right: zoom to show the hex regularity
ax2 = axs[1]
mask = (centroids[:, 0] < 60) & (centroids[:, 1] < 60)
ax2.scatter(centroids[mask, 0], centroids[mask, 1], s=20, c="#444444", alpha=0.7)
ax2.set_aspect("equal")
ax2.set_title("Zoom — hex-grid jitter is subtle")
ax2.set_xlabel("x (µm)")
fig.suptitle("Step 1: Background cell positions", fontsize=12)
fig.tight_layout()
plt.show()
Requested: 500 cells | Actual: 500 positions
When n_points exceeds the hexagonal grid capacity, the remaining positions are drawn from a 2-D uniform distribution, filtered by a minimum separation threshold, and merged with the hex grid (least-isolated candidates added first). If the canvas is still full after filtering, the simulator proceeds with the maximum achievable cell count and prints a warning — increase canvas_size or decrease n_points if this occurs.
# Illustrate canvas capacity: request progressively more cells on the same canvas
n_requests = [100, 300, 600, 1200]
fig, axs = plt.subplots(1, len(n_requests), figsize=(14, 3.5), sharex=True, sharey=True)
for ax, n_req in zip(axs, n_requests):
pts = get_background_point_pattern(n_points=n_req, max_area=24.0,
canvas_size=CANVAS, seed=SEED)
ax.scatter(pts[:, 0], pts[:, 1], s=3, c="#444444", alpha=0.5)
ax.set_aspect("equal")
ax.set_title(f"requested={n_req}\nactual={len(pts)}", fontsize=8)
ax.set_xticks([]); ax.set_yticks([])
fig.suptitle("Canvas capacity: requested vs actual cell count (canvas=200 µm, max_area=24 µm²)",
fontsize=10)
fig.tight_layout()
plt.show()
Step 2 — Assign Cell Types¶
Cell types are assigned using a Voronoi territory algorithm:
Scatter seed points on the canvas, each randomly assigned a cell type according to
celltype_proportion;Every cell inherits the cell type of its nearest seed (impact terrorities);
continuitycontrols territory granularity: it sets how many seeds are placed relative to cell count;fuzzinessinjects random label swaps near territory boundaries, creating a mixing zone;
| Parameter | Effect | Range |
|---|---|---|
continuity |
Territory size: low → many small patches, high → large blobs | (0, 1] |
fuzziness |
Boundary sharpness: 0 → sharp edges, 0.5 → almost random | [0, 1) |
The cell type assignment step can be easily called with generate_celltypes() function, let's see it with the default values.
For an exhaustive parameter scan of both, see
02s_cell_parameter_deep_dive.ipynb§2b.1–2b.2.
ct = generate_celltypes(
n_cells=len(centroids),
ct_prop=CT_PROP,
positions=centroids,
continuity=0.2,
fuzziness=0.05,
seed=SEED
)
np.unique(ct, return_counts=True)
(array([np.str_('ct_0'), np.str_('ct_1'), np.str_('ct_2')], dtype=object),
array([200, 153, 147]))
# Step 2: assign cell types — 2×2 quick overview
cont_vals = [0.1, 0.5] # fine-grained vs coarse territories
fuzz_vals = [0.0, 0.5] # sharp vs mixed boundaries
fig, axs = plt.subplots(2, 2, figsize=(6, 5), sharex=True, sharey=True)
for row, fuzz in enumerate(fuzz_vals):
for col, cont in enumerate(cont_vals):
ct = generate_celltypes(
n_cells=len(centroids), ct_prop=CT_PROP,
positions=centroids, continuity=cont, fuzziness=fuzz, seed=SEED,
)
ax = axs[row, col]
for label, color in CT_PALETTE.items():
mask = ct == label
ax.scatter(centroids[mask, 0], centroids[mask, 1],
c=color, s=6, alpha=0.8, label=label)
ax.set_aspect("equal")
ax.set_title(f"cont={cont} | fuzz={fuzz}", fontsize=9)
if col == 0:
ax.set_ylabel("y (µm)")
if row == 1:
ax.set_xlabel("x (µm)")
handles = [mpatches.Patch(facecolor=c, label=k) for k, c in CT_PALETTE.items()]
fig.legend(handles=handles, loc="lower center", ncol=len(handles),
bbox_to_anchor=(0.5, 0), fontsize=8, frameon=False)
fig.suptitle("Step 2: Cell-type assignment: continuity × fuzziness overview", fontsize=12)
fig.tight_layout()
fig.subplots_adjust(bottom=0.1)
plt.show()
# Store the default assignment for use in later steps
celltypes = generate_celltypes(
n_cells=len(centroids), ct_prop=CT_PROP,
positions=centroids, continuity=0.2, fuzziness=0.05, seed=SEED,
)
Step 3 — Generate Nucleus Polygons¶
Every cell gets an irregular nucleus polygon whose shape is controlled by MorphologySpec. Key fields at a glance:
| Field | Default | Effect |
|---|---|---|
mean_area |
24 µm² | Mean nucleus area |
elongation |
1.2 | Axis ratio: 1 = round, >1 = stretched |
orientation_mode |
"random" |
random, radial, tangential, aligned |
boundary_noise_apt |
0.1 | Outline irregularity (0 = smooth, ~0.6 = jagged) |
expansion_ratio |
1.8 | Nucleus radius × this = cell radius (Step 4) |
For the full parameter reference, usage patterns, and parameter scan, see
02s_cell_parameter_deep_dive.ipynb §2a.3.
Nucleus polygons are generated with generate_nuclei_by_celltype(), which accepts per-cell-type MorphologySpec overrides and falls back to default_morphology for unspecified fields.
default_morph = MorphologySpec.default()
print("Default MorphologySpec:\n ", default_morph)
# ── Default MorphologySpec ───────────────────────────────────────────────────
nuc_default = generate_nuclei_by_celltype(
positions=centroids,
cell_types=celltypes,
celltype_morphology={}, # no per-type overrides
default_morphology=default_morph,
seed=SEED,
)
# ── Custom: ct_0 elongated, ct_1 large & round, ct_2 default ────────────────
custom_morph = {
"ct_0": MorphologySpec(mean_area=30.0, elongation=3.0, orientation_mode="tangential"),
"ct_1": MorphologySpec(mean_area=55.0, elongation=1.0, boundary_noise_apt=0.05),
# ct_2 → inherits default_morph entirely
}
nuc_custom = generate_nuclei_by_celltype(
positions=centroids,
cell_types=celltypes,
celltype_morphology=custom_morph,
default_morphology=default_morph,
seed=SEED,
)
Default MorphologySpec: MorphologySpec(mean_area=24.0, cv_area=0.5, elongation=1.2, boundary_noise_apt=0.1, n_vertices=12, orientation_mode='random', orientation_noise=0.1, orientation_axis=None, expansion_ratio=1.8)
If we plot the two types of morphology, you can see the differences between them. For now, do not worry about the overlapping of nucleus polygons, they will be taken care of in the later stages.
def draw_nuclei(ax, nuc_polygons, cell_types, palette=CT_PALETTE,
xlim=None, ylim=None, title=""):
"""Helper: draw nucleus polygons coloured by cell type."""
for poly, ct in zip(nuc_polygons, cell_types):
if poly is None or poly.is_empty:
continue
ax.add_patch(MplPoly(np.array(poly.exterior.coords), closed=True,
fc=palette.get(ct, "#888888"), ec="k", lw=0.25, alpha=0.78))
if xlim: ax.set_xlim(*xlim)
if ylim: ax.set_ylim(*ylim)
ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([])
if title: ax.set_title(title, fontsize=9)
# Visualise (zoom to a 80×80 µm corner for clarity)
cx, cy = 75.0, 75.0 # zoom centre
pad = 50.0
zoom_mask = (abs(centroids[:, 0] - cx) < pad) & (abs(centroids[:, 1] - cy) < pad)
z_pos = centroids[zoom_mask]
z_ct = celltypes[zoom_mask]
z_nucd = [p for p, m in zip(nuc_default, zoom_mask) if m]
z_nucc = [p for p, m in zip(nuc_custom, zoom_mask) if m]
xlim = (z_pos[:, 0].min() - 3, z_pos[:, 0].max() + 3)
ylim = (z_pos[:, 1].min() - 3, z_pos[:, 1].max() + 3)
fig, axs = plt.subplots(1, 2, figsize=(11, 5.5))
draw_nuclei(axs[0], z_nucd, z_ct, xlim=xlim, ylim=ylim,
title="Default MorphologySpec (all cell types)")
draw_nuclei(axs[1], z_nucc, z_ct, xlim=xlim, ylim=ylim,
title="Custom per-cell-type MorphologySpec\n(ct_0 elongated, ct_1 large & round, ct_2 default)")
handles = [mpatches.Patch(facecolor=c, label=k) for k, c in CT_PALETTE.items()]
fig.legend(handles=handles, loc="lower center", ncol=len(handles),
bbox_to_anchor=(0.5, 0), fontsize=8, frameon=False)
fig.suptitle("Step 3: Nucleus polygons — default vs per-cell-type MorphologySpec", fontsize=12)
fig.tight_layout()
fig.subplots_adjust(bottom=0.08)
plt.show()
Step 4 — Expand Nuclei to Cell Polygons (Voronoi)¶
Each nucleus is expanded outward by a per-cell expansion radius (proportional to MorphologySpec.expansion_ratio × effective radius), then clipped against its Voronoi cell. This ratio can be defined per cell type in cell-type-specific MorphologySpec.
nucleus polygon
↓ get_expansion_radius() (per-cell radius from morphology)
expansion circle
↓ nuclei_to_cells_voronoi()
Voronoi cell polygon (non-overlapping, fills the canvas)
The zoom below shows all three layers — nucleus (solid), cell polygon (outline), and how adjacent cells tile perfectly.
# Step 4: Voronoi expansion using the default nuclei from Step 3
d_cells = get_expansion_radius(
nuc_polygons=nuc_default,
celltype_labels=celltypes,
celltype_morphology={},
default_morphology=default_morph,
)
nuc_clipped, cell_polygons = nuclei_to_cells_voronoi(nuc_default, d_cells=d_cells)
print(f"Cells with valid polygons: {sum(p is not None and not p.is_empty for p in cell_polygons)}")
Cells with valid polygons: 500
# ── Zoom to a small region to show the three layers ──────────────────────────
cx, cy = 75.0, 75.0 # zoom centre
pad = 50.0
zoom_cells = [
(nuc_clipped[i], cell_polygons[i], celltypes[i])
for i in range(len(nuc_clipped))
if abs(centroids[i, 0] - cx) < pad and abs(centroids[i, 1] - cy) < pad
]
fig, axs = plt.subplots(1, 3, figsize=(15, 5))
titles = ["Nuclei only", "Cell polygons (Voronoi)", "Both layers"]
for ax, title in zip(axs, titles):
for nuc, cell, ct in zoom_cells:
color = CT_PALETTE.get(ct, "#aaaaaa")
if "Nuclei" in title or "Both" in title:
if nuc and not nuc.is_empty:
ax.add_patch(MplPoly(np.array(nuc.exterior.coords), closed=True,
fc=color, ec="k", lw=0.5, alpha=0.85, zorder=2))
if "Cell" in title or "Both" in title:
if cell and not cell.is_empty:
ax.add_patch(MplPoly(np.array(cell.exterior.coords), closed=True,
fc=color, ec="k", lw=0.4, alpha=0.2, zorder=1))
ax.set_xlim(cx - pad, cx + pad); ax.set_ylim(cy - pad, cy + pad)
ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([])
ax.set_title(title, fontsize=10)
handles = [mpatches.Patch(facecolor=c, label=k) for k, c in CT_PALETTE.items()]
fig.legend(handles=handles, loc="lower center", ncol=len(handles),
bbox_to_anchor=(0.5, 0), fontsize=8, frameon=False)
axs[1].set_xlabel("x (µm)"); axs[0].set_ylabel("y (µm)")
fig.suptitle("Step 4: Nucleus → expansion radius → Voronoi cell polygon (zoom)", fontsize=12)
fig.tight_layout()
fig.subplots_adjust(bottom=0.1)
plt.show()
# TODO Nucleus expansion for cell polygon -> More variable cases
Step 5 — Prototypes: Structured Micro-Environments¶
Steps 1–4 produce a uniform random background. Prototypes let you embed structured micro-environments — tumour cores, immune rings, stromal tracts — then fill remaining space with background cells.
The prototype workflow uses three classes:
| Class | Role | Has position? |
|---|---|---|
PrototypeSpec |
Blueprint — pattern type, cell composition, morphology | ✗ |
PrototypeSpecInstance |
One placed realisation of a blueprint | ✓ |
PrototypeScene |
Groups several blueprints sharing a common centre | ✗ |
For a full parameter reference, multi-prototype scenes, and advanced examples see
02p_prototype_deep_dive.ipynb.
# 1. Define a blueprint — pattern, cell composition, morphology
vessel_spec = PrototypeSpec(
pattern="chain",
pattern_params={"length": 150.0, "width": 25.0},
cell_type_composition={"ct_0": 1},
morphology=MorphologySpec(mean_area=18.0, elongation=2.2, orientation_mode="aligned"),
n_cells=70,
)
# 2. Place it on the canvas (no position in the spec — supply it here)
vessel_instance = PrototypeSpecInstance(
spec=vessel_spec,
center=np.array([100.0, 100.0]),
prototype_id=1, # 0 = background, ≥1 = prototype
seed=10,
)
# 3. Run — prototype cells are placed first; background fills the rest
cfg_proto = 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_proto = SpotlessSimulator(cfg_proto)
sim_proto.generate_gene_parameters()
sim_proto.initialize_cells(prototype_instances=[vessel_instance])
gdf = sim_proto.cell_gdf
print(f"Total cells: {len(gdf)} | prototype (id=1): {(gdf['prototype_id'] == 1).sum()}")
Total cells: 500 | prototype (id=1): 70
PROTO_COLORS = {0: "#cccccc", 1: "#e377c2"}
PROTO_LABELS = {0: "background", 1: "tumour cluster"}
gdf["_ct_color"] = gdf["celltype"].map(CT_PALETTE)
gdf["_proto_color"] = gdf["prototype_id"].map(PROTO_COLORS)
fig, axs = plt.subplots(1, 2, figsize=(12, 6))
for ax, color_col, title in [
(axs[0], "_ct_color", "Cell type"),
(axs[1], "_proto_color", "Prototype origin"),
]:
gdf.set_geometry("cell_geometry").plot(
ax=ax, color=gdf[color_col], alpha=0.25, edgecolor="k", linewidth=0.25)
gdf.set_geometry("nucleus_geometry").plot(
ax=ax, color=gdf[color_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)")
ct_handles = [mpatches.Patch(facecolor=c, label=k) for k, c in CT_PALETTE.items()]
pr_handles = [mpatches.Patch(facecolor=PROTO_COLORS[k], label=v)
for k, v in PROTO_LABELS.items()]
axs[0].legend(handles=ct_handles, fontsize=8, frameon=False)
axs[1].legend(handles=pr_handles, fontsize=8, frameon=False)
fig.suptitle("Step 5: Prototype insertion — tumour cluster (prototype_id=1)", fontsize=12)
fig.tight_layout()
plt.show()
Summary¶
| Step | Function | Key controls |
|---|---|---|
| 1 — Positions | get_background_point_pattern() |
n_cells, canvas_size, mean_area |
| 2 — Cell types | generate_celltypes() |
continuity, fuzziness, celltype_proportion |
| 3 — Nuclei | generate_nuclei_by_celltype() |
MorphologySpec (mean_area, elongation, orientation_mode) |
| 4 — Cell polygons | nuclei_to_cells_voronoi() |
expansion_ratio in MorphologySpec |
| 5 — Prototypes | initialize_cells(prototype_instances=[…]) |
PrototypeSpec, PrototypeSpecInstance |
Related notebooks:
02s_cell_parameter_deep_dive.ipynb— exhaustive parameter scans for spatial patterns, morphology, continuity and fuzziness02p_prototype_deep_dive.ipynb— full prototype reference:PrototypeScene, multi-prototype layouts, and generation paths03_simulate_counts.ipynb— how the cell layout feeds into the Negative-Binomial count model