NF1 3D pilot cropped voxel view#
View real 3D single-cell crops (with mask overlays) from two NF1 wells in one CytoDataFrame.
Most of the setup below isn’t about CytoDataFrame – it’s working around this
data’s layout, which doesn’t look like typical CellProfiler output:
There are no
Image_FileName_*/Image_PathName_*columns, so each row’s raw image path is built from the patient/well/field metadata (load_image_set).Every well’s mask file has the exact same generic name (
nuclei_mask.tiff), so combining two wells into one table needsstage_maskto tell them apart – a single-well notebook wouldn’t need this at all.
The actual CytoDataFrame(...) call at the end is just a handful of arguments.
import os
import pathlib
import re
import tempfile
import pandas as pd
from cytodataframe import CytoDataFrame
# Local mount point for bandicoot. Set BANDICOOT_LOCAL_BASE if it is mounted
# somewhere else on your machine.
BANDICOOT_LOCAL_BASE = pathlib.Path(
os.environ.get("BANDICOOT_LOCAL_BASE", "~/mnt/bandicoot")
).expanduser()
DATA_DIR = BANDICOOT_LOCAL_BASE / "NF1_organoid_data/data"
PROFILES_DIR = (
DATA_DIR / "image_based_profiles_production_zedprofiler/ibp/sc_profiles_related"
)
if not PROFILES_DIR.is_dir():
raise FileNotFoundError(
f"Profiles not found at {PROFILES_DIR}. Set the BANDICOOT_LOCAL_BASE "
"environment variable to wherever bandicoot is mounted on this machine."
)
# An "image set" here is one well + field-of-view (one 3D image). Add every
# image set you want to view to this list -- e.g. all wells/fields in a
# plate for per-plate QC -- not just the two shown here.
IMAGE_SETS = [
"NF0055_T1__NF0055_T1__B10__F1",
"NF0014_T1__NF0014_T1__C4__F2",
]
CHANNEL = "DNA"
CHANNEL_CODE = "405"
COMPARTMENT = "Nuclei"
# Every well's mask file has the same generic name (e.g. "nuclei_mask.tiff"),
# but CytoDataFrame's data_mask_context_dir only matches masks by filename
# pattern within one shared directory -- it can't tell two identically-named
# files in different wells apart. MASK_LINK_DIR is a scratch directory (not
# part of the repo) that `stage_mask` below fills with per-well symlinks to
# the real mask files, renamed to embed each well's own identifier, so that
# matching works.
MASK_LINK_DIR = pathlib.Path(tempfile.gettempdir()) / "cytodataframe_nf1_3d_mask_links"
MASK_LINK_DIR.mkdir(exist_ok=True)
def load_image_set(image_set: str) -> tuple[pd.DataFrame, pathlib.Path, pathlib.Path]:
profiles = pd.read_parquet(PROFILES_DIR / f"{image_set}.parquet").head(1)
row = profiles.iloc[0]
patient = row["Metadata_Biology_PatientTumor"]
# e.g. B10-1
well_field = (
f"{row['Metadata_Experiment_WellID']}-{row['Metadata_Imaging_FieldID']}"
)
channel_path = (
DATA_DIR
/ patient
/ "zstack_images"
/ well_field
/ f"{well_field}_{CHANNEL_CODE}.tif"
)
mask_path = (
DATA_DIR
/ patient
/ "segmentation_masks"
/ well_field
/ f"{COMPARTMENT.lower()}_mask.tiff"
)
profiles[f"Image_FileName_{CHANNEL}"] = str(channel_path)
return profiles, channel_path, mask_path
def stage_mask(channel_path: pathlib.Path, mask_path: pathlib.Path) -> pathlib.Path:
# See MASK_LINK_DIR above for why this exists.
link = MASK_LINK_DIR / f"{channel_path.stem}__{mask_path.name}"
if not link.exists():
link.symlink_to(mask_path)
return link
Load one object per well into a single two-row table.
profile_rows = []
for image_set in IMAGE_SETS:
profiles, channel_path, mask_path = load_image_set(image_set)
stage_mask(channel_path, mask_path)
profile_rows.append(profiles)
profiles = pd.concat(profile_rows, ignore_index=True)
mask_name = mask_path.name
bbox_column_map = {
"x_min": f"{COMPARTMENT}_NoChannel_VolumeSizeShape_MinX",
"x_max": f"{COMPARTMENT}_NoChannel_VolumeSizeShape_MaxX",
"y_min": f"{COMPARTMENT}_NoChannel_VolumeSizeShape_MinY",
"y_max": f"{COMPARTMENT}_NoChannel_VolumeSizeShape_MaxY",
"z_min": f"{COMPARTMENT}_NoChannel_VolumeSizeShape_MinZ",
"z_max": f"{COMPARTMENT}_NoChannel_VolumeSizeShape_MaxZ",
}
center_columns = [
f"{COMPARTMENT}_NoChannel_VolumeSizeShape_Center{axis}" for axis in "XYZ"
]
voxel_view = CytoDataFrame(
data=profiles[[f"Image_FileName_{CHANNEL}"]],
data_bounding_box=profiles[list(bbox_column_map.values())],
compartment_center_xy=profiles[center_columns],
data_mask_context_dir=str(MASK_LINK_DIR),
segmentation_file_regex={rf"__{re.escape(mask_name)}$": r"_\d+\.tif$"},
display_options={
"width": 260,
"height": 260,
"table_max_height": "580px",
"label_overlay_mode": "filled",
# Voxel size (x, y, z) in um. These TIFFs carry no voxel-size
# metadata, and z is coarser than xy, so without this the objects are
# drawn too flat. xy_spacing (0.1) is the NF1 3D pipeline manifests'
# recorded value; z_spacing (0.5) was chosen visually against these
# two objects rather than read from the manifests (which record 1.0).
"volume_spacing": (0.1, 0.1, 0.5),
"volume_bbox_column_map": bbox_column_map,
},
)
# The rendered widget includes a "Mask" checkbox to toggle the overlay on/off,
# and a red dot marking each object's center.
voxel_view
Static snapshot (for non-interactive view)
| Image_FileName_DNA | |
|---|---|
| 0 | |
| 1 |