diff --git a/js/widget.js b/js/widget.js
index 2d399bed..561cecc4 100644
--- a/js/widget.js
+++ b/js/widget.js
@@ -1,5 +1,6 @@
import './widget.css';
+import { options, set_options } from './global_variables/fetch_options';
import { networkFromParquet } from './read_parquet/network_from_parquet';
import { objects_from_parquet } from './read_parquet/objects_from_parquet';
import {
@@ -12,6 +13,69 @@ import { landscape_sst } from './viz/landscape_sst';
import { matrix_viz } from './viz/matrix_viz';
import { render_enrich } from './widgets/enrich_widget';
+function issueCrossPlatformWarning(message, model, el, showInNotebook = true) {
+ /* eslint-disable-next-line no-console */
+ console.warn(`⚠️ ${message}`);
+
+ if (showInNotebook) {
+ const warnDiv = document.createElement('div');
+ warnDiv.style.color = 'orange';
+ warnDiv.style.padding = '6px';
+ warnDiv.style.fontSize = '0.9em';
+ warnDiv.style.fontWeight = 'bold';
+ warnDiv.textContent = `⚠️ ${message}`;
+ el.appendChild(warnDiv);
+ }
+
+ if (model?.send) {
+ model.send({ event: 'js_warning', message });
+ }
+}
+
+const fetchLandscapeTechnology = async (model, _el) => {
+ const base_url = model.get('base_url');
+ const token = model.get('token');
+
+ try {
+ set_options(token);
+ const url = `${base_url}/landscape_parameters.json`;
+ const response = await fetch(url, options.fetch);
+
+ if (!response.ok) {
+ const error = new Error(
+ `Failed to fetch landscape_parameters.json: ${response.statusText}`
+ );
+ error.status = response.status;
+ throw error;
+ }
+
+ const json = await response.json();
+
+ if (!json.technology) {
+ const message =
+ 'The landscape_parameters.json file appears to be missing the `technology` field. Please verify its contents.';
+
+ /* eslint-disable-next-line no-console */
+ console.warn(`⚠️ ${message}`);
+ model.send({ event: 'js_error', message });
+ throw new Error(message);
+ }
+
+ return json.technology;
+ } catch (error) {
+ const errorResult = handleAsyncError(error, {
+ context: 'fetchLandscapeTechnology',
+ messages: {
+ notFound: 'landscape_parameters.json not found',
+ unexpected: 'Error fetching landscape_parameters.json',
+ },
+ });
+
+ model.send({ event: 'js_error', message: errorResult.message });
+ return null;
+ }
+};
+
// Remove export keywords from render functions
const render_landscape_ist = async ({ model, el }) => {
const token = model.get('token');
@@ -136,14 +200,60 @@ const render_landscape_h_e = async ({ model, el }) => {
);
};
+const DEFAULT_TECHNOLOGY = 'Xenium';
+
const render_landscape = async ({ model, el }) => {
- const technology = model.get('technology');
+ let technology = model.get('technology');
+ const userPassedTechnology =
+ Object.prototype.hasOwnProperty.call(model, 'attributes') &&
+ Object.prototype.hasOwnProperty.call(model.attributes, 'technology');
+
+ if (!technology) {
+ issueCrossPlatformWarning(
+ 'Technology was not passed in the function – attempting to fetch this from landscape_parameters.json.',
+ model,
+ el,
+ false
+ );
+
+ const fetchedTech = await fetchLandscapeTechnology(model, el);
- if (['MERSCOPE', 'Xenium', 'Chromium'].includes(technology)) {
+ if (!fetchedTech) {
+ // Fallback to DEFAULT_TECHNOLOGY with a strong warning
+ const fallbackMsg =
+ `Neither technology was explicitly passed nor found in landscape_parameters.json. ` +
+ `Falling back to default: ${DEFAULT_TECHNOLOGY}`;
+ issueCrossPlatformWarning(fallbackMsg, model, el);
+
+ technology = DEFAULT_TECHNOLOGY;
+ } else {
+ technology = fetchedTech;
+ }
+
+ model.set('technology', technology);
+ model.save_changes();
+ } else if (userPassedTechnology) {
+ issueCrossPlatformWarning(
+ 'Setting `technology` manually is deprecated and will be removed in a future release. Please rely on automatic detection via landscape_parameters.json.',
+ model,
+ el
+ );
+ }
+
+ if (
+ !['MERSCOPE', DEFAULT_TECHNOLOGY, 'Visium-HD', 'h&e'].includes(technology)
+ ) {
+ const msg = `Unsupported technology: ${technology}`;
+ handleValidationWarning(msg);
+ model.send({ event: 'js_warning', message: msg });
+ return;
+ }
+
+ if (['MERSCOPE', DEFAULT_TECHNOLOGY, 'Chromium'].includes(technology)) {
return render_landscape_ist({ model, el });
- } else if (['Visium-HD'].includes(technology)) {
+ } else if (technology === 'Visium-HD') {
return render_landscape_sst({ model, el });
- } else if (['h&e'].includes(technology)) {
+ } else if (technology === 'h&e') {
return render_landscape_h_e({ model, el });
}
};
@@ -172,6 +282,18 @@ const render_matrix_new = async ({ model, el }) => {
// Main render function - no export keyword
async function render({ model, el }) {
let cleanup = null;
+ model.on('msg:custom', (msg) => {
+ if (msg.event === 'py_warning') {
+ /* eslint-disable-next-line no-console */
+ console.warn('[PYTHON WARNING]', msg.message);
+ el.innerHTML += `
⚠️ ${msg.message}
`;
+ } else if (msg.event === 'py_error') {
+ /* eslint-disable-next-line no-console */
+ console.error('[PYTHON ERROR]', msg.message);
+ el.innerHTML += `❌ ${msg.message}
`;
+ }
+ });
+
try {
const componentType = model.get('component');
diff --git a/notebooks/Custom_Segmentation.ipynb b/notebooks/Custom_Segmentation.ipynb
index 0822baec..4161db3a 100644
--- a/notebooks/Custom_Segmentation.ipynb
+++ b/notebooks/Custom_Segmentation.ipynb
@@ -2,7 +2,7 @@
"cells": [
{
"cell_type": "code",
- "execution_count": 5,
+ "execution_count": 1,
"id": "9bd79809-7286-463c-a92a-e6315856d0a1",
"metadata": {},
"outputs": [
@@ -10,8 +10,6 @@
"name": "stdout",
"output_type": "stream",
"text": [
- "The autoreload extension is already loaded. To reload it, use:\n",
- " %reload_ext autoreload\n",
"env: ANYWIDGET_HMR=1\n"
]
}
@@ -24,17 +22,17 @@
},
{
"cell_type": "code",
- "execution_count": 6,
+ "execution_count": 2,
"id": "236fbcbe-36de-483b-9afd-13a379a56899",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
- "'0.4.0'"
+ "'0.9.0'"
]
},
- "execution_count": 6,
+ "execution_count": 2,
"metadata": {},
"output_type": "execute_result"
}
@@ -57,7 +55,7 @@
},
{
"cell_type": "code",
- "execution_count": 7,
+ "execution_count": 3,
"id": "46377171-5592-418f-a6a1-0658a8494954",
"metadata": {},
"outputs": [],
@@ -76,7 +74,7 @@
},
{
"cell_type": "code",
- "execution_count": 8,
+ "execution_count": 5,
"id": "cb0602d3-c572-417b-88e3-ba036098f050",
"metadata": {},
"outputs": [
@@ -90,126 +88,48 @@
"Calculating mean expression\n",
"Calculating variance\n",
"All meta gene files are succesfully saved.\n",
- "data/xenium_landscape_files/Xenium_Prime_Human_Skin_FFPE_outs/cbg_cellpose2\n",
"\n",
- "========Write gene-specific parquet files========\n",
- "Processing gene 0: A2ML1\n",
- "Processing gene 100: ADIPOR1\n",
- "Processing gene 200: ANKRD40\n",
- "Processing gene 300: ATF2\n",
- "Processing gene 400: BDNF\n",
- "Processing gene 500: CALCRL\n",
- "Processing gene 600: CCL20\n",
- "Processing gene 700: CD72\n",
- "Processing gene 800: CFC1\n",
- "Processing gene 900: CNKSR3\n",
- "Processing gene 1000: CSNK1A1\n",
- "Processing gene 1100: CYTH2\n",
- "Processing gene 1200: DMKN\n",
- "Processing gene 1300: DeprecatedCodeword_0994\n",
- "Processing gene 1400: DeprecatedCodeword_15617\n",
- "Processing gene 1500: DeprecatedCodeword_4666\n",
- "Processing gene 1600: ECD\n",
- "Processing gene 1700: EPCAM\n",
- "Processing gene 1800: FANCC\n",
- "Processing gene 1900: FMO3\n",
- "Processing gene 2000: GALNS\n",
- "Processing gene 2100: GPATCH11\n",
- "Processing gene 2200: H3F3B\n",
- "Processing gene 2300: HOXB9\n",
- "Processing gene 2400: IFNW1\n",
- "Processing gene 2500: INCA1\n",
- "Processing gene 2600: JAM2\n",
- "Processing gene 2700: KIR3DL1\n",
- "Processing gene 2800: LILRA6\n",
- "Processing gene 2900: MALL\n",
- "Processing gene 3000: MEST\n",
- "Processing gene 3100: MTCH2\n",
- "Processing gene 3200: NCSTN\n",
- "Processing gene 3300: NORAD\n",
- "Processing gene 3400: NXPH2\n",
- "Processing gene 3500: NegControlCodeword_18746\n",
- "Processing gene 3600: NegControlCodeword_18846\n",
- "Processing gene 3700: NegControlCodeword_18946\n",
- "Processing gene 3800: NegControlCodeword_19046\n",
- "Processing gene 3900: NegControlCodeword_19146\n",
- "Processing gene 4000: NegControlCodeword_19246\n",
- "Processing gene 4100: P2RX1\n",
- "Processing gene 4200: PDE6H\n",
- "Processing gene 4300: PKIA\n",
- "Processing gene 4400: PPARD\n",
- "Processing gene 4500: PRXL2A\n",
- "Processing gene 4600: RABL2B\n",
- "Processing gene 4700: RGN\n",
- "Processing gene 4800: RUBCN\n",
- "Processing gene 4900: SERPINA9\n",
- "Processing gene 5000: SLC17A8\n",
- "Processing gene 5100: SMC1A\n",
- "Processing gene 5200: SPATS2L\n",
- "Processing gene 5300: STX7\n",
- "Processing gene 5400: TENT5B\n",
- "Processing gene 5500: TMEM130\n",
- "Processing gene 5600: TPX2\n",
- "Processing gene 5700: TUFM\n",
- "Processing gene 5800: UnassignedCodeword_0100\n",
- "Processing gene 5900: UnassignedCodeword_0579\n",
- "Processing gene 6000: UnassignedCodeword_0998\n",
- "Processing gene 6100: UnassignedCodeword_10427\n",
- "Processing gene 6200: UnassignedCodeword_10846\n",
- "Processing gene 6300: UnassignedCodeword_11242\n",
- "Processing gene 6400: UnassignedCodeword_11661\n",
- "Processing gene 6500: UnassignedCodeword_12151\n",
- "Processing gene 6600: UnassignedCodeword_12526\n",
- "Processing gene 6700: UnassignedCodeword_12880\n",
- "Processing gene 6800: UnassignedCodeword_13294\n",
- "Processing gene 6900: UnassignedCodeword_13728\n",
- "Processing gene 7000: UnassignedCodeword_14115\n",
- "Processing gene 7100: UnassignedCodeword_14486\n",
- "Processing gene 7200: UnassignedCodeword_1487\n",
- "Processing gene 7300: UnassignedCodeword_15237\n",
- "Processing gene 7400: UnassignedCodeword_15662\n",
- "Processing gene 7500: UnassignedCodeword_16071\n",
- "Processing gene 7600: UnassignedCodeword_16476\n",
- "Processing gene 7700: UnassignedCodeword_16830\n",
- "Processing gene 7800: UnassignedCodeword_17241\n",
- "Processing gene 7900: UnassignedCodeword_17672\n",
- "Processing gene 8000: UnassignedCodeword_18196\n",
- "Processing gene 8100: UnassignedCodeword_1886\n",
- "Processing gene 8200: UnassignedCodeword_2384\n",
- "Processing gene 8300: UnassignedCodeword_2846\n",
- "Processing gene 8400: UnassignedCodeword_3332\n",
- "Processing gene 8500: UnassignedCodeword_3845\n",
- "Processing gene 8600: UnassignedCodeword_4262\n",
- "Processing gene 8700: UnassignedCodeword_4726\n",
- "Processing gene 8800: UnassignedCodeword_5195\n",
- "Processing gene 8900: UnassignedCodeword_5693\n",
- "Processing gene 9000: UnassignedCodeword_6174\n",
- "Processing gene 9100: UnassignedCodeword_6629\n",
- "Processing gene 9200: UnassignedCodeword_7161\n",
- "Processing gene 9300: UnassignedCodeword_7569\n",
- "Processing gene 9400: UnassignedCodeword_8046\n",
- "Processing gene 9500: UnassignedCodeword_8534\n",
- "Processing gene 9600: UnassignedCodeword_9011\n",
- "Processing gene 9700: UnassignedCodeword_9525\n",
- "Processing gene 9800: UnassignedCodeword_9939\n",
- "Processing gene 9900: XPO1\n",
- "Processing gene 10000: ZNF687\n",
- "All gene-specific parquet files are succesfully saved.\n",
+ "========Make meta cells in pixel space========\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/Users/jishar/Documents/celldega/src/celldega/pre/__init__.py:656: UserWarning: Geometry is in a geographic CRS. Results from 'centroid' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation.\n",
"\n",
- "========Make meta cells in pixel space========\n",
+ " meta_cell[\"center_x\"] = meta_cell.centroid.x\n",
+ "/Users/jishar/Documents/celldega/src/celldega/pre/__init__.py:657: UserWarning: Geometry is in a geographic CRS. Results from 'centroid' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation.\n",
+ "\n",
+ " meta_cell[\"center_y\"] = meta_cell.centroid.y\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
"Done.\n",
+ "data/landscape_files/Xenium_V1_human_Pancreas_FFPE_outs_test/cbg_proseg\n",
+ "Processing gene 0: AMY2A\n",
+ "Processing gene 100: PTGDS\n",
+ "Processing gene 200: EGFL7\n",
+ "Processing gene 300: ESR1\n",
+ "Processing gene 400: NegControlCodeword_0523\n",
+ "Processing gene 500: UnassignedCodeword_0459\n",
+ "All gene-specific parquet files are succesfully saved.\n",
"\n",
"========Create clusters and meta clusters files========\n",
"Cell clusters and meta cluster files created successfully.\n",
"\n",
- "========Create cell boundary spatial tiles========\n"
+ "========Create cell boundary spatial tiles========\n",
+ "custom technology\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
- "Processing coarse tiles: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 9/9 [00:15<00:00, 1.77s/it]\n"
+ "Processing coarse tiles: 100%|███████████████████████████████████████████████████████████████████████████████████| 7/7 [00:24<00:00, 3.56s/it]\n"
]
},
{
@@ -227,8 +147,8 @@
}
],
"source": [
- "path_landscape_files=\"data/xenium_landscape_files/Xenium_Prime_Human_Skin_FFPE_outs\"\n",
- "path_segmentation_files=\"data/processed_data/xenium_skin/cellpose2/\"\n",
+ "path_landscape_files=\"data/landscape_files/Xenium_V1_human_Pancreas_FFPE_outs_test/\"\n",
+ "path_segmentation_files=\"data/processed_data/xenium_pancreas/proseg/\"\n",
"\n",
"dega.pre.add_custom_segmentation(path_landscape_files=path_landscape_files, \n",
" path_segmentation_files=path_segmentation_files)"
@@ -244,29 +164,22 @@
},
{
"cell_type": "code",
- "execution_count": 9,
+ "execution_count": 7,
"id": "7d440da2-1354-4653-ab09-bc24400f8833",
"metadata": {},
"outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Server running on port 55358\n"
- ]
- },
{
"data": {
"application/vnd.jupyter.widget-view+json": {
- "model_id": "767f649f38764b20815f31721dc9be66",
+ "model_id": "60e4ec5c3c0e402f86228e2b4a6cf8a9",
"version_major": 2,
"version_minor": 0
},
"text/plain": [
- "Landscape(base_url='http://localhost:55358/data/xenium_landscape_files/Xenium_Prime_Human_Skin_FFPE_outs', seg…"
+ "Landscape(base_url='http://localhost:52696/data/landscape_files/Xenium_V1_human_Pancreas_FFPE_outs_test/', cel…"
]
},
- "execution_count": 9,
+ "execution_count": 7,
"metadata": {},
"output_type": "execute_result"
}
@@ -275,9 +188,8 @@
"server_address = dega.viz.get_local_server()\n",
"\n",
"landscape_ist = dega.viz.Landscape(\n",
- " technology='Xenium',\n",
" base_url = f\"http://localhost:{server_address}/{path_landscape_files}\",\n",
- " segmentation='cellpose2'\n",
+ " segmentation='proseg'\n",
")\n",
"\n",
"landscape_ist"
@@ -308,7 +220,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.9.20"
+ "version": "3.11.5"
},
"widgets": {
"application/vnd.jupyter.widget-state+json": {
diff --git a/notebooks/Xenium_pre-process.ipynb b/notebooks/Xenium_pre-process.ipynb
index 19a872b2..f7b9d86b 100644
--- a/notebooks/Xenium_pre-process.ipynb
+++ b/notebooks/Xenium_pre-process.ipynb
@@ -10,7 +10,7 @@
},
{
"cell_type": "code",
- "execution_count": 1,
+ "execution_count": 3,
"id": "b81ab32e",
"metadata": {},
"outputs": [
@@ -18,16 +18,10 @@
"name": "stdout",
"output_type": "stream",
"text": [
+ "The autoreload extension is already loaded. To reload it, use:\n",
+ " %reload_ext autoreload\n",
"env: ANYWIDGET_HMR=1\n"
]
- },
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "/Users/feni/Documents/celldega/dega/lib/python3.12/site-packages/h5py/__init__.py:36: UserWarning: h5py is running against HDF5 1.14.5 when it was built against 1.14.6, this may cause problems\n",
- " _warn((\"h5py is running against HDF5 {0} when it was built against {1}, \"\n"
- ]
}
],
"source": [
@@ -215,7 +209,6 @@
],
"source": [
"landscape_ist = dega.viz.Landscape(\n",
- " technology='Xenium',\n",
" base_url = f\"http://localhost:{dega.viz.get_local_server()}/{path_landscape_files}\",\n",
")\n",
"\n",
@@ -247,7 +240,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.12.7"
+ "version": "3.11.5"
},
"toc": {
"base_numbering": 1,
diff --git a/pyproject.toml b/pyproject.toml
index 220902b4..8efe16a5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,6 +6,7 @@ build-backend = "hatchling.build"
name = "celldega"
version = "0.13.0"
readme = "README.md"
+requires-python = ">=3.8"
dependencies = [
"anndata~=0.11.0",
"anywidget~=0.9.18",
diff --git a/src/celldega/pre/__init__.py b/src/celldega/pre/__init__.py
index d72ad2f3..dd8323ee 100644
--- a/src/celldega/pre/__init__.py
+++ b/src/celldega/pre/__init__.py
@@ -664,6 +664,75 @@ def _load_meta_cell_by_technology(technology, path_meta_cell_micron):
return meta_cell
+def _make_names_unique(index: pd.Index) -> pd.Index:
+ """
+ Ensure uniqueness of values in a pandas Index by appending suffixes to duplicates.
+
+ For each duplicated entry, appends a hyphen and a count (e.g., 'name', 'name-1', 'name-2', ...).
+ The first occurrence of each name is left unchanged.
+
+ Parameters:
+ index (pd.Index): A pandas Index potentially containing duplicate values.
+
+ Returns:
+ pd.Index: A new Index with all values made unique.
+ """
+ seen = {}
+ unique_names = []
+ for name in index:
+ if name not in seen:
+ seen[name] = 0
+ unique_names.append(name)
+ else:
+ seen[name] += 1
+ unique_names.append(f"{name}-{seen[name]}")
+ return pd.Index(unique_names)
+
+
+def _align_and_deduplicate_genes(
+ cbg_custom: pd.DataFrame, path_landscape_files: str
+) -> pd.DataFrame:
+ """
+ Ensures genes in cbg_custom and meta_gene are identical,
+ and makes duplicate gene names unique by suffixing.
+ Raises an error if there is a mismatch in gene sets.
+
+ Parameters:
+ -----------
+ cbg_custom : pd.DataFrame
+ DataFrame containing custom cell-by-gene matrix.
+ path_landscape_files : str
+ Path to directory containing meta_gene.parquet.
+
+ Returns:
+ --------
+ pd.DataFrame
+ cbg_custom with deduplicated gene names (columns).
+ """
+ meta_gene_path = Path(path_landscape_files) / "meta_gene.parquet"
+ meta_gene = pd.read_parquet(meta_gene_path)
+
+ # Compare unordered gene sets before deduplication
+ genes_meta = set(meta_gene.index)
+ genes_cbg = set(cbg_custom.columns)
+
+ if genes_meta != genes_cbg:
+ missing_in_cbg = genes_meta - genes_cbg
+ missing_in_meta = genes_cbg - genes_meta
+ raise ValueError(
+ f"Mismatch between cbg_custom and meta_gene genes.\n"
+ f"Missing in cbg_custom (up to 20): {list(missing_in_cbg)[:20]}\n"
+ f"Missing in meta_gene (up to 20): {list(missing_in_meta)[:20]}"
+ )
+
+ # Make gene names unique consistently across both
+ cbg_custom.columns = _make_names_unique(pd.Index(cbg_custom.columns))
+ meta_gene.index = _make_names_unique(pd.Index(meta_gene.index))
+
+ # Align column order to meta_gene index order
+ return cbg_custom.loc[:, meta_gene.index]
+
+
def make_meta_cell_image_coord(
technology,
path_transformation_matrix,
@@ -826,6 +895,8 @@ def make_chromium_from_anndata(adata, path_landscape_files):
save_landscape_parameters(
technology="Chromium",
path_landscape_files=path_landscape_files,
+ image_width=100,
+ image_height=100,
image_name="",
tile_size=1,
image_info=[],
@@ -851,6 +922,8 @@ def get_max_zoom_level(path_image_pyramid):
def save_landscape_parameters(
technology,
path_landscape_files,
+ image_width,
+ image_height,
image_name="dapi_files",
tile_size=1000,
image_info=None,
@@ -895,6 +968,7 @@ def save_landscape_parameters(
"tile_size": "N.A.",
"image_info": image_info,
"image_format": image_format,
+ "image_dimensions": {"width": image_width, "height": image_height},
"use_int_index": "N.A.",
}
elif technology != "custom":
@@ -905,6 +979,7 @@ def save_landscape_parameters(
"tile_size": tile_size,
"image_info": image_info,
"image_format": image_format,
+ "image_dimensions": {"width": image_width, "height": image_height},
"use_int_index": use_int_index,
}
else:
@@ -935,11 +1010,7 @@ def add_custom_segmentation(
cbg_custom = pd.read_parquet(Path(path_segmentation_files) / "cell_by_gene_matrix.parquet")
- # make sure all genes are present in cbg_custom
- meta_gene = pd.read_parquet(Path(path_landscape_files) / "meta_gene.parquet")
- missing_cols = meta_gene.index.difference(cbg_custom.columns)
- for col in missing_cols:
- cbg_custom[col] = 0
+ cbg_custom = _align_and_deduplicate_genes(cbg_custom, path_landscape_files)
make_meta_gene(
cbg=cbg_custom,
@@ -1010,6 +1081,8 @@ def add_custom_segmentation(
save_landscape_parameters(
technology=segmentation_parameters["technology"],
path_landscape_files=path_landscape_files,
+ image_width=width,
+ image_height=height,
image_name="dapi_files",
tile_size=tile_size,
image_format=".webp",
diff --git a/src/celldega/pre/run_pre_processing.py b/src/celldega/pre/run_pre_processing.py
index 3a565851..bf193fad 100644
--- a/src/celldega/pre/run_pre_processing.py
+++ b/src/celldega/pre/run_pre_processing.py
@@ -196,6 +196,9 @@ def main(
# Make meta gene files
dega.pre.make_meta_gene(cbg, str(paths["meta_gene"]))
+ # Check if the genes are unique before saving the cbg files
+ cbg = dega.pre._align_and_deduplicate_genes(cbg, path_landscape_files)
+
# Save CBG gene parquet files
dega.pre.save_cbg_gene_parquets(path_landscape_files, cbg, verbose=True)
@@ -248,7 +251,9 @@ def main(
dega.pre.save_landscape_parameters(
technology,
path_landscape_files,
- "dapi_files",
+ image_width=tile_bounds["x_max"],
+ image_height=tile_bounds["y_max"],
+ image_name="dapi_files",
tile_size=tile_size,
image_info=dega.pre.get_image_info(technology, image_tile_layer),
image_format=".webp",
diff --git a/src/celldega/viz/widget.py b/src/celldega/viz/widget.py
index 80263b27..8b0e03b2 100644
--- a/src/celldega/viz/widget.py
+++ b/src/celldega/viz/widget.py
@@ -69,7 +69,7 @@ class Landscape(anywidget.AnyWidget):
_css = Path(__file__).parent / "../static" / "widget.css"
component = traitlets.Unicode("Landscape").tag(sync=True)
- technology = traitlets.Unicode("sst").tag(sync=True)
+ technology = traitlets.Unicode("").tag(sync=True)
base_url = traitlets.Unicode("").tag(sync=True)
token = traitlets.Unicode("").tag(sync=True)
creds = traitlets.Dict({}).tag(sync=True)
@@ -104,6 +104,14 @@ class Landscape(anywidget.AnyWidget):
height = traitlets.Int(800).tag(sync=True)
def __init__(self, **kwargs):
+ technology_value = kwargs.get("technology", "")
+ if technology_value:
+ warnings.warn(
+ "[DEPRECATION WARNING] Passing `technology` manually to the Landscape widget is deprecated and will be removed in a future release. "
+ "Please rely on automatic detection via `landscape_parameters.json`.",
+ stacklevel=2,
+ )
+
adata = kwargs.pop("adata", None) or kwargs.pop("AnnData", None)
pq_meta_cell = kwargs.pop("meta_cell_parquet", None)
pq_meta_cluster = kwargs.pop("meta_cluster_parquet", None)
@@ -232,6 +240,9 @@ def _df_to_bytes(df):
super().__init__(**kwargs)
+ # handle messages from the frontend for warnings/errors
+ self.on_msg(self._handle_frontend_message)
+
# store DataFrames locally without syncing to the frontend
self.meta_cell = meta_cell_df
self.meta_nbhd = meta_nbhd_df
@@ -262,6 +273,19 @@ def _df_to_bytes(df):
elif self.nbhd_edit:
self.nbhd_geojson = {"type": "FeatureCollection", "features": []}
+ def _handle_frontend_message(self, _, content, buffers=None):
+ event = content.get("event")
+ message = content.get("message", "")
+
+ if event == "js_warning":
+ print(f"JavaScript warning: {message}")
+ warnings.warn(message, stacklevel=2)
+ elif event == "js_error":
+ print(f"JavaScript error: {message}")
+ warnings.warn(f"JavaScript error: {message}", stacklevel=2)
+ else:
+ print(f"Unhandled frontend event: {event}")
+
# @traitlets.observe("nbhd")
# def _on_nbhd_change(self, change):
# new = change["new"]
diff --git a/tests/unit/test_enrich/test_enrich_widget.py b/tests/unit/test_enrich/test_enrich_widget.py
index 2e74a113..36ff331f 100644
--- a/tests/unit/test_enrich/test_enrich_widget.py
+++ b/tests/unit/test_enrich/test_enrich_widget.py
@@ -34,4 +34,3 @@ def test_enrich_traitlets_update() -> None:
assert w.top_n_genes == 20
w.background_list = ["X", "Y"]
assert w.background_list == ["X", "Y"]
-
diff --git a/tests/unit/test_pre/test_pre_tiles.py b/tests/unit/test_pre/test_pre_tiles.py
index 36af7bb6..7778fa92 100644
--- a/tests/unit/test_pre/test_pre_tiles.py
+++ b/tests/unit/test_pre/test_pre_tiles.py
@@ -1,13 +1,14 @@
import importlib.util
import math
+from pathlib import Path
import sys
import types
-from pathlib import Path
import numpy as np
import pandas as pd
import pytest
+
try:
import geopandas as gpd
import polars as pl
@@ -33,9 +34,7 @@
sys.modules["celldega.pre.boundary_tile"] = boundary_tile
spec_b.loader.exec_module(boundary_tile)
-spec_t = importlib.util.spec_from_file_location(
- "celldega.pre.trx_tile", PRE_ROOT / "trx_tile.py"
-)
+spec_t = importlib.util.spec_from_file_location("celldega.pre.trx_tile", PRE_ROOT / "trx_tile.py")
trx_tile = importlib.util.module_from_spec(spec_t)
trx_tile.__package__ = "celldega.pre"
sys.modules["celldega.pre.trx_tile"] = trx_tile
@@ -50,6 +49,7 @@
TILE_SIZE = 250
BBOX = (0, 500, 0, 500)
+
def create_cell_polygon(df: pd.DataFrame) -> Polygon:
"""
Constructs a Shapely Polygon from a DataFrame containing 'vertex_x' and 'vertex_y' columns.
@@ -76,16 +76,18 @@ def create_cell_polygon(df: pd.DataFrame) -> Polygon:
if len(df) < 3:
raise ValueError("At least three vertices are required to construct a polygon.")
- return Polygon(zip(df["vertex_x"], df["vertex_y"]))
+ return Polygon(zip(df["vertex_x"], df["vertex_y"], strict=False))
+
@pytest.fixture
def make_synthetic_data(tmp_path):
def _make(technology):
return _generate_synthetic_data(tmp_path, technology)
+
return _make
-def _generate_synthetic_data(tmp_path: Path, technology: str) -> dict[str, Path]:
+def _generate_synthetic_data(tmp_path: Path, technology: str) -> dict[str, Path]:
"""
Generate synthetic spatial transcriptomics data for testing purposes.
@@ -182,18 +184,15 @@ def _generate_synthetic_data(tmp_path: Path, technology: str) -> dict[str, Path]
df_meta_cell = pd.DataFrame({"name": [f"cell_{i}" for i in range(N_CELLS)]})
df_meta_cell.to_parquet(tmp_path / "cell_metadata.parquet", index=False)
- points = [
- (rng.uniform(BBOX[0], BBOX[1]), rng.uniform(BBOX[2], BBOX[3]))
- for _ in range(N_TRX)
- ]
- genes = [f"G{i%3}" for i in range(N_TRX)]
+ points = [(rng.uniform(BBOX[0], BBOX[1]), rng.uniform(BBOX[2], BBOX[3])) for _ in range(N_TRX)]
+ genes = [f"G{i % 3}" for i in range(N_TRX)]
if technology == "MERSCOPE":
df_trx = pl.DataFrame(
{
"gene": genes,
"global_x": [p[0] for p in points],
"global_y": [p[1] for p in points],
- "cell_id": [f"cell_{i%N_CELLS}" for i in range(N_TRX)],
+ "cell_id": [f"cell_{i % N_CELLS}" for i in range(N_TRX)],
"transcript_id": list(range(N_TRX)),
}
)
@@ -203,7 +202,7 @@ def _generate_synthetic_data(tmp_path: Path, technology: str) -> dict[str, Path]
"feature_name": genes,
"x_location": [p[0] for p in points],
"y_location": [p[1] for p in points],
- "cell_id": [f"cell_{i%N_CELLS}" for i in range(N_TRX)],
+ "cell_id": [f"cell_{i % N_CELLS}" for i in range(N_TRX)],
"transcript_id": list(range(N_TRX)),
}
)
@@ -290,9 +289,7 @@ def test_tiles(make_synthetic_data, technology) -> None:
assert total_trx == N_TRX
# Step 3: Ensure that every transcript maps to one of the generated transcript tile coordinates
- produced_trx_tiles = {
- tuple(map(int, p.stem.split("_")[-2:])) for p in trx_tile_files
- }
+ produced_trx_tiles = {tuple(map(int, p.stem.split("_")[-2:])) for p in trx_tile_files}
if technology == "MERSCOPE":
df_trx = pl.read_parquet(paths["trx_path"]).to_pandas()
@@ -303,7 +300,7 @@ def test_tiles(make_synthetic_data, technology) -> None:
else:
raise ValueError(f"Unsupported technology: {technology}")
- for x, y in zip(df_trx[xcol], df_trx[ycol]):
+ for x, y in zip(df_trx[xcol], df_trx[ycol], strict=False):
i = int((x - bounds["x_min"]) // TILE_SIZE)
j = int((y - bounds["y_min"]) // TILE_SIZE)
assert (i, j) in produced_trx_tiles
@@ -348,10 +345,7 @@ def test_tiles(make_synthetic_data, technology) -> None:
polygons = df_cells["Geometry"]
elif technology == "Xenium":
df_cells = pd.read_parquet(paths["boundaries_path"])
- polygons = (
- df_cells.groupby("cell_id")[["vertex_x", "vertex_y"]]
- .apply(create_cell_polygon)
- )
+ polygons = df_cells.groupby("cell_id")[["vertex_x", "vertex_y"]].apply(create_cell_polygon)
else:
raise ValueError(f"Unsupported technology: {technology}")
@@ -363,15 +357,10 @@ def test_tiles(make_synthetic_data, technology) -> None:
assert len(all_cells) >= expected_cells
# Verify that each expected cell polygon maps to a cell tile by centroid location
- produced_cell_tiles = {
- tuple(map(int, p.stem.split("_")[-2:])) for p in cell_tile_files
- }
+ produced_cell_tiles = {tuple(map(int, p.stem.split("_")[-2:])) for p in cell_tile_files}
for poly in polygons:
- if not (
- BBOX[0] <= poly.centroid.x < BBOX[1]
- and BBOX[2] <= poly.centroid.y < BBOX[3]
- ):
+ if not (BBOX[0] <= poly.centroid.x < BBOX[1] and BBOX[2] <= poly.centroid.y < BBOX[3]):
continue
i = int((poly.centroid.x - bounds["x_min"]) // TILE_SIZE)
j = int((poly.centroid.y - bounds["y_min"]) // TILE_SIZE)
- assert (i, j) in produced_cell_tiles
\ No newline at end of file
+ assert (i, j) in produced_cell_tiles
diff --git a/tests/unit/test_viz/test_landscape_colors.py b/tests/unit/test_viz/test_landscape_colors.py
index 27e053ca..988ff56e 100644
--- a/tests/unit/test_viz/test_landscape_colors.py
+++ b/tests/unit/test_viz/test_landscape_colors.py
@@ -1,24 +1,32 @@
+import io
+import json
+from unittest.mock import patch
+
import numpy as np
import pandas as pd
import pytest
+
try:
import anndata as ad
+
from celldega.viz import Landscape
except Exception as e: # pragma: no cover - if deps missing skip
pytest.skip(f"celldega modules unavailable: {e}", allow_module_level=True)
-def test_leiden_colors_added_if_missing() -> None:
+def mock_urlopen_success(*args, **kwargs):
+ fake_json = json.dumps({"technology": "Xenium"}).encode("utf-8")
+ return io.BytesIO(fake_json)
+
+
+@patch("celldega.viz.widget.urllib.request.urlopen", side_effect=mock_urlopen_success)
+def test_leiden_colors_added_if_missing(mock_urlopen) -> None:
adata = ad.AnnData(np.zeros((3, 3)))
adata.obs["leiden"] = pd.Categorical(["0", "1", "0"])
adata.uns.pop("leiden_colors", None)
- adata.obsm["X_umap"] = np.array([
- [0.0, 0.0],
- [1.0, 1.0],
- [2.0, 2.0]
- ])
+ adata.obsm["X_umap"] = np.array([[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]])
widget = Landscape(adata=adata)
diff --git a/tests/unit/test_viz/test_widget.py b/tests/unit/test_viz/test_widget.py
index 871ab094..e331041c 100644
--- a/tests/unit/test_viz/test_widget.py
+++ b/tests/unit/test_viz/test_widget.py
@@ -1,6 +1,9 @@
-"""Tests for Clustergram widget with Parquet input."""
+"""Tests for Clustergram and Landscape widgets with Parquet input."""
+import io
import json
+from unittest.mock import patch
+import warnings
import numpy as np
import pandas as pd
@@ -17,6 +20,16 @@
pytest.skip(f"celldega modules unavailable: {e}", allow_module_level=True)
+def test_landscape_deprecated_technology_argument_warning():
+ with warnings.catch_warnings(record=True) as w:
+ warnings.simplefilter("always")
+ _ = Landscape(technology="MERSCOPE")
+ messages = [str(warn.message) for warn in w]
+ assert any("deprecated" in msg.lower() for msg in messages), (
+ "Expected deprecation warning for `technology` argument"
+ )
+
+
def make_simple_matrix() -> Matrix:
np.random.seed(0)
df = pd.DataFrame(np.random.rand(4, 5))
@@ -41,7 +54,7 @@ def test_export_viz_parquet_returns_bytes() -> None:
assert set(pq) == expected_keys
for key in expected_keys - {"meta"}:
assert isinstance(pq[key], bytes | bytearray)
- assert pq[key] # non-empty
+ assert pq[key] # ensure non-empty
assert isinstance(pq["meta"], dict)
@@ -50,11 +63,8 @@ def test_clustergram_initializes_with_parquet() -> None:
pq = mat.export_viz_parquet()
widget = Clustergram(matrix=mat)
-
- # Confirm meta is set correctly
assert widget.network_meta == pq["meta"]
- # Confirm dynamic parquet attributes exist and match expected values
for attr, key in [
("mat_parquet", "mat"),
("row_nodes_parquet", "row_nodes"),
@@ -63,15 +73,12 @@ def test_clustergram_initializes_with_parquet() -> None:
("col_linkage_parquet", "col_linkage"),
]:
assert hasattr(widget, attr), f"Missing attribute: {attr}"
- assert getattr(widget, attr) == pq[key], (
- f"Attribute {attr} does not match expected parquet value"
- )
+ assert getattr(widget, attr) == pq[key]
def test_clustergram_selected_genes_trait() -> None:
mat = make_simple_matrix()
widget = Clustergram(matrix=mat)
-
assert widget.selected_genes == []
assert widget.top_n_genes == 50
@@ -79,7 +86,27 @@ def test_clustergram_selected_genes_trait() -> None:
assert widget.selected_genes == ["A", "B"]
-def test_landscape_nbhd_geojson_and_metadata() -> None:
+# ---------- Landscape Patch and Tests ----------
+
+
+class MockHTTPResponse(io.BytesIO):
+ def __init__(self, data: bytes):
+ super().__init__(data)
+ self.headers = {} # Mimic real HTTPResponse
+
+
+def mock_urlopen_with_technology(*args, **kwargs):
+ """Valid JSON containing technology."""
+ return MockHTTPResponse(json.dumps({"technology": "Xenium"}).encode("utf-8"))
+
+
+def mock_urlopen_missing_technology(*args, **kwargs):
+ """JSON missing the technology field."""
+ return MockHTTPResponse(json.dumps({}).encode("utf-8"))
+
+
+@patch("celldega.viz.widget.urllib.request.urlopen", side_effect=mock_urlopen_with_technology)
+def test_landscape_nbhd_geojson_and_metadata(mock_urlopen) -> None:
gdf = gpd.GeoDataFrame(
{"name": ["a"], "cat": ["x"]},
geometry=[Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])],
@@ -87,8 +114,6 @@ def test_landscape_nbhd_geojson_and_metadata() -> None:
meta_nbhd = pd.DataFrame({"area": [1]}, index=["a"])
widget = Landscape(nbhd=gdf, meta_nbhd=meta_nbhd)
-
- # drop geometry_pixel column from gdf
gdf = gdf.drop(columns=["geometry_pixel"], errors="ignore")
assert widget.nbhd_geojson == json.loads(gdf.to_json())