Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,68 @@ All notable changes to Celldega are documented here. This project follows
[Keep a Changelog](https://keepachangelog.com/) conventions and
[semantic versioning](https://semver.org/).

## [0.24.1] - 2026-08-06

Patch release fixing a NumPy binary-incompatibility crash on import in
environments that already had NumPy 1.x installed.

### Fixed

- **`import celldega` failed with `ValueError: numpy.dtype size changed`** in
environments with a preinstalled NumPy 1.x (e.g. a conda base). The `numpy>=1.23`
floor introduced in 0.24.0 was satisfied by the existing 1.x, so it was left in
place while NumPy-2-built wheels (`h5py`, `anndata`, ...) were installed,
producing a dtype-ABI mismatch. The floor is now a hard `numpy>=2`, forcing the
runtime NumPy to match the wheels.

## [0.24.0] - 2026-08-06

Removes Scanpy from the default install path and modernizes the spatial-data
stack. Cluster/gene coloring is now generated without Scanpy, several viral
(GPL) and unused dependencies are dropped, and a fresh-environment install no
longer fails on `pkg_resources`.

### Added

- **`scanpy` optional extra** — Scanpy is now installed only via
`pip install celldega[scanpy]`, needed solely for
`celldega.clust.Matrix.downsample_to()` (already behind a lazy import with a
clear error message).

### Changed

- **Cluster and gene coloring no longer depend on Scanpy.** The color palettes
in `Landscape`/`Clustergram` widgets and in `pre` (`_create_cluster_colors`,
`make_meta_gene`) and neighborhood gradients (`_ring_colors`) are now built
from a deterministic `colorsys` HSV generator instead of triggering a
`sc.pl.umap` call to populate `adata.uns[...]_colors`. Widgets no longer mutate
the caller's `AnnData`.
- **`qc.orthogonal_expression_calc` returns data instead of plotting.** It now
returns `(results, orthogonal_summary)` DataFrames rather than rendering
Seaborn/Matplotlib figures, so callers can plot with any library.
- **Modernized the spatial-data stack** — `spatialdata>=0.7.2,<0.8`,
`spatialdata-io>=0.7.1`, `ome-zarr>=0.12.2`, and `zarr>=3` (with `open_zarr`
migrated to the Zarr v3 storage API). `numpy>=2` is now required by the updated
stack.

### Removed

- **Dropped viral/copyleft dependencies** `igraph` (GPL-2.0-or-later) and
`leidenalg` (GPL-3.0-or-later), plus the `pytest-html` dev dependency
(MPL-2.0) — none were imported by celldega.
- **Dropped unused runtime dependencies** `squidpy`, `dask`, and `datashader`
(not imported by celldega; `dask`/`datashader` remain available transitively
through `spatialdata`). Scanpy moved to an optional extra (see Added).

### Fixed

- **Fresh-environment install failed with `ModuleNotFoundError: pkg_resources`**
([#292]). The old `spatialdata` pin held back `xarray_schema`, which imports
`pkg_resources` (removed in setuptools 82+). Bumping to `spatialdata>=0.7.2`
drops `xarray_schema` entirely, fixing the import at its root.

[#292]: https://github.com/broadinstitute/celldega/issues/292

## [0.23.1] - 2026-08-05

Fixes a batch of `Yearbook` selection, coloring, and layout bugs so that
Expand Down
188 changes: 94 additions & 94 deletions docs/assets/js/celldega.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "celldega",
"version": "0.23.1",
"version": "0.24.1",
"type": "module",
"engines": {
"node": ">=16",
Expand Down
41 changes: 18 additions & 23 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,32 @@ build-backend = "hatchling.build"

[project]
name = "celldega"
version = "0.23.1"
version = "0.24.1"
readme = "README.md"
requires-python = ">=3.11,<3.13"
dependencies = [
"anndata>=0.12,<0.13",
"scanpy[leiden]>=1.11,<1.12",
"squidpy>=1.6,<1.7",
"spatialdata>=0.2,<0.3",
"spatialdata-io>=0.1,<0.2",
"ome-zarr<0.10",
# spatialdata>=0.7.2 dropped the xarray_schema dep whose pkg_resources import
# broke installs under setuptools>=82 (github.com/broadinstitute/celldega/issues/292).
# Capped <0.8 because 0.8 requires Python>=3.12 and we still support 3.11.
"spatialdata>=0.7.2,<0.8",
"spatialdata-io>=0.7.1,<0.8",
"ome-zarr>=0.12.2",
"mudata",

# xarray_schema still imports pkg_resources
"setuptools<81",
"zarr>=3",

# Keep Zarr 2 because ome-zarr/spatialdata stack still expects FSStore
"zarr>=2.18,<3",

# Pin <2: avoids numpy2/numpy1-ABI binary mismatches (e.g. h5py) in envs with preinstalled numpy1 wheels
"numpy>=1.23,<2",
"pandas>=2.2,<2.4",
# numpy>=2 is required by the modern spatialdata stack (multiscale-spatial-image),
# and must be a hard floor (not just >=1.23): in envs with a preinstalled numpy 1.x
# (e.g. conda base), a looser floor leaves numpy 1.x in place while numpy-2-built
# wheels (h5py, anndata, ...) get installed, causing a dtype-ABI mismatch on import.
"numpy>=2",
"pandas>=2.2",
"scipy",
"dask",
"datashader",
"polars>=1.10,<2",
"pyarrow",
"statsmodels>=0.14,<0.15",

"igraph",
"leidenalg",

"geopandas",
"libpysal",
"shapely>=2.0,<2.2",
Expand Down Expand Up @@ -67,7 +62,6 @@ dev = [
# Unit testing
"pytest",
"pytest-cov",
"pytest-html",
"pytest-mock",

# Git hooks automation
Expand All @@ -89,6 +83,10 @@ dev = [
"nbconvert",
"nbformat",
]
# optional dependency for celldega.clust.Matrix.downsample_to()
scanpy = [
"scanpy>=1.11,<1.12"
]

# Optional dependencies for pre-processing
pre = [
Expand Down Expand Up @@ -195,18 +193,15 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
known-first-party = ["celldega"]
known-third-party = [
"anndata",
"dask",
"geopandas",
"matplotlib",
"numpy",
"pandas",
"polars",
"scanpy",
"scipy",
"seaborn",
"shapely",
"spatialdata",
"squidpy",
"zarr",
]
lines-after-imports = 2
Expand Down
19 changes: 10 additions & 9 deletions src/celldega/nbhd/gradient.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from __future__ import annotations

import colorsys
from typing import TYPE_CHECKING, Any

import geopandas as gpd
Expand Down Expand Up @@ -165,15 +166,15 @@ def _resolve_clip_boundary(
return None


def _ring_colors(cmap_name: str, n: int) -> list[str]:
"""Sample ``n`` hex colors from a matplotlib colormap (dark -> light)."""
import matplotlib.colors as mcolors
import matplotlib.pyplot as plt

def _ring_colors(hue: float, n: int) -> list[str]:
"""Generate ``n`` hex colors along a fixed hue (dark -> light)."""
if n == 0:
return []
cmap = plt.get_cmap(cmap_name)
return [mcolors.to_hex(cmap(v)) for v in np.linspace(0.8, 0.3, n)]
colors = []
for value in np.linspace(0.4, 0.95, n):
r, g, b = colorsys.hsv_to_rgb(hue, 0.75, value)
colors.append(f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}")
return colors


def _calc_gradient(
Expand Down Expand Up @@ -351,8 +352,8 @@ def inward_label(i: int) -> str:
out_labels = gdf_rings.loc[gdf_rings["direction"] == "outward", "ring_range_um"].tolist()
in_labels = gdf_rings.loc[gdf_rings["direction"] == "inward", "ring_range_um"].tolist()
color_map = {
**dict(zip(out_labels, _ring_colors("Blues", len(out_labels)), strict=True)),
**dict(zip(in_labels, _ring_colors("Reds", len(in_labels)), strict=True)),
**dict(zip(out_labels, _ring_colors(0.6, len(out_labels)), strict=True)), # blue
**dict(zip(in_labels, _ring_colors(0.0, len(in_labels)), strict=True)), # red
}
gdf_rings["color"] = gdf_rings["ring_range_um"].map(color_map).fillna("#cccccc")

Expand Down
46 changes: 16 additions & 30 deletions src/celldega/pre/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,14 @@
pyvips = None

import base64
import colorsys
import hashlib
import json
from pathlib import Path
import subprocess
import warnings
import xml.etree.ElementTree as ET

from matplotlib.colors import to_hex
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy.sparse import csr_matrix, issparse
Expand Down Expand Up @@ -93,6 +92,12 @@ def _load_xenium_cluster_data(data_dir, meta_cell):
return default_clustering, clusters, ser_counts


def _hsv_to_hex(h: float) -> str:
"""Convert HSV color to hex string."""
r, g, b = colorsys.hsv_to_rgb(h, 0.65, 0.9)
return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}"


def _create_cluster_colors(clusters):
"""
Create color mapping for clusters.
Expand All @@ -103,13 +108,11 @@ def _create_cluster_colors(clusters):
Returns:
- List of colors for clusters
"""
palettes = [plt.get_cmap(name).colors for name in plt.colormaps() if "tab" in name]
flat_colors = [color for palette in palettes for color in palette]
flat_colors_hex = [to_hex(color) for color in flat_colors]
n = len(clusters)
palette = [_hsv_to_hex(i / n) for i in range(n)]

return [
(flat_colors_hex[i % len(flat_colors_hex)] if "Blank" not in cluster else "#FFFFFF")
for i, cluster in enumerate(clusters)
(palette[i] if "Blank" not in cluster else "#FFFFFF") for i, cluster in enumerate(clusters)
]


Expand Down Expand Up @@ -1032,14 +1035,7 @@ def make_meta_gene(cbg, path_output):
print("\n========Write meta gene files========")
genes = cbg.columns.tolist()

palettes = [plt.get_cmap(name).colors for name in plt.colormaps() if "tab" in name]
flat_colors = [color for palette in palettes for color in palette]
flat_colors_hex = [to_hex(color) for color in flat_colors]

colors = [
flat_colors_hex[i % len(flat_colors_hex)] if "Blank" not in gene else "#FFFFFF"
for i, gene in enumerate(genes)
]
colors = _create_cluster_colors(genes)

ser_color = pd.Series(colors, index=genes)
meta_gene = calc_meta_gene_data(cbg)
Expand Down Expand Up @@ -1546,9 +1542,11 @@ def write_xenium_transform(
# Function to open a Zarr file
def open_zarr(path: str) -> zarr.Group:
store = (
zarr.ZipStore(path, mode="r") if path.endswith(".zip") else zarr.DirectoryStore(path)
zarr.storage.ZipStore(path, mode="r")
if path.endswith(".zip")
else zarr.storage.LocalStore(path, read_only=True)
)
return zarr.group(store=store)
return zarr.open_group(store=store, mode="r")

try:
# Open the cells Zarr file
Expand Down Expand Up @@ -1761,10 +1759,6 @@ def add_clustering_from_adata(
The Landscape widget can use the custom clustering by setting the
`segmentation` parameter to match the `segmentation_name`.
"""
from contextlib import suppress

import scanpy as sc

path_lf = Path(path_dega_files)

# Determine output directory
Expand All @@ -1788,15 +1782,7 @@ def add_clustering_from_adata(
clusters = cluster_counts.index.tolist()

color_key = f"{cluster_key}_colors"
colors = None
if color_key in adata.uns:
colors = adata.uns[color_key]
else:
# Try to generate colors using scanpy
with suppress(Exception):
sc.pl.umap(adata, color=cluster_key, show=False)
plt.close()
colors = adata.uns.get(color_key)
colors = adata.uns.get(color_key)

# Fallback to generated colors
if colors is None:
Expand Down
Loading
Loading