stat-agent-demo / stat_agent /core /data_slice.py
yzhang2863
Initial release v0.1.0
0084130
Raw
History Blame Contribute Delete
11.2 kB
"""
DataSlice: Core data structure for spatial transcriptomics.
Each DataSlice represents a single, independent unit of spatial data with:
- Unique slice_id (0, 1, 2, ...)
- Modality ('gene' or 'protein')
- Data level ('cell' or 'spot')
- AnnData with expression matrix
- Optional images (H&E, DAPI, protein channels)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, Any, Optional
import anndata as ad
import numpy as np
@dataclass
class DataSlice:
"""
A single slice of spatial transcriptomics data.
Represents one independent data unit with unique slice_id.
Each slice can be:
- Gene expression (modality='gene', data_level='cell' or 'spot')
- Protein expression (modality='protein', data_level='cell')
Attributes
----------
slice_id : int
Unique identifier (typically parsed from filename)
modality : str
'gene' or 'protein'
data_level : str
'cell' (single-cell) or 'spot' (spatial transcriptomics spots)
adata : AnnData
Expression matrix (cells/spots × genes/proteins)
images : Dict[str, ndarray]
Named images, e.g., {'he': array, 'dapi': array}
Empty dict if no images available
metadata : Dict[str, Any]
Additional information (tissue_name, etc.)
Examples
--------
>>> # Cell-level gene expression
>>> slice_0 = DataSlice(
... slice_id=0,
... modality='gene',
... data_level='cell',
... adata=adata_gene,
... images={'he': he_image},
... metadata={'tissue_name': 'breast_cancer_rep1'}
... )
>>> # Spot-level gene expression (Visium)
>>> slice_1 = DataSlice(
... slice_id=1,
... modality='gene',
... data_level='spot',
... adata=adata_visium,
... images={'he': he_image},
... metadata={'tissue_name': 'visium_sample'}
... )
>>> # Protein expression
>>> slice_2 = DataSlice(
... slice_id=2,
... modality='protein',
... data_level='cell',
... adata=adata_protein,
... images={'cd3': cd3_img, 'cd8': cd8_img},
... metadata={'tissue_name': 'protein_panel'}
... )
"""
slice_id: int
modality: str # 'gene' or 'protein'
data_level: str # 'cell' or 'spot'
adata: ad.AnnData
images: Dict[str, np.ndarray] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
"""Validate data after initialization."""
# Validate modality
if self.modality not in ['gene', 'protein']:
raise ValueError(f"Invalid modality: {self.modality}. Must be 'gene' or 'protein'")
# Validate data_level
if self.data_level not in ['cell', 'spot']:
raise ValueError(f"Invalid data_level: {self.data_level}. Must be 'cell' or 'spot'")
# Validate AnnData has required columns
if 'x' not in self.adata.obs.columns or 'y' not in self.adata.obs.columns:
raise ValueError("AnnData must have 'x' and 'y' columns in obs")
# ========================================
# Type checking properties
# ========================================
@property
def is_gene(self) -> bool:
"""Check if this slice contains gene expression data."""
return self.modality == 'gene'
@property
def is_protein(self) -> bool:
"""Check if this slice contains protein expression data."""
return self.modality == 'protein'
@property
def is_cell_level(self) -> bool:
"""Check if this is cell-level data (single-cell resolution)."""
return self.data_level == 'cell'
@property
def is_spot_level(self) -> bool:
"""Check if this is spot-level data (spatial transcriptomics spots)."""
return self.data_level == 'spot'
# ========================================
# Data access properties
# ========================================
@property
def n_obs(self) -> int:
"""Number of observations (cells or spots)."""
return int(self.adata.n_obs)
@property
def n_vars(self) -> int:
"""Number of variables (genes or proteins)."""
return int(self.adata.n_vars)
@property
def primary_image(self) -> Optional[np.ndarray]:
"""
Get the first available image.
Returns None if no images are available.
Useful for quick access when only one image is expected.
"""
return next(iter(self.images.values())) if self.images else None
@property
def coordinate_range(self) -> Dict[str, tuple]:
"""
Get spatial coordinate range.
Returns
-------
dict
{'x': (min, max), 'y': (min, max)}
"""
return {
'x': (self.adata.obs['x'].min(), self.adata.obs['x'].max()),
'y': (self.adata.obs['y'].min(), self.adata.obs['y'].max())
}
# ========================================
# Annotation checking
# ========================================
def has_celltype(self) -> bool:
"""
Check if celltype annotations exist.
Returns True only if:
- 'celltype' column exists in adata.obs
- At least one non-null celltype value exists
"""
if 'celltype' not in self.adata.obs.columns:
return False
return bool(self.adata.obs['celltype'].notna().any())
def has_niche_labels(self) -> bool:
"""Check if niche labels exist."""
if 'niche_label' not in self.adata.obs.columns:
return False
return bool(self.adata.obs['niche_label'].notna().any())
def has_deconv_weights(self) -> bool:
"""Check if deconvolution weights exist (for spot data)."""
return 'deconv_weights' in self.adata.obsm
def has_celltype_colors(self) -> bool:
"""Check if celltype colors are defined."""
return 'celltype_colors' in self.adata.uns
def get_celltype_colors(self) -> Optional[Dict[str, str]]:
"""
Get celltype colors from .uns['celltype_colors'].
Returns
-------
dict or None
Mapping of celltype name to color (hex format), or None if not set
"""
return self.adata.uns.get('celltype_colors')
def set_celltype_colors(self, colors: Dict[str, str]):
"""
Set celltype colors in .uns['celltype_colors'].
Parameters
----------
colors : Dict[str, str]
Mapping of celltype name to color (hex format like '#RRGGBB' or 'rgb(r,g,b)')
"""
self.adata.uns['celltype_colors'] = colors
def ensure_celltype_colors(self):
"""
Auto-generate celltype colors if not present.
Only generates colors if:
1. Celltype annotations exist
2. Colors are not already defined
Uses HSL golden ratio spacing for visually distinct colors.
"""
if not self.has_celltype():
return # No celltypes to color
if self.has_celltype_colors():
return # Already has colors
# Get unique celltypes
celltypes = self.adata.obs['celltype'].unique()
valid_celltypes = sorted([ct for ct in celltypes if isinstance(ct, str)])
if not valid_celltypes:
return # No valid celltypes
# Generate colors using HSL golden ratio
colors = self._generate_celltype_colors(valid_celltypes)
self.set_celltype_colors(colors)
def _generate_celltype_colors(self, celltypes: list) -> Dict[str, str]:
"""
Generate distinct colors for celltypes using HSL golden ratio spacing.
Parameters
----------
celltypes : list
List of celltype names
Returns
-------
dict
Mapping of celltype to color (rgb format)
"""
import numpy as np
colors = {}
golden_ratio = 0.618033988749895
hue = np.random.random() # Start with random hue
for celltype in celltypes:
hue += golden_ratio
hue %= 1
saturation = 0.6 + np.random.random() * 0.2 # 0.6-0.8
lightness = 0.45 + np.random.random() * 0.15 # 0.45-0.6
# Convert HSL to RGB
r, g, b = self._hsl_to_rgb(hue, saturation, lightness)
colors[celltype] = f'rgb({int(r*255)}, {int(g*255)}, {int(b*255)})'
return colors
def _hsl_to_rgb(self, h: float, s: float, l: float) -> tuple:
"""Convert HSL to RGB."""
def hue2rgb(p, q, t):
if t < 0:
t += 1
if t > 1:
t -= 1
if t < 1/6:
return p + (q - p) * 6 * t
if t < 1/2:
return q
if t < 2/3:
return p + (q - p) * (2/3 - t) * 6
return p
if s == 0:
r = g = b = l
else:
q = l * (1 + s) if l < 0.5 else l + s - l * s
p = 2 * l - q
r = hue2rgb(p, q, h + 1/3)
g = hue2rgb(p, q, h)
b = hue2rgb(p, q, h - 1/3)
return r, g, b
# ========================================
# Summary and display
# ========================================
def get_summary(self) -> Dict[str, Any]:
"""
Get slice summary for display and logging.
Returns
-------
dict
Summary information about this slice
"""
summary = {
'slice_id': self.slice_id,
'modality': self.modality,
'data_level': self.data_level,
'data_level_detection': self.metadata.get('data_level_detection', ''),
'n_obs': self.n_obs,
'n_vars': self.n_vars,
'has_celltype': self.has_celltype(),
'celltypes': sorted(self.adata.obs['celltype'].unique().tolist()) if self.has_celltype() else [],
'has_celltype_colors': self.has_celltype_colors(),
'image_count': len(self.images),
'image_names': list(self.images.keys()),
'image_match': self.metadata.get('image_match', ''),
'tissue_name': self.metadata.get('tissue_name', f'slice_{self.slice_id}'),
}
# Add spot-specific info
if self.is_spot_level:
summary['has_deconv_weights'] = self.has_deconv_weights()
if 'spot_shape' in self.adata.uns:
summary['spot_shape'] = str(self.adata.uns['spot_shape'])
if 'spot_diameter' in self.adata.uns:
summary['spot_diameter'] = float(self.adata.uns['spot_diameter'])
return summary
def __repr__(self) -> str:
"""String representation for debugging."""
tissue_name = self.metadata.get('tissue_name', 'unknown')
return (f"DataSlice(slice_id={self.slice_id}, modality='{self.modality}', "
f"data_level='{self.data_level}', n_obs={self.n_obs}, "
f"n_vars={self.n_vars}, tissue='{tissue_name}')")