diff --git a/docs/architecture.md b/docs/architecture.md index f0d544b..6b709aa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,7 +76,7 @@ Declared parameters drive the autogenerated form (via TissUUmaps plugin system). ### Rendering -- **SVG overlay:** uses OpenSeadragon’s SVG overlay (`overlayUtils._d3nodes`) to draw polygons and helper outlines. +- **Canvas overlay (new):** geometry is fetched once per tile/expansion-parameter signature (via `include_geometry=true`), cached on the client, and redrawn onto a canvas overlay. Subsequent overlay switches reuse cached polygons/outlines and only fetch per-label styles/values (`include_geometry=false`), eliminating the heavy SVG DOM rebuilds. - **Tile overview:** HTML canvas minimap (~320 px square) with gradient styling; clicking selects and centers tiles, so an extra on-slide grid is unnecessary. - **Viewport utilities:** helper functions convert pixel coordinates to viewport space (accounting for image flip). @@ -95,12 +95,18 @@ Declared parameters drive the autogenerated form (via TissUUmaps plugin system). | `P2_CRC_annotated.h5ad` | AnnData with expression + observations | ~4 GB; must fit in RAM. Optional optimizations include using Zarr or downsampling. | ## Performance Considerations - - **Initial load**: dominated by reading TIFF + `.h5ad`; expect ~20–30 seconds on SSD. Loading is single-threaded (library limitation). - **Overlay computation**: per tile, typically < 1 s thanks to caching of expanded labels. +- **Payload size**: first overlay per tile includes full geometry; subsequent overlays can omit geometry to keep responses small and speed up switching. - **Memory footprint**: entire H&E (≈1.2 GB in RAM), sparse labels, AnnData (4 GB), plus caches. Ensure ≥8 GB free RAM during development. - **Tile overview**: drawn in canvas to avoid heavy DOM operations; scaled rendering keeps hover/selection lightweight even for thousands of tiles. +## Change Log: Canvas Overlay & Geometry Caching + +- **Previous architecture:** every overlay request returned all polygons/outlines and rebuilt an SVG overlay in the browser. Large tiles led to heavy JSON payloads and thousands of `` nodes, slowing interactions and pan/zoom refreshes. +- **Current architecture:** the backend accepts `include_geometry` (default `true`) and, when enabled, returns a `geometry` block (expanded/raw polygons plus outlines). The frontend caches this geometry per tile/expansion signature and redraws via a canvas overlay. Later overlay switches send `include_geometry=false`, reusing cached shapes and only updating per-label styling/values. +- **Result:** smaller payloads after the first request, no SVG DOM thrash, and faster overlay redraws on interaction or viewport changes. + ## Extensibility Hooks - **New overlay types**: add a branch in `_prepare_gene_overlay` / `_prepare_obs_overlay` or create a new handler. Extend the JS rendering branch to draw the new type. diff --git a/docs/guide.md b/docs/guide.md index 8e3fbeb..dff8307 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -175,3 +175,4 @@ The docs are served at `http://127.0.0.1:8000`. Hit `Ctrl+C` to stop. For a stat With these steps a new developer can reproduce the environment, understand the code layout, and extend the plugin safely. + diff --git a/docs/images/default-loading.png b/docs/images/default-loading.png new file mode 100644 index 0000000..d27f5e3 Binary files /dev/null and b/docs/images/default-loading.png differ diff --git a/docs/images/tool-in-use.png b/docs/images/tool-in-use.png new file mode 100644 index 0000000..1224944 Binary files /dev/null and b/docs/images/tool-in-use.png differ diff --git a/plugins/Bin2CellExplorer.js b/plugins/Bin2CellExplorer.js deleted file mode 100644 index 92399eb..0000000 --- a/plugins/Bin2CellExplorer.js +++ /dev/null @@ -1,1007 +0,0 @@ -var Bin2CellExplorer; -Bin2CellExplorer = { - name: "Bin2Cell Explorer", - parameters: { - _sec_data: { label: "Dataset", type: "section", collapsed: false }, - he_path: { label: "H&E image (.tif/.tiff)", type: "text", default: "/Users/jjoseph/.tissuumaps/plugins/he.tiff" }, - he_path_browse: { label: "Browse H&E image…", type: "button" }, - labels_path: { label: "Label matrix (.npz)", type: "text", default: "/Users/jjoseph/.tissuumaps/plugins/he.npz" }, - labels_path_browse: { label: "Browse label matrix…", type: "button" }, - adata_path: { label: "AnnData (.h5ad)", type: "text", default: "/Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad" }, - adata_path_browse: { label: "Browse AnnData…", type: "button" }, - obsm_key: { label: "obsm coord key", type: "select", default: "spatial_cropped_150_buffer" }, - tile_h: { label: "Tile height (px)", type: "number", default: 1500 }, - tile_w: { label: "Tile width (px)", type: "number", default: 1500 }, - stride_h: { label: "Stride height (px, optional)", type: "number", default: "" }, - stride_w: { label: "Stride width (px, optional)", type: "number", default: "" }, - load_dataset_btn: { label: "Load dataset", type: "button" }, - - _sec_overlay: { label: "Overlay", type: "section", collapsed: false }, - tile_id: { label: "Tile ID", type: "select", default: "" }, - overlay_type: { label: "Overlay type", type: "select", default: "gene", options: ["gene", "observation"] }, - genes: { label: "Gene(s) (comma separated)", type: "text", default: "COL1A1" }, - obs_col: { label: "Observation column", type: "select", default: "" }, - category: { label: "Category filter (optional)", type: "select", default: "" }, - render_mode: { label: "Render mode", type: "select", default: "fill", options: ["fill", "outline"] }, - color_mode: { label: "Gene color mode", type: "select", default: "gradient", options: ["gradient", "binary", "solid"] }, - gradient_color: { label: "Gradient color (optional)", type: "text", default: "#4285f4", attributes: { type: "color" } }, - gene_color: { label: "Gene color (solid mode)", type: "text", default: "#ff6b6b", attributes: { type: "color" } }, - expr_quantile: { label: "Expr. quantile (0-1)", type: "number", default: "" }, - top_n: { label: "Top N labels", type: "number", default: "" }, - b2c_mode: { label: "Expand mode", type: "select", default: "fixed", options: ["fixed", "volume_ratio"] }, - max_bin_distance: { label: "Max bin distance", type: "number", default: 2.0 }, - mpp: { label: "Microns per pixel", type: "number", default: 0.3 }, - bin_um: { label: "Bin size (µm)", type: "number", default: 2.0 }, - volume_ratio: { label: "Volume ratio", type: "number", default: 4.0 }, - overlay_alpha: { label: "Overlay alpha (0-1)", type: "number", default: 0.5 }, - highlight_color: { label: "Highlight color", type: "text", default: "#39ff14" }, - highlight_width: { label: "Highlight width", type: "number", default: 2.0 }, - all_expanded_outline: { label: "Show expanded outlines", type: "checkbox", default: false }, - all_nuclei_outline: { label: "Show nuclei outlines", type: "checkbox", default: false }, - apply_overlay_btn: { label: "Update overlay", type: "button" }, - - _sec_export: { label: "Export & Presets", type: "section", collapsed: true }, - export_name: { label: "Export name (optional)", type: "text", default: "" }, - export_geojson_btn: { label: "Export overlay to GeoJSON", type: "button" }, - save_preset_name: { label: "Preset name", type: "text", default: "" }, - save_preset_btn: { label: "Save preset", type: "button" }, - preset_select: { label: "Presets", type: "select", default: "" }, - load_preset_btn: { label: "Apply preset", type: "button" }, - delete_preset_btn: { label: "Delete preset", type: "button" } - } -}; - -Bin2CellExplorer.state = { - datasetLoaded: false, - tiles: [], - obsColumns: [], - obsMetadata: {}, - obsMetadataRequests: {}, - obsCategorySelections: {}, - pendingCategoryValue: "", - selectedObsCol: null, - genesPreview: [], - overlays: null, - layers: {}, - presets: {}, - slideShape: null, - tileOverviewCanvas: null, - tileOverviewScale: null, - selectedTileId: null -}; - -Bin2CellExplorer.init = function(container) { - Bin2CellExplorer.state.container = container; - container.classList.add("bin2cell-explorer-panel"); - - const legend = document.createElement("div"); - legend.id = "Bin2CellExplorer_legend"; - legend.className = "bin2cell-legend mt-2"; - container.appendChild(legend); - - const status = document.createElement("div"); - status.id = "Bin2CellExplorer_status"; - status.className = "bin2cell-status mt-2 small text-muted"; - container.appendChild(status); - - const overviewBlock = document.createElement("div"); - overviewBlock.id = "Bin2CellExplorer_tile_overview_block"; - overviewBlock.className = "tile-overview mt-3"; - const overviewTitle = document.createElement("div"); - overviewTitle.className = "small fw-bold mb-1"; - overviewTitle.textContent = "Tile overview"; - const overviewCanvas = document.createElement("canvas"); - overviewCanvas.id = "Bin2CellExplorer_tile_overview"; - overviewCanvas.width = 320; - overviewCanvas.height = 320; - overviewCanvas.style.border = "1px solid rgba(0,0,0,0.1)"; - overviewCanvas.style.borderRadius = "12px"; - overviewCanvas.style.background = "linear-gradient(135deg, #fdfbfb, #ebedee)"; - overviewCanvas.style.boxShadow = "0 6px 18px rgba(15,23,42,0.15)"; - overviewCanvas.style.cursor = "pointer"; - const overviewHint = document.createElement("div"); - overviewHint.className = "small text-muted mt-1"; - overviewHint.id = "Bin2CellExplorer_tile_overview_hint"; - overviewHint.textContent = "Load dataset to display tiles"; - overviewBlock.appendChild(overviewTitle); - overviewBlock.appendChild(overviewCanvas); - overviewBlock.appendChild(overviewHint); - container.appendChild(overviewBlock); - - Bin2CellExplorer.state.tileOverviewCanvas = overviewCanvas; - overviewCanvas.addEventListener("click", Bin2CellExplorer.onTileOverviewClick); - - interfaceUtils.alert("Bin2Cell Explorer loaded"); - Bin2CellExplorer.toggleOverlayInputs("gene"); -}; - -Bin2CellExplorer.inputTrigger = function(inputName) { - switch (inputName) { - case "load_dataset_btn": - Bin2CellExplorer.loadDataset(); - break; - case "he_path_browse": - Bin2CellExplorer.browseForFile("he_path"); - break; - case "labels_path_browse": - Bin2CellExplorer.browseForFile("labels_path"); - break; - case "adata_path_browse": - Bin2CellExplorer.browseForFile("adata_path"); - break; - case "overlay_type": - Bin2CellExplorer.toggleOverlayInputs(Bin2CellExplorer.get("overlay_type")); - break; - case "color_mode": - Bin2CellExplorer.updateGeneColorControls(); - break; - case "apply_overlay_btn": - Bin2CellExplorer.requestOverlay(); - break; - case "export_geojson_btn": - Bin2CellExplorer.exportOverlay(); - break; - case "save_preset_btn": - Bin2CellExplorer.savePreset(); - break; - case "load_preset_btn": - Bin2CellExplorer.applySelectedPreset(); - break; - case "delete_preset_btn": - Bin2CellExplorer.deleteSelectedPreset(); - break; - case "preset_select": - Bin2CellExplorer.populatePresetPreview(); - break; - default: - break; - } -}; - -Bin2CellExplorer.toggleOverlayInputs = function(mode) { - const geneIds = ["genes", "color_mode", "gradient_color", "gene_color", "expr_quantile", "top_n"]; - const obsIds = ["obs_col", "category"]; - geneIds.forEach((id) => Bin2CellExplorer.toggleParam(id, mode === "gene")); - obsIds.forEach((id) => Bin2CellExplorer.toggleParam(id, mode === "observation")); - if (mode === "gene") { - Bin2CellExplorer.updateGeneColorControls(); - } else if (mode === "observation" && Bin2CellExplorer.state.datasetLoaded) { - Bin2CellExplorer.populateCategorySelect(Bin2CellExplorer.get("obs_col") || ""); - } -}; - -Bin2CellExplorer.toggleParam = function(name, visible) { - const domId = "Bin2CellExplorer_" + name; - const element = document.getElementById(domId); - if (!element) return; - let wrapper = element.closest(".form-group, .row, .input-group"); - if (!wrapper) { - wrapper = element.parentElement; - } - if (wrapper) wrapper.style.display = visible ? "" : "none"; -}; - -Bin2CellExplorer.ensureObject = function(payload) { - if (!payload) return {}; - if (typeof payload === "string") { - try { - return JSON.parse(payload); - } catch (err) { - console.warn("Bin2CellExplorer: failed to parse payload", err); - return {}; - } - } - return payload; -}; - -Bin2CellExplorer.setStatus = function(msg) { - const status = document.getElementById("Bin2CellExplorer_status"); - if (status) status.textContent = msg || ""; -}; - -Bin2CellExplorer.updateGeneColorControls = function() { - const mode = Bin2CellExplorer.get("color_mode"); - Bin2CellExplorer.toggleParam("gradient_color", mode === "gradient"); - Bin2CellExplorer.toggleParam("gene_color", mode === "solid"); -}; - -Bin2CellExplorer.browseForFile = function(field) { - const current = Bin2CellExplorer.get(field) || ""; - const payload = { - field: field, - current_path: current, - }; - Bin2CellExplorer.api( - "pick_file", - payload, - function(resp) { - const data = Bin2CellExplorer.ensureObject(resp); - if (!data || data.status === "cancelled") { - Bin2CellExplorer.setStatus("File selection cancelled."); - return; - } - if (data.status !== "ok" || !data.path) { - Bin2CellExplorer.setStatus("File selection failed."); - return; - } - interfaceUtils.setValueForElement("Bin2CellExplorer_" + field, "value", data.path); - Bin2CellExplorer.setStatus("Selected " + data.path); - }, - Bin2CellExplorer.handleError - ); -}; - -Bin2CellExplorer.loadDataset = function() { - Bin2CellExplorer.setStatus("Loading dataset…"); - const payload = { - he_path: Bin2CellExplorer.get("he_path"), - labels_path: Bin2CellExplorer.get("labels_path"), - adata_path: Bin2CellExplorer.get("adata_path"), - obsm_key: Bin2CellExplorer.get("obsm_key"), - tile_h: Bin2CellExplorer.get("tile_h"), - tile_w: Bin2CellExplorer.get("tile_w"), - stride_h: Bin2CellExplorer.get("stride_h"), - stride_w: Bin2CellExplorer.get("stride_w") - }; - Bin2CellExplorer.api( - "load_dataset", - payload, - function(resp) { - Bin2CellExplorer.onDatasetLoaded(Bin2CellExplorer.ensureObject(resp)); - }, - Bin2CellExplorer.handleError - ); -}; - -Bin2CellExplorer.onDatasetLoaded = function(data) { - Bin2CellExplorer.state.datasetLoaded = true; - Bin2CellExplorer.state.tiles = data.tiles || []; - Bin2CellExplorer.state.obsColumns = data.obs_columns || []; - Bin2CellExplorer.state.obsMetadata = {}; - Bin2CellExplorer.state.obsMetadataRequests = {}; - Bin2CellExplorer.state.obsCategorySelections = {}; - Bin2CellExplorer.state.pendingCategoryValue = ""; - Bin2CellExplorer.state.selectedObsCol = null; - Bin2CellExplorer.state.genesPreview = data.genes_preview || []; - Bin2CellExplorer.state.datasetId = data.dataset_id; - Bin2CellExplorer.state.hePath = data.he_path; - Bin2CellExplorer.state.slideShape = data.shape; - - Bin2CellExplorer.populateSelect("tile_id", Bin2CellExplorer.state.tiles.map(function(tile) { return tile.id; })); - Bin2CellExplorer.populateSelect("obs_col", Bin2CellExplorer.state.obsColumns); - Bin2CellExplorer.populateSelect("category", [""]); - Bin2CellExplorer.populateSelect("obsm_key", data.available_obsm || [], data.obsm_key); - - if (Bin2CellExplorer.state.tiles.length) { - Bin2CellExplorer.set("tile_id", String(Bin2CellExplorer.state.tiles[0].id)); - Bin2CellExplorer.state.selectedTileId = Bin2CellExplorer.state.tiles[0].id; - } - if (Bin2CellExplorer.state.obsColumns.length) { - const firstObs = Bin2CellExplorer.state.obsColumns[0]; - Bin2CellExplorer.set("obs_col", firstObs); - Bin2CellExplorer.onObsColumnChange(firstObs); - } else { - Bin2CellExplorer.populateCategorySelect(""); - } - - Bin2CellExplorer.requestPresets(); - - Bin2CellExplorer.renderTileOverview(); - Bin2CellExplorer.attachTileSelectListener(); - Bin2CellExplorer.attachObsSelectListener(); - Bin2CellExplorer.attachCategorySelectListener(); - - Bin2CellExplorer.setStatus("Dataset loaded (" + Bin2CellExplorer.state.tiles.length + " tiles)"); -}; - -Bin2CellExplorer.populateSelect = function(name, options, selected) { - const domId = "Bin2CellExplorer_" + name; - interfaceUtils.cleanSelect(domId); - options.forEach(function(opt) { - interfaceUtils.addSingleElementToSelect(domId, String(opt)); - }); - if (selected !== undefined && selected !== null && selected !== "") { - interfaceUtils.setValueForElement(domId, "value", String(selected)); - } - if (name === "category") { - const selectEl = document.getElementById(domId); - if (selectEl && selectEl.options.length) { - for (let i = 0; i < selectEl.options.length; i += 1) { - if (selectEl.options[i].value === "") { - selectEl.options[i].text = selectEl.options[i].text || "All categories"; - if (!selectEl.options[i].text.trim()) { - selectEl.options[i].text = "All categories"; - } - break; - } - } - } - } -}; - -Bin2CellExplorer.attachTileSelectListener = function() { - const select = document.getElementById("Bin2CellExplorer_tile_id"); - if (!select || select.__bin2cell_tile_listener) return; - select.addEventListener("change", function() { - const value = parseInt(select.value, 10); - if (!isNaN(value)) { - Bin2CellExplorer.state.selectedTileId = value; - Bin2CellExplorer.renderTileOverview(); - Bin2CellExplorer.panToTile(value, true); - } - }); - select.__bin2cell_tile_listener = true; -}; - -Bin2CellExplorer.attachObsSelectListener = function() { - const select = document.getElementById("Bin2CellExplorer_obs_col"); - if (!select || select.__bin2cell_obs_listener) return; - select.addEventListener("change", function() { - Bin2CellExplorer.onObsColumnChange(select.value || ""); - }); - select.__bin2cell_obs_listener = true; -}; - -Bin2CellExplorer.attachCategorySelectListener = function() { - const select = document.getElementById("Bin2CellExplorer_category"); - if (!select || select.__bin2cell_category_listener) return; - select.addEventListener("change", function() { - const col = Bin2CellExplorer.get("obs_col"); - if (col) { - Bin2CellExplorer.state.obsCategorySelections[col] = select.value || ""; - } - }); - select.__bin2cell_category_listener = true; -}; - -Bin2CellExplorer.onObsColumnChange = function(column, options) { - if (!Bin2CellExplorer.state.datasetLoaded) return; - const value = column || ""; - Bin2CellExplorer.state.selectedObsCol = value || null; - const opts = options || {}; - if (!opts.keepCategory) { - const cached = value ? Bin2CellExplorer.state.obsCategorySelections[value] : ""; - Bin2CellExplorer.state.pendingCategoryValue = cached || ""; - interfaceUtils.setValueForElement("Bin2CellExplorer_category", "value", ""); - } - Bin2CellExplorer.ensureObsMetadata(value); -}; - -Bin2CellExplorer.ensureObsMetadata = function(column) { - const col = column || ""; - Bin2CellExplorer.populateCategorySelect(col); - if (!col) return; - if (Bin2CellExplorer.state.obsMetadata[col]) { - Bin2CellExplorer.populateCategorySelect(col); - return; - } - if (Bin2CellExplorer.state.obsMetadataRequests[col]) { - return; - } - Bin2CellExplorer.state.obsMetadataRequests[col] = true; - Bin2CellExplorer.api( - "describe_obs_column", - { obs_col: col }, - function(resp) { - delete Bin2CellExplorer.state.obsMetadataRequests[col]; - const data = Bin2CellExplorer.ensureObject(resp) || {}; - Bin2CellExplorer.state.obsMetadata[col] = data; - Bin2CellExplorer.populateCategorySelect(col); - }, - function(jqXHR, textStatus, errorThrown) { - delete Bin2CellExplorer.state.obsMetadataRequests[col]; - Bin2CellExplorer.handleError(jqXHR, textStatus, errorThrown); - } - ); -}; - -Bin2CellExplorer.populateCategorySelect = function(column) { - const metadata = column ? Bin2CellExplorer.state.obsMetadata[column] : null; - const categories = (metadata && Array.isArray(metadata.categories)) ? metadata.categories.slice() : []; - const options = [""].concat(categories); - let desired = Bin2CellExplorer.state.pendingCategoryValue; - if (!desired) { - desired = Bin2CellExplorer.get("category") || ""; - } - if (options.indexOf(desired) === -1) { - desired = ""; - } - Bin2CellExplorer.populateSelect("category", options, desired); - if (desired && Bin2CellExplorer.state.pendingCategoryValue === desired) { - Bin2CellExplorer.state.pendingCategoryValue = ""; - } - if (metadata && Array.isArray(metadata.categories) && column) { - if (desired) { - Bin2CellExplorer.state.obsCategorySelections[column] = desired; - } else { - delete Bin2CellExplorer.state.obsCategorySelections[column]; - } - } -}; - -Bin2CellExplorer.requestOverlay = function() { - if (!Bin2CellExplorer.state.datasetLoaded) { - interfaceUtils.alert("Load a dataset first."); - return; - } - const overlayType = Bin2CellExplorer.get("overlay_type") || "gene"; - const payload = Bin2CellExplorer.collectOverlayParams(); - payload.overlay_type = overlayType; - - Bin2CellExplorer.setStatus("Computing overlay…"); - Bin2CellExplorer.api( - "get_overlay", - payload, - function(resp) { - const data = Bin2CellExplorer.ensureObject(resp); - Bin2CellExplorer.renderOverlay(data); - Bin2CellExplorer.renderLegend(data); - Bin2CellExplorer.setStatus("Overlay ready (" + data.overlay_type + ")"); - }, - Bin2CellExplorer.handleError - ); -}; - -Bin2CellExplorer.renderTileOverview = function() { - const canvas = Bin2CellExplorer.state.tileOverviewCanvas; - const hint = document.getElementById("Bin2CellExplorer_tile_overview_hint"); - if (!canvas) return; - const ctx = canvas.getContext("2d"); - const tiles = Bin2CellExplorer.state.tiles || []; - const shape = Bin2CellExplorer.state.slideShape; - if (!tiles.length || !shape) { - ctx.clearRect(0, 0, canvas.width, canvas.height); - if (hint) hint.textContent = "Load dataset to display tiles"; - Bin2CellExplorer.state.tileOverviewScale = null; - return; - } - - const slideHeight = shape[0]; - const slideWidth = shape[1]; - const margin = 16; - const maxWidth = 320; - const maxHeight = 320; - const scale = Math.min((maxWidth - margin * 2) / slideWidth, (maxHeight - margin * 2) / slideHeight); - const canvasWidth = Math.ceil(slideWidth * scale + margin * 2); - const canvasHeight = Math.ceil(slideHeight * scale + margin * 2); - canvas.width = canvasWidth; - canvas.height = canvasHeight; - ctx.clearRect(0, 0, canvas.width, canvas.height); - const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height); - gradient.addColorStop(0, "#fdfbfb"); - gradient.addColorStop(1, "#ebedee"); - ctx.fillStyle = gradient; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - const selectedId = Bin2CellExplorer.state.selectedTileId; - ctx.lineWidth = 1; - ctx.strokeStyle = "rgba(60,60,60,0.5)"; - ctx.font = "10px sans-serif"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - - tiles.forEach(function(tile) { - const x = margin + tile.c0 * scale; - const y = margin + tile.r0 * scale; - const w = (tile.c1 - tile.c0) * scale; - const h = (tile.r1 - tile.r0) * scale; - if (tile.id === selectedId) { - ctx.fillStyle = "rgba(255, 193, 7, 0.45)"; - ctx.fillRect(x, y, w, h); - } - ctx.strokeStyle = "rgba(60,60,60,0.6)"; - ctx.strokeRect(x, y, w, h); - if (w > 10 && h > 10) { - ctx.fillStyle = "#202124"; - ctx.fillText(String(tile.id), x + w / 2, y + h / 2); - } - }); - - if (hint) { - hint.textContent = "Click a tile to select and center"; - } - Bin2CellExplorer.state.tileOverviewScale = { scale: scale, margin: margin }; -}; - -Bin2CellExplorer.onTileOverviewClick = function(evt) { - const canvas = Bin2CellExplorer.state.tileOverviewCanvas; - const scaleInfo = Bin2CellExplorer.state.tileOverviewScale; - if (!canvas || !scaleInfo || !Bin2CellExplorer.state.tiles.length) return; - const rect = canvas.getBoundingClientRect(); - const x = (evt.clientX - rect.left); - const y = (evt.clientY - rect.top); - const slideX = (x - scaleInfo.margin) / scaleInfo.scale; - const slideY = (y - scaleInfo.margin) / scaleInfo.scale; - if (slideX < 0 || slideY < 0) return; - const tile = Bin2CellExplorer.state.tiles.find(function(t) { - return slideX >= t.c0 && slideX < t.c1 && slideY >= t.r0 && slideY < t.r1; - }); - if (!tile) return; - Bin2CellExplorer.state.selectedTileId = tile.id; - Bin2CellExplorer.set("tile_id", String(tile.id)); - Bin2CellExplorer.renderTileOverview(); - Bin2CellExplorer.panToTile(tile.id, true); -}; - -Bin2CellExplorer.panToTile = function(tileId, animate) { - const tile = Bin2CellExplorer.state.tiles.find(function(t) { return t.id === tileId; }); - const shape = Bin2CellExplorer.state.slideShape; - if (!tile || !shape) return; - const viewer = tmapp[tmapp["object_prefix"] + "_viewer"]; - if (!viewer || !viewer.world.getItemCount()) return; - const image = viewer.world.getItemAt(0); - const width = shape[1]; - const height = shape[0]; - const xNorm = tile.c0 / width; - const yNorm = tile.r0 / height; - const wNorm = (tile.c1 - tile.c0) / width; - const hNorm = (tile.r1 - tile.r0) / height; - const rect = new OpenSeadragon.Rect(xNorm, yNorm, wNorm, hNorm); - viewer.viewport.fitBounds(rect, animate !== false); -}; - -Bin2CellExplorer.collectOverlayParams = function() { - const params = { - tile_id: Bin2CellExplorer.get("tile_id"), - overlay_type: Bin2CellExplorer.get("overlay_type"), - genes: Bin2CellExplorer.get("genes"), - obs_col: Bin2CellExplorer.get("obs_col"), - category: Bin2CellExplorer.get("category"), - render_mode: Bin2CellExplorer.get("render_mode"), - color_mode: Bin2CellExplorer.get("color_mode"), - gradient_color: Bin2CellExplorer.get("gradient_color"), - gene_color: Bin2CellExplorer.get("gene_color"), - expr_quantile: Bin2CellExplorer.get("expr_quantile"), - top_n: Bin2CellExplorer.get("top_n"), - b2c_mode: Bin2CellExplorer.get("b2c_mode"), - max_bin_distance: Bin2CellExplorer.get("max_bin_distance"), - mpp: Bin2CellExplorer.get("mpp"), - bin_um: Bin2CellExplorer.get("bin_um"), - volume_ratio: Bin2CellExplorer.get("volume_ratio"), - overlay_alpha: Bin2CellExplorer.get("overlay_alpha"), - highlight_color: Bin2CellExplorer.get("highlight_color"), - highlight_width: Bin2CellExplorer.get("highlight_width"), - all_expanded_outline: Bin2CellExplorer.isChecked("all_expanded_outline"), - all_nuclei_outline: Bin2CellExplorer.isChecked("all_nuclei_outline") - }; - return params; -}; - -Bin2CellExplorer.exportOverlay = function() { - if (!Bin2CellExplorer.state.datasetLoaded) { - interfaceUtils.alert("Load a dataset first."); - return; - } - const params = Bin2CellExplorer.collectOverlayParams(); - params.name = Bin2CellExplorer.get("export_name"); - Bin2CellExplorer.api( - "export_overlay", - params, - function(resp) { - const data = Bin2CellExplorer.ensureObject(resp); - Bin2CellExplorer.setStatus("GeoJSON written to " + data.path); - interfaceUtils.alert("Overlay exported:\n" + data.path); - }, - Bin2CellExplorer.handleError - ); -}; - -Bin2CellExplorer.collectPresetConfig = function() { - return Bin2CellExplorer.collectOverlayParams(); -}; - -Bin2CellExplorer.savePreset = function() { - const name = (Bin2CellExplorer.get("save_preset_name") || "").trim(); - if (!name) { - interfaceUtils.alert("Provide a preset name."); - return; - } - const payload = { - name: name, - config: Bin2CellExplorer.collectPresetConfig() - }; - Bin2CellExplorer.api( - "save_preset", - payload, - function(resp) { - const data = Bin2CellExplorer.ensureObject(resp); - Bin2CellExplorer.setStatus("Preset saved."); - Bin2CellExplorer.requestPresets(name); - }, - Bin2CellExplorer.handleError - ); -}; - -Bin2CellExplorer.requestPresets = function(selectName) { - Bin2CellExplorer.api( - "list_presets", - {}, - function(resp) { - const data = Bin2CellExplorer.ensureObject(resp); - Bin2CellExplorer.state.presets = data.presets || {}; - const names = Object.keys(Bin2CellExplorer.state.presets); - Bin2CellExplorer.populateSelect("preset_select", [""].concat(names), selectName || ""); - }, - Bin2CellExplorer.handleError - ); -}; - -Bin2CellExplorer.applySelectedPreset = function() { - const select = Bin2CellExplorer.get("preset_select"); - if (!select || !(select in Bin2CellExplorer.state.presets)) { - interfaceUtils.alert("Select a preset first."); - return; - } - const config = Bin2CellExplorer.state.presets[select]; - if (!config) return; - let pendingCategory = null; - let pendingObsCol = null; - Object.keys(config).forEach(function(key) { - if (key === "category") { - pendingCategory = config[key] || ""; - return; - } - const domId = "Bin2CellExplorer_" + key; - if (document.getElementById(domId)) { - interfaceUtils.setValueForElement(domId, "value", config[key]); - if (key === "obs_col") { - pendingObsCol = config[key] || ""; - } - } - }); - Bin2CellExplorer.toggleOverlayInputs(config.overlay_type || Bin2CellExplorer.get("overlay_type")); - if (pendingCategory !== null) { - Bin2CellExplorer.state.pendingCategoryValue = pendingCategory; - } - if (pendingObsCol !== null) { - Bin2CellExplorer.onObsColumnChange(pendingObsCol, { keepCategory: pendingCategory !== null }); - } else if (pendingCategory !== null) { - Bin2CellExplorer.populateCategorySelect(Bin2CellExplorer.get("obs_col") || ""); - } - Bin2CellExplorer.setStatus("Preset applied: " + select); -}; - -Bin2CellExplorer.populatePresetPreview = function() { - const select = Bin2CellExplorer.get("preset_select"); - if (!select) return; - const preset = Bin2CellExplorer.state.presets[select]; - if (!preset) return; - Bin2CellExplorer.setStatus("Preset '" + select + "' ready to apply."); -}; - -Bin2CellExplorer.deleteSelectedPreset = function() { - const select = Bin2CellExplorer.get("preset_select"); - if (!select || !(select in Bin2CellExplorer.state.presets)) { - interfaceUtils.alert("Select a preset to delete."); - return; - } - Bin2CellExplorer.api( - "delete_preset", - { name: select }, - function() { - Bin2CellExplorer.setStatus("Preset deleted: " + select); - Bin2CellExplorer.requestPresets(); - }, - Bin2CellExplorer.handleError - ); -}; - -Bin2CellExplorer.handleError = function(jqXHR, textStatus, errorThrown) { - let message = ""; - if (jqXHR) { - if (jqXHR.responseJSON) { - message = jqXHR.responseJSON.message || JSON.stringify(jqXHR.responseJSON); - } else if (jqXHR.responseText) { - try { - const parsed = JSON.parse(jqXHR.responseText); - message = parsed.message || jqXHR.responseText; - } catch (parseErr) { - message = jqXHR.responseText; - } - } - } - if (!message && errorThrown) { - message = errorThrown; - } - if (!message && textStatus) { - message = textStatus; - } - if (!message) { - message = "Unknown error"; - } - Bin2CellExplorer.setStatus("Error: " + message); - interfaceUtils.alert("Bin2Cell Explorer error:\n" + message); -}; - -Bin2CellExplorer.isChecked = function(name) { - return interfaceUtils.isChecked("Bin2CellExplorer_" + name); -}; - -Bin2CellExplorer.renderOverlay = function(data) { - Bin2CellExplorer.state.overlays = data; - const layer = Bin2CellExplorer.ensureSvgLayer(); - if (!layer) return; - - layer.selectAll("*").remove(); - Bin2CellExplorer.state.layers = { - gene: {}, - categories: {}, - outlines: {} - }; - - if (data.overlay_type === "gene") { - (data.overlays || []).forEach(function(geneOverlay) { - Bin2CellExplorer.drawGeneOverlay(layer, geneOverlay); - }); - } else if (data.overlay_type === "observation") { - Bin2CellExplorer.drawObservationOverlay(layer, data); - } - - Bin2CellExplorer.drawOutlines(layer, data); - Bin2CellExplorer.renderTileOverview(); -}; - -Bin2CellExplorer.ensureSvgLayer = function() { - const op = tmapp["object_prefix"]; - const base = overlayUtils._d3nodes[op + "_svgnode"]; - if (!base) { - interfaceUtils.alert("Viewer not ready yet."); - return null; - } - if (!Bin2CellExplorer.state.d3layer) { - Bin2CellExplorer.state.d3layer = base.append("g").attr("id", op + "_bin2cell_layer"); - } - return Bin2CellExplorer.state.d3layer; -}; - - -Bin2CellExplorer.drawGeneOverlay = function(layer, overlay) { - const gene = overlay.gene; - const group = layer.append("g").attr("class", "bin2cell-gene-layer").attr("data-gene", gene); - const renderMode = overlay.render_mode || "fill"; - - (overlay.features || []).forEach(function(feature) { - const d = Bin2CellExplorer.polygonsToPath(feature.polygons || []); - if (!d) return; - const path = group.append("path") - .attr("class", "bin2cell-gene-feature") - .attr("d", d) - .attr("stroke", feature.stroke || "none") - .attr("stroke-width", feature.stroke_width || 1.0) - .attr("fill", renderMode === "fill" ? (feature.fill || "none") : "none") - .attr("vector-effect", "non-scaling-stroke") - .attr("data-label", feature.label) - .attr("data-value", feature.value); - }); - - Bin2CellExplorer.state.layers.gene[gene] = group; -}; - -Bin2CellExplorer.drawObservationOverlay = function(layer, data) { - const renderMode = data.render_mode || "fill"; - const features = data.features || []; - const categoryGroups = {}; - - features.forEach(function(feature) { - const category = feature.category || "__uncategorized__"; - if (!categoryGroups[category]) { - categoryGroups[category] = layer.append("g") - .attr("class", "bin2cell-category-layer") - .attr("data-category", category); - } - const d = Bin2CellExplorer.polygonsToPath(feature.polygons || []); - if (!d) return; - categoryGroups[category].append("path") - .attr("d", d) - .attr("stroke", feature.stroke || "none") - .attr("stroke-width", feature.stroke_width || 1.0) - .attr("fill", renderMode === "fill" ? (feature.fill || "none") : "none") - .attr("vector-effect", "non-scaling-stroke") - .attr("data-label", feature.label); - }); - - Bin2CellExplorer.state.layers.categories = categoryGroups; -}; - -Bin2CellExplorer.drawOutlines = function(layer, data) { - const outlinesGroup = layer.append("g").attr("class", "bin2cell-outline-layer"); - - (data.expanded_outline || []).forEach(function(line) { - const pathData = Bin2CellExplorer.lineToPath(line); - if (!pathData) return; - outlinesGroup.append("path") - .attr("d", pathData) - .attr("stroke", "rgba(180,180,180,0.7)") - .attr("stroke-width", 1.0) - .attr("fill", "none") - .attr("vector-effect", "non-scaling-stroke") - .attr("data-kind", "expanded"); - }); - - (data.nuclei_outline || []).forEach(function(line) { - const pathData = Bin2CellExplorer.lineToPath(line); - if (!pathData) return; - outlinesGroup.append("path") - .attr("d", pathData) - .attr("stroke", "rgba(160,160,160,0.5)") - .attr("stroke-width", 0.8) - .attr("fill", "none") - .attr("vector-effect", "non-scaling-stroke") - .attr("data-kind", "nuclei"); - }); - - Bin2CellExplorer.state.layers.outlines.group = outlinesGroup; -}; - - -Bin2CellExplorer.imageToViewport = function(x, y, tiledImage) { - const viewer = tmapp[tmapp["object_prefix"] + "_viewer"]; - if (!viewer) return { x: x, y: y }; - const image = tiledImage || viewer.world.getItemAt(0); - if (!image) return { x: x, y: y }; - let px = x; - if (image.getFlip && image.getFlip()) { - px = image.getContentSize().x - px; - } - const point = image.imageToViewportCoordinates(px, y, true); - return point; -}; - -Bin2CellExplorer.polygonsToPath = function(polygons) { - if (!polygons || !polygons.length) return ""; - const viewer = tmapp[tmapp["object_prefix"] + "_viewer"]; - if (!viewer || !viewer.world.getItemCount()) return ""; - const image = viewer.world.getItemAt(0); - const segments = []; - polygons.forEach(function(poly) { - if (!poly || poly.length < 3) return; - let path = ""; - poly.forEach(function(pt, idx) { - const vp = Bin2CellExplorer.imageToViewport(pt[0], pt[1], image); - path += (idx === 0 ? "M" : "L") + vp.x + " " + vp.y; - }); - path += "Z"; - segments.push(path); - }); - return segments.join(" "); -}; - -Bin2CellExplorer.lineToPath = function(points) { - if (!points || !points.length) return ""; - const viewer = tmapp[tmapp["object_prefix"] + "_viewer"]; - if (!viewer || !viewer.world.getItemCount()) return ""; - const image = viewer.world.getItemAt(0); - let path = ""; - points.forEach(function(pt, idx) { - const vp = Bin2CellExplorer.imageToViewport(pt[0], pt[1], image); - path += (idx === 0 ? "M" : "L") + vp.x + " " + vp.y; - }); - return path; -}; - -Bin2CellExplorer.renderLegend = function(data) { - const legend = document.getElementById("Bin2CellExplorer_legend"); - if (!legend) return; - legend.innerHTML = ""; - - if (data.overlay_type === "gene") { - Bin2CellExplorer.buildGeneLegend(legend, data.overlays || []); - } else if (data.overlay_type === "observation") { - Bin2CellExplorer.buildObservationLegend(legend, data.legend || {}); - } -}; - -Bin2CellExplorer.buildGeneLegend = function(container, overlays) { - if (!overlays.length) { - container.textContent = "No genes in overlay."; - return; - } - const title = document.createElement("div"); - title.textContent = "Genes"; - title.className = "fw-bold mb-1"; - container.appendChild(title); - - overlays.forEach(function(overlay) { - const gene = overlay.gene; - const legend = overlay.legend || {}; - - const row = document.createElement("div"); - row.className = "bin2cell-legend-row"; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.checked = true; - checkbox.dataset.gene = gene; - checkbox.addEventListener("change", function(evt) { - Bin2CellExplorer.toggleGeneLayer(gene, evt.target.checked); - }); - - const label = document.createElement("label"); - label.textContent = " " + gene; - label.className = "ms-1"; - - row.appendChild(checkbox); - row.appendChild(label); - - if (legend.type === "continuous" && legend.gradient) { - const gradient = document.createElement("div"); - gradient.className = "bin2cell-gradient"; - gradient.style.height = "12px"; - gradient.style.flex = "1"; - gradient.style.marginLeft = "8px"; - gradient.style.borderRadius = "4px"; - gradient.style.background = "linear-gradient(to right," + legend.gradient.map(function(stop) { - return stop[1]; - }).join(",") + ")"; - gradient.title = "min: " + legend.min + " max: " + legend.max; - row.appendChild(gradient); - } else if (legend.type === "solid" && legend.color) { - const swatch = document.createElement("span"); - swatch.className = "bin2cell-swatch"; - swatch.style.display = "inline-block"; - swatch.style.width = "16px"; - swatch.style.height = "16px"; - swatch.style.marginLeft = "8px"; - swatch.style.borderRadius = "3px"; - swatch.style.border = "1px solid rgba(0,0,0,0.2)"; - swatch.style.background = legend.color; - row.appendChild(swatch); - } - container.appendChild(row); - }); -}; - -Bin2CellExplorer.buildObservationLegend = function(container, legendData) { - const items = legendData.items || []; - if (!items.length) { - container.textContent = "No observation categories."; - return; - } - const title = document.createElement("div"); - title.textContent = legendData.obs_col || "Observation"; - title.className = "fw-bold mb-1"; - container.appendChild(title); - - items.forEach(function(item) { - const row = document.createElement("div"); - row.className = "bin2cell-legend-row"; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.checked = true; - checkbox.dataset.category = item.label; - checkbox.addEventListener("change", function(evt) { - Bin2CellExplorer.toggleCategoryLayer(item.label, evt.target.checked); - }); - - const swatch = document.createElement("span"); - swatch.className = "bin2cell-swatch"; - swatch.style.display = "inline-block"; - swatch.style.width = "14px"; - swatch.style.height = "14px"; - swatch.style.marginLeft = "8px"; - swatch.style.borderRadius = "3px"; - swatch.style.background = item.color; - - const label = document.createElement("label"); - label.textContent = " " + item.label; - label.className = "ms-1"; - - row.appendChild(checkbox); - row.appendChild(swatch); - row.appendChild(label); - container.appendChild(row); - }); -}; - -Bin2CellExplorer.toggleGeneLayer = function(gene, visible) { - const group = Bin2CellExplorer.state.layers.gene[gene]; - if (!group) return; - group.style("display", visible ? null : "none"); -}; - -Bin2CellExplorer.toggleCategoryLayer = function(category, visible) { - const group = Bin2CellExplorer.state.layers.categories[category]; - if (!group) return; - group.style("display", visible ? null : "none"); -}; diff --git a/plugins/Bin2CellExplorer.py b/plugins/Bin2CellExplorer.py deleted file mode 100644 index d59068c..0000000 --- a/plugins/Bin2CellExplorer.py +++ /dev/null @@ -1,1374 +0,0 @@ -import json -import logging -import math -import os -from collections import Counter, OrderedDict, defaultdict -from dataclasses import dataclass -from typing import Dict, Iterable, List, Optional, Tuple - -import numpy as np -from flask import make_response - -try: - import anndata as ad -except ImportError: # pragma: no cover - runtime guard - ad = None - -from scipy.sparse import load_npz, issparse -from skimage import io, measure -from skimage.segmentation import expand_labels, find_boundaries - -from matplotlib import cm, colors -from PySide6.QtCore import QMetaObject, QObject, Qt, Q_ARG, Slot -from PySide6.QtWidgets import QApplication, QFileDialog - -LOGGER = logging.getLogger(__name__) - -_GLOBAL_STATE: Dict[str, object] = {} -_FILE_DIALOG_HELPER: Optional["FileDialogHelper"] = None - -OBS_CATEGORY_LIMIT = 256 - - -def _rgba_to_css(rgba: Tuple[float, float, float, float], *, alpha_override: Optional[float] = None) -> str: - r, g, b, a = rgba - if alpha_override is not None: - a = alpha_override - return f"rgba({int(round(r * 255))},{int(round(g * 255))},{int(round(b * 255))},{round(a, 4)})" - - -def _get_cmap(cmap: object) -> "colors.Colormap": - if isinstance(cmap, str): - return cm.get_cmap(cmap) - return cmap # type: ignore[return-value] - - -def _colormap_sample(cmap: object, value: float, alpha: Optional[float] = None) -> str: - cmap_obj = _get_cmap(cmap) - rgba = cmap_obj(np.clip(value, 0.0, 1.0)) - return _rgba_to_css(rgba, alpha_override=alpha) - - -def _sample_gradient(cmap: object, steps: int = 8) -> List[Tuple[float, str]]: - cmap_obj = _get_cmap(cmap) - gradient = [] - for i in range(steps): - pos = i / max(steps - 1, 1) - gradient.append((pos, _rgba_to_css(cmap_obj(pos)))) - return gradient - - -def _unique_sorted(iterable: Iterable) -> List: - return sorted(set(iterable)) - - -def _color_to_css(value: object, *, alpha_override: Optional[float] = None) -> Optional[str]: - if value is None: - return None - try: - rgba = colors.to_rgba(value) - except ValueError: - return None - return _rgba_to_css(rgba, alpha_override=alpha_override) - - -@dataclass(frozen=True) -class Tile: - id: int - r0: int - r1: int - c0: int - c1: int - - def to_dict(self) -> Dict[str, int]: - return {"id": self.id, "r0": self.r0, "r1": self.r1, "c0": self.c0, "c1": self.c1} - - -class B2CContext: - """ - Lightweight loader for H&E image, sparse nuclei labels, and AnnData container. - Provides centroid pixel coordinates and convenience accessors. - """ - - def __init__( - self, - he_image_path: str, - labels_npz_path: str, - adata_path: str, - *, - obsm_key: Optional[str] = None, - ): - if ad is None: - raise ImportError("anndata is required (pip install anndata scanpy).") - - he_image_path = os.path.abspath(os.path.expanduser(os.path.expandvars(he_image_path))) - labels_npz_path = os.path.abspath(os.path.expanduser(os.path.expandvars(labels_npz_path))) - adata_path = os.path.abspath(os.path.expanduser(os.path.expandvars(adata_path))) - - if not os.path.exists(he_image_path): - raise FileNotFoundError(f"H&E image not found: {he_image_path}") - if not os.path.exists(labels_npz_path): - raise FileNotFoundError(f"Label NPZ not found: {labels_npz_path}") - if not os.path.exists(adata_path): - raise FileNotFoundError(f"AnnData (.h5ad) not found: {adata_path}") - - LOGGER.info("Loading H&E image: %s", he_image_path) - self.he = io.imread(he_image_path) - - LOGGER.info("Loading sparse labels: %s", labels_npz_path) - self.lab_sp = load_npz(labels_npz_path) - - if self.lab_sp.shape != self.he.shape[:2]: - raise ValueError( - f"Image/label size mismatch: H&E {self.he.shape[:2]} vs labels {self.lab_sp.shape}" - ) - - LOGGER.info("Loading AnnData: %s", adata_path) - self.adata = ad.read_h5ad(adata_path) - - self._gene_cache: Dict[str, np.ndarray] = {} - self._obs_cache: Dict[str, np.ndarray] = {} - self._obs_meta_cache: Dict[str, Dict[str, object]] = {} - - self._available_obsm = [ - k - for k, arr in self.adata.obsm.items() - if hasattr(arr, "shape") and arr.shape[1] >= 2 - ] - if not self._available_obsm: - raise ValueError("No obsm entries with >=2 columns were found in AnnData.") - - self.obsm_key = self._resolve_obsm_key(obsm_key) - xy = np.asarray(self.adata.obsm[self.obsm_key]) - self.cols = np.rint(xy[:, 0]).astype(int) - self.rows = np.rint(xy[:, 1]).astype(int) - - H, W = self.shape - np.clip(self.cols, 0, W - 1, out=self.cols) - np.clip(self.rows, 0, H - 1, out=self.rows) - - def _resolve_obsm_key(self, preferred: Optional[str]) -> str: - if preferred and preferred in self._available_obsm: - return preferred - for candidate in ("spatial_cropped_150_buffer", "spatial"): - if candidate in self._available_obsm: - return candidate - return self._available_obsm[0] - - @property - def shape(self) -> Tuple[int, int]: - return self.lab_sp.shape - - @property - def available_obsm(self) -> List[str]: - return list(self._available_obsm) - - @property - def gene_names(self) -> List[str]: - return list(map(str, self.adata.var_names)) - - @property - def obs_columns(self) -> List[str]: - return list(map(str, self.adata.obs.columns)) - - def crop_dense(self, r0: int, r1: int, c0: int, c1: int) -> Tuple[np.ndarray, np.ndarray]: - he = self.he[r0:r1, c0:c1] - lab = self.lab_sp[r0:r1, c0:c1].toarray().astype(np.int32, copy=False) - return he, lab - - def gene_vector(self, gene: str) -> np.ndarray: - if gene not in self._gene_cache: - if gene not in self.adata.var_names: - raise KeyError(f"Gene '{gene}' not found in AnnData.") - values = self.adata[:, gene].X - if issparse(values): - values = values.toarray() - self._gene_cache[gene] = np.asarray(values).ravel() - return self._gene_cache[gene] - - def obs_vector(self, column: str) -> np.ndarray: - if column not in self._obs_cache: - if column not in self.adata.obs.columns: - raise KeyError(f"Column '{column}' not found in AnnData.obs.") - self._obs_cache[column] = self.adata.obs[column].to_numpy() - return self._obs_cache[column] - - def obs_metadata(self, column: str) -> Dict[str, object]: - if column not in self.adata.obs.columns: - raise KeyError(f"Column '{column}' not found in AnnData.obs.") - cached = self._obs_meta_cache.get(column) - if cached is not None: - return cached - metadata = self._build_obs_metadata(column) - self._obs_meta_cache[column] = metadata - return metadata - - def _build_obs_metadata(self, column: str) -> Dict[str, object]: - series = self.adata.obs[column] - dtype = getattr(series, "dtype", None) - categories: List[str] = [] - limit_hit = False - - if dtype is not None and hasattr(dtype, "categories"): - cat_values = list(getattr(series.cat, "categories", getattr(dtype, "categories", []))) - categories = [str(cat) for cat in cat_values] - elif dtype is not None and getattr(dtype, "kind", "") in {"O", "U", "S"}: - raw_unique = series.dropna().astype(str).unique() - limit_hit = raw_unique.size > OBS_CATEGORY_LIMIT - if not limit_hit: - categories = _unique_sorted(map(str, raw_unique.tolist())) - else: - limit_hit = True - - color_key = f"{column}_colors" - raw_colors = self.adata.uns.get(color_key) - color_map: Dict[str, str] = {} - if isinstance(raw_colors, (list, tuple, np.ndarray)) and categories: - for cat, raw_color in zip(categories, raw_colors): - if raw_color is None: - continue - raw_color_str = str(raw_color) - if raw_color_str: - color_map[str(cat)] = raw_color_str - - return { - "categories": categories, - "color_map": color_map, - "category_limit_hit": limit_hit, - } - - def obs_color_map(self, column: str) -> Dict[str, str]: - meta = self.obs_metadata(column) - colors_dict = meta.get("color_map") or {} - return dict(colors_dict) # shallow copy - - -def make_tiles( - ctx: B2CContext, tile_h: int = 1500, tile_w: int = 1500, stride_h: Optional[int] = None, stride_w: Optional[int] = None -) -> List[Tile]: - H, W = ctx.shape - stride_h = tile_h if stride_h is None else stride_h - stride_w = tile_w if stride_w is None else stride_w - - tiles: List[Tile] = [] - tid = 0 - r = 0 - while r < H: - r1 = min(r + tile_h, H) - c = 0 - while c < W: - c1 = min(c + tile_w, W) - tiles.append(Tile(tid, r, r1, c, c1)) - tid += 1 - if c1 == W: - break - c += stride_w - if r1 == H: - break - r += stride_h - return tiles - - -def _compute_expansion_distance( - lab_raw: np.ndarray, - *, - mode: str, - max_bin_distance: float, - mpp: float, - bin_um: float, - volume_ratio: float, -) -> int: - dist_px_fixed = int(math.ceil(max(1.0, max_bin_distance * (bin_um / mpp)))) - if mode != "volume_ratio": - return dist_px_fixed - - lab_ids, counts = np.unique(lab_raw[lab_raw > 0], return_counts=True) - if lab_ids.size == 0: - return dist_px_fixed - - r_eff = np.sqrt(counts / np.pi) - delta_r = (np.sqrt(volume_ratio) - 1.0) * r_eff - dist_px = int(np.clip(np.median(delta_r), 1, 5 * dist_px_fixed)) - return max(1, dist_px) - - -def _expand_labels_tile( - ctx: B2CContext, - tile: Tile, - *, - mode: str, - max_bin_distance: float, - mpp: float, - bin_um: float, - volume_ratio: float, - pad_factor: int = 2, -) -> Tuple[np.ndarray, np.ndarray, int]: - r0, r1, c0, c1 = tile.r0, tile.r1, tile.c0, tile.c1 - he_crop, lab_raw = ctx.crop_dense(r0, r1, c0, c1) - - dist_px = _compute_expansion_distance( - lab_raw, - mode=mode, - max_bin_distance=max_bin_distance, - mpp=mpp, - bin_um=bin_um, - volume_ratio=volume_ratio, - ) - - H, W = ctx.shape - pad = pad_factor * dist_px - - rp0 = max(r0 - pad, 0) - rp1 = min(r1 + pad, H) - cp0 = max(c0 - pad, 0) - cp1 = min(c1 + pad, W) - - lab_pad = ctx.lab_sp[rp0:rp1, cp0:cp1].toarray().astype(np.int32, copy=False) - lab_exp_pad = expand_labels(lab_pad, distance=dist_px) - - r0_rel, r1_rel = r0 - rp0, r1 - rp0 - c0_rel, c1_rel = c0 - cp0, c1 - cp0 - - lab_exp = lab_exp_pad[r0_rel:r1_rel, c0_rel:c1_rel] - return he_crop, lab_exp.astype(np.int32, copy=False), int(dist_px) - - -def _polygons_from_labels(lab: np.ndarray, tile: Tile) -> Dict[int, List[List[List[float]]]]: - r0, c0 = tile.r0, tile.c0 - output: Dict[int, List[List[List[float]]]] = {} - labels = np.unique(lab) - for lbl in labels: - if lbl <= 0: - continue - mask = lab == lbl - if not mask.any(): - continue - contours = measure.find_contours(mask.astype(np.uint8), 0.5) - poly_list: List[List[List[float]]] = [] - for contour in contours: - if contour.shape[0] < 3: - continue - polygon: List[List[float]] = [] - for y, x in contour: - polygon.append([float(x + c0), float(y + r0)]) - if polygon and polygon[0] != polygon[-1]: - polygon.append(polygon[0]) - if polygon: - poly_list.append(polygon) - if poly_list: - output[int(lbl)] = poly_list - return output - - -def _outline_paths(lab: np.ndarray, tile: Tile) -> List[List[List[float]]]: - r0, c0 = tile.r0, tile.c0 - edges = find_boundaries(lab, mode="inner") - contours = measure.find_contours(edges.astype(np.uint8), 0.5) - paths: List[List[List[float]]] = [] - for contour in contours: - if contour.shape[0] < 2: - continue - path: List[List[float]] = [] - for y, x in contour: - path.append([float(x + c0), float(y + r0)]) - paths.append(path) - return paths - - -def _centroids_for_tile(ctx: B2CContext, tile: Tile) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: - r0, r1, c0, c1 = tile.r0, tile.r1, tile.c0, tile.c1 - in_tile = (ctx.rows >= r0) & (ctx.rows < r1) & (ctx.cols >= c0) & (ctx.cols < c1) - indices = np.where(in_tile)[0] - if indices.size == 0: - empty_int = np.empty((0,), dtype=np.int32) - empty_local = np.empty((0, 2), dtype=np.int32) - return empty_int, empty_int, empty_int, empty_local - - rr_local = (ctx.rows[indices] - r0).astype(np.int32) - cc_local = (ctx.cols[indices] - c0).astype(np.int32) - local = np.stack([rr_local, cc_local], axis=1) - return indices, ctx.rows[indices], ctx.cols[indices], local - - -class TileCache: - def __init__(self, max_items: int = 6): - self._max = max_items - self._data: "OrderedDict[Tuple, Dict]" = OrderedDict() - - def clear(self) -> None: - self._data.clear() - - def get(self, key: Tuple) -> Optional[Dict]: - entry = self._data.get(key) - if entry is not None: - self._data.move_to_end(key) - return entry - - def set(self, key: Tuple, value: Dict) -> None: - self._data[key] = value - self._data.move_to_end(key) - while len(self._data) > self._max: - self._data.popitem(last=False) - - -class Plugin: - def __init__(self, app): - self.app = app - self.out_dir = os.path.expanduser("~/.tissuumaps/plugins/Bin2CellExplorer") - os.makedirs(self.out_dir, exist_ok=True) - self.log_path = os.path.join(self.out_dir, "Bin2CellExplorer.log") - if not LOGGER.handlers: - handler = logging.FileHandler(self.log_path) - handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) - LOGGER.addHandler(handler) - LOGGER.setLevel(logging.INFO) - - global _GLOBAL_STATE - if not _GLOBAL_STATE: - _GLOBAL_STATE = { - "context": None, - "tiles": [], - "dataset_id": None, - "tile_cache": TileCache(max_items=4), - "dataset_config": None, - "loading": False, - } - self._state = _GLOBAL_STATE - self.current_params: Dict[str, str] = {} - self.state_path = os.path.join(self.out_dir, "dataset_state.json") - if self._state.get("dataset_config") is None: - cached = self._load_cached_config() - if cached: - self._state["dataset_config"] = cached - - self.presets_path = os.path.join(self.out_dir, "presets.json") - self.presets = self._load_presets() - - # ------------------------------------------------------------------ Helpers - - def _json_response(self, payload: Dict) -> "flask.Response": - return make_response(json.dumps(payload, default=_json_default), 200, {"Content-Type": "application/json"}) - - def _error_response(self, status: int, message: str, exc: Optional[Exception] = None): - if exc is not None: - LOGGER.exception(message) - else: - LOGGER.error(message) - payload = {"status": "error", "message": message} - if exc is not None: - payload["detail"] = repr(exc) - return make_response(json.dumps(payload), status, {"Content-Type": "application/json"}) - - def _error_from_exception(self, exc: Exception, context: str): - if isinstance(exc, FileNotFoundError): - status = 404 - message = str(exc) - elif isinstance(exc, (ValueError, KeyError, RuntimeError)): - status = 400 - message = str(exc) - elif isinstance(exc, ImportError): - status = 500 - message = f"{context}: missing dependency ({exc})" - else: - status = 500 - message = f"{context}: {exc}" - return self._error_response(status, message, exc) - - def _ensure_context(self) -> B2CContext: - if self.context is None: - config = self._state.get("dataset_config") or self._load_cached_config() - if config: - LOGGER.info("Rehydrating dataset from cached config.") - ctx = B2CContext( - he_image_path=config["he_path"], - labels_npz_path=config["labels_path"], - adata_path=config["adata_path"], - obsm_key=config.get("obsm_key"), - ) - tiles = make_tiles( - ctx, - tile_h=config.get("tile_h", 1500) or 1500, - tile_w=config.get("tile_w", 1500) or 1500, - stride_h=config.get("stride_h"), - stride_w=config.get("stride_w"), - ) - self.context = ctx - self.tiles = tiles - self.dataset_id = os.path.basename(config["adata_path"]) - self.tile_cache.clear() - self._state["dataset_config"] = config - else: - raise RuntimeError("Load a dataset first.") - return self.context - - @property - def context(self) -> Optional[B2CContext]: - return self._state.get("context") # type: ignore[return-value] - - @context.setter - def context(self, value: Optional[B2CContext]) -> None: - self._state["context"] = value - - @property - def tiles(self) -> List[Tile]: - return self._state.get("tiles", []) # type: ignore[return-value] - - @tiles.setter - def tiles(self, value: List[Tile]) -> None: - self._state["tiles"] = value - - @property - def dataset_id(self) -> Optional[str]: - return self._state.get("dataset_id") # type: ignore[return-value] - - @dataset_id.setter - def dataset_id(self, value: Optional[str]) -> None: - self._state["dataset_id"] = value - - @property - def tile_cache(self) -> TileCache: - cache = self._state.get("tile_cache") - if cache is None: - cache = TileCache(max_items=4) - self._state["tile_cache"] = cache - return cache # type: ignore[return-value] - - def _load_presets(self) -> Dict[str, Dict]: - if not os.path.exists(self.presets_path): - return {} - try: - with open(self.presets_path, "r") as fh: - presets = json.load(fh) - return presets if isinstance(presets, dict) else {} - except Exception as exc: # pragma: no cover - runtime guard - LOGGER.warning("Failed to read presets: %s", exc) - return {} - - def _save_presets(self) -> None: - with open(self.presets_path, "w") as fh: - json.dump(self.presets, fh, indent=2) - - # ------------------------------------------------------------------ Endpoints - - def pick_file(self, params: Dict): - try: - field = str(params.get("field") or "") - if field not in {"he_path", "labels_path", "adata_path"}: - raise ValueError("Unsupported field for file picker.") - - current = params.get("current_path") or "" - start_dir = params.get("start_dir") or "" - if current: - current = os.path.abspath(os.path.expanduser(os.path.expandvars(str(current)))) - if os.path.isdir(current): - start_dir = current - else: - start_dir = os.path.dirname(current) - if not start_dir: - start_dir = os.path.expanduser("~") - - filters = { - "he_path": "H&E images (*.tif *.tiff *.TIF *.TIFF);;All files (*)", - "labels_path": "Label matrices (*.npz *.NPZ);;All files (*)", - "adata_path": "AnnData (*.h5ad *.H5AD);;All files (*)", - } - captions = { - "he_path": "Select H&E image", - "labels_path": "Select label matrix (.npz)", - "adata_path": "Select AnnData (.h5ad)", - } - - app = QApplication.instance() - if app is None: - raise RuntimeError("QApplication instance not found; cannot open file dialog.") - - global _FILE_DIALOG_HELPER - if _FILE_DIALOG_HELPER is None: - helper = FileDialogHelper() - helper.moveToThread(app.thread()) - _FILE_DIALOG_HELPER = helper - - filename, _ = _FILE_DIALOG_HELPER.get_open_file_name( - captions[field], - start_dir, - filters[field], - ) - - if not filename: - return self._json_response({"status": "cancelled"}) - - path = os.path.abspath(filename) - if not os.path.isfile(path): - raise FileNotFoundError(f"Selected path is not a file: {path}") - return self._json_response({"status": "ok", "path": path, "field": field}) - except Exception as exc: - return self._error_from_exception(exc, "Failed to pick file") - - def _load_cached_config(self) -> Optional[Dict[str, object]]: - if not hasattr(self, "state_path"): - return None - if not os.path.exists(self.state_path): - return None - try: - with open(self.state_path, "r") as fh: - data = json.load(fh) - if isinstance(data, dict): - return data - except Exception as exc: - LOGGER.warning("Failed to read cached dataset config: %s", exc) - return None - - def load_dataset(self, params: Dict): - try: - self._state["loading"] = True - he_path = params.get("he_path", "") - labels_path = params.get("labels_path", "") - adata_path = params.get("adata_path", "") - obsm_key = params.get("obsm_key") - tile_h = int(params.get("tile_h", 1500) or 1500) - tile_w = int(params.get("tile_w", 1500) or 1500) - stride_h_param = params.get("stride_h") - stride_w_param = params.get("stride_w") - stride_h = int(stride_h_param) if stride_h_param not in (None, "", "None") else None - stride_w = int(stride_w_param) if stride_w_param not in (None, "", "None") else None - - requested_config = { - "he_path": he_path, - "labels_path": labels_path, - "adata_path": adata_path, - "obsm_key": obsm_key, - "tile_h": tile_h, - "tile_w": tile_w, - "stride_h": stride_h, - "stride_w": stride_w, - } - - existing_config = self._state.get("dataset_config") - if existing_config and all(existing_config.get(k) == requested_config.get(k) for k in requested_config): - LOGGER.info("Dataset already loaded; reusing cached context.") - ctx = self._ensure_context() - tiles = self.tiles - payload = { - "status": "ok", - "tile_count": len(tiles), - "tiles": [tile.to_dict() for tile in tiles], - "obsm_key": ctx.obsm_key, - "available_obsm": ctx.available_obsm, - "obs_columns": ctx.obs_columns, - "gene_count": len(ctx.gene_names), - "genes_preview": ctx.gene_names[:512], - "dataset_id": self.dataset_id, - "shape": ctx.shape, - **existing_config, - } - return self._json_response(payload) - - ctx = B2CContext( - he_image_path=he_path, - labels_npz_path=labels_path, - adata_path=adata_path, - obsm_key=obsm_key, - ) - tiles = make_tiles(ctx, tile_h=tile_h, tile_w=tile_w, stride_h=stride_h, stride_w=stride_w) - - self.context = ctx - self.tiles = tiles - self.tile_cache.clear() - self.dataset_id = os.path.basename(adata_path) - - dataset_config = { - "he_path": he_path, - "labels_path": labels_path, - "adata_path": adata_path, - "obsm_key": ctx.obsm_key, - "tile_h": tile_h, - "tile_w": tile_w, - "stride_h": stride_h, - "stride_w": stride_w, - } - - payload = { - "status": "ok", - "tile_count": len(tiles), - "tiles": [tile.to_dict() for tile in tiles], - "obsm_key": ctx.obsm_key, - "available_obsm": ctx.available_obsm, - "obs_columns": ctx.obs_columns, - "gene_count": len(ctx.gene_names), - "genes_preview": ctx.gene_names[:512], - "dataset_id": self.dataset_id, - "shape": ctx.shape, - **dataset_config, - } - self._state["dataset_config"] = dataset_config - try: - with open(self.state_path, "w") as fh: - json.dump(dataset_config, fh) - except Exception as exc: - LOGGER.warning("Failed to persist dataset config: %s", exc) - LOGGER.info("Dataset loaded successfully: %d tiles", len(tiles)) - return self._json_response(payload) - except Exception as exc: - return self._error_from_exception(exc, "Failed to load dataset") - finally: - self._state["loading"] = False - - def describe_dataset(self, params: Dict): - try: - ctx = self._ensure_context() - payload = { - "dataset_id": self.dataset_id, - "shape": ctx.shape, - "tile_count": len(self.tiles), - "obsm_key": ctx.obsm_key, - "available_obsm": ctx.available_obsm, - "obs_columns": ctx.obs_columns, - "gene_count": len(ctx.gene_names), - } - return self._json_response(payload) - except Exception as exc: - return self._error_from_exception(exc, "Failed to describe dataset") - - def list_tiles(self, params: Dict): - try: - self._ensure_context() - return self._json_response({"tiles": [tile.to_dict() for tile in self.tiles]}) - except Exception as exc: - return self._error_from_exception(exc, "Failed to list tiles") - - def list_obs_columns(self, params: Dict): - try: - ctx = self._ensure_context() - cols = ctx.obs_columns - prefer = [] - fallback = [] - for col in cols: - series = ctx.obs_vector(col) - if series.dtype.kind in {"O", "U", "S"} or str(series.dtype).startswith("category"): - prefer.append(col) - else: - fallback.append(col) - ordered = prefer + [c for c in fallback if c not in prefer] - return self._json_response({"columns": ordered}) - except Exception as exc: - return self._error_from_exception(exc, "Failed to list observation columns") - - def describe_obs_column(self, params: Dict): - try: - ctx = self._ensure_context() - obs_col = params.get("obs_col") - if not obs_col: - raise ValueError("Provide 'obs_col'.") - meta = ctx.obs_metadata(obs_col) - payload = { - "obs_col": obs_col, - "categories": meta.get("categories", []), - "color_map": meta.get("color_map", {}), - "category_limit_hit": meta.get("category_limit_hit", False), - } - return self._json_response(payload) - except Exception as exc: - return self._error_from_exception(exc, "Failed to describe observation column") - - def list_genes(self, params: Dict): - try: - ctx = self._ensure_context() - limit = int(params.get("limit", 0) or 0) - genes = ctx.gene_names - if limit > 0: - genes = genes[:limit] - return self._json_response({"genes": genes}) - except Exception as exc: - return self._error_from_exception(exc, "Failed to list genes") - - # ------------------------------------------------------------------ Overlay core - - def _tile_entry( - self, - tile_id: int, - *, - b2c_mode: str, - max_bin_distance: float, - mpp: float, - bin_um: float, - volume_ratio: float, - pad_factor: int = 2, - ) -> Dict: - ctx = self._ensure_context() - if tile_id < 0 or tile_id >= len(self.tiles): - raise ValueError(f"Tile id {tile_id} out of range.") - tile = self.tiles[tile_id] - - cache_key = ( - self.dataset_id, - tile_id, - b2c_mode, - round(max_bin_distance, 3), - round(mpp, 4), - round(bin_um, 4), - round(volume_ratio, 4), - pad_factor, - ) - cached = self.tile_cache.get(cache_key) - if cached is not None: - return cached - - _, lab_raw = ctx.crop_dense(tile.r0, tile.r1, tile.c0, tile.c1) - he_crop, lab_exp, dist_px = _expand_labels_tile( - ctx, - tile, - mode=b2c_mode, - max_bin_distance=max_bin_distance, - mpp=mpp, - bin_um=bin_um, - volume_ratio=volume_ratio, - pad_factor=pad_factor, - ) - - centroid_idx, rows_abs, cols_abs, local_rc = _centroids_for_tile(ctx, tile) - labels_at_centroids = lab_exp[local_rc[:, 0], local_rc[:, 1]] if local_rc.size else np.empty((0,), dtype=np.int32) - - polygons_exp = _polygons_from_labels(lab_exp, tile) - polygons_raw = _polygons_from_labels(lab_raw, tile) - - outline_exp = _outline_paths(lab_exp, tile) - outline_raw = _outline_paths(lab_raw, tile) - - entry = { - "tile": tile, - "lab_exp": lab_exp, - "lab_raw": lab_raw, - "dist_px": dist_px, - "polygons_exp": polygons_exp, - "polygons_raw": polygons_raw, - "outline_exp": outline_exp, - "outline_raw": outline_raw, - "centroid_indices": centroid_idx, - "centroid_rows": rows_abs, - "centroid_cols": cols_abs, - "labels_at_centroids": labels_at_centroids, - } - self.tile_cache.set(cache_key, entry) - return entry - - def get_overlay(self, params: Dict): - try: - if self._state.get("loading"): - return self._error_response(409, "Dataset is still loading; try again in a moment.") - overlay_type = params.get("overlay_type", "gene") - tile_id = int(params.get("tile_id", 0)) - b2c_mode = params.get("b2c_mode", "fixed") - max_bin_distance = float(params.get("max_bin_distance", 2.0) or 2.0) - mpp = float(params.get("mpp", 0.3) or 0.3) - bin_um = float(params.get("bin_um", 2.0) or 2.0) - volume_ratio = float(params.get("volume_ratio", 4.0) or 4.0) - pad_factor = int(params.get("pad_factor", 2) or 2) - - entry = self._tile_entry( - tile_id, - b2c_mode=b2c_mode, - max_bin_distance=max_bin_distance, - mpp=mpp, - bin_um=bin_um, - volume_ratio=volume_ratio, - pad_factor=pad_factor, - ) - - if overlay_type == "gene": - payload = self._prepare_gene_overlay(entry, params) - elif overlay_type == "observation": - payload = self._prepare_obs_overlay(entry, params) - else: - raise ValueError(f"Unknown overlay_type '{overlay_type}'") - - payload["tile"] = entry["tile"].to_dict() - payload["dist_px"] = entry["dist_px"] - payload["b2c_mode"] = b2c_mode - payload["max_bin_distance"] = max_bin_distance - payload["mpp"] = mpp - payload["bin_um"] = bin_um - payload["volume_ratio"] = volume_ratio - payload["pad_factor"] = pad_factor - - return self._json_response(payload) - except Exception as exc: - return self._error_from_exception(exc, "Failed to build overlay") - - def _prepare_gene_overlay(self, entry: Dict, params: Dict) -> Dict: - ctx = self._ensure_context() - genes_value = params.get("genes") or params.get("gene") - if not genes_value: - raise ValueError("Provide 'gene' or 'genes' for gene overlay.") - if isinstance(genes_value, str): - genes = [g.strip() for g in genes_value.split(",") if g.strip()] - else: - genes = list(genes_value) - - if not genes: - raise ValueError("No gene names provided.") - - color_mode = params.get("color_mode", "gradient") - gene_color = params.get("gene_color", "#ff6b6b") or "#ff6b6b" - gradient_color = params.get("gradient_color") - render_mode = params.get("render_mode", "fill") - expr_quantile = params.get("expr_quantile") - expr_quantile = float(expr_quantile) if expr_quantile not in (None, "") else None - top_n = int(params.get("top_n", 0) or 0) - overlay_alpha = float(params.get("overlay_alpha", 0.5) or 0.5) - show_centroids = str(params.get("show_centroids", "false")).lower() in ("1", "true", "yes", "on") - highlight_color = params.get("highlight_color", "#39ff14") - highlight_width = float(params.get("highlight_width", 2.0) or 2.0) - cmap_name = params.get("cmap_name", "viridis") - vmin_param = params.get("vmin") - vmax_param = params.get("vmax") - vmin = float(vmin_param) if vmin_param not in (None, "") else None - vmax = float(vmax_param) if vmax_param not in (None, "") else None - all_expanded_outline = str(params.get("all_expanded_outline", "false")).lower() in ("1", "true", "yes", "on") - all_nuclei_outline = str(params.get("all_nuclei_outline", "false")).lower() in ("1", "true", "yes", "on") - - try: - solid_rgba = colors.to_rgba(gene_color) - except ValueError: - gene_color = "#ff6b6b" - solid_rgba = colors.to_rgba(gene_color) - - custom_cmap = None - if color_mode == "gradient" and gradient_color: - try: - target_rgba = colors.to_rgba(gradient_color) - except ValueError: - target_rgba = colors.to_rgba("#4285f4") - custom_cmap = colors.LinearSegmentedColormap.from_list( - "bin2cell_custom_gradient", - [(0.0, (1.0, 1.0, 1.0, 0.0)), (1.0, target_rgba)], - ) - else: - target_rgba = None - - lab_exp: np.ndarray = entry["lab_exp"] - polygons_exp: Dict[int, List[List[List[float]]]] = entry["polygons_exp"] - polygons_raw: Dict[int, List[List[List[float]]]] = entry["polygons_raw"] - polygons_raw: Dict[int, List[List[List[float]]]] = entry["polygons_raw"] - centroid_indices: np.ndarray = entry["centroid_indices"] - labels_at_centroids: np.ndarray = entry["labels_at_centroids"] - - overlays = [] - centroid_payload = [] - - for gene in genes: - values = ctx.gene_vector(gene) - tile_values = values[centroid_indices] if centroid_indices.size else np.empty((0,), dtype=float) - - selected_mask = np.ones_like(tile_values, dtype=bool) - threshold = None - if expr_quantile is not None and tile_values.size: - threshold = float(np.quantile(tile_values, expr_quantile)) - selected_mask &= tile_values > threshold - - selected_indices = centroid_indices[selected_mask] - selected_values = tile_values[selected_mask] - selected_labels = labels_at_centroids[selected_mask] - - label_expr: Dict[int, float] = {} - for lbl, val in zip(selected_labels, selected_values): - if lbl <= 0: - continue - if (lbl not in label_expr) or (val > label_expr[lbl]): - label_expr[int(lbl)] = float(val) - - if top_n and len(label_expr) > top_n: - top_labels = sorted(label_expr.keys(), key=lambda l: label_expr[l], reverse=True)[:top_n] - label_expr = {lbl: label_expr[lbl] for lbl in top_labels} - - if not label_expr: - overlays.append( - { - "gene": gene, - "features": [], - "legend": { - "type": "empty", - "gene": gene, - "message": "No labels passed filters in this tile.", - }, - } - ) - continue - - values_array = np.array(list(label_expr.values()), dtype=float) - vmin_local = float(values_array.min()) if vmin is None else vmin - vmax_local = float(values_array.max()) if vmax is None else vmax - if vmax_local == vmin_local: - vmax_local = vmin_local + 1e-6 - norm = colors.Normalize(vmin=vmin_local, vmax=vmax_local) - - feature_polygons = polygons_exp if all_expanded_outline else polygons_raw - features = [] - for lbl, expr_value in label_expr.items(): - polygons = feature_polygons.get(lbl) or polygons_raw.get(lbl) or polygons_exp.get(lbl) - if not polygons: - continue - if color_mode == "binary": - fill_color = _rgba_to_css(colors.to_rgba(highlight_color), overlay_alpha) - stroke_color = highlight_color - elif color_mode == "solid": - fill_color = _rgba_to_css(solid_rgba, alpha_override=overlay_alpha) - stroke_color = gene_color - else: - color_fraction = float(norm(expr_value)) - cmap_source = custom_cmap or cmap_name - fill_color = _colormap_sample(cmap_source, color_fraction, alpha=overlay_alpha) - stroke_color = _colormap_sample(cmap_source, color_fraction, alpha=1.0) - - feature = { - "label": int(lbl), - "polygons": polygons, - "fill": fill_color if render_mode == "fill" else None, - "stroke": stroke_color, - "stroke_width": highlight_width, - "value": float(expr_value), - } - features.append(feature) - - legend = { - "type": "binary" if color_mode == "binary" else ("solid" if color_mode == "solid" else "continuous"), - "gene": gene, - "color": gene_color if color_mode == "solid" else highlight_color, - "overlay_alpha": overlay_alpha, - } - if color_mode == "gradient": - cmap_source = custom_cmap or cmap_name - legend.update( - { - "min": vmin_local, - "max": vmax_local, - "cmap": cmap_name, - "gradient": _sample_gradient(cmap_source), - "gradient_color": gradient_color, - } - ) - - overlays.append( - { - "gene": gene, - "features": features, - "legend": legend, - "render_mode": render_mode, - "color_mode": color_mode, - "gene_color": gene_color, - "gradient_color": gradient_color, - } - ) - - if show_centroids and centroid_indices.size: - gene_centroids = [] - for lbl, idx_global, row, col, value in zip( - labels_at_centroids[selected_mask], - selected_indices, - entry["centroid_rows"][selected_mask], - entry["centroid_cols"][selected_mask], - selected_values, - ): - if lbl <= 0: - continue - gene_centroids.append( - { - "index": int(idx_global), - "label": int(lbl), - "x": float(col), - "y": float(row), - "value": float(value), - } - ) - centroid_payload.append({"gene": gene, "points": gene_centroids}) - - payload: Dict = { - "overlay_type": "gene", - "overlays": overlays, - "all_expanded_outline": all_expanded_outline, - "all_nuclei_outline": all_nuclei_outline, - "expanded_outline": entry["outline_exp"] if all_expanded_outline else [], - "nuclei_outline": entry["outline_raw"] if all_nuclei_outline else [], - } - if show_centroids: - payload["centroids"] = centroid_payload - return payload - - def _prepare_obs_overlay(self, entry: Dict, params: Dict) -> Dict: - ctx = self._ensure_context() - obs_col = params.get("obs_col") - if not obs_col: - raise ValueError("Provide 'obs_col' for observation overlay.") - - lab_exp: np.ndarray = entry["lab_exp"] - polygons_exp: Dict[int, List[List[List[float]]]] = entry["polygons_exp"] - polygons_raw: Dict[int, List[List[List[float]]]] = entry["polygons_raw"] - centroid_indices: np.ndarray = entry["centroid_indices"] - labels_at_centroids: np.ndarray = entry["labels_at_centroids"] - - category_filter = params.get("category") - render_mode = params.get("render_mode", "fill") - overlay_alpha = float(params.get("overlay_alpha", 0.5) or 0.5) - cmap_name = params.get("cmap_name", "tab20") - highlight_color = params.get("highlight_color", "#39ff14") - highlight_width = float(params.get("highlight_width", 2.0) or 2.0) - show_centroids = str(params.get("show_centroids", "false")).lower() in ("1", "true", "yes", "on") - all_expanded_outline = str(params.get("all_expanded_outline", "true")).lower() in ("1", "true", "yes", "on") - all_nuclei_outline = str(params.get("all_nuclei_outline", "false")).lower() in ("1", "true", "yes", "on") - legend_outside = str(params.get("legend_outside", "true")).lower() in ("1", "true", "yes", "on") - - obs_vals = ctx.obs_vector(obs_col) - tile_vals = obs_vals[centroid_indices] if centroid_indices.size else np.empty((0,), dtype=object) - - label_to_cat: Dict[int, str] = {} - counts: Dict[int, Counter] = defaultdict(Counter) - for lbl, val in zip(labels_at_centroids, tile_vals): - if lbl <= 0: - continue - key = "" if val is None or (isinstance(val, float) and math.isnan(val)) else str(val) - counts[int(lbl)][key] += 1 - for lbl, cnt in counts.items(): - if not cnt: - continue - label_to_cat[lbl] = cnt.most_common(1)[0][0] - - if not label_to_cat: - return { - "overlay_type": "observation", - "obs_col": obs_col, - "features": [], - "legend": {"type": "empty", "message": "No categories found in tile."}, - } - - meta = ctx.obs_metadata(obs_col) - ordered_categories = list(meta.get("categories") or []) - predefined_colors = dict(meta.get("color_map") or {}) - - if category_filter: - allowed_categories = {category_filter} - else: - allowed_categories = set(label_to_cat.values()) - - if ordered_categories: - categories_sorted = [cat for cat in ordered_categories if cat in allowed_categories] - remainder = [cat for cat in allowed_categories if cat not in categories_sorted] - categories_sorted.extend(sorted(remainder)) - else: - categories_sorted = sorted(allowed_categories) - if not categories_sorted and allowed_categories: - categories_sorted = sorted(allowed_categories) - - cmap_obj = cm.get_cmap(cmap_name, max(1, len(categories_sorted) or 1)) - denom = max(1, len(categories_sorted) - 1) - category_styles: Dict[str, Dict[str, Optional[str]]] = {} - for idx, cat in enumerate(categories_sorted): - base_color = predefined_colors.get(cat) - if base_color: - fill_css = _color_to_css(base_color, alpha_override=overlay_alpha) if render_mode == "fill" else None - stroke_css = _color_to_css(base_color, alpha_override=1.0) - legend_color = base_color - else: - fraction = idx / denom if denom else 0.0 - fill_css = _colormap_sample(cmap_obj, fraction, alpha=overlay_alpha) if render_mode == "fill" else None - stroke_css = _colormap_sample(cmap_obj, fraction, alpha=1.0) - legend_color = stroke_css - category_styles[cat] = { - "fill": fill_css, - "stroke": stroke_css, - "legend": legend_color, - } - - feature_polygons = polygons_exp if all_expanded_outline else polygons_raw - features = [] - for lbl, cat in label_to_cat.items(): - if category_filter and cat != category_filter: - continue - polygons = feature_polygons.get(lbl) or polygons_raw.get(lbl) or polygons_exp.get(lbl) - if not polygons: - continue - style = category_styles.get(cat) - if not style: - continue - fill_color = style["fill"] if render_mode == "fill" else None - stroke_color = style["stroke"] or highlight_color - - features.append( - { - "label": int(lbl), - "category": cat, - "polygons": polygons, - "fill": fill_color, - "stroke": stroke_color, - "stroke_width": highlight_width, - } - ) - - legend = { - "type": "categorical", - "obs_col": obs_col, - "items": [ - {"label": cat, "color": (category_styles.get(cat) or {}).get("legend", highlight_color)} - for cat in categories_sorted - ], - "legend_outside": legend_outside, - } - - payload: Dict = { - "overlay_type": "observation", - "obs_col": obs_col, - "category_filter": category_filter, - "features": features, - "legend": legend, - "render_mode": render_mode, - "all_expanded_outline": all_expanded_outline, - "all_nuclei_outline": all_nuclei_outline, - "expanded_outline": entry["outline_exp"] if all_expanded_outline else [], - "nuclei_outline": entry["outline_raw"] if all_nuclei_outline else [], - } - - if show_centroids and centroid_indices.size: - points = [] - for lbl, idx_global, row, col, obs_val in zip( - labels_at_centroids, - centroid_indices, - entry["centroid_rows"], - entry["centroid_cols"], - tile_vals, - ): - if lbl <= 0: - continue - label_str = label_to_cat.get(int(lbl)) - points.append( - { - "index": int(idx_global), - "label": int(lbl), - "category": label_str, - "x": float(col), - "y": float(row), - } - ) - payload["centroids"] = [{"points": points}] - - return payload - - def export_overlay(self, params: Dict): - try: - response = self.get_overlay(params) - if response.status_code and response.status_code >= 400: - return response - payload = json.loads(response.get_data(as_text=True)) - - tile_id = payload["tile"]["id"] - overlay_type = payload["overlay_type"] - name = params.get("name") - if not name: - suffix = overlay_type - if overlay_type == "gene" and payload.get("overlays"): - suffix = "_".join(ov["gene"] for ov in payload["overlays"]) - if overlay_type == "observation": - suffix = payload.get("obs_col", "observation") - name = f"tile{tile_id}_{suffix}" - - safe_name = "".join(ch if ch.isalnum() or ch in ("_", "-", ".") else "_" for ch in name) - out_path = os.path.join(self.out_dir, f"{safe_name}.geojson") - - features = [] - if overlay_type == "gene": - for gene_overlay in payload.get("overlays", []): - gene_name = gene_overlay.get("gene") - for feature in gene_overlay.get("features", []): - geom = _feature_polygons_to_geojson(feature["polygons"]) - props = { - "tile_id": tile_id, - "overlay_type": "gene", - "gene": gene_name, - "value": feature.get("value"), - } - features.append({"type": "Feature", "geometry": geom, "properties": props}) - else: - obs_col = payload.get("obs_col") - for feature in payload.get("features", []): - geom = _feature_polygons_to_geojson(feature["polygons"]) - props = { - "tile_id": tile_id, - "overlay_type": "observation", - "obs_col": obs_col, - "category": feature.get("category"), - } - features.append({"type": "Feature", "geometry": geom, "properties": props}) - - geojson = {"type": "FeatureCollection", "features": features} - with open(out_path, "w") as fh: - json.dump(geojson, fh) - - return self._json_response({"status": "ok", "path": out_path}) - except Exception as exc: - return self._error_from_exception(exc, "Failed to export overlay") - - def save_preset(self, params: Dict): - try: - name = params.get("name") - if not name: - raise ValueError("Preset requires 'name'.") - config = params.get("config") - if not isinstance(config, dict): - raise ValueError("Preset requires 'config' JSON object.") - self.presets[name] = config - self._save_presets() - return self._json_response({"status": "ok", "name": name}) - except Exception as exc: - return self._error_from_exception(exc, "Failed to save preset") - - def list_presets(self, params: Dict): - try: - return self._json_response({"presets": self.presets}) - except Exception as exc: - return self._error_from_exception(exc, "Failed to list presets") - - def delete_preset(self, params: Dict): - try: - name = params.get("name") - if not name or name not in self.presets: - raise KeyError("Preset not found.") - del self.presets[name] - self._save_presets() - return self._json_response({"status": "ok", "deleted": name}) - except Exception as exc: - return self._error_from_exception(exc, "Failed to delete preset") - - -def _feature_polygons_to_geojson(polygons: List[List[List[float]]]) -> Dict: - if not polygons: - return {"type": "GeometryCollection", "geometries": []} - if len(polygons) == 1: - return {"type": "Polygon", "coordinates": [polygons[0]]} - return {"type": "MultiPolygon", "coordinates": [[poly] for poly in polygons]} - - -def _json_default(obj): - if isinstance(obj, Tile): - return obj.to_dict() - if isinstance(obj, np.generic): - return obj.item() - if isinstance(obj, np.ndarray): - return obj.tolist() - raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") -class FileDialogHelper(QObject): - def __init__(self) -> None: - super().__init__() - self._result: Tuple[str, str] = ("", "") - - @Slot(str, str, str) - def _open_dialog(self, caption: str, start_dir: str, filters: str) -> None: - self._result = QFileDialog.getOpenFileName(None, caption, start_dir, filters) - - def get_open_file_name(self, caption: str, start_dir: str, filters: str) -> Tuple[str, str]: - self._result = ("", "") - QMetaObject.invokeMethod( - self, - "_open_dialog", - Qt.BlockingQueuedConnection, - Q_ARG(str, caption), - Q_ARG(str, start_dir), - Q_ARG(str, filters), - ) - return self._result diff --git a/plugins/Bin2CellExplorer.yml b/plugins/Bin2CellExplorer.yml deleted file mode 100644 index 1abd7f7..0000000 --- a/plugins/Bin2CellExplorer.yml +++ /dev/null @@ -1,4 +0,0 @@ -name: Bin2Cell Explorer -version: 0.1.0 -author: Joel Joseph -description: "Interactive Bin2Cell overlays for TissUUmaps with gene and observation controls." diff --git a/plugins/Bin2CellExplorer/Bin2CellExplorer.log b/plugins/Bin2CellExplorer/Bin2CellExplorer.log deleted file mode 100644 index f5251bd..0000000 --- a/plugins/Bin2CellExplorer/Bin2CellExplorer.log +++ /dev/null @@ -1,469 +0,0 @@ -2025-10-30 09:10:22,627 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:10:25,824 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:10:26,442 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:13:16,302 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:13:19,414 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:13:19,879 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:25:47,318 [ERROR] Load a dataset first. -Traceback (most recent call last): - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 695, in get_overlay - entry = self._tile_entry( - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 627, in _tile_entry - ctx = self._ensure_context() - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 423, in _ensure_context - raise RuntimeError("Load a dataset first.") -RuntimeError: Load a dataset first. -2025-10-30 09:41:59,619 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:42:02,978 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps -2025-10-30 09:42:02,979 [ERROR] Failed to load dataset: [Errno 21] Is a directory: '/Users/jjoseph/.tissuumaps' -Traceback (most recent call last): - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 520, in load_dataset - ctx = B2CContext( - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 98, in __init__ - self.lab_sp = load_npz(labels_npz_path) - File "/Users/jjoseph/miniconda3/envs/tissuumaps_env/lib/python3.9/site-packages/scipy/sparse/_matrix_io.py", line 134, in load_npz - with np.load(file, **PICKLE_KWARGS) as loaded: - File "/Users/jjoseph/miniconda3/envs/tissuumaps_env/lib/python3.9/site-packages/numpy/lib/npyio.py", line 427, in load - fid = stack.enter_context(open(os_fspath(file), "rb")) -IsADirectoryError: [Errno 21] Is a directory: '/Users/jjoseph/.tissuumaps' -2025-10-30 09:43:36,939 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:43:40,766 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:43:41,426 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:43:43,524 [INFO] Dataset loaded successfully: 224 tiles -2025-10-30 09:43:57,687 [ERROR] Load a dataset first. -Traceback (most recent call last): - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 695, in get_overlay - entry = self._tile_entry( - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 627, in _tile_entry - ctx = self._ensure_context() - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 423, in _ensure_context - raise RuntimeError("Load a dataset first.") -RuntimeError: Load a dataset first. -2025-10-30 09:44:37,912 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:44:37,912 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:44:37,912 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:44:37,912 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:44:37,912 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:44:37,912 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:44:37,912 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:44:37,912 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:44:37,912 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:44:46,704 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:44:46,704 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:44:46,704 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:44:46,712 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:44:46,712 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:44:46,712 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:44:46,732 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:44:46,732 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:44:46,732 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:44:48,094 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:44:48,094 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:44:48,094 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:44:48,094 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:44:48,094 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:44:48,094 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:44:48,094 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:44:48,094 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:44:48,094 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:45:06,180 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:45:10,950 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:45:12,299 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:45:14,546 [INFO] Dataset loaded successfully: 224 tiles -2025-10-30 09:46:36,831 [ERROR] Load a dataset first. -Traceback (most recent call last): - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 701, in get_overlay - entry = self._tile_entry( - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 631, in _tile_entry - ctx = self._ensure_context() - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 424, in _ensure_context - raise RuntimeError("Load a dataset first.") -RuntimeError: Load a dataset first. -2025-10-30 09:47:00,537 [ERROR] Load a dataset first. -Traceback (most recent call last): - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 701, in get_overlay - entry = self._tile_entry( - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 631, in _tile_entry - ctx = self._ensure_context() - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 424, in _ensure_context - raise RuntimeError("Load a dataset first.") -RuntimeError: Load a dataset first. -2025-10-30 09:49:37,031 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:49:40,455 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:49:41,030 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:49:43,186 [INFO] Dataset loaded successfully: 224 tiles -2025-10-30 09:50:24,867 [INFO] Rehydrating dataset from cached config. -2025-10-30 09:50:24,867 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:50:28,907 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:50:29,618 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:50:41,471 [INFO] Rehydrating dataset from cached config. -2025-10-30 09:50:41,471 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:50:45,066 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:50:45,773 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:51:45,464 [INFO] Rehydrating dataset from cached config. -2025-10-30 09:51:45,465 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:51:50,247 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:51:50,912 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:53:04,034 [INFO] Rehydrating dataset from cached config. -2025-10-30 09:53:04,035 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:53:07,710 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:53:08,340 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:55:43,314 [INFO] Rehydrating dataset from cached config. -2025-10-30 09:55:43,315 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:55:47,235 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:55:47,823 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:57:10,799 [INFO] Rehydrating dataset from cached config. -2025-10-30 09:57:10,800 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:57:15,035 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:57:15,665 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:57:42,239 [ERROR] Failed to build overlay: _rgba_to_css() takes 1 positional argument but 2 were given -Traceback (most recent call last): - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 739, in get_overlay - payload = self._prepare_obs_overlay(entry, params) - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 975, in _prepare_obs_overlay - cat_to_color = {cat: _rgba_to_css(cmap(i / max(1, len(categories_sorted) - 1)), overlay_alpha) for i, cat in enumerate(categories_sorted)} - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 975, in - cat_to_color = {cat: _rgba_to_css(cmap(i / max(1, len(categories_sorted) - 1)), overlay_alpha) for i, cat in enumerate(categories_sorted)} -TypeError: _rgba_to_css() takes 1 positional argument but 2 were given -2025-10-30 09:58:19,294 [INFO] Rehydrating dataset from cached config. -2025-10-30 09:58:19,294 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 09:58:23,120 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 09:58:23,797 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 09:59:57,802 [INFO] Rehydrating dataset from cached config. -2025-10-30 09:59:57,803 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 10:00:01,578 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 10:00:02,359 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 10:10:33,917 [INFO] Dataset already loaded; reusing cached context. -2025-10-30 10:10:33,917 [INFO] Rehydrating dataset from cached config. -2025-10-30 10:10:33,918 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 10:10:37,339 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 10:10:37,982 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 10:11:28,256 [INFO] Rehydrating dataset from cached config. -2025-10-30 10:11:28,256 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 10:11:31,662 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 10:11:32,408 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 10:12:55,498 [INFO] Rehydrating dataset from cached config. -2025-10-30 10:12:55,499 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 10:12:58,992 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 10:12:59,639 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 10:14:17,785 [INFO] Rehydrating dataset from cached config. -2025-10-30 10:14:17,787 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 10:14:21,855 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 10:14:22,482 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 10:15:21,698 [INFO] Rehydrating dataset from cached config. -2025-10-30 10:15:21,699 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 10:15:25,071 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 10:15:25,654 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 10:16:34,825 [INFO] Rehydrating dataset from cached config. -2025-10-30 10:16:34,826 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 10:16:38,605 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 10:16:39,151 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 10:17:52,307 [INFO] Rehydrating dataset from cached config. -2025-10-30 10:17:52,307 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 10:17:56,537 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 10:17:57,273 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 10:19:13,552 [INFO] Rehydrating dataset from cached config. -2025-10-30 10:19:13,553 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 10:19:17,363 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 10:19:17,990 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 11:14:34,929 [INFO] Dataset already loaded; reusing cached context. -2025-10-30 11:14:34,929 [INFO] Rehydrating dataset from cached config. -2025-10-30 11:14:34,929 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 11:14:38,790 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 11:14:39,567 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 11:20:10,130 [INFO] Dataset already loaded; reusing cached context. -2025-10-30 11:20:10,130 [INFO] Rehydrating dataset from cached config. -2025-10-30 11:20:10,131 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 11:20:13,561 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 11:20:14,218 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 11:23:01,731 [INFO] Dataset already loaded; reusing cached context. -2025-10-30 11:23:01,731 [INFO] Rehydrating dataset from cached config. -2025-10-30 11:23:01,731 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 11:23:04,941 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 11:23:05,503 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 11:25:21,888 [INFO] Dataset already loaded; reusing cached context. -2025-10-30 11:25:21,888 [INFO] Rehydrating dataset from cached config. -2025-10-30 11:25:21,888 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 11:25:25,760 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 11:25:26,490 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 11:32:24,352 [INFO] Dataset already loaded; reusing cached context. -2025-10-30 11:32:24,352 [INFO] Rehydrating dataset from cached config. -2025-10-30 11:32:24,352 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 11:32:27,752 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 11:32:28,333 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 11:32:51,515 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 11:32:54,844 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 11:32:55,517 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 11:32:57,767 [INFO] Dataset loaded successfully: 25 tiles -2025-10-30 11:33:16,315 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 11:33:19,761 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 11:33:20,521 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 11:33:22,942 [INFO] Dataset loaded successfully: 36 tiles -2025-10-30 11:40:07,623 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 11:40:11,580 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 11:40:12,292 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 11:40:15,114 [INFO] Dataset loaded successfully: 132 tiles -2025-10-30 11:40:47,548 [INFO] Rehydrating dataset from cached config. -2025-10-30 11:40:47,548 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 11:40:50,911 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 11:40:51,519 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 11:44:00,167 [INFO] Rehydrating dataset from cached config. -2025-10-30 11:44:00,167 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 11:44:03,535 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 11:44:04,292 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 11:45:39,501 [INFO] Rehydrating dataset from cached config. -2025-10-30 11:45:39,501 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 11:45:42,942 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 11:45:43,541 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 11:45:56,460 [INFO] Rehydrating dataset from cached config. -2025-10-30 11:45:56,461 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 11:46:07,101 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 11:46:11,740 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 12:08:32,655 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 12:08:35,782 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 12:08:36,444 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 12:08:38,567 [INFO] Dataset loaded successfully: 25 tiles -2025-10-30 12:09:21,923 [INFO] Rehydrating dataset from cached config. -2025-10-30 12:09:21,924 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 12:09:25,370 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 12:09:26,004 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 12:26:40,470 [INFO] Rehydrating dataset from cached config. -2025-10-30 12:26:40,471 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 12:26:41,353 [INFO] Rehydrating dataset from cached config. -2025-10-30 12:26:41,354 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 12:26:41,456 [INFO] Rehydrating dataset from cached config. -2025-10-30 12:26:41,457 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-30 12:27:09,833 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 12:27:10,271 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 12:27:10,392 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-30 12:27:15,969 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 12:27:15,969 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-30 12:27:15,983 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 05:04:51,510 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 05:04:54,978 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 05:04:55,701 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 05:04:57,981 [INFO] Dataset loaded successfully: 132 tiles -2025-10-31 05:05:19,949 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 05:05:23,362 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 05:05:23,929 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 05:05:26,217 [INFO] Dataset loaded successfully: 56 tiles -2025-10-31 05:05:34,844 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 05:05:38,080 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 05:05:38,644 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 05:05:41,139 [INFO] Dataset loaded successfully: 56 tiles -2025-10-31 05:07:33,869 [INFO] Rehydrating dataset from cached config. -2025-10-31 05:07:33,870 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 05:07:37,372 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 05:07:37,969 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 05:17:53,119 [INFO] Rehydrating dataset from cached config. -2025-10-31 05:17:53,120 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 05:17:56,686 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 05:17:57,585 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 05:28:33,249 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 05:28:36,742 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 05:28:37,300 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 05:28:39,546 [INFO] Dataset loaded successfully: 224 tiles -2025-10-31 05:29:42,242 [INFO] Rehydrating dataset from cached config. -2025-10-31 05:29:42,242 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 05:29:48,007 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 05:29:48,653 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 05:38:57,173 [INFO] Rehydrating dataset from cached config. -2025-10-31 05:38:57,174 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 05:39:00,601 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 05:39:01,239 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 05:39:47,074 [INFO] Rehydrating dataset from cached config. -2025-10-31 05:39:47,074 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 05:39:50,578 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 05:39:51,210 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 05:40:03,795 [INFO] Rehydrating dataset from cached config. -2025-10-31 05:40:03,795 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 08:16:02,379 [ERROR] Working outside of application context. - -This typically means that you attempted to use functionality that needed -the current application. To solve this, set up an application context -with app.app_context(). See the documentation for more information. -Traceback (most recent call last): - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 534, in list_data_files - return self._json_response(payload) - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 380, in _json_response - return make_response(json.dumps(payload, default=_json_default), 200, {"Content-Type": "application/json"}) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/flask/helpers.py", line 192, in make_response - return current_app.make_response(args) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/werkzeug/local.py", line 318, in __get__ - obj = instance._get_current_object() - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/werkzeug/local.py", line 519, in _get_current_object - raise RuntimeError(unbound_message) from None -RuntimeError: Working outside of application context. - -This typically means that you attempted to use functionality that needed -the current application. To solve this, set up an application context -with app.app_context(). See the documentation for more information. -2025-10-31 08:36:46,900 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps -2025-10-31 08:36:46,901 [ERROR] Failed to load dataset: ImageIO does not generally support reading folders. Limited support may be available via specific plugins. Specify the plugin explicitly using the `plugin` kwarg, e.g. `plugin='DICOM'` -Traceback (most recent call last): - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 596, in load_dataset - ctx = B2CContext( - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 98, in __init__ - self.he = io.imread(he_image_path) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/skimage/io/_io.py", line 60, in imread - img = call_plugin('imread', fname, plugin=plugin, **plugin_args) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/skimage/io/manage_plugins.py", line 217, in call_plugin - return func(*args, **kwargs) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/skimage/io/_plugins/imageio_plugin.py", line 11, in imread - out = np.asarray(imageio_imread(*args, **kwargs)) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/imageio/v3.py", line 53, in imread - with imopen(uri, "r", **plugin_kwargs) as img_file: - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/imageio/core/imopen.py", line 223, in imopen - raise err_type(err_msg) -OSError: ImageIO does not generally support reading folders. Limited support may be available via specific plugins. Specify the plugin explicitly using the `plugin` kwarg, e.g. `plugin='DICOM'` -2025-10-31 08:37:16,713 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps -2025-10-31 08:37:16,713 [ERROR] Failed to load dataset: ImageIO does not generally support reading folders. Limited support may be available via specific plugins. Specify the plugin explicitly using the `plugin` kwarg, e.g. `plugin='DICOM'` -Traceback (most recent call last): - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 596, in load_dataset - ctx = B2CContext( - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 98, in __init__ - self.he = io.imread(he_image_path) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/skimage/io/_io.py", line 60, in imread - img = call_plugin('imread', fname, plugin=plugin, **plugin_args) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/skimage/io/manage_plugins.py", line 217, in call_plugin - return func(*args, **kwargs) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/skimage/io/_plugins/imageio_plugin.py", line 11, in imread - out = np.asarray(imageio_imread(*args, **kwargs)) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/imageio/v3.py", line 53, in imread - with imopen(uri, "r", **plugin_kwargs) as img_file: - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/imageio/core/imopen.py", line 223, in imopen - raise err_type(err_msg) -OSError: ImageIO does not generally support reading folders. Limited support may be available via specific plugins. Specify the plugin explicitly using the `plugin` kwarg, e.g. `plugin='DICOM'` -2025-10-31 08:39:24,948 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 08:39:25,195 [ERROR] requires the 'imagecodecs' package -Traceback (most recent call last): - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 598, in load_dataset - ctx = B2CContext( - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 98, in __init__ - self.he = io.imread(he_image_path) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/skimage/io/_io.py", line 60, in imread - img = call_plugin('imread', fname, plugin=plugin, **plugin_args) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/skimage/io/manage_plugins.py", line 217, in call_plugin - return func(*args, **kwargs) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/skimage/io/_plugins/tifffile_plugin.py", line 74, in imread - return tifffile_imread(fname, **kwargs) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/tifffile/tifffile.py", line 1273, in imread - return tif.asarray( - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/tifffile/tifffile.py", line 4532, in asarray - result = page0.asarray( - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/tifffile/tifffile.py", line 9471, in asarray - for _ in self.segments( - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/tifffile/tifffile.py", line 9281, in segments - yield from executor.map(decode, segments) - File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/concurrent/futures/_base.py", line 608, in result_iterator - yield fs.pop().result() - File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/concurrent/futures/_base.py", line 438, in result - return self.__get_result() - File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/concurrent/futures/_base.py", line 390, in __get_result - raise self._exception - File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/concurrent/futures/thread.py", line 52, in run - result = self.fn(*self.args, **self.kwargs) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/tifffile/tifffile.py", line 9254, in decode - return func(decode(*args, **decodeargs)) # type: ignore - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/tifffile/tifffile.py", line 8678, in decode_raise_compression - raise ValueError(f'{exc}') -ValueError: requires the 'imagecodecs' package -2025-10-31 08:40:31,260 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 08:40:31,514 [ERROR] requires the 'imagecodecs' package -Traceback (most recent call last): - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 598, in load_dataset - ctx = B2CContext( - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 98, in __init__ - self.he = io.imread(he_image_path) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/skimage/io/_io.py", line 60, in imread - img = call_plugin('imread', fname, plugin=plugin, **plugin_args) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/skimage/io/manage_plugins.py", line 217, in call_plugin - return func(*args, **kwargs) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/skimage/io/_plugins/tifffile_plugin.py", line 74, in imread - return tifffile_imread(fname, **kwargs) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/tifffile/tifffile.py", line 1273, in imread - return tif.asarray( - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/tifffile/tifffile.py", line 4532, in asarray - result = page0.asarray( - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/tifffile/tifffile.py", line 9471, in asarray - for _ in self.segments( - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/tifffile/tifffile.py", line 9281, in segments - yield from executor.map(decode, segments) - File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/concurrent/futures/_base.py", line 608, in result_iterator - yield fs.pop().result() - File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/concurrent/futures/_base.py", line 438, in result - return self.__get_result() - File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/concurrent/futures/_base.py", line 390, in __get_result - raise self._exception - File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/concurrent/futures/thread.py", line 52, in run - result = self.fn(*self.args, **self.kwargs) - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/tifffile/tifffile.py", line 9254, in decode - return func(decode(*args, **decodeargs)) # type: ignore - File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/tifffile/tifffile.py", line 8678, in decode_raise_compression - raise ValueError(f'{exc}') -ValueError: requires the 'imagecodecs' package -2025-10-31 08:41:42,242 [INFO] Dataset already loaded; reusing cached context. -2025-10-31 08:41:42,242 [INFO] Rehydrating dataset from cached config. -2025-10-31 08:41:42,242 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 08:41:45,879 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 08:41:46,254 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 08:42:02,714 [INFO] Rehydrating dataset from cached config. -2025-10-31 08:42:02,714 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 08:42:06,060 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 08:42:06,537 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 08:43:46,004 [INFO] Rehydrating dataset from cached config. -2025-10-31 08:43:46,004 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 08:43:49,758 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 08:43:50,150 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 08:45:37,271 [INFO] Rehydrating dataset from cached config. -2025-10-31 08:45:37,271 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 08:45:42,069 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 08:45:42,546 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 08:46:07,362 [ERROR] Failed to build overlay: _rgba_to_css() takes 1 positional argument but 2 were given -Traceback (most recent call last): - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 795, in get_overlay - payload = self._prepare_obs_overlay(entry, params) - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 1031, in _prepare_obs_overlay - cat_to_color = {cat: _rgba_to_css(cmap(i / max(1, len(categories_sorted) - 1)), overlay_alpha) for i, cat in enumerate(categories_sorted)} - File "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.py", line 1031, in - cat_to_color = {cat: _rgba_to_css(cmap(i / max(1, len(categories_sorted) - 1)), overlay_alpha) for i, cat in enumerate(categories_sorted)} -TypeError: _rgba_to_css() takes 1 positional argument but 2 were given -2025-10-31 08:51:59,945 [INFO] Dataset already loaded; reusing cached context. -2025-10-31 08:51:59,946 [INFO] Rehydrating dataset from cached config. -2025-10-31 08:51:59,946 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 08:52:03,186 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 08:52:03,589 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 08:52:03,751 [INFO] Dataset already loaded; reusing cached context. -2025-10-31 08:52:03,751 [INFO] Rehydrating dataset from cached config. -2025-10-31 08:52:03,751 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 08:52:08,004 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 08:52:08,349 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 08:52:24,155 [INFO] Rehydrating dataset from cached config. -2025-10-31 08:52:24,156 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 08:52:27,359 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 08:52:27,760 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 08:54:05,657 [INFO] Rehydrating dataset from cached config. -2025-10-31 08:54:05,657 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 08:54:09,475 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 08:54:09,815 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 08:55:29,205 [INFO] Rehydrating dataset from cached config. -2025-10-31 08:55:29,206 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 08:55:33,112 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 08:55:33,607 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 08:58:06,930 [INFO] Dataset already loaded; reusing cached context. -2025-10-31 08:58:06,930 [INFO] Rehydrating dataset from cached config. -2025-10-31 08:58:06,930 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 08:58:10,068 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 08:58:10,455 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 09:02:31,121 [INFO] Rehydrating dataset from cached config. -2025-10-31 09:02:31,121 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 09:02:34,612 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 09:02:35,286 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 09:02:44,101 [INFO] Rehydrating dataset from cached config. -2025-10-31 09:02:44,101 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 09:02:47,740 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 09:02:48,150 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad -2025-10-31 09:04:28,688 [INFO] Rehydrating dataset from cached config. -2025-10-31 09:04:28,689 [INFO] Loading H&E image: /Users/jjoseph/.tissuumaps/plugins/he.tiff -2025-10-31 09:04:32,266 [INFO] Loading sparse labels: /Users/jjoseph/.tissuumaps/plugins/he.npz -2025-10-31 09:04:32,750 [INFO] Loading AnnData: /Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad diff --git a/plugins/Bin2CellExplorer/dataset_state.json b/plugins/Bin2CellExplorer/dataset_state.json deleted file mode 100644 index 33c3d13..0000000 --- a/plugins/Bin2CellExplorer/dataset_state.json +++ /dev/null @@ -1 +0,0 @@ -{"he_path": "/Users/jjoseph/.tissuumaps/plugins/he.tiff", "labels_path": "/Users/jjoseph/.tissuumaps/plugins/he.npz", "adata_path": "/Users/jjoseph/.tissuumaps/plugins/P2_CRC_annotated.h5ad", "obsm_key": "spatial_cropped_150_buffer", "tile_h": 1500, "tile_w": 1500, "stride_h": null, "stride_w": null} \ No newline at end of file diff --git a/plugins/CellExplorer.js b/plugins/CellExplorer.js new file mode 100644 index 0000000..7417b37 --- /dev/null +++ b/plugins/CellExplorer.js @@ -0,0 +1,1895 @@ +var Bin2CellExplorer; +Bin2CellExplorer = { + name: "Cell Explorer", + parameters: { + _sec_data: { label: "Dataset", type: "section", collapsed: false }, + he_path: { label: "H&E image (.tif/.tiff)", type: "text", default: "" }, + he_path_browse: { label: "Browse H&E image…", type: "button" }, + labels_path: { label: "Label matrix (.npz)", type: "text", default: "" }, + labels_path_browse: { label: "Browse label matrix…", type: "button" }, + adata_path: { label: "AnnData (.h5ad)", type: "text", default: "" }, + adata_path_browse: { label: "Browse AnnData…", type: "button" }, + obsm_key: { label: "obsm coord key", type: "select", default: "spatial_cropped_150_buffer" }, + tile_h: { label: "Tile height (px)", type: "number", default: 1500 }, + tile_w: { label: "Tile width (px)", type: "number", default: 1500 }, + stride_h: { label: "Stride height (px, optional)", type: "number", default: "" }, + stride_w: { label: "Stride width (px, optional)", type: "number", default: "" }, + single_tile_mode: { label: "Single-tile focus (disable caching)", type: "checkbox", default: false }, + stage_to_local: { label: "Stage dataset to node-local scratch", type: "checkbox", default: false }, + stage_root: { label: "Scratch override (optional)", type: "text", default: "" }, + tile_cache_size: { label: "Tile cache size", type: "number", default: 32 }, + warm_cache: { label: "Warm cache after load", type: "checkbox", default: true }, + warm_cache_tiles: { label: "Tiles to prewarm (0 = all)", type: "number", default: 32 }, + tile_workers: { label: "Tile worker threads", type: "number", default: 4 }, + load_dataset_btn: { label: "Load dataset", type: "button" }, + + _sec_overlay: { label: "Overlay", type: "section", collapsed: false }, + tile_id: { label: "Tile ID", type: "select", default: "" }, + overlay_type: { label: "Overlay type", type: "select", default: "gene", options: ["gene", "observation"] }, + genes: { label: "Gene(s) (comma separated)", type: "text", default: "COL1A1" }, + obs_col: { label: "Observation column", type: "select", default: "" }, + category: { label: "Category filter (optional)", type: "select", default: "" }, + render_mode: { label: "Render mode", type: "select", default: "fill", options: ["fill", "outline"] }, + color_mode: { label: "Gene color mode", type: "select", default: "gradient", options: ["gradient", "solid"] }, + gradient_color: { label: "Gradient color (optional)", type: "text", default: "#4285f4", attributes: { type: "color" } }, + gene_color: { label: "Gene color (solid mode)", type: "text", default: "#ff6b6b", attributes: { type: "color" } }, + expr_quantile: { label: "Expr. quantile (0-1)", type: "number", default: "" }, + top_n: { label: "Top N labels", type: "number", default: "" }, + b2c_mode: { label: "Expand mode", type: "select", default: "none", options: ["none", "fixed", "volume_ratio"] }, + max_bin_distance: { label: "Max bin distance", type: "number", default: 2.0 }, + mpp: { label: "Microns per pixel", type: "number", default: 0.3 }, + bin_um: { label: "Bin size (µm)", type: "number", default: 2.0 }, + volume_ratio: { label: "Volume ratio", type: "number", default: 4.0 }, + render_mode: { label: "Render mode", type: "select", default: "fill", options: ["fill", "outline"] }, + overlay_alpha: { label: "Overlay opacity (0-1)", type: "number", default: 0.7 }, + highlight_color: { label: "Outline color", type: "text", default: "#39ff14", attributes: { type: "color" } }, + highlight_width: { label: "Outline width", type: "number", default: 2.0 }, + all_expanded_outline: { label: "Show expanded outlines (all cells)", type: "checkbox", default: false }, + expanded_outlines_selected: { label: "Show expanded outlines (selected only)", type: "checkbox", default: false }, + all_nuclei_outline: { label: "Show nuclei outlines (all cells)", type: "checkbox", default: false }, + nuclei_outlines_selected: { label: "Show nuclei outlines (selected only)", type: "checkbox", default: true }, + nuclei_outline_color: { label: "Nuclei outline color", type: "text", default: "#000000", attributes: { type: "color" } }, + nuclei_outline_alpha: { label: "Nuclei outline opacity (0-1)", type: "number", default: 0.6 }, + apply_overlay_btn: { label: "Update overlay", type: "button" }, + + + + + _sec_export: { label: "Export & Presets", type: "section", collapsed: true }, + export_name: { label: "Export name (optional)", type: "text", default: "" }, + export_geojson_btn: { label: "Export overlay to GeoJSON", type: "button" }, + save_preset_name: { label: "Preset name", type: "text", default: "" }, + save_preset_btn: { label: "Save preset", type: "button" }, + preset_select: { label: "Presets", type: "select", default: "" }, + load_preset_btn: { label: "Apply preset", type: "button" }, + delete_preset_btn: { label: "Delete preset", type: "button" } + } +}; + +Bin2CellExplorer.state = { + datasetLoaded: false, + tiles: [], + obsColumns: [], + obsMetadata: {}, + obsMetadataRequests: {}, + obsCategorySelections: {}, + pendingCategoryValue: "", + selectedObsCol: null, + genesPreview: [], + overlays: null, + layers: {}, + presets: {}, + slideShape: null, + tileOverviewCanvas: null, + tileOverviewScale: null, + selectedTileId: null, + filePickerModalId: null, + filePickerField: null, + lastOverlayPayload: null, + renderPayload: null, + geometryCache: {}, + hiddenGenes: {}, + hiddenCategories: {}, + canvasOverlay: null, + canvasCtx: null, + viewerHooksInstalled: false, + overlayRefreshToken: null, + cacheWarmStatus: null, + singleTileMode: false, + // NEW: Track last request parameters for smart updates + lastRequest: { + geometry: null, // { tile_id, b2c_mode, mpp, bin_um, volume_ratio, ... } + color: null, // { overlay_type, gene, obs_col, category, color_mode, ... } + visual: null // { overlay_alpha, render_mode, all_expanded_outline, ... } + } +}; + +Bin2CellExplorer._prefixes = ["Bin2CellExplorer_", "CellExplorer_"]; +Bin2CellExplorer._findElement = function (name) { + for (let i = 0; i < Bin2CellExplorer._prefixes.length; i += 1) { + const el = document.getElementById(Bin2CellExplorer._prefixes[i] + name); + if (el) return el; + } + return null; +}; +Bin2CellExplorer._domId = function (name) { + for (let i = 0; i < Bin2CellExplorer._prefixes.length; i += 1) { + const id = Bin2CellExplorer._prefixes[i] + name; + if (document.getElementById(id)) return id; + } + // Prefer the filename-based prefix if nothing is mounted yet. + return "CellExplorer_" + name; +}; + +Bin2CellExplorer.get = function (name) { + let element = Bin2CellExplorer._findElement(name); + if (!element) { + return ""; + } + if (element.matches && element.matches("input, select, textarea")) { + // element is already the actual form control + } else { + const nested = element.querySelector("input, select, textarea"); + if (nested) { + element = nested; + } + } + if (element.tagName === "SELECT") { + return element.value || ""; + } + if (element.type === "checkbox") { + return element.checked; + } + if (element.type === "number" || element.type === "range") { + const raw = element.value; + return raw === "" ? "" : Number(raw); + } + const value = element.value !== undefined ? element.value : element.textContent; + return value == null ? "" : value; +}; + +Bin2CellExplorer.set = function (name, value) { + let element = Bin2CellExplorer._findElement(name); + if (!element) return; + if (!(element.matches && element.matches("input, select, textarea"))) { + const nested = element.querySelector("input, select, textarea"); + if (nested) { + element = nested; + } + } + if (!element) return; + if (element.tagName === "SELECT") { + element.value = value == null ? "" : String(value); + element.dispatchEvent(new Event("change", { bubbles: true })); + return; + } + if (element.type === "checkbox") { + element.checked = !!value; + element.dispatchEvent(new Event("change", { bubbles: true })); + return; + } + element.value = value == null ? "" : value; + element.dispatchEvent(new Event("input", { bubbles: true })); +}; + +Bin2CellExplorer.init = function (container) { + Bin2CellExplorer.state.container = container; + container.classList.add("bin2cell-explorer-panel"); + + const legend = document.createElement("div"); + legend.id = "Bin2CellExplorer_legend"; + legend.className = "bin2cell-legend mt-2"; + container.appendChild(legend); + + const status = document.createElement("div"); + status.id = "Bin2CellExplorer_status"; + status.className = "bin2cell-status mt-2 small text-muted"; + container.appendChild(status); + + const overviewBlock = document.createElement("div"); + overviewBlock.id = "Bin2CellExplorer_tile_overview_block"; + overviewBlock.className = "tile-overview mt-3"; + const overviewTitle = document.createElement("div"); + overviewTitle.className = "small fw-bold mb-1"; + overviewTitle.textContent = "Tile overview"; + const overviewCanvas = document.createElement("canvas"); + overviewCanvas.id = "Bin2CellExplorer_tile_overview"; + overviewCanvas.width = 320; + overviewCanvas.height = 320; + overviewCanvas.style.border = "1px solid rgba(0,0,0,0.1)"; + overviewCanvas.style.borderRadius = "12px"; + overviewCanvas.style.background = "linear-gradient(135deg, #fdfbfb, #ebedee)"; + overviewCanvas.style.boxShadow = "0 6px 18px rgba(15,23,42,0.15)"; + overviewCanvas.style.cursor = "pointer"; + const overviewHint = document.createElement("div"); + overviewHint.className = "small text-muted mt-1"; + overviewHint.id = "Bin2CellExplorer_tile_overview_hint"; + overviewHint.textContent = "Load dataset to display tiles"; + overviewBlock.appendChild(overviewTitle); + overviewBlock.appendChild(overviewCanvas); + overviewBlock.appendChild(overviewHint); + container.appendChild(overviewBlock); + + Bin2CellExplorer.state.tileOverviewCanvas = overviewCanvas; + overviewCanvas.addEventListener("click", Bin2CellExplorer.onTileOverviewClick); + + interfaceUtils.alert("Bin2Cell Explorer loaded"); + + // Listen for file selection messages from web file browser iframe + window.addEventListener("message", function (event) { + if (event.data && event.data.type === "b2c_file_selected") { + Bin2CellExplorer.onWebFilePicked(event.data.field, event.data.path); + } + }); + + Bin2CellExplorer.toggleOverlayInputs("gene"); + Bin2CellExplorer.updateDatasetMode(); + + // Set initial visibility for expansion and render params + Bin2CellExplorer.toggleExpansionParams("none"); // Default: hide all expansion params + Bin2CellExplorer.toggleRenderParams("fill"); // Default mode +}; + +/** + * Set up dynamic show/hide of parameters based on dropdown values + */ +Bin2CellExplorer.setupConditionalVisibility = function () { + // Wait a bit for TissUUmaps to create the UI elements + setTimeout(function () { + // Helper to get the row element for a parameter + const getParamRow = function (paramName) { + // TissUUmaps typically creates inputs with IDs like "Bin2CellExplorer_paramName" + const prefixes = Bin2CellExplorer._prefixes; + for (let i = 0; i < prefixes.length; i++) { + const elem = document.getElementById(prefixes[i] + paramName); + if (elem && elem.closest) { + return elem.closest('.form-group, .row, tr'); + } + } + return null; + }; + + // Update visibility based on b2c_mode + const updateExpansionVisibility = function () { + const mode = Bin2CellExplorer.get("b2c_mode") || "fixed"; + + const maxBinRow = getParamRow("max_bin_distance"); + const mppRow = getParamRow("mpp"); + const binUmRow = getParamRow("bin_um"); + const volumeRow = getParamRow("volume_ratio"); + + // Show/hide based on mode + if (mode === "none") { + // Hide all expansion params + if (maxBinRow) maxBinRow.style.display = "none"; + if (mppRow) mppRow.style.display = "none"; + if (binUmRow) binUmRow.style.display = "none"; + if (volumeRow) volumeRow.style.display = "none"; + } else if (mode === "fixed") { + // Show fixed-mode params, hide volume params + if (maxBinRow) maxBinRow.style.display = ""; + if (mppRow) mppRow.style.display = ""; + if (binUmRow) binUmRow.style.display = ""; + if (volumeRow) volumeRow.style.display = "none"; + } else if (mode === "volume_ratio") { + // Hide max_bin_distance, show others + if (maxBinRow) maxBinRow.style.display = "none"; + if (mppRow) mppRow.style.display = ""; + if (binUmRow) binUmRow.style.display = ""; + if (volumeRow) volumeRow.style.display = ""; + } + }; + + // Update visibility based on render_mode + const updateRenderVisibility = function () { + const mode = Bin2CellExplorer.get("render_mode") || "fill"; + + const colorRow = getParamRow("highlight_color"); + const widthRow = getParamRow("highlight_width"); + + // Only show highlight options when render_mode is "outline" + if (mode === "outline") { + if (colorRow) colorRow.style.display = ""; + if (widthRow) widthRow.style.display = ""; + } else { + if (colorRow) colorRow.style.display = "none"; + if (widthRow) widthRow.style.display = "none"; + } + }; + + // Initial update + updateExpansionVisibility(); + updateRenderVisibility(); + + // Add change listeners + const b2cSelect = document.getElementById("Bin2CellExplorer_b2c_mode"); + if (b2cSelect) { + b2cSelect.addEventListener("change", updateExpansionVisibility); + } + + const renderSelect = document.getElementById("Bin2CellExplorer_render_mode"); + if (renderSelect) { + renderSelect.addEventListener("change", updateRenderVisibility); + } + + console.log("[CellExplorer] ✨ Conditional visibility initialized"); + }, 500); // Wait 500ms for UI to be created +}; + +Bin2CellExplorer.inputTrigger = function (inputName) { + switch (inputName) { + case "load_dataset_btn": + Bin2CellExplorer.loadDataset(); + break; + case "he_path_browse": + Bin2CellExplorer.browseForFile("he_path"); + break; + case "labels_path_browse": + Bin2CellExplorer.browseForFile("labels_path"); + break; + case "adata_path_browse": + Bin2CellExplorer.browseForFile("adata_path"); + break; + case "single_tile_mode": + Bin2CellExplorer.updateDatasetMode(); + break; + case "overlay_type": + Bin2CellExplorer.toggleOverlayInputs(Bin2CellExplorer.get("overlay_type")); + break; + case "color_mode": + Bin2CellExplorer.updateGeneColorControls(); + break; + case "b2c_mode": + Bin2CellExplorer.toggleExpansionParams(Bin2CellExplorer.get("b2c_mode")); + break; + case "render_mode": + Bin2CellExplorer.toggleRenderParams(Bin2CellExplorer.get("render_mode")); + break; + case "apply_overlay_btn": + Bin2CellExplorer.requestOverlay(); + break; + case "export_geojson_btn": + Bin2CellExplorer.exportOverlay(); + break; + case "save_preset_btn": + Bin2CellExplorer.savePreset(); + break; + case "load_preset_btn": + Bin2CellExplorer.applySelectedPreset(); + break; + case "delete_preset_btn": + Bin2CellExplorer.deleteSelectedPreset(); + break; + case "preset_select": + Bin2CellExplorer.populatePresetPreview(); + break; + default: + break; + } +}; + +Bin2CellExplorer.toggleOverlayInputs = function (mode) { + const geneIds = ["genes", "color_mode", "gradient_color", "gene_color", "expr_quantile", "top_n"]; + const obsIds = ["obs_col", "category"]; + geneIds.forEach((id) => Bin2CellExplorer.toggleParam(id, mode === "gene")); + obsIds.forEach((id) => Bin2CellExplorer.toggleParam(id, mode === "observation")); + if (mode === "gene") { + Bin2CellExplorer.updateGeneColorControls(); + } else if (mode === "observation" && Bin2CellExplorer.state.datasetLoaded) { + Bin2CellExplorer.populateCategorySelect(Bin2CellExplorer.get("obs_col") || ""); + } +}; + +Bin2CellExplorer.toggleExpansionParams = function (mode) { + // Show/hide expansion parameters based on b2c_mode + const fixedOnlyIds = ["max_bin_distance"]; + const volumeOnlyIds = ["volume_ratio"]; + const commonIds = ["mpp", "bin_um"]; + + if (mode === "none") { + // Hide all expansion parameters + fixedOnlyIds.forEach((id) => Bin2CellExplorer.toggleParam(id, false)); + volumeOnlyIds.forEach((id) => Bin2CellExplorer.toggleParam(id, false)); + commonIds.forEach((id) => Bin2CellExplorer.toggleParam(id, false)); + } else { + // Show/hide based on mode + fixedOnlyIds.forEach((id) => Bin2CellExplorer.toggleParam(id, mode === "fixed")); + volumeOnlyIds.forEach((id) => Bin2CellExplorer.toggleParam(id, mode === "volume_ratio")); + commonIds.forEach((id) => Bin2CellExplorer.toggleParam(id, true)); + } +}; + +Bin2CellExplorer.toggleRenderParams = function (mode) { + // Show/hide highlight parameters based on render_mode + const outlineOnlyIds = ["highlight_color", "highlight_width"]; + outlineOnlyIds.forEach((id) => Bin2CellExplorer.toggleParam(id, mode === "outline")); +}; + +Bin2CellExplorer.toggleParam = function (name, visible) { + const element = Bin2CellExplorer._findElement(name); + if (!element) return; + let wrapper = element.closest(".form-group, .row, .input-group"); + if (!wrapper) { + wrapper = element.parentElement; + } + if (wrapper) wrapper.style.display = visible ? "" : "none"; +}; + +Bin2CellExplorer.ensureObject = function (payload) { + if (!payload) return {}; + if (typeof payload === "string") { + try { + return JSON.parse(payload); + } catch (err) { + console.warn("Bin2CellExplorer: failed to parse payload", err); + return {}; + } + } + return payload; +}; + +Bin2CellExplorer.setStatus = function (msg) { + const status = document.getElementById("Bin2CellExplorer_status") || document.getElementById("CellExplorer_status"); + if (status) status.textContent = msg || ""; +}; + +Bin2CellExplorer.updateGeneColorControls = function () { + const mode = Bin2CellExplorer.get("color_mode"); + Bin2CellExplorer.toggleParam("gradient_color", mode === "gradient"); + Bin2CellExplorer.toggleParam("gene_color", mode === "solid"); +}; + +Bin2CellExplorer.browseForFile = function (field) { + const current = Bin2CellExplorer.get(field) || ""; + const payload = { + field: field, + current_path: current, + }; + Bin2CellExplorer.api( + "pick_file", + payload, + function (resp) { + const data = Bin2CellExplorer.ensureObject(resp); + if (data && data.status === "web") { + Bin2CellExplorer.openWebFileBrowser(field, data); + return; + } + if (!data || data.status === "cancelled") { + Bin2CellExplorer.setStatus("File selection cancelled."); + return; + } + if (data.status !== "ok" || !data.path) { + Bin2CellExplorer.setStatus("File selection failed."); + return; + } + // Set value robustly + Bin2CellExplorer.set(field, data.path); + const el = Bin2CellExplorer._findElement(field); + if (el) { + el.value = String(data.path); + el.setAttribute("value", String(data.path)); + } + console.log("DEBUG: Browse set " + field + " to:", data.path, "(post-set read:", Bin2CellExplorer.get(field), ")"); + Bin2CellExplorer.setStatus("Selected " + data.path); + }, + Bin2CellExplorer.handleError + ); +}; + +Bin2CellExplorer.openWebFileBrowser = function (field, data) { + const modalUID = "Bin2CellExplorer_filepicker"; + Bin2CellExplorer.state.filePickerField = field; + Bin2CellExplorer.state.filePickerModalId = modalUID; + + // Use API call instead of direct iframe URL + console.log("DEBUG: Opening web file browser for field:", field); + Bin2CellExplorer.api( + "filetree", + { + field: field, + start_rel: data.start_rel || "", + }, + function (response) { + console.log("DEBUG: Got filetree response:", response); + const data = Bin2CellExplorer.ensureObject(response); + const iframe = document.createElement("iframe"); + iframe.srcdoc = data.html; // Use srcdoc to set HTML content directly + iframe.width = "100%"; + iframe.height = "360px"; + iframe.style.border = "0"; + + const content = document.createElement("div"); + content.appendChild(iframe); + + const closeBtn = HTMLElementUtils.createButton({ + extraAttributes: { class: "btn btn-primary mx-2" }, + }); + closeBtn.innerText = "Cancel"; + closeBtn.addEventListener("click", function () { + Bin2CellExplorer.closeWebFileBrowser(); + }); + const buttons = document.createElement("div"); + buttons.appendChild(closeBtn); + + interfaceUtils.generateModal("Select file", content, buttons, modalUID); + setTimeout(function () { + $(`#${modalUID}_modal`).on("hidden.bs.modal", function () { + Bin2CellExplorer.closeWebFileBrowser(true); + }); + }, 0); + }, + Bin2CellExplorer.handleError + ); +}; + +Bin2CellExplorer.closeWebFileBrowser = function (skipHide) { + const modalUID = Bin2CellExplorer.state.filePickerModalId; + if (!skipHide && modalUID) { + $(`#${modalUID}_modal`).modal("hide"); + } + Bin2CellExplorer.state.filePickerModalId = null; + Bin2CellExplorer.state.filePickerField = null; +}; + +Bin2CellExplorer.onWebFilePicked = function (field, absolutePath) { + if (!absolutePath || field !== Bin2CellExplorer.state.filePickerField) { + return; + } + // Set value robustly + Bin2CellExplorer.set(field, absolutePath); + const el = Bin2CellExplorer._findElement(field); + if (el) { + el.value = String(absolutePath); + el.setAttribute("value", String(absolutePath)); + } + console.log("DEBUG: Web browse set " + field + " to:", absolutePath, "(post-set read:", Bin2CellExplorer.get(field), ")"); + Bin2CellExplorer.closeWebFileBrowser(); +}; + +Bin2CellExplorer.loadDataset = function () { + const he_path = Bin2CellExplorer.get("he_path"); + const labels_path = Bin2CellExplorer.get("labels_path"); + const adata_path = Bin2CellExplorer.get("adata_path"); + + console.log("DEBUG: UI values - he_path:", he_path, "labels_path:", labels_path, "adata_path:", adata_path); + + // Front-end validation: require all three paths before calling backend + if (!he_path || !labels_path || !adata_path) { + const missing = []; + if (!he_path) missing.push("H&E image"); + if (!labels_path) missing.push("labels matrix"); + if (!adata_path) missing.push("AnnData"); + const msg = "Please provide: " + missing.join(", ") + ". Use Browse or paste full paths."; + console.error("Bin2CellExplorer:", msg); + Bin2CellExplorer.setStatus(msg); + interfaceUtils.alert(msg); + return; + } + + Bin2CellExplorer.setStatus("Loading dataset…"); + const payload = { + he_path: he_path, + labels_path: labels_path, + adata_path: adata_path, + obsm_key: Bin2CellExplorer.get("obsm_key"), + tile_h: Bin2CellExplorer.get("tile_h"), + tile_w: Bin2CellExplorer.get("tile_w"), + stride_h: Bin2CellExplorer.get("stride_h"), + stride_w: Bin2CellExplorer.get("stride_w"), + single_tile_mode: Bin2CellExplorer.isChecked("single_tile_mode"), + stage_to_local: Bin2CellExplorer.isChecked("stage_to_local"), + stage_root: Bin2CellExplorer.get("stage_root"), + tile_cache_size: Bin2CellExplorer.get("tile_cache_size"), + warm_cache: Bin2CellExplorer.isChecked("warm_cache"), + warm_cache_tiles: Bin2CellExplorer.get("warm_cache_tiles"), + warm_b2c_mode: Bin2CellExplorer.get("b2c_mode"), + warm_max_bin_distance: Bin2CellExplorer.get("max_bin_distance"), + warm_mpp: Bin2CellExplorer.get("mpp"), + warm_bin_um: Bin2CellExplorer.get("bin_um"), + warm_volume_ratio: Bin2CellExplorer.get("volume_ratio"), + tile_workers: Bin2CellExplorer.get("tile_workers") + }; + Bin2CellExplorer.api( + "load_dataset", + payload, + function (resp) { + Bin2CellExplorer.onDatasetLoaded(Bin2CellExplorer.ensureObject(resp)); + }, + Bin2CellExplorer.handleError + ); +}; + +Bin2CellExplorer.onDatasetLoaded = function (data) { + Bin2CellExplorer.state.datasetLoaded = true; + Bin2CellExplorer.state.tiles = data.tiles || []; + Bin2CellExplorer.state.obsColumns = data.obs_columns || []; + Bin2CellExplorer.state.obsMetadata = {}; + Bin2CellExplorer.state.obsMetadataRequests = {}; + Bin2CellExplorer.state.obsCategorySelections = {}; + Bin2CellExplorer.state.pendingCategoryValue = ""; + Bin2CellExplorer.state.selectedObsCol = null; + Bin2CellExplorer.state.genesPreview = data.genes_preview || []; + Bin2CellExplorer.state.datasetId = data.dataset_id; + Bin2CellExplorer.state.hePath = data.he_path; + Bin2CellExplorer.state.slideShape = data.shape; + Bin2CellExplorer.state.geometryCache = {}; + Bin2CellExplorer.state.hiddenGenes = {}; + Bin2CellExplorer.state.hiddenCategories = {}; + Bin2CellExplorer.state.renderPayload = null; + + // Update the input field with the normalized path (relative for web server, absolute for standalone) + // This prevents TissUUmaps from trying to add the layer with the wrong path format + if (data.he_path) { + interfaceUtils.setValueForElement(Bin2CellExplorer._domId("he_path"), "value", data.he_path); + } + + if (typeof data.tile_cache_size !== "undefined") { + interfaceUtils.setValueForElement(Bin2CellExplorer._domId("tile_cache_size"), "value", data.tile_cache_size); + } + if (typeof data.stage_to_local !== "undefined") { + Bin2CellExplorer.setCheckbox("stage_to_local", !!data.stage_to_local); + } + if (typeof data.stage_root !== "undefined" && data.stage_root !== null) { + interfaceUtils.setValueForElement(Bin2CellExplorer._domId("stage_root"), "value", data.stage_root || ""); + } + if (typeof data.warm_cache !== "undefined") { + Bin2CellExplorer.setCheckbox("warm_cache", !!data.warm_cache); + } + if (typeof data.warm_cache_tiles !== "undefined") { + interfaceUtils.setValueForElement(Bin2CellExplorer._domId("warm_cache_tiles"), "value", data.warm_cache_tiles); + } + if (typeof data.tile_workers !== "undefined") { + interfaceUtils.setValueForElement(Bin2CellExplorer._domId("tile_workers"), "value", data.tile_workers); + } + if (typeof data.single_tile_mode !== "undefined") { + Bin2CellExplorer.setCheckbox("single_tile_mode", !!data.single_tile_mode); + } + Bin2CellExplorer.updateDatasetMode(); + + Bin2CellExplorer.populateSelect("tile_id", Bin2CellExplorer.state.tiles.map(function (tile) { return tile.id; })); + Bin2CellExplorer.populateSelect("obs_col", Bin2CellExplorer.state.obsColumns); + Bin2CellExplorer.populateSelect("category", [""]); + Bin2CellExplorer.populateSelect("obsm_key", data.available_obsm || [], data.obsm_key); + + if (Bin2CellExplorer.state.tiles.length) { + Bin2CellExplorer.set("tile_id", String(Bin2CellExplorer.state.tiles[0].id)); + Bin2CellExplorer.state.selectedTileId = Bin2CellExplorer.state.tiles[0].id; + } + if (Bin2CellExplorer.state.obsColumns.length) { + const firstObs = Bin2CellExplorer.state.obsColumns[0]; + Bin2CellExplorer.set("obs_col", firstObs); + Bin2CellExplorer.onObsColumnChange(firstObs); + } else { + Bin2CellExplorer.populateCategorySelect(""); + } + + Bin2CellExplorer.requestPresets(); + + Bin2CellExplorer.renderTileOverview(); + Bin2CellExplorer.attachTileSelectListener(); + Bin2CellExplorer.attachObsSelectListener(); + Bin2CellExplorer.attachCategorySelectListener(); + + Bin2CellExplorer.state.cacheWarmStatus = data.cache_warm_status || null; + if (Bin2CellExplorer.state.cacheWarmStatus && Bin2CellExplorer.state.cacheWarmStatus.active) { + Bin2CellExplorer.setStatus( + "Dataset loaded (" + + Bin2CellExplorer.state.tiles.length + + " tiles). Cache warming " + + Bin2CellExplorer.state.cacheWarmStatus.progress + + "/" + + (Bin2CellExplorer.state.cacheWarmStatus.total || "?") + + "…" + ); + } else { + Bin2CellExplorer.setStatus("Dataset loaded (" + Bin2CellExplorer.state.tiles.length + " tiles)"); + } +}; + +Bin2CellExplorer.populateSelect = function (name, options, selected) { + const domId = Bin2CellExplorer._domId(name); + interfaceUtils.cleanSelect(domId); + options.forEach(function (opt) { + interfaceUtils.addSingleElementToSelect(domId, String(opt)); + }); + if (selected !== undefined && selected !== null && selected !== "") { + interfaceUtils.setValueForElement(domId, "value", String(selected)); + } + if (name === "category") { + const selectEl = document.getElementById(domId); + if (selectEl && selectEl.options.length) { + for (let i = 0; i < selectEl.options.length; i += 1) { + if (selectEl.options[i].value === "") { + selectEl.options[i].text = selectEl.options[i].text || "All categories"; + if (!selectEl.options[i].text.trim()) { + selectEl.options[i].text = "All categories"; + } + break; + } + } + } + } +}; + +Bin2CellExplorer.attachTileSelectListener = function () { + const select = Bin2CellExplorer._findElement("tile_id"); + if (!select || select.__bin2cell_tile_listener) return; + select.addEventListener("change", function () { + const value = parseInt(select.value, 10); + if (!isNaN(value)) { + Bin2CellExplorer.state.selectedTileId = value; + Bin2CellExplorer.renderTileOverview(); + Bin2CellExplorer.panToTile(value, true); + } + }); + select.__bin2cell_tile_listener = true; +}; + +Bin2CellExplorer.attachObsSelectListener = function () { + const select = Bin2CellExplorer._findElement("obs_col"); + if (!select || select.__bin2cell_obs_listener) return; + select.addEventListener("change", function () { + Bin2CellExplorer.onObsColumnChange(select.value || ""); + }); + select.__bin2cell_obs_listener = true; +}; + +Bin2CellExplorer.attachCategorySelectListener = function () { + const select = Bin2CellExplorer._findElement("category"); + if (!select || select.__bin2cell_category_listener) return; + select.addEventListener("change", function () { + const col = Bin2CellExplorer.get("obs_col"); + if (col) { + Bin2CellExplorer.state.obsCategorySelections[col] = select.value || ""; + } + }); + select.__bin2cell_category_listener = true; +}; + +Bin2CellExplorer.onObsColumnChange = function (column, options) { + if (!Bin2CellExplorer.state.datasetLoaded) return; + const value = column || ""; + Bin2CellExplorer.state.selectedObsCol = value || null; + const opts = options || {}; + if (!opts.keepCategory) { + const cached = value ? Bin2CellExplorer.state.obsCategorySelections[value] : ""; + Bin2CellExplorer.state.pendingCategoryValue = cached || ""; + interfaceUtils.setValueForElement(Bin2CellExplorer._domId("category"), "value", ""); + } + Bin2CellExplorer.ensureObsMetadata(value); +}; + +Bin2CellExplorer.ensureObsMetadata = function (column) { + const col = column || ""; + Bin2CellExplorer.populateCategorySelect(col); + if (!col) return; + if (Bin2CellExplorer.state.obsMetadata[col]) { + Bin2CellExplorer.populateCategorySelect(col); + return; + } + if (Bin2CellExplorer.state.obsMetadataRequests[col]) { + return; + } + Bin2CellExplorer.state.obsMetadataRequests[col] = true; + Bin2CellExplorer.api( + "describe_obs_column", + { obs_col: col }, + function (resp) { + delete Bin2CellExplorer.state.obsMetadataRequests[col]; + const data = Bin2CellExplorer.ensureObject(resp) || {}; + Bin2CellExplorer.state.obsMetadata[col] = data; + Bin2CellExplorer.populateCategorySelect(col); + }, + function (jqXHR, textStatus, errorThrown) { + delete Bin2CellExplorer.state.obsMetadataRequests[col]; + Bin2CellExplorer.handleError(jqXHR, textStatus, errorThrown); + } + ); +}; + +Bin2CellExplorer.populateCategorySelect = function (column) { + const metadata = column ? Bin2CellExplorer.state.obsMetadata[column] : null; + const categories = (metadata && Array.isArray(metadata.categories)) ? metadata.categories.slice() : []; + const options = [""].concat(categories); + let desired = Bin2CellExplorer.state.pendingCategoryValue; + if (!desired) { + desired = Bin2CellExplorer.get("category") || ""; + } + if (options.indexOf(desired) === -1) { + desired = ""; + } + Bin2CellExplorer.populateSelect("category", options, desired); + if (desired && Bin2CellExplorer.state.pendingCategoryValue === desired) { + Bin2CellExplorer.state.pendingCategoryValue = ""; + } + if (metadata && Array.isArray(metadata.categories) && column) { + if (desired) { + Bin2CellExplorer.state.obsCategorySelections[column] = desired; + } else { + delete Bin2CellExplorer.state.obsCategorySelections[column]; + } + } +}; + +Bin2CellExplorer.requestOverlay = function () { + if (!Bin2CellExplorer.state.datasetLoaded) { + interfaceUtils.alert("Load a dataset first."); + return; + } + + const overlayType = Bin2CellExplorer.get("overlay_type") || "gene"; + const payload = Bin2CellExplorer.collectOverlayParams(); + payload.overlay_type = overlayType; + const tileId = Number(payload.tile_id); + + // SMART UPDATE: Detect what changed + const changeType = Bin2CellExplorer.detectChangeType(payload); + + if (changeType === 'NONE') { + console.log('[CellExplorer] ⏭️ No changes detected, skipping update'); + return; + } + + if (changeType === 'VISUAL') { + console.log('[CellExplorer] ⚡ Visual-only change, updating instantly!'); + const startTime = performance.now(); + Bin2CellExplorer.updateVisualOnly(payload); + const elapsed = performance.now() - startTime; + Bin2CellExplorer.setStatus(`Overlay updated (${elapsed.toFixed(1)}ms)`); + return; + } + + // For COLOR or GEOMETRY changes, make backend request + console.log(`[CellExplorer] 🔄 ${changeType} change, requesting from backend...`); + payload.include_geometry = Bin2CellExplorer.shouldRequestGeometry(tileId, payload); + + Bin2CellExplorer.setStatus("Computing overlay…"); + Bin2CellExplorer.api( + "get_overlay", + payload, + function (resp) { + const data = Bin2CellExplorer.ensureObject(resp); + Bin2CellExplorer.renderOverlay(data); + Bin2CellExplorer.updateLastRequest(payload); // Track successful request + Bin2CellExplorer.setStatus("Overlay ready (" + data.overlay_type + ")"); + }, + Bin2CellExplorer.handleError + ); +}; + +/** + * Update visual parameters only (instant, no backend request). + * This is called when ONLY visual parameters changed. + */ +Bin2CellExplorer.updateVisualOnly = function (params) { + const payload = Bin2CellExplorer.state.lastOverlayPayload; + + if (!payload) { + console.warn('[CellExplorer] ⚠️ No cached overlay data, making full backend request'); + Bin2CellExplorer.state.lastRequest = { geometry: null, color: null, visual: null }; + return Bin2CellExplorer.requestOverlay(); + } + + // Update visual parameters in cached payload + payload.overlay_alpha = parseFloat(params.overlay_alpha) || 0.7; + payload.render_mode = params.render_mode || "fill"; + payload.highlight_color = params.highlight_color || "#39ff14"; + payload.highlight_width = parseFloat(params.highlight_width) || 2.0; + payload.all_expanded_outline = params.all_expanded_outline; + payload.expanded_outlines_selected = params.expanded_outlines_selected; + payload.all_nuclei_outline = params.all_nuclei_outline; + payload.nuclei_outlines_selected = params.nuclei_outlines_selected; + payload.nuclei_outline_color = params.nuclei_outline_color || "#000000"; + payload.nuclei_outline_alpha = parseFloat(params.nuclei_outline_alpha) || 0.6; + + // Re-render canvas with updated parameters (fast!) + Bin2CellExplorer.drawOverlayToCanvas(payload); + Bin2CellExplorer.state.renderPayload = payload; + + // Update tracked visual params + Bin2CellExplorer.updateLastRequest(params); + + console.log('[CellExplorer] ✨ Visual update complete'); +}; + +Bin2CellExplorer.renderTileOverview = function () { + const canvas = Bin2CellExplorer.state.tileOverviewCanvas; + const hint = document.getElementById("Bin2CellExplorer_tile_overview_hint"); + if (!canvas) return; + const ctx = canvas.getContext("2d"); + const tiles = Bin2CellExplorer.state.tiles || []; + const shape = Bin2CellExplorer.state.slideShape; + if (!tiles.length || !shape) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + if (hint) hint.textContent = "Load dataset to display tiles"; + Bin2CellExplorer.state.tileOverviewScale = null; + return; + } + + const slideHeight = shape[0]; + const slideWidth = shape[1]; + const margin = 16; + const maxWidth = 320; + const maxHeight = 320; + const scale = Math.min((maxWidth - margin * 2) / slideWidth, (maxHeight - margin * 2) / slideHeight); + const canvasWidth = Math.ceil(slideWidth * scale + margin * 2); + const canvasHeight = Math.ceil(slideHeight * scale + margin * 2); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + ctx.clearRect(0, 0, canvas.width, canvas.height); + const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height); + gradient.addColorStop(0, "#fdfbfb"); + gradient.addColorStop(1, "#ebedee"); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const selectedId = Bin2CellExplorer.state.selectedTileId; + ctx.lineWidth = 1; + ctx.strokeStyle = "rgba(60,60,60,0.5)"; + ctx.font = "10px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + tiles.forEach(function (tile) { + const x = margin + tile.c0 * scale; + const y = margin + tile.r0 * scale; + const w = (tile.c1 - tile.c0) * scale; + const h = (tile.r1 - tile.r0) * scale; + if (tile.id === selectedId) { + ctx.fillStyle = "rgba(255, 193, 7, 0.45)"; + ctx.fillRect(x, y, w, h); + } + ctx.strokeStyle = "rgba(60,60,60,0.6)"; + ctx.strokeRect(x, y, w, h); + if (w > 10 && h > 10) { + ctx.fillStyle = "#202124"; + ctx.fillText(String(tile.id), x + w / 2, y + h / 2); + } + }); + + if (hint) { + hint.textContent = "Click a tile to select and center"; + } + Bin2CellExplorer.state.tileOverviewScale = { scale: scale, margin: margin }; +}; + +Bin2CellExplorer.onTileOverviewClick = function (evt) { + const canvas = Bin2CellExplorer.state.tileOverviewCanvas; + const scaleInfo = Bin2CellExplorer.state.tileOverviewScale; + if (!canvas || !scaleInfo || !Bin2CellExplorer.state.tiles.length) return; + const rect = canvas.getBoundingClientRect(); + const x = (evt.clientX - rect.left); + const y = (evt.clientY - rect.top); + const slideX = (x - scaleInfo.margin) / scaleInfo.scale; + const slideY = (y - scaleInfo.margin) / scaleInfo.scale; + if (slideX < 0 || slideY < 0) return; + const tile = Bin2CellExplorer.state.tiles.find(function (t) { + return slideX >= t.c0 && slideX < t.c1 && slideY >= t.r0 && slideY < t.r1; + }); + if (!tile) return; + Bin2CellExplorer.state.selectedTileId = tile.id; + Bin2CellExplorer.set("tile_id", String(tile.id)); + Bin2CellExplorer.renderTileOverview(); + Bin2CellExplorer.panToTile(tile.id, true); +}; + +Bin2CellExplorer.panToTile = function (tileId, animate) { + const tile = Bin2CellExplorer.state.tiles.find(function (t) { return t.id === tileId; }); + const shape = Bin2CellExplorer.state.slideShape; + if (!tile || !shape) return; + const viewer = tmapp[tmapp["object_prefix"] + "_viewer"]; + if (!viewer || !viewer.world.getItemCount()) return; + const image = viewer.world.getItemAt(0); + const width = shape[1]; + const height = shape[0]; + const xNorm = tile.c0 / width; + const yNorm = tile.r0 / height; + const wNorm = (tile.c1 - tile.c0) / width; + const hNorm = (tile.r1 - tile.r0) / height; + const rect = new OpenSeadragon.Rect(xNorm, yNorm, wNorm, hNorm); + viewer.viewport.fitBounds(rect, animate !== false); +}; + +Bin2CellExplorer.collectOverlayParams = function () { + const params = { + tile_id: Bin2CellExplorer.get("tile_id"), + overlay_type: Bin2CellExplorer.get("overlay_type"), + genes: Bin2CellExplorer.get("genes"), + obs_col: Bin2CellExplorer.get("obs_col"), + category: Bin2CellExplorer.get("category"), + render_mode: Bin2CellExplorer.get("render_mode"), + color_mode: Bin2CellExplorer.get("color_mode"), + gradient_color: Bin2CellExplorer.get("gradient_color"), + gene_color: Bin2CellExplorer.get("gene_color"), + expr_quantile: Bin2CellExplorer.get("expr_quantile"), + top_n: Bin2CellExplorer.get("top_n"), + b2c_mode: Bin2CellExplorer.get("b2c_mode"), + max_bin_distance: Bin2CellExplorer.get("max_bin_distance"), + mpp: Bin2CellExplorer.get("mpp"), + bin_um: Bin2CellExplorer.get("bin_um"), + volume_ratio: Bin2CellExplorer.get("volume_ratio"), + overlay_alpha: Bin2CellExplorer.get("overlay_alpha"), + highlight_color: Bin2CellExplorer.get("highlight_color"), + highlight_width: Bin2CellExplorer.get("highlight_width"), + all_expanded_outline: Bin2CellExplorer.isChecked("all_expanded_outline"), + expanded_outlines_selected: Bin2CellExplorer.isChecked("expanded_outlines_selected"), + all_nuclei_outline: Bin2CellExplorer.isChecked("all_nuclei_outline"), + nuclei_outlines_selected: Bin2CellExplorer.isChecked("nuclei_outlines_selected"), + nuclei_outline_color: Bin2CellExplorer.get("nuclei_outline_color"), + nuclei_outline_alpha: Bin2CellExplorer.get("nuclei_outline_alpha") + }; + return params; +}; + +Bin2CellExplorer.geometrySignature = function (payload) { + if (!payload) return ""; + const tileId = payload.tile_id != null ? Number(payload.tile_id) : -1; + return [ + tileId, + payload.b2c_mode, + payload.max_bin_distance, + payload.mpp, + payload.bin_um, + payload.volume_ratio, + payload.pad_factor || 2 + ].join("|"); +}; + +/** + * Detect what type of parameters changed. + * Returns: 'VISUAL' | 'COLOR' | 'GEOMETRY' | 'NONE' + */ +Bin2CellExplorer.detectChangeType = function (newParams) { + const last = Bin2CellExplorer.state.lastRequest; + + // First request ever + if (!last.geometry && !last.color && !last.visual) { + return 'GEOMETRY'; + } + + // Check geometry parameters + const geometryParams = ['tile_id', 'b2c_mode', 'max_bin_distance', 'mpp', 'bin_um', 'volume_ratio']; + for (let i = 0; i < geometryParams.length; i++) { + const param = geometryParams[i]; + if (String(newParams[param]) !== String(last.geometry[param])) { + console.log(`[CellExplorer] Geometry changed: ${param} (${last.geometry[param]} → ${newParams[param]})`); + return 'GEOMETRY'; + } + } + + // Check color parameters + const colorParams = ['overlay_type', 'genes', 'obs_col', 'category', 'color_mode', 'gradient_color', 'expr_quantile']; + for (let i = 0; i < colorParams.length; i++) { + const param = colorParams[i]; + if (String(newParams[param] || '') !== String(last.color[param] || '')) { + console.log(`[CellExplorer] Color changed: ${param} (${last.color[param]} → ${newParams[param]})`); + return 'COLOR'; + } + } + + // Check visual parameters + const visualParams = ['overlay_alpha', 'render_mode', 'highlight_color', 'highlight_width', + 'all_expanded_outline', 'expanded_outlines_selected', 'all_nuclei_outline', 'nuclei_outlines_selected', + 'nuclei_outline_color', 'nuclei_outline_alpha']; + for (let i = 0; i < visualParams.length; i++) { + const param = visualParams[i]; + if (String(newParams[param] || '') !== String(last.visual[param] || '')) { + console.log(`[CellExplorer] Visual changed: ${param} (${last.visual[param]} → ${newParams[param]})`); + return 'VISUAL'; + } + } + + return 'NONE'; // No changes +}; + +/** + * Update cached parameters after successful request. + */ +Bin2CellExplorer.updateLastRequest = function (params) { + Bin2CellExplorer.state.lastRequest = { + geometry: { + tile_id: params.tile_id, + b2c_mode: params.b2c_mode, + max_bin_distance: params.max_bin_distance, + mpp: params.mpp, + bin_um: params.bin_um, + volume_ratio: params.volume_ratio, + pad_factor: params.pad_factor || 2 + }, + color: { + overlay_type: params.overlay_type, + genes: params.genes, + obs_col: params.obs_col, + category: params.category, + color_mode: params.color_mode, + gradient_color: params.gradient_color, + expr_quantile: params.expr_quantile + }, + visual: { + overlay_alpha: params.overlay_alpha, + render_mode: params.render_mode, + highlight_color: params.highlight_color, + highlight_width: params.highlight_width, + all_expanded_outline: params.all_expanded_outline, + expanded_outlines_selected: params.expanded_outlines_selected, + all_nuclei_outline: params.all_nuclei_outline, + nuclei_outlines_selected: params.nuclei_outlines_selected, + nuclei_outline_color: params.nuclei_outline_color, + nuclei_outline_alpha: params.nuclei_outline_alpha + } + }; +}; + +Bin2CellExplorer.shouldRequestGeometry = function (tileId, params) { + if (tileId == null || isNaN(tileId)) return true; + const sig = Bin2CellExplorer.geometrySignature(params); + const cached = Bin2CellExplorer.state.geometryCache[tileId]; + if (!cached) return true; + return cached.signature !== sig; +}; + +Bin2CellExplorer.exportOverlay = function () { + if (!Bin2CellExplorer.state.datasetLoaded) { + interfaceUtils.alert("Load a dataset first."); + return; + } + const params = Bin2CellExplorer.collectOverlayParams(); + params.name = Bin2CellExplorer.get("export_name"); + Bin2CellExplorer.api( + "export_overlay", + params, + function (resp) { + const data = Bin2CellExplorer.ensureObject(resp); + Bin2CellExplorer.setStatus("GeoJSON written to " + data.path); + interfaceUtils.alert("Overlay exported:\n" + data.path); + }, + Bin2CellExplorer.handleError + ); +}; + +Bin2CellExplorer.collectPresetConfig = function () { + return Bin2CellExplorer.collectOverlayParams(); +}; + +Bin2CellExplorer.savePreset = function () { + const name = (Bin2CellExplorer.get("save_preset_name") || "").trim(); + if (!name) { + interfaceUtils.alert("Provide a preset name."); + return; + } + const payload = { + name: name, + config: Bin2CellExplorer.collectPresetConfig() + }; + Bin2CellExplorer.api( + "save_preset", + payload, + function (resp) { + const data = Bin2CellExplorer.ensureObject(resp); + Bin2CellExplorer.setStatus("Preset saved."); + Bin2CellExplorer.requestPresets(name); + }, + Bin2CellExplorer.handleError + ); +}; + +Bin2CellExplorer.requestPresets = function (selectName) { + Bin2CellExplorer.api( + "list_presets", + {}, + function (resp) { + const data = Bin2CellExplorer.ensureObject(resp); + Bin2CellExplorer.state.presets = data.presets || {}; + const names = Object.keys(Bin2CellExplorer.state.presets); + Bin2CellExplorer.populateSelect("preset_select", [""].concat(names), selectName || ""); + }, + Bin2CellExplorer.handleError + ); +}; + +Bin2CellExplorer.applySelectedPreset = function () { + const select = Bin2CellExplorer.get("preset_select"); + if (!select || !(select in Bin2CellExplorer.state.presets)) { + interfaceUtils.alert("Select a preset first."); + return; + } + const config = Bin2CellExplorer.state.presets[select]; + if (!config) return; + let pendingCategory = null; + let pendingObsCol = null; + Object.keys(config).forEach(function (key) { + if (key === "category") { + pendingCategory = config[key] || ""; + return; + } + const domId = "Bin2CellExplorer_" + key; + if (document.getElementById(domId)) { + interfaceUtils.setValueForElement(domId, "value", config[key]); + if (key === "obs_col") { + pendingObsCol = config[key] || ""; + } + } + }); + Bin2CellExplorer.toggleOverlayInputs(config.overlay_type || Bin2CellExplorer.get("overlay_type")); + if (pendingCategory !== null) { + Bin2CellExplorer.state.pendingCategoryValue = pendingCategory; + } + if (pendingObsCol !== null) { + Bin2CellExplorer.onObsColumnChange(pendingObsCol, { keepCategory: pendingCategory !== null }); + } else if (pendingCategory !== null) { + Bin2CellExplorer.populateCategorySelect(Bin2CellExplorer.get("obs_col") || ""); + } + Bin2CellExplorer.setStatus("Preset applied: " + select); +}; + +Bin2CellExplorer.populatePresetPreview = function () { + const select = Bin2CellExplorer.get("preset_select"); + if (!select) return; + const preset = Bin2CellExplorer.state.presets[select]; + if (!preset) return; + Bin2CellExplorer.setStatus("Preset '" + select + "' ready to apply."); +}; + +Bin2CellExplorer.deleteSelectedPreset = function () { + const select = Bin2CellExplorer.get("preset_select"); + if (!select || !(select in Bin2CellExplorer.state.presets)) { + interfaceUtils.alert("Select a preset to delete."); + return; + } + Bin2CellExplorer.api( + "delete_preset", + { name: select }, + function () { + Bin2CellExplorer.setStatus("Preset deleted: " + select); + Bin2CellExplorer.requestPresets(); + }, + Bin2CellExplorer.handleError + ); +}; + +Bin2CellExplorer.handleError = function (jqXHR, textStatus, errorThrown) { + let message = ""; + if (jqXHR) { + if (jqXHR.responseJSON) { + message = jqXHR.responseJSON.message || JSON.stringify(jqXHR.responseJSON); + } else if (jqXHR.responseText) { + try { + const parsed = JSON.parse(jqXHR.responseText); + message = parsed.message || jqXHR.responseText; + } catch (parseErr) { + message = jqXHR.responseText; + } + } + } + if (!message && errorThrown) { + message = errorThrown; + } + if (!message && textStatus) { + message = textStatus; + } + if (!message) { + message = "Unknown error"; + } + Bin2CellExplorer.setStatus("Error: " + message); + interfaceUtils.alert("Bin2Cell Explorer error:\n" + message); +}; + +Bin2CellExplorer.isChecked = function (name) { + const el = Bin2CellExplorer._findElement(name); + return !!(el && el.checked); +}; + +Bin2CellExplorer.setCheckbox = function (name, value) { + const el = Bin2CellExplorer._findElement(name); + if (el) { + el.checked = !!value; + } +}; + +Bin2CellExplorer.updateDatasetMode = function () { + const single = Bin2CellExplorer.isChecked("single_tile_mode"); + Bin2CellExplorer.toggleDatasetControl("tile_cache_size", !single); + Bin2CellExplorer.toggleDatasetControl("warm_cache", !single); + Bin2CellExplorer.toggleDatasetControl("warm_cache_tiles", !single); +}; + +Bin2CellExplorer.toggleDatasetControl = function (name, enabled) { + const el = Bin2CellExplorer._findElement(name); + if (!el) return; + el.disabled = !enabled; + const wrapper = el.closest(".form-group, .row, .input-group"); + if (wrapper) { + wrapper.style.opacity = enabled ? "" : "0.5"; + } +}; + +Bin2CellExplorer.ensureCanvasOverlay = function () { + const viewer = tmapp[tmapp["object_prefix"] + "_viewer"]; + if (!viewer) return null; + const container = viewer.container || viewer.canvas || viewer.element; + if (!container) return null; + if (!Bin2CellExplorer.state.canvasOverlay) { + const canvas = document.createElement("canvas"); + canvas.id = "Bin2CellExplorer_canvas_overlay"; + canvas.style.position = "absolute"; + canvas.style.top = "0"; + canvas.style.left = "0"; + canvas.style.width = "100%"; + canvas.style.height = "100%"; + canvas.style.pointerEvents = "none"; + canvas.style.zIndex = 10; + container.appendChild(canvas); + Bin2CellExplorer.state.canvasOverlay = canvas; + Bin2CellExplorer.state.canvasCtx = canvas.getContext("2d"); + // Hide legacy SVG layer if present to avoid double rendering + if (Bin2CellExplorer.state.d3layer && Bin2CellExplorer.state.d3layer.remove) { + Bin2CellExplorer.state.d3layer.remove(); + Bin2CellExplorer.state.d3layer = null; + } + } + return Bin2CellExplorer.state.canvasOverlay; +}; + +Bin2CellExplorer.projectPoint = function (image, viewer, pt) { + const vp = image.imageToViewportCoordinates(pt[0], pt[1], true); + const px = viewer.viewport.pixelFromPoint(vp, true); + return px; +}; + +Bin2CellExplorer.drawPolygonList = function (ctx, polygons, style, projectFn) { + if (!polygons || !polygons.length) return; + polygons.forEach(function (poly) { + if (!poly || poly.length < 3) return; + const path = new Path2D(); + poly.forEach(function (pt, idx) { + const screen = projectFn(pt); + if (idx === 0) { + path.moveTo(screen.x, screen.y); + } else { + path.lineTo(screen.x, screen.y); + } + }); + path.closePath(); + if (style.fill) { + ctx.fillStyle = style.fill; + ctx.fill(path); + } + if (style.stroke) { + ctx.strokeStyle = style.stroke; + ctx.lineWidth = style.strokeWidth || 1.0; + ctx.stroke(path); + } else if (style.fill) { + // Add subtle border to filled cells to reduce "floating" effect + ctx.strokeStyle = "rgba(0,0,0,0.15)"; + ctx.lineWidth = 0.5; + ctx.stroke(path); + } + }); +}; + +Bin2CellExplorer.drawLineList = function (ctx, lines, style, projectFn) { + if (!lines || !lines.length) return; + ctx.strokeStyle = style.stroke || "rgba(150,150,150,0.6)"; + ctx.lineWidth = style.strokeWidth || 1.0; + lines.forEach(function (line) { + if (!line || line.length < 2) return; + const path = new Path2D(); + line.forEach(function (pt, idx) { + const screen = projectFn(pt); + if (idx === 0) { + path.moveTo(screen.x, screen.y); + } else { + path.lineTo(screen.x, screen.y); + } + }); + ctx.stroke(path); + }); +}; + +Bin2CellExplorer.inflateGeometry = function (data) { + if (!data || !data.tile) return data; + const tileId = data.tile.id; + const sig = Bin2CellExplorer.geometrySignature(data); + if (data.geometry) { + Bin2CellExplorer.state.geometryCache[tileId] = { + signature: sig, + geometry: data.geometry + }; + } + const cached = Bin2CellExplorer.state.geometryCache[tileId]; + if (!cached || !cached.geometry) { + return data; + } + + const geom = cached.geometry; + const result = JSON.parse(JSON.stringify(data)); + const useExpanded = !!result.all_expanded_outline; + const polySource = useExpanded ? geom.polygons_exp : geom.polygons_raw; + + if (!result.expanded_outline || !result.expanded_outline.length) { + result.expanded_outline = geom.outline_exp || []; + } + if (!result.nuclei_outline || !result.nuclei_outline.length) { + result.nuclei_outline = geom.outline_raw || []; + } + + // Per-label outlines cache (for selected-only mode) + const perLabelNuclei = geom.per_label_nuclei || {}; + const perLabelExpanded = geom.per_label_expanded || {}; + + if (result.overlay_type === "gene") { + (result.overlays || []).forEach(function (overlay) { + (overlay.features || []).forEach(function (feature) { + // Restore polygons + if (!feature.polygons || !feature.polygons.length) { + const polys = (polySource && polySource[feature.label]) || []; + feature.polygons = polys; + } + // Restore per-feature nuclei outlines + if (!feature.nuclei_outline_paths || !feature.nuclei_outline_paths.length) { + feature.nuclei_outline_paths = perLabelNuclei[feature.label] || []; + } + // Restore per-feature expanded outlines + if (!feature.expanded_outline_paths || !feature.expanded_outline_paths.length) { + feature.expanded_outline_paths = perLabelExpanded[feature.label] || []; + } + }); + }); + } else if (result.overlay_type === "observation") { + (result.features || []).forEach(function (feature) { + // Restore polygons + if (!feature.polygons || !feature.polygons.length) { + const polys = (polySource && polySource[feature.label]) || []; + feature.polygons = polys; + } + // Restore per-feature nuclei outlines + if (!feature.nuclei_outline_paths || !feature.nuclei_outline_paths.length) { + feature.nuclei_outline_paths = perLabelNuclei[feature.label] || []; + } + // Restore per-feature expanded outlines + if (!feature.expanded_outline_paths || !feature.expanded_outline_paths.length) { + feature.expanded_outline_paths = perLabelExpanded[feature.label] || []; + } + }); + } + return result; +}; + +Bin2CellExplorer.drawOverlayToCanvas = function (payload) { + const canvas = Bin2CellExplorer.ensureCanvasOverlay(); + const ctx = Bin2CellExplorer.state.canvasCtx; + const viewer = tmapp[tmapp["object_prefix"] + "_viewer"]; + if (!canvas || !ctx || !viewer) return; + if (!viewer.world || !viewer.world.getItemCount()) return; + const image = viewer.world.getItemAt(0); + if (!image) return; + + const width = viewer.container ? viewer.container.clientWidth : canvas.clientWidth; + const height = viewer.container ? viewer.container.clientHeight : canvas.clientHeight; + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = width + "px"; + canvas.style.height = height + "px"; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + + const projectFn = function (pt) { + return Bin2CellExplorer.projectPoint(image, viewer, pt); + }; + + // Draw outlines based on payload flags + const showExpandedAll = payload.all_expanded_outline || false; + const showExpandedSelected = payload.expanded_outlines_selected || false; + const showNucleiAll = payload.all_nuclei_outline || false; + const showNucleiSelected = payload.nuclei_outlines_selected || false; + + // Collect label IDs of selected/displayed cells for filtering + let selectedLabels = null; + if (showExpandedSelected || showNucleiSelected) { + selectedLabels = new Set(); + if (payload.overlay_type === "gene") { + (payload.overlays || []).forEach(function (overlay) { + if (Bin2CellExplorer.state.hiddenGenes[overlay.gene]) return; + (overlay.features || []).forEach(function (feature) { + if (feature.label != null) { + selectedLabels.add(feature.label); + } + }); + }); + } else if (payload.overlay_type === "observation") { + (payload.features || []).forEach(function (feature) { + if (Bin2CellExplorer.state.hiddenCategories[feature.category]) return; + if (feature.label != null) { + selectedLabels.add(feature.label); + } + }); + } + } + + // Draw expanded outlines (all cells) + if (showExpandedAll && payload.expanded_outline && payload.expanded_outline.length) { + Bin2CellExplorer.drawLineList(ctx, payload.expanded_outline, { + stroke: "rgba(100,100,100,0.9)", // Darker gray, more opaque - matches selected-only + strokeWidth: 1.0 + }, projectFn); + } + + // Draw nuclei outlines (all cells) + if (showNucleiAll && payload.nuclei_outline && payload.nuclei_outline.length) { + const nucleiColor = payload.nuclei_outline_color || "#000000"; + const nucleiAlpha = payload.nuclei_outline_alpha != null ? payload.nuclei_outline_alpha : 0.6; + const nucleiStroke = Bin2CellExplorer.hexToRgba(nucleiColor, nucleiAlpha); + Bin2CellExplorer.drawLineList(ctx, payload.nuclei_outline, { + stroke: nucleiStroke, + strokeWidth: 0.8 + }, projectFn); + } + + // Get overlay_alpha from payload + const overlayAlpha = payload.overlay_alpha != null ? payload.overlay_alpha : 0.7; + const renderMode = payload.render_mode || "fill"; + const highlightColor = payload.highlight_color || "#39ff14"; + const highlightWidth = payload.highlight_width || 2.0; + + if (payload.overlay_type === "gene") { + (payload.overlays || []).forEach(function (overlay) { + if (Bin2CellExplorer.state.hiddenGenes[overlay.gene]) return; + (overlay.features || []).forEach(function (feature) { + if (!feature.polygons || !feature.polygons.length) return; + + let fillColor = null; + let strokeColor = null; + let strokeWidth = 1.0; + + if (renderMode === "fill") { + // Fill mode: show colored fills with subtle borders + if (feature.fill) { + fillColor = Bin2CellExplorer.applyAlpha(feature.fill, overlayAlpha); + } + } else if (renderMode === "outline") { + // Outline mode: no fill, use highlight color for stroke + fillColor = null; + strokeColor = Bin2CellExplorer.applyAlpha(highlightColor, overlayAlpha); + strokeWidth = highlightWidth; + } + + const style = { + fill: fillColor, + stroke: strokeColor, + strokeWidth: strokeWidth + }; + Bin2CellExplorer.drawPolygonList(ctx, feature.polygons, style, projectFn); + + // Draw per-feature outlines if selected-only mode is enabled + if (showExpandedSelected && feature.expanded_outline_paths) { + Bin2CellExplorer.drawLineList(ctx, feature.expanded_outline_paths, { + stroke: "rgba(100,100,100,0.9)", // Darker gray, more opaque + strokeWidth: 1.0 + }, projectFn); + } + + if (showNucleiSelected && feature.nuclei_outline_paths) { + const nucleiColor = payload.nuclei_outline_color || "#000000"; + const nucleiAlpha = payload.nuclei_outline_alpha != null ? payload.nuclei_outline_alpha : 0.6; + const nucleiStroke = Bin2CellExplorer.hexToRgba(nucleiColor, nucleiAlpha); + Bin2CellExplorer.drawLineList(ctx, feature.nuclei_outline_paths, { + stroke: nucleiStroke, + strokeWidth: 0.8 + }, projectFn); + } + }); + }); + } else if (payload.overlay_type === "observation") { + (payload.features || []).forEach(function (feature) { + if (Bin2CellExplorer.state.hiddenCategories[feature.category]) return; + if (!feature.polygons || !feature.polygons.length) return; + + let fillColor = null; + let strokeColor = null; + let strokeWidth = 1.0; + + if (renderMode === "fill") { + // Fill mode: show colored fills + if (feature.fill) { + fillColor = Bin2CellExplorer.applyAlpha(feature.fill, overlayAlpha); + } + } else if (renderMode === "outline") { + // Outline mode: no fill, use highlight color for stroke + fillColor = null; + strokeColor = Bin2CellExplorer.applyAlpha(highlightColor, overlayAlpha); + strokeWidth = highlightWidth; + } + + const style = { + fill: fillColor, + stroke: strokeColor, + strokeWidth: strokeWidth + }; + Bin2CellExplorer.drawPolygonList(ctx, feature.polygons, style, projectFn); + + // Draw per-feature outlines if selected-only mode is enabled + if (showExpandedSelected && feature.expanded_outline_paths) { + Bin2CellExplorer.drawLineList(ctx, feature.expanded_outline_paths, { + stroke: "rgba(100,100,100,0.9)", // Darker gray, more opaque + strokeWidth: 1.0 + }, projectFn); + } + + if (showNucleiSelected && feature.nuclei_outline_paths) { + const nucleiColor = payload.nuclei_outline_color || "#000000"; + const nucleiAlpha = payload.nuclei_outline_alpha != null ? payload.nuclei_outline_alpha : 0.6; + const nucleiStroke = Bin2CellExplorer.hexToRgba(nucleiColor, nucleiAlpha); + Bin2CellExplorer.drawLineList(ctx, feature.nuclei_outline_paths, { + stroke: nucleiStroke, + strokeWidth: 0.8 + }, projectFn); + } + }); + } +}; + +Bin2CellExplorer.renderOverlay = function (data) { + const resolved = Bin2CellExplorer.inflateGeometry(data); + Bin2CellExplorer.state.overlays = resolved; + Bin2CellExplorer.state.renderPayload = resolved; + Bin2CellExplorer.state.lastOverlayPayload = resolved; + Bin2CellExplorer.drawOverlayToCanvas(resolved); + Bin2CellExplorer.renderLegend(resolved); + Bin2CellExplorer.renderTileOverview(); + Bin2CellExplorer.ensureViewerHooks(); +}; + + +Bin2CellExplorer.imageToViewport = function (x, y, tiledImage) { + const viewer = tmapp[tmapp["object_prefix"] + "_viewer"]; + if (!viewer) return { x: x, y: y }; + const image = tiledImage || viewer.world.getItemAt(0); + if (!image) return { x: x, y: y }; + let px = x; + if (image.getFlip && image.getFlip()) { + px = image.getContentSize().x - px; + } + const point = image.imageToViewportCoordinates(px, y, true); + return point; +}; + +Bin2CellExplorer.polygonsToPath = function (polygons) { + if (!polygons || !polygons.length) return ""; + const viewer = tmapp[tmapp["object_prefix"] + "_viewer"]; + if (!viewer || !viewer.world.getItemCount()) return ""; + const image = viewer.world.getItemAt(0); + const segments = []; + polygons.forEach(function (poly) { + if (!poly || poly.length < 3) return; + let path = ""; + poly.forEach(function (pt, idx) { + const vp = Bin2CellExplorer.imageToViewport(pt[0], pt[1], image); + path += (idx === 0 ? "M" : "L") + vp.x + " " + vp.y; + }); + path += "Z"; + segments.push(path); + }); + return segments.join(" "); +}; + +Bin2CellExplorer.lineToPath = function (points) { + if (!points || !points.length) return ""; + const viewer = tmapp[tmapp["object_prefix"] + "_viewer"]; + if (!viewer || !viewer.world.getItemCount()) return ""; + const image = viewer.world.getItemAt(0); + let path = ""; + points.forEach(function (pt, idx) { + const vp = Bin2CellExplorer.imageToViewport(pt[0], pt[1], image); + path += (idx === 0 ? "M" : "L") + vp.x + " " + vp.y; + }); + return path; +}; + +Bin2CellExplorer.renderLegend = function (data) { + const legend = document.getElementById("Bin2CellExplorer_legend"); + if (!legend) return; + legend.innerHTML = ""; + + if (data.overlay_type === "gene") { + Bin2CellExplorer.buildGeneLegend(legend, data.overlays || []); + } else if (data.overlay_type === "observation") { + Bin2CellExplorer.buildObservationLegend(legend, data.legend || {}); + } +}; + +Bin2CellExplorer.buildGeneLegend = function (container, overlays) { + if (!overlays.length) { + container.textContent = "No genes in overlay."; + return; + } + const title = document.createElement("div"); + title.textContent = "Genes"; + title.className = "fw-bold mb-1"; + container.appendChild(title); + + overlays.forEach(function (overlay) { + const gene = overlay.gene; + const legend = overlay.legend || {}; + + const row = document.createElement("div"); + row.className = "bin2cell-legend-row"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = true; + checkbox.dataset.gene = gene; + checkbox.addEventListener("change", function (evt) { + Bin2CellExplorer.toggleGeneLayer(gene, evt.target.checked); + }); + + const label = document.createElement("label"); + label.textContent = " " + gene; + label.className = "ms-1"; + + row.appendChild(checkbox); + row.appendChild(label); + + if (legend.type === "continuous" && legend.gradient) { + const gradient = document.createElement("div"); + gradient.className = "bin2cell-gradient"; + gradient.style.height = "12px"; + gradient.style.flex = "1"; + gradient.style.marginLeft = "8px"; + gradient.style.borderRadius = "4px"; + gradient.style.background = "linear-gradient(to right," + legend.gradient.map(function (stop) { + return stop[1]; + }).join(",") + ")"; + gradient.title = "min: " + legend.min + " max: " + legend.max; + row.appendChild(gradient); + } else if (legend.type === "solid" && legend.color) { + const swatch = document.createElement("span"); + swatch.className = "bin2cell-swatch"; + swatch.style.display = "inline-block"; + swatch.style.width = "16px"; + swatch.style.height = "16px"; + swatch.style.marginLeft = "8px"; + swatch.style.borderRadius = "3px"; + swatch.style.border = "1px solid rgba(0,0,0,0.2)"; + swatch.style.background = legend.color; + row.appendChild(swatch); + } + container.appendChild(row); + }); +}; + +Bin2CellExplorer.buildObservationLegend = function (container, legendData) { + const items = legendData.items || []; + if (!items.length) { + container.textContent = "No observation categories."; + return; + } + const title = document.createElement("div"); + title.textContent = legendData.obs_col || "Observation"; + title.className = "fw-bold mb-1"; + container.appendChild(title); + + items.forEach(function (item) { + const row = document.createElement("div"); + row.className = "bin2cell-legend-row"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = true; + checkbox.dataset.category = item.label; + checkbox.addEventListener("change", function (evt) { + Bin2CellExplorer.toggleCategoryLayer(item.label, evt.target.checked); + }); + + const swatch = document.createElement("span"); + swatch.className = "bin2cell-swatch"; + swatch.style.display = "inline-block"; + swatch.style.width = "14px"; + swatch.style.height = "14px"; + swatch.style.marginLeft = "8px"; + swatch.style.borderRadius = "3px"; + swatch.style.background = item.color; + + const label = document.createElement("label"); + label.textContent = " " + item.label; + label.className = "ms-1"; + + row.appendChild(checkbox); + row.appendChild(swatch); + row.appendChild(label); + container.appendChild(row); + }); +}; + +Bin2CellExplorer.toggleGeneLayer = function (gene, visible) { + if (!gene) return; + if (visible) { + delete Bin2CellExplorer.state.hiddenGenes[gene]; + } else { + Bin2CellExplorer.state.hiddenGenes[gene] = true; + } + Bin2CellExplorer.scheduleOverlayRefresh(); +}; + +Bin2CellExplorer.toggleCategoryLayer = function (category, visible) { + if (!category) return; + if (visible) { + delete Bin2CellExplorer.state.hiddenCategories[category]; + } else { + Bin2CellExplorer.state.hiddenCategories[category] = true; + } + Bin2CellExplorer.scheduleOverlayRefresh(); +}; + +Bin2CellExplorer.ensureViewerHooks = function () { + if (Bin2CellExplorer.state.viewerHooksInstalled) return; + const viewer = tmapp[tmapp["object_prefix"] + "_viewer"]; + if (!viewer) return; + const schedule = function () { + Bin2CellExplorer.scheduleOverlayRefresh(); + }; + viewer.addHandler("animation", schedule); + viewer.addHandler("resize", schedule); + viewer.addHandler("flip", schedule); + viewer.addHandler("rotate", schedule); + Bin2CellExplorer.state.viewerHooksInstalled = true; +}; + +Bin2CellExplorer.scheduleOverlayRefresh = function () { + if (!Bin2CellExplorer.state.renderPayload) return; + if (Bin2CellExplorer.state.overlayRefreshToken) { + cancelAnimationFrame(Bin2CellExplorer.state.overlayRefreshToken); + } + Bin2CellExplorer.state.overlayRefreshToken = requestAnimationFrame(function () { + Bin2CellExplorer.state.overlayRefreshToken = null; + Bin2CellExplorer.rebuildOverlayGeometry(); + }); +}; + +Bin2CellExplorer.rebuildOverlayGeometry = function () { + const payload = Bin2CellExplorer.state.renderPayload || Bin2CellExplorer.state.lastOverlayPayload; + if (!payload) return; + Bin2CellExplorer.drawOverlayToCanvas(payload); +}; + +// Helper to apply alpha to any color (hex or rgba) +Bin2CellExplorer.applyAlpha = function (color, alpha) { + if (!color) return null; + + // If it's already rgba, replace the alpha + if (color.startsWith('rgba(')) { + return color.replace(/,\s*[\d.]+\)$/, `, ${alpha})`); + } + + // If it's rgb, convert to rgba + if (color.startsWith('rgb(')) { + return color.replace(')', `, ${alpha})`).replace('rgb', 'rgba'); + } + + // If it's hex, convert to rgba + if (color.startsWith('#')) { + return Bin2CellExplorer.hexToRgba(color, alpha); + } + + return color; +}; + +// Helper to convert #RRGGBB to rgba(r,g,b,a) +Bin2CellExplorer.hexToRgba = function (hex, alpha) { + try { + const h = String(hex || "").trim(); + let r = 160, g = 160, b = 160; + if (/^#?[0-9a-fA-F]{6}$/.test(h)) { + const s = h.charAt(0) === '#' ? h.substring(1) : h; + r = parseInt(s.substring(0, 2), 16); + g = parseInt(s.substring(2, 4), 16); + b = parseInt(s.substring(4, 6), 16); + } + const a = Math.max(0, Math.min(1, Number(alpha))); + return `rgba(${r},${g},${b},${a})`; + } catch (e) { + return `rgba(160,160,160,${Math.max(0, Math.min(1, Number(alpha) || 0.5))})`; + } +}; +// Ensure filename-based global exists for TissUUmaps plugin loader, cause we changed name +var CellExplorer = Bin2CellExplorer; diff --git a/plugins/CellExplorer.py b/plugins/CellExplorer.py new file mode 100644 index 0000000..8bfd790 --- /dev/null +++ b/plugins/CellExplorer.py @@ -0,0 +1,2745 @@ +import json +import logging +import math +import os +import shutil +import tempfile +import threading +import gzip +from collections import Counter, OrderedDict, defaultdict +from dataclasses import dataclass +from concurrent.futures import Future, ThreadPoolExecutor, as_completed +from textwrap import dedent +from typing import Dict, Iterable, List, Optional, Tuple + +import numpy as np +from flask import make_response, render_template_string + +try: + import anndata as ad +except ImportError: # pragma: no cover - runtime guard + ad = None + +from scipy.sparse import load_npz, issparse +from skimage import io, measure +from skimage.segmentation import expand_labels, find_boundaries + +from matplotlib import cm, colors +from PySide6.QtCore import QMetaObject, QObject, Qt, Q_ARG, Slot +from PySide6.QtWidgets import QApplication, QFileDialog + +LOGGER = logging.getLogger(__name__) + +_GLOBAL_STATE: Dict[str, object] = {} +_FILE_DIALOG_HELPER: Optional["FileDialogHelper"] = None + +OBS_CATEGORY_LIMIT = 256 + +_FILETREE_TEMPLATE = dedent( + """ + + + + + + Select file + + + + + + +
+ + + + """ +) + + +def _as_bool(value: object) -> bool: + if isinstance(value, bool): + return value + if value is None: + return False + if isinstance(value, (int, float)): + return value != 0 + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def _safe_int(value: object, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _safe_float(value: object, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _normalize_path(path: str) -> str: + if not path: + return "" + return os.path.abspath(os.path.expanduser(os.path.expandvars(path))) + + +def _rgba_to_css(rgba: Tuple[float, float, float, float], *, alpha_override: Optional[float] = None) -> str: + r, g, b, a = rgba + if alpha_override is not None: + a = alpha_override + return f"rgba({int(round(r * 255))},{int(round(g * 255))},{int(round(b * 255))},{round(a, 4)})" + + +def _get_cmap(cmap: object) -> "colors.Colormap": + if isinstance(cmap, str): + return cm.get_cmap(cmap) + return cmap # type: ignore[return-value] + + +def _colormap_sample(cmap: object, value: float, alpha: Optional[float] = None) -> str: + cmap_obj = _get_cmap(cmap) + rgba = cmap_obj(np.clip(value, 0.0, 1.0)) + return _rgba_to_css(rgba, alpha_override=alpha) + + +def _sample_gradient(cmap: object, steps: int = 8) -> List[Tuple[float, str]]: + cmap_obj = _get_cmap(cmap) + gradient = [] + for i in range(steps): + pos = i / max(steps - 1, 1) + gradient.append((pos, _rgba_to_css(cmap_obj(pos)))) + return gradient + + +def _unique_sorted(iterable: Iterable) -> List: + return sorted(set(iterable)) + + +def _color_to_css(value: object, *, alpha_override: Optional[float] = None) -> Optional[str]: + if value is None: + return None + try: + rgba = colors.to_rgba(value) + except ValueError: + return None + return _rgba_to_css(rgba, alpha_override=alpha_override) + + +@dataclass(frozen=True) +class Tile: + id: int + r0: int + r1: int + c0: int + c1: int + + def to_dict(self) -> Dict[str, int]: + return {"id": self.id, "r0": self.r0, "r1": self.r1, "c0": self.c0, "c1": self.c1} + + +class B2CContext: + """ + Lightweight loader for H&E image, sparse nuclei labels, and AnnData container. + Provides centroid pixel coordinates and convenience accessors. + """ + + def __init__( + self, + he_image_path: str, + labels_npz_path: str, + adata_path: str, + *, + obsm_key: Optional[str] = None, + ): + if ad is None: + raise ImportError("anndata is required (pip install anndata scanpy).") + + he_image_path = os.path.abspath(os.path.expanduser(os.path.expandvars(he_image_path))) + labels_npz_path = os.path.abspath(os.path.expanduser(os.path.expandvars(labels_npz_path))) + adata_path = os.path.abspath(os.path.expanduser(os.path.expandvars(adata_path))) + + if not os.path.exists(he_image_path): + raise FileNotFoundError(f"H&E image not found: {he_image_path}") + if os.path.isdir(he_image_path): + raise IsADirectoryError(f"H&E image path is a directory, expected a file: {he_image_path}") + if not os.path.exists(labels_npz_path): + raise FileNotFoundError(f"Label NPZ not found: {labels_npz_path}") + if os.path.isdir(labels_npz_path): + raise IsADirectoryError(f"Label NPZ path is a directory, expected a file: {labels_npz_path}") + if not os.path.exists(adata_path): + raise FileNotFoundError(f"AnnData (.h5ad) not found: {adata_path}") + if os.path.isdir(adata_path): + raise IsADirectoryError(f"AnnData path is a directory, expected a file: {adata_path}") + + LOGGER.info("Loading H&E image: %s", he_image_path) + self.he = io.imread(he_image_path) + + LOGGER.info("Loading sparse labels: %s", labels_npz_path) + self.lab_sp = load_npz(labels_npz_path) + + if self.lab_sp.shape != self.he.shape[:2]: + raise ValueError( + f"Image/label size mismatch: H&E {self.he.shape[:2]} vs labels {self.lab_sp.shape}" + ) + + LOGGER.info("Loading AnnData: %s", adata_path) + self.adata = ad.read_h5ad(adata_path) + + self._gene_cache: Dict[str, np.ndarray] = {} + self._obs_cache: Dict[str, np.ndarray] = {} + self._obs_meta_cache: Dict[str, Dict[str, object]] = {} + + self._available_obsm = [ + k + for k, arr in self.adata.obsm.items() + if hasattr(arr, "shape") and arr.shape[1] >= 2 + ] + if not self._available_obsm: + raise ValueError("No obsm entries with >=2 columns were found in AnnData.") + + self.obsm_key = self._resolve_obsm_key(obsm_key) + xy = np.asarray(self.adata.obsm[self.obsm_key]) + self.cols = np.rint(xy[:, 0]).astype(int) + self.rows = np.rint(xy[:, 1]).astype(int) + + H, W = self.shape + np.clip(self.cols, 0, W - 1, out=self.cols) + np.clip(self.rows, 0, H - 1, out=self.rows) + + def _resolve_obsm_key(self, preferred: Optional[str]) -> str: + if preferred and preferred in self._available_obsm: + return preferred + for candidate in ("spatial_cropped_150_buffer", "spatial"): + if candidate in self._available_obsm: + return candidate + return self._available_obsm[0] + + @property + def shape(self) -> Tuple[int, int]: + return self.lab_sp.shape + + @property + def available_obsm(self) -> List[str]: + return list(self._available_obsm) + + @property + def gene_names(self) -> List[str]: + return list(map(str, self.adata.var_names)) + + @property + def obs_columns(self) -> List[str]: + return list(map(str, self.adata.obs.columns)) + + def crop_dense(self, r0: int, r1: int, c0: int, c1: int) -> Tuple[np.ndarray, np.ndarray]: + he = self.he[r0:r1, c0:c1] + lab = self.lab_sp[r0:r1, c0:c1].toarray().astype(np.int32, copy=False) + return he, lab + + def gene_vector(self, gene: str) -> np.ndarray: + if gene not in self._gene_cache: + if gene not in self.adata.var_names: + raise KeyError(f"Gene '{gene}' not found in AnnData.") + values = self.adata[:, gene].X + if issparse(values): + values = values.toarray() + self._gene_cache[gene] = np.asarray(values).ravel() + return self._gene_cache[gene] + + def obs_vector(self, column: str) -> np.ndarray: + if column not in self._obs_cache: + if column not in self.adata.obs.columns: + raise KeyError(f"Column '{column}' not found in AnnData.obs.") + self._obs_cache[column] = self.adata.obs[column].to_numpy() + return self._obs_cache[column] + + def obs_metadata(self, column: str) -> Dict[str, object]: + if column not in self.adata.obs.columns: + raise KeyError(f"Column '{column}' not found in AnnData.obs.") + cached = self._obs_meta_cache.get(column) + if cached is not None: + return cached + metadata = self._build_obs_metadata(column) + self._obs_meta_cache[column] = metadata + return metadata + + def _build_obs_metadata(self, column: str) -> Dict[str, object]: + series = self.adata.obs[column] + dtype = getattr(series, "dtype", None) + categories: List[str] = [] + limit_hit = False + + if dtype is not None and hasattr(dtype, "categories"): + cat_values = list(getattr(series.cat, "categories", getattr(dtype, "categories", []))) + categories = [str(cat) for cat in cat_values] + elif dtype is not None and getattr(dtype, "kind", "") in {"O", "U", "S"}: + raw_unique = series.dropna().astype(str).unique() + limit_hit = raw_unique.size > OBS_CATEGORY_LIMIT + if not limit_hit: + categories = _unique_sorted(map(str, raw_unique.tolist())) + else: + limit_hit = True + + color_key = f"{column}_colors" + raw_colors = self.adata.uns.get(color_key) + color_map: Dict[str, str] = {} + if isinstance(raw_colors, (list, tuple, np.ndarray)) and categories: + for cat, raw_color in zip(categories, raw_colors): + if raw_color is None: + continue + raw_color_str = str(raw_color) + if raw_color_str: + color_map[str(cat)] = raw_color_str + + return { + "categories": categories, + "color_map": color_map, + "category_limit_hit": limit_hit, + } + + def obs_color_map(self, column: str) -> Dict[str, str]: + meta = self.obs_metadata(column) + colors_dict = meta.get("color_map") or {} + return dict(colors_dict) # shallow copy + + +def make_tiles( + ctx: B2CContext, tile_h: int = 1500, tile_w: int = 1500, stride_h: Optional[int] = None, stride_w: Optional[int] = None +) -> List[Tile]: + H, W = ctx.shape + stride_h = tile_h if stride_h is None else stride_h + stride_w = tile_w if stride_w is None else stride_w + + tiles: List[Tile] = [] + tid = 0 + r = 0 + while r < H: + r1 = min(r + tile_h, H) + c = 0 + while c < W: + c1 = min(c + tile_w, W) + tiles.append(Tile(tid, r, r1, c, c1)) + tid += 1 + if c1 == W: + break + c += stride_w + if r1 == H: + break + r += stride_h + return tiles + + +def _compute_expansion_distance( + lab_raw: np.ndarray, + *, + mode: str, + max_bin_distance: float, + mpp: float, + bin_um: float, + volume_ratio: float, +) -> int: + dist_px_fixed = int(math.ceil(max(1.0, max_bin_distance * (bin_um / mpp)))) + if mode != "volume_ratio": + return dist_px_fixed + + lab_ids, counts = np.unique(lab_raw[lab_raw > 0], return_counts=True) + if lab_ids.size == 0: + return dist_px_fixed + + r_eff = np.sqrt(counts / np.pi) + delta_r = (np.sqrt(volume_ratio) - 1.0) * r_eff + dist_px = int(np.clip(np.median(delta_r), 1, 5 * dist_px_fixed)) + return max(1, dist_px) + + +def _expand_labels_tile( + ctx: B2CContext, + tile: Tile, + *, + mode: str, + max_bin_distance: float, + mpp: float, + bin_um: float, + volume_ratio: float, + pad_factor: int = 2, +) -> Tuple[np.ndarray, np.ndarray, int]: + r0, r1, c0, c1 = tile.r0, tile.r1, tile.c0, tile.c1 + he_crop, lab_raw = ctx.crop_dense(r0, r1, c0, c1) + + dist_px = _compute_expansion_distance( + lab_raw, + mode=mode, + max_bin_distance=max_bin_distance, + mpp=mpp, + bin_um=bin_um, + volume_ratio=volume_ratio, + ) + + H, W = ctx.shape + pad = pad_factor * dist_px + + rp0 = max(r0 - pad, 0) + rp1 = min(r1 + pad, H) + cp0 = max(c0 - pad, 0) + cp1 = min(c1 + pad, W) + + lab_pad = ctx.lab_sp[rp0:rp1, cp0:cp1].toarray().astype(np.int32, copy=False) + lab_exp_pad = expand_labels(lab_pad, distance=dist_px) + + r0_rel, r1_rel = r0 - rp0, r1 - rp0 + c0_rel, c1_rel = c0 - cp0, c1 - cp0 + + lab_exp = lab_exp_pad[r0_rel:r1_rel, c0_rel:c1_rel] + return he_crop, lab_exp.astype(np.int32, copy=False), int(dist_px) + + +def _polygons_from_labels(lab: np.ndarray, tile: Tile) -> Dict[int, List[List[List[float]]]]: + """ + FAST polygon extraction using cv2.findContours in C++. + This is 10-100x faster than the pure Python approach. + """ + import cv2 + + r0, c0 = tile.r0, tile.c0 + output: Dict[int, List[List[List[float]]]] = {} + + # Get unique labels (excluding background) + unique_labels = np.unique(lab) + unique_labels = unique_labels[unique_labels > 0] + + if len(unique_labels) == 0: + return output + + # Process each label - cv2.findContours is MUCH faster than skimage + for lbl in unique_labels: + mask = (lab == lbl).astype(np.uint8) * 255 + + # cv2.findContours is implemented in C++ and is very fast + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + poly_list = [] + for contour in contours: + if len(contour) < 3: + continue + # contour is shape (N, 1, 2) - reshape to (N, 2) + contour = contour.reshape(-1, 2) + polygon = [[float(x + c0), float(y + r0)] for x, y in contour] + if polygon and polygon[0] != polygon[-1]: + polygon.append(polygon[0]) + if polygon: + poly_list.append(polygon) + + if poly_list: + output[int(lbl)] = poly_list + + return output + + +def _outline_paths(lab: np.ndarray, tile: Tile) -> List[List[List[float]]]: + r0, c0 = tile.r0, tile.c0 + edges = find_boundaries(lab, mode="inner") + contours = measure.find_contours(edges.astype(np.uint8), 0.5) + paths: List[List[List[float]]] = [] + for contour in contours: + if contour.shape[0] < 2: + continue + path: List[List[float]] = [] + for y, x in contour: + path.append([float(x + c0), float(y + r0)]) + paths.append(path) + return paths + +def _outline_paths_per_label(lab: np.ndarray, tile: Tile, labels: Optional[List[int]] = None) -> Dict[int, List[List[List[float]]]]: + """ + Generate outline paths for each individual label using cv2 (fast C++ implementation). + """ + import cv2 + + r0, c0 = tile.r0, tile.c0 + result: Dict[int, List[List[List[float]]]] = {} + + unique_labels = labels if labels is not None else np.unique(lab).tolist() + unique_labels = [lbl for lbl in unique_labels if lbl != 0] + + if not unique_labels: + return result + + for lbl in unique_labels: + mask = (lab == lbl).astype(np.uint8) * 255 + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + paths = [] + for contour in contours: + if len(contour) < 2: + continue + contour = contour.reshape(-1, 2) + path = [[float(x + c0), float(y + r0)] for x, y in contour] + paths.append(path) + + if paths: + result[int(lbl)] = paths + + return result + + + +def _centroids_for_tile(ctx: B2CContext, tile: Tile) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + r0, r1, c0, c1 = tile.r0, tile.r1, tile.c0, tile.c1 + in_tile = (ctx.rows >= r0) & (ctx.rows < r1) & (ctx.cols >= c0) & (ctx.cols < c1) + indices = np.where(in_tile)[0] + if indices.size == 0: + empty_int = np.empty((0,), dtype=np.int32) + empty_local = np.empty((0, 2), dtype=np.int32) + return empty_int, empty_int, empty_int, empty_local + + rr_local = (ctx.rows[indices] - r0).astype(np.int32) + cc_local = (ctx.cols[indices] - c0).astype(np.int32) + local = np.stack([rr_local, cc_local], axis=1) + return indices, ctx.rows[indices], ctx.cols[indices], local + + +class TileCache: + def __init__(self, max_items: int = 6): + self._max = max_items + self._data: "OrderedDict[Tuple, Dict]" = OrderedDict() + self._lock = threading.RLock() + + def clear(self) -> None: + with self._lock: + self._data.clear() + + def get(self, key: Tuple) -> Optional[Dict]: + with self._lock: + entry = self._data.get(key) + if entry is not None: + self._data.move_to_end(key) + return entry + + def set(self, key: Tuple, value: Dict) -> None: + with self._lock: + self._data[key] = value + self._data.move_to_end(key) + while len(self._data) > self._max: + self._data.popitem(last=False) + + +class GeometryCache: + """ + Cache for tile geometry (polygons, outlines, centroids). + Invalidated ONLY when geometry-affecting parameters change. + + Geometry parameters: b2c_mode, max_bin_distance, mpp, bin_um, volume_ratio, pad_factor + """ + def __init__(self, max_items: int = 100): + self._max = max_items + self._data: "OrderedDict[Tuple, Dict]" = OrderedDict() + self._lock = threading.RLock() + self._hits = 0 + self._misses = 0 + + def _make_key( + self, + dataset_id: str, + tile_id: int, + b2c_mode: str, + max_bin_distance: float, + mpp: float, + bin_um: float, + volume_ratio: float, + pad_factor: int, + ) -> Tuple: + """ + Create cache key from ONLY geometry-affecting parameters. + Round floats to avoid cache misses from floating-point precision. + """ + return ( + dataset_id, + tile_id, + b2c_mode, + round(max_bin_distance, 3), + round(mpp, 4), + round(bin_um, 4), + round(volume_ratio, 4), + pad_factor, + ) + + def get(self, key: Tuple) -> Optional[Dict]: + """Thread-safe cache get with LRU update""" + with self._lock: + entry = self._data.get(key) + if entry is not None: + self._data.move_to_end(key) + self._hits += 1 + return entry + else: + self._misses += 1 + return None + + def set(self, key: Tuple, value: Dict) -> None: + """Thread-safe cache set with LRU eviction""" + with self._lock: + self._data[key] = value + self._data.move_to_end(key) + + # Evict oldest if over limit + while len(self._data) > self._max: + evicted_key, _ = self._data.popitem(last=False) + LOGGER.debug(f"GeometryCache: Evicted {evicted_key}") + + def stats(self) -> Dict[str, object]: + """Get cache statistics""" + with self._lock: + total = self._hits + self._misses + hit_rate = (self._hits / total * 100) if total > 0 else 0 + return { + "size": len(self._data), + "max": self._max, + "hits": self._hits, + "misses": self._misses, + "hit_rate": round(hit_rate, 2), + } + + def clear(self) -> None: + """Clear all cached geometry""" + with self._lock: + self._data.clear() + self._hits = 0 + self._misses = 0 + + +class ColorCache: + """ + Cache for colored overlays. + Invalidated when color parameters OR geometry parameters change. + Higher capacity than GeometryCache since colors change more frequently. + + Color parameters: overlay_type, gene, obs_col, category, color_mode, gradient_color, expr_quantile + """ + def __init__(self, max_items: int = 200): + self._max = max_items + self._data: "OrderedDict[Tuple, Dict]" = OrderedDict() + self._lock = threading.RLock() + self._hits = 0 + self._misses = 0 + + def _make_key( + self, + dataset_id: str, + tile_id: int, + overlay_type: str, + geometry_key: Tuple, + gene: Optional[str] = None, + obs_col: Optional[str] = None, + category: Optional[str] = None, + color_mode: str = "gradient", + gradient_color: Optional[str] = None, + expr_quantile: Optional[float] = None, + ) -> Tuple: + """ + Create cache key from color parameters + geometry key. + + The geometry_key ensures that color cache is invalidated when + geometry changes (since we need to recolor the new geometry). + """ + if overlay_type == "gene": + return ( + dataset_id, + tile_id, + "gene", + geometry_key, # Include geometry key for invalidation + gene, + color_mode, + gradient_color, + round(expr_quantile, 4) if expr_quantile else None, + ) + else: # observation + return ( + dataset_id, + tile_id, + "obs", + geometry_key, + obs_col, + category or "__all__", + ) + + def get(self, key: Tuple) -> Optional[Dict]: + """Thread-safe cache get with LRU update""" + with self._lock: + entry = self._data.get(key) + if entry is not None: + self._data.move_to_end(key) + self._hits += 1 + return entry + else: + self._misses += 1 + return None + + def set(self, key: Tuple, value: Dict) -> None: + """Thread-safe cache set with LRU eviction""" + with self._lock: + self._data[key] = value + self._data.move_to_end(key) + + while len(self._data) > self._max: + self._data.popitem(last=False) + + def invalidate_geometry(self, geometry_key: Tuple) -> int: + """ + Invalidate all color cache entries for a specific geometry. + Called when geometry parameters change. + + Returns: Number of entries invalidated + """ + with self._lock: + keys_to_remove = [ + k for k in self._data.keys() if len(k) > 3 and k[3] == geometry_key + ] + for k in keys_to_remove: + del self._data[k] + return len(keys_to_remove) + + def stats(self) -> Dict[str, object]: + """Get cache statistics""" + with self._lock: + total = self._hits + self._misses + hit_rate = (self._hits / total * 100) if total > 0 else 0 + return { + "size": len(self._data), + "max": self._max, + "hits": self._hits, + "misses": self._misses, + "hit_rate": round(hit_rate, 2), + } + + def clear(self) -> None: + """Clear all cached colors""" + with self._lock: + self._data.clear() + self._hits = 0 + self._misses = 0 + + +class Plugin: + def __init__(self, app): + self.app = app + self.out_dir = os.path.expanduser("~/.tissuumaps/plugins/CellExplorer") + os.makedirs(self.out_dir, exist_ok=True) + self.log_path = os.path.join(self.out_dir, "CellExplorer.log") + if not LOGGER.handlers: + handler = logging.FileHandler(self.log_path) + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) + LOGGER.addHandler(handler) + LOGGER.setLevel(logging.INFO) + + # CRITICAL FIX: Use app-level storage instead of module-level global + # This survives module reloading which TissUUmaps does on each request + if not hasattr(app, '_cell_explorer_state'): + LOGGER.info("🆕 Initializing NEW app-level state (first time or after app restart)") + app._cell_explorer_state = { + "context": None, + "tiles": [], + "dataset_id": None, + "tile_cache": TileCache(max_items=32), + "geometry_cache": GeometryCache(max_items=100), + "color_cache": ColorCache(max_items=200), + "tile_cache_size": 32, + "dataset_config": None, + "loading": False, + "cache_warm_job": None, + "staged_dir": None, + "staged_dir_managed": False, + "worker_pool": None, + "worker_count": 1, + "tile_jobs": {}, + "tile_jobs_lock": threading.RLock(), + "single_tile_mode": False, + } + else: + LOGGER.info(f"♻️ Reusing EXISTING app-level state (context exists: {app._cell_explorer_state.get('context') is not None})") + + self._state = app._cell_explorer_state + self.current_params: Dict[str, str] = {} + self.state_path = os.path.join(self.out_dir, "dataset_state.json") + if self._state.get("dataset_config") is None: + cached = self._load_cached_config() + if cached: + self._state["dataset_config"] = cached + + self.presets_path = os.path.join(self.out_dir, "presets.json") + self.presets = self._load_presets() + + # Helpers + def _json_response(self, payload: Dict) -> "flask.Response": + return make_response(json.dumps(payload, default=_json_default), 200, {"Content-Type": "application/json"}) + + def _json_response_compressed(self, payload: Dict) -> "flask.Response": + """ + Return gzip-compressed JSON response. + + Reduces payload size by 70-90% for typical JSON responses. + Browsers automatically decompress gzip responses. + """ + json_str = json.dumps(payload, default=_json_default) + json_bytes = json_str.encode('utf-8') + + # Compress (level 6 is good balance of speed vs compression) + compressed = gzip.compress(json_bytes, compresslevel=6) + + response = make_response(compressed, 200) + response.headers['Content-Type'] = 'application/json' + response.headers['Content-Encoding'] = 'gzip' + response.headers['Content-Length'] = len(compressed) + + LOGGER.debug( + "Response: %d bytes → %d bytes (%.1f%% of original)", + len(json_bytes), len(compressed), len(compressed) / len(json_bytes) * 100 + ) + + return response + + def _error_response(self, status: int, message: str, exc: Optional[Exception] = None): + if exc is not None: + LOGGER.exception(message) + else: + LOGGER.error(message) + payload = {"status": "error", "message": message} + if exc is not None: + payload["detail"] = repr(exc) + return make_response(json.dumps(payload), status, {"Content-Type": "application/json"}) + + def _error_from_exception(self, exc: Exception, context: str): + if isinstance(exc, FileNotFoundError): + status = 404 + message = str(exc) + elif isinstance(exc, (ValueError, KeyError, RuntimeError)): + status = 400 + message = str(exc) + elif isinstance(exc, ImportError): + status = 500 + message = f"{context}: missing dependency ({exc})" + else: + status = 500 + message = f"{context}: {exc}" + return self._error_response(status, message, exc) + + def _ensure_context(self) -> B2CContext: + if self.context is None: + config = self._state.get("dataset_config") or self._load_cached_config() + if config: + LOGGER.warning("⚠️ Context is None, rehydrating dataset (this should only happen once per session!)") + ctx = B2CContext( + he_image_path=config["he_path"], + labels_npz_path=config["labels_path"], + adata_path=config["adata_path"], + obsm_key=config.get("obsm_key"), + ) + tiles = make_tiles( + ctx, + tile_h=config.get("tile_h", 1500) or 1500, + tile_w=config.get("tile_w", 1500) or 1500, + stride_h=config.get("stride_h"), + stride_w=config.get("stride_w"), + ) + self.context = ctx + self.tiles = tiles + self.dataset_id = os.path.basename(config["adata_path"]) + + # IMPORTANT: Only clear old TileCache, NOT the new caches! + self.tile_cache.clear() + # DO NOT clear geometry_cache or color_cache here! + + self._state["dataset_config"] = config + LOGGER.info(f"✅ Context rehydrated. Dataset ID: {self.dataset_id}") + else: + raise RuntimeError("Load a dataset first.") + return self.context + + @property + def context(self) -> Optional[B2CContext]: + ctx = self._state.get("context") + if ctx is None: + LOGGER.debug(f"🔴 context getter: returning None (app._cell_explorer_state id: {id(self._state)})") + else: + LOGGER.debug(f"🟢 context getter: returning context (ctx id: {id(ctx)}, state id: {id(self._state)})") + return ctx # type: ignore[return-value] + + @context.setter + def context(self, value: Optional[B2CContext]) -> None: + if value is None: + LOGGER.warning(f"🔴 context setter: Setting context to None! (state id: {id(self._state)})") + else: + LOGGER.info(f"🟢 context setter: Storing context (ctx id: {id(value)}, state id: {id(self._state)})") + self._state["context"] = value + + @property + def tiles(self) -> List[Tile]: + return self._state.get("tiles", []) # type: ignore[return-value] + + @tiles.setter + def tiles(self, value: List[Tile]) -> None: + self._state["tiles"] = value + + @property + def dataset_id(self) -> Optional[str]: + return self._state.get("dataset_id") # type: ignore[return-value] + + @dataset_id.setter + def dataset_id(self, value: Optional[str]) -> None: + self._state["dataset_id"] = value + + @property + def tile_cache(self) -> TileCache: + cache = self._state.get("tile_cache") + if cache is None: + cache = TileCache(max_items=self._state.get("tile_cache_size", 32)) + self._state["tile_cache"] = cache + return cache # type: ignore[return-value] + + def _load_presets(self) -> Dict[str, Dict]: + if not os.path.exists(self.presets_path): + return {} + try: + with open(self.presets_path, "r") as fh: + presets = json.load(fh) + return presets if isinstance(presets, dict) else {} + except Exception as exc: # pragma: no cover - runtime guard + LOGGER.warning("Failed to read presets: %s", exc) + return {} + + def _save_presets(self) -> None: + with open(self.presets_path, "w") as fh: + json.dump(self.presets, fh, indent=2) + + def _config_paths_valid(self, config: Dict) -> bool: + required = ["he_path", "labels_path", "adata_path"] + for key in required: + path = config.get(key) + if not path or not os.path.exists(os.path.abspath(os.path.expanduser(str(path)))): + LOGGER.warning("Cached path for %s is missing: %s", key, path) + return False + return True + + def _normalize_tile_cache_size(self, value: object) -> int: + default_size = self._state.get("tile_cache_size") or 32 + size = _safe_int(value, default_size) + return max(1, min(512, size)) + + def _configure_tile_cache(self, size: int) -> None: + size = self._normalize_tile_cache_size(size) + current_size = self._state.get("tile_cache_size", 4) + cache = self._state.get("tile_cache") + if cache is None: + cache = TileCache(max_items=size) + if current_size == size and cache is self._state.get("tile_cache"): + self._state["tile_cache_size"] = size + return + new_cache = TileCache(max_items=size) + if hasattr(cache, "_data"): + for key, value in cache._data.items(): + new_cache.set(key, value) + self._state["tile_cache"] = new_cache + self._state["tile_cache_size"] = size + + def _resolve_stage_root(self, override: Optional[str]) -> Optional[str]: + candidates = [ + override, + os.environ.get("TISSUUMAPS_STAGE_ROOT"), + os.environ.get("BIN2CELL_STAGE_ROOT"), + os.environ.get("SLURM_TMPDIR"), + os.environ.get("TMPDIR"), + ] + for candidate in candidates: + if not candidate: + continue + normalized = _normalize_path(candidate) + if not normalized: + continue + try: + os.makedirs(normalized, exist_ok=True) + return normalized + except OSError as exc: + LOGGER.warning("Failed to prepare staging root %s: %s", normalized, exc) + return None + + def _cleanup_staged_dir(self) -> None: + staged_dir = self._state.get("staged_dir") + managed = self._state.get("staged_dir_managed", False) + if staged_dir and managed and os.path.isdir(staged_dir): + try: + shutil.rmtree(staged_dir) + LOGGER.info("Removed previous staged dataset at %s", staged_dir) + except Exception as exc: + LOGGER.warning("Failed to remove staged dataset directory %s: %s", staged_dir, exc) + self._state["staged_dir"] = None + self._state["staged_dir_managed"] = False + + def _stage_dataset_files( + self, + *, + he_path: str, + labels_path: str, + adata_path: str, + stage_root: str, + ) -> Tuple[Dict[str, str], Dict[str, object]]: + os.makedirs(stage_root, exist_ok=True) + dest_root = tempfile.mkdtemp(prefix="b2c_stage_", dir=stage_root) + staged_paths: Dict[str, str] = {} + inputs = { + "he_path": he_path, + "labels_path": labels_path, + "adata_path": adata_path, + } + for key, src in inputs.items(): + normalized_src = _normalize_path(src) + if not normalized_src: + continue + # Validate that the path exists and is a file, not a directory + if not os.path.exists(normalized_src): + raise FileNotFoundError(f"File not found for {key}: {normalized_src}") + if os.path.isdir(normalized_src): + raise IsADirectoryError(f"Path for {key} is a directory, expected a file: {normalized_src}") + try: + common = os.path.commonpath([normalized_src, stage_root]) + except ValueError: + common = "" + if common == stage_root: + staged_paths[key] = normalized_src + continue + dest_path = os.path.join(dest_root, os.path.basename(normalized_src)) + LOGGER.info("Staging %s -> %s", normalized_src, dest_path) + shutil.copy2(normalized_src, dest_path) + staged_paths[key] = dest_path + self._state["staged_dir"] = dest_root + self._state["staged_dir_managed"] = True + return staged_paths, { + "staged_dir": dest_root, + "stage_root": stage_root, + "files": staged_paths, + } + + def _stop_cache_warmer(self) -> None: + job = self._state.get("cache_warm_job") + if not job: + return + stop_event = job.get("stop_event") + if stop_event: + stop_event.set() + thread = job.get("thread") + if thread and thread.is_alive(): + thread.join(timeout=0.1) + self._state["cache_warm_job"] = None + + def _start_cache_warmer( + self, + *, + enabled: bool, + tile_limit: int, + warm_params: Dict[str, object], + ) -> None: + self._stop_cache_warmer() + if not enabled: + return + if not self.tiles: + return + if self.context is None: + return + stop_event = threading.Event() + total = tile_limit if tile_limit > 0 else len(self.tiles) + job: Dict[str, object] = { + "stop_event": stop_event, + "progress": 0, + "total": total, + "params": warm_params, + } + + def worker() -> None: + warmed = 0 + future_map: Dict[Future, int] = {} + scheduled = 0 + for tile in self.tiles: + if stop_event.is_set(): + break + try: + future = self._schedule_tile_job( + tile.id, + b2c_mode=warm_params.get("b2c_mode", "fixed"), + max_bin_distance=_safe_float(warm_params.get("max_bin_distance"), 2.0), + mpp=_safe_float(warm_params.get("mpp"), 0.3), + bin_um=_safe_float(warm_params.get("bin_um"), 2.0), + volume_ratio=_safe_float(warm_params.get("volume_ratio"), 4.0), + pad_factor=max(1, _safe_int(warm_params.get("pad_factor"), 2)), + ) + future_map[future] = tile.id + scheduled += 1 + except Exception as exc: # pragma: no cover - background log + LOGGER.warning("Cache warm scheduling failed for tile %s: %s", tile.id, exc) + continue + if tile_limit and scheduled >= tile_limit: + break + job["total"] = scheduled + if not future_map: + job["done"] = True + return + for future in as_completed(list(future_map.keys())): + if stop_event.is_set(): + break + tile_id = future_map.get(future) + try: + future.result() + except Exception as exc: # pragma: no cover - background log + LOGGER.warning("Cache warm failed for tile %s: %s", tile_id, exc) + warmed += 1 + job["progress"] = warmed + if tile_limit and warmed >= tile_limit: + break + job["done"] = True + + thread = threading.Thread(target=worker, name="Bin2CellCacheWarmer", daemon=True) + job["thread"] = thread + self._state["cache_warm_job"] = job + thread.start() + + def _cache_warm_status(self) -> Dict[str, object]: + job = self._state.get("cache_warm_job") + if not job: + return {"active": False, "progress": 0, "total": 0} + thread = job.get("thread") + active = bool(thread and thread.is_alive()) + status = { + "active": active, + "progress": job.get("progress", 0), + "total": job.get("total") or len(self.tiles), + } + if job.get("done"): + status["active"] = False + status["done"] = True + return status + + def _shutdown_worker_pool(self) -> None: + pool = self._state.get("worker_pool") + if pool: + pool.shutdown(wait=False) + self._state["worker_pool"] = None + self._state["worker_count"] = 0 + jobs = self._state.get("tile_jobs") + if jobs: + for future in jobs.values(): + if isinstance(future, Future): + future.cancel() + self._state["tile_jobs"] = {} + + def _configure_worker_pool(self, requested: Optional[int]) -> ThreadPoolExecutor: + desired = max(1, min(_safe_int(requested, os.cpu_count() or 1), (os.cpu_count() or 1))) + pool = self._state.get("worker_pool") + current = self._state.get("worker_count", 0) + if pool and current == desired: + return pool + if pool: + pool.shutdown(wait=False) + pool = ThreadPoolExecutor( + max_workers=desired, + thread_name_prefix="Bin2CellTile", + ) + self._state["worker_pool"] = pool + self._state["worker_count"] = desired + if "tile_jobs" not in self._state: + self._state["tile_jobs"] = {} + if "tile_jobs_lock" not in self._state: + self._state["tile_jobs_lock"] = threading.RLock() + return pool + + def _tile_cache_key( + self, + tile_id: int, + *, + b2c_mode: str, + max_bin_distance: float, + mpp: float, + bin_um: float, + volume_ratio: float, + pad_factor: int, + ) -> Tuple: + return ( + self.dataset_id, + tile_id, + b2c_mode, + round(max_bin_distance, 3), + round(mpp, 4), + round(bin_um, 4), + round(volume_ratio, 4), + pad_factor, + ) + + def _schedule_tile_job( + self, + tile_id: int, + *, + b2c_mode: str, + max_bin_distance: float, + mpp: float, + bin_um: float, + volume_ratio: float, + pad_factor: int, + ) -> Future: + cache_key = self._tile_cache_key( + tile_id, + b2c_mode=b2c_mode, + max_bin_distance=max_bin_distance, + mpp=mpp, + bin_um=bin_um, + volume_ratio=volume_ratio, + pad_factor=pad_factor, + ) + cached = self.tile_cache.get(cache_key) + if cached is not None: + future: Future = Future() + future.set_result(cached) + return future + + single_tile_mode = bool(self._state.get("single_tile_mode")) + if single_tile_mode: + self.tile_cache.clear() + self._cancel_tile_jobs(keep_key=cache_key) + + pool = self._state.get("worker_pool") + if pool is None: + pool = self._configure_worker_pool(self._state.get("worker_count") or 1) + + jobs = self._state.setdefault("tile_jobs", {}) + lock = self._state.setdefault("tile_jobs_lock", threading.RLock()) + with lock: + existing = jobs.get(cache_key) + if existing: + return existing + + future = pool.submit( + self._tile_entry, + tile_id, + b2c_mode=b2c_mode, + max_bin_distance=max_bin_distance, + mpp=mpp, + bin_um=bin_um, + volume_ratio=volume_ratio, + pad_factor=pad_factor, + cache_key=cache_key, + ) + + def _cleanup(fut: Future) -> None: + with lock: + jobs.pop(cache_key, None) + + future.add_done_callback(_cleanup) + jobs[cache_key] = future + return future + + def _cancel_tile_jobs(self, keep_key: Optional[Tuple] = None) -> None: + jobs = self._state.get("tile_jobs") + if not jobs: + return + lock = self._state.setdefault("tile_jobs_lock", threading.RLock()) + with lock: + for key in list(jobs.keys()): + if keep_key is not None and key == keep_key: + continue + future = jobs.pop(key, None) + if isinstance(future, Future): + future.cancel() + + # Endpoints + + def pick_file(self, params: Dict): + try: + field = str(params.get("field") or "") + if field not in {"he_path", "labels_path", "adata_path"}: + raise ValueError("Unsupported field for file picker.") + + current = params.get("current_path") or "" + start_dir = params.get("start_dir") or "" + if current: + current = os.path.abspath(os.path.expanduser(os.path.expandvars(str(current)))) + if os.path.isdir(current): + start_dir = current + else: + start_dir = os.path.dirname(current) + if not start_dir: + start_dir = os.path.expanduser("~") + + slide_root = os.path.abspath(self.app.config.get("SLIDE_DIR", ".")) + start_rel = "" + try: + start_abs = os.path.abspath(start_dir) + if os.path.commonpath([start_abs, slide_root]) == slide_root: + rel = os.path.relpath(start_abs, slide_root) + start_rel = "." if rel == "." else rel + except Exception: + start_rel = "" + + use_web_picker = not self.app.config.get("isStandalone") + if use_web_picker: + return self._json_response( + { + "status": "web", + "field": field, + "start_rel": start_rel, + } + ) + + filters = { + "he_path": "H&E images (*.tif *.tiff *.TIF *.TIFF);;All files (*)", + "labels_path": "Label matrices (*.npz *.NPZ);;All files (*)", + "adata_path": "AnnData (*.h5ad *.H5AD);;All files (*)", + } + captions = { + "he_path": "Select H&E image", + "labels_path": "Select label matrix (.npz)", + "adata_path": "Select AnnData (.h5ad)", + } + + app = QApplication.instance() + if app is None: + raise RuntimeError("QApplication instance not found; cannot open file dialog.") + + global _FILE_DIALOG_HELPER + if _FILE_DIALOG_HELPER is None: + helper = FileDialogHelper() + helper.moveToThread(app.thread()) + _FILE_DIALOG_HELPER = helper + + filename, _ = _FILE_DIALOG_HELPER.get_open_file_name( + captions[field], + start_dir, + filters[field], + ) + + if not filename: + return self._json_response({"status": "cancelled"}) + + path = os.path.abspath(filename) + if not os.path.isfile(path): + raise FileNotFoundError(f"Selected path is not a file: {path}") + return self._json_response({"status": "ok", "path": path, "field": field}) + except Exception as exc: + return self._error_from_exception(exc, "Failed to pick file") + + def _resolve_root(self, rel_path: str) -> str: + base = os.path.abspath(self.app.config.get("SLIDE_DIR", ".")) + target = os.path.abspath(os.path.join(base, rel_path.lstrip("/"))) + if os.path.commonpath([target, base]) != base: + raise ValueError("Path outside slide directory") + if not os.path.exists(target): + raise FileNotFoundError(target) + return target + + #clean this up + def _normalize_path_for_response(self, path: str) -> str: + """ + Convert a file path to the format expected by TissUUmaps based on mode. + - In web server mode: convert absolute paths within SLIDE_DIR to relative paths + - In standalone mode: return absolute paths as-is + """ + if not path: + return path + is_standalone = self.app.config.get("isStandalone", False) + if is_standalone: + # In standalone mode, return absolute paths as is + return os.path.abspath(os.path.expanduser(os.path.expandvars(path))) + # In web server mode, convert to relative if within SLIDE_DIR + slide_dir = os.path.abspath(self.app.config.get("SLIDE_DIR", ".")) + try: + # Try to normalize the path + abs_path = os.path.abspath(os.path.expanduser(os.path.expandvars(path))) + if os.path.commonpath([abs_path, slide_dir]) == slide_dir: + rel_path = os.path.relpath(abs_path, slide_dir) + if rel_path == ".": + # If file is at root of SLIDE_DIR, return just the filename + return os.path.basename(abs_path) + return rel_path + except (ValueError, OSError): + + pass + # If path is outside SLIDE_DIR or normalization failed, return as-is + # (TissUUmaps will handle the error appropriately) + return path + + def filetree(self, params: Dict): + field = str(params.get("field") or "") + if field not in {"he_path", "labels_path", "adata_path"}: + return self._error_response(400, "Unsupported field") + start_rel = params.get("start_rel") or "" + base_path = os.path.abspath(self.app.config.get("SLIDE_DIR", ".")) + html = render_template_string( + _FILETREE_TEMPLATE, + field=field, + base_path=base_path, + start_rel=start_rel, + ) + # Return HTML as JSON string for API consumption + return self._json_response({"html": html}) + + def filetree_data(self, params: Dict): + rel_root = params.get("root") or "." + try: + root = self._resolve_root(rel_root) + except Exception as exc: + return self._error_from_exception(exc, "Invalid root") + entries = [] + try: + with os.scandir(root) as it: + for entry in sorted(it, key=lambda e: e.name.lower()): + if entry.name.startswith("."): + continue + rel_path = os.path.relpath(entry.path, os.path.abspath(self.app.config.get("SLIDE_DIR", "."))) + if entry.is_dir(): + entries.append( + { + "text": entry.name, + "icon": "jstree-folder", + "state": {"opened": False}, + "data": {"isdirectory": True}, + "children": True, + } + ) + else: + entries.append( + { + "text": entry.name, + "icon": "jstree-file", + "data": {"isdirectory": False, "relpath": rel_path}, + } + ) + except Exception as exc: + return self._error_from_exception(exc, "Failed to read directory") + return self._json_response(entries) + + def _load_cached_config(self) -> Optional[Dict[str, object]]: + if not hasattr(self, "state_path"): + return None + if not os.path.exists(self.state_path): + return None + try: + with open(self.state_path, "r") as fh: + data = json.load(fh) + if isinstance(data, dict) and self._config_paths_valid(data): + return data + except Exception as exc: + LOGGER.warning("Failed to read cached dataset config: %s", exc) + return None + + def load_dataset(self, params: Dict): + try: + self._state["loading"] = True + self._stop_cache_warmer() + self._shutdown_worker_pool() + he_path = params.get("he_path", "") + labels_path = params.get("labels_path", "") + adata_path = params.get("adata_path", "") + LOGGER.info( + "load_dataset request paths: he_path=%r, labels_path=%r, adata_path=%r", + he_path, + labels_path, + adata_path, + ) + obsm_key = params.get("obsm_key") + tile_h = int(params.get("tile_h", 1500) or 1500) + tile_w = int(params.get("tile_w", 1500) or 1500) + stride_h_param = params.get("stride_h") + stride_w_param = params.get("stride_w") + stride_h = int(stride_h_param) if stride_h_param not in (None, "", "None") else None + stride_w = int(stride_w_param) if stride_w_param not in (None, "", "None") else None + single_tile_mode = _as_bool(params.get("single_tile_mode")) + tile_cache_size = self._normalize_tile_cache_size(params.get("tile_cache_size")) + if single_tile_mode: + tile_cache_size = 1 + self._configure_tile_cache(tile_cache_size) + tile_workers = max(1, min(_safe_int(params.get("tile_workers"), os.cpu_count() or 1), os.cpu_count() or 1)) + self._configure_worker_pool(tile_workers) + stage_to_local = _as_bool(params.get("stage_to_local")) + stage_root_override = params.get("stage_root") + stage_root = self._resolve_stage_root(stage_root_override) if stage_to_local else None + if stage_to_local and not stage_root: + raise ValueError( + "Local staging requested but no scratch directory is available. " + "Set 'stage_root' or export TISSUUMAPS_STAGE_ROOT / SLURM_TMPDIR." + ) + warm_cache_requested = _as_bool(params.get("warm_cache", True)) + warm_cache = warm_cache_requested and not single_tile_mode + warm_cache_tiles = 0 if single_tile_mode else max(0, _safe_int(params.get("warm_cache_tiles"), 0)) + warm_params = { + "b2c_mode": params.get("warm_b2c_mode") or params.get("b2c_mode") or "fixed", + "max_bin_distance": _safe_float(params.get("warm_max_bin_distance"), 2.0), + "mpp": _safe_float(params.get("warm_mpp"), 0.3), + "bin_um": _safe_float(params.get("warm_bin_um"), 2.0), + "volume_ratio": _safe_float(params.get("warm_volume_ratio"), 4.0), + "pad_factor": max(1, _safe_int(params.get("warm_pad_factor"), 2)), + } + + + source_he_path = _normalize_path(he_path) + source_labels_path = _normalize_path(labels_path) + source_adata_path = _normalize_path(adata_path) + + # Validate paths early with clear error messages + if not source_he_path or not source_labels_path or not source_adata_path: + missing = [] + if not source_he_path: + missing.append("H&E image (.tif/.tiff)") + if not source_labels_path: + missing.append("labels NPZ file") + if not source_adata_path: + missing.append("AnnData (.h5ad) file") + + raise ValueError(f"Missing required files: {', '.join(missing)}. Please use the 'Browse' buttons or enter the paths manually in the text fields.") + + if not os.path.exists(source_he_path): + raise FileNotFoundError(f"H&E image file not found: {source_he_path}") + if os.path.isdir(source_he_path): + raise IsADirectoryError(f"H&E image path is a directory, not a file: {source_he_path}. Please select the image file (e.g., he.tiff) inside this directory.") + + if not os.path.exists(source_labels_path): + raise FileNotFoundError(f"Labels NPZ file not found: {source_labels_path}") + if os.path.isdir(source_labels_path): + raise IsADirectoryError(f"Labels NPZ path is a directory, not a file: {source_labels_path}. Please select the .npz file inside this directory.") + + if not os.path.exists(source_adata_path): + raise FileNotFoundError(f"AnnData file not found: {source_adata_path}") + if os.path.isdir(source_adata_path): + raise IsADirectoryError(f"AnnData path is a directory, not a file: {source_adata_path}. Please select the .h5ad file inside this directory.") + + requested_config = { + "source_he_path": source_he_path, + "source_labels_path": source_labels_path, + "source_adata_path": source_adata_path, + "obsm_key": obsm_key, + "tile_h": tile_h, + "tile_w": tile_w, + "stride_h": stride_h, + "stride_w": stride_w, + "stage_to_local": stage_to_local, + "stage_root": stage_root if stage_to_local else None, + "tile_workers": tile_workers, + "single_tile_mode": single_tile_mode, + } + self._state["single_tile_mode"] = single_tile_mode + + existing_config = self._state.get("dataset_config") + if existing_config and not self._config_paths_valid(existing_config): + LOGGER.warning("Clearing invalid cached dataset config.") + existing_config = None + self._state["dataset_config"] = None + + if existing_config and all(existing_config.get(k) == requested_config.get(k) for k in requested_config): + LOGGER.info("Dataset already loaded; reusing cached context.") + ctx = self._ensure_context() + tiles = self.tiles + updated_config = dict(existing_config) + updated_config.update( + { + "tile_cache_size": tile_cache_size, + "warm_cache": warm_cache, + "warm_cache_tiles": warm_cache_tiles, + "stage_to_local": stage_to_local, + "stage_root": stage_root, + "tile_workers": tile_workers, + "single_tile_mode": single_tile_mode, + } + ) + self._state["dataset_config"] = updated_config + self._state["single_tile_mode"] = single_tile_mode + payload = { + "status": "ok", + "tile_count": len(tiles), + "tiles": [tile.to_dict() for tile in tiles], + "obsm_key": ctx.obsm_key, + "available_obsm": ctx.available_obsm, + "obs_columns": ctx.obs_columns, + "gene_count": len(ctx.gene_names), + "genes_preview": ctx.gene_names[:512], + "dataset_id": self.dataset_id, + "shape": ctx.shape, + **updated_config, + } + payload.update( + { + "tile_cache_size": tile_cache_size, + "stage_to_local": stage_to_local, + "stage_root": stage_root, + "warm_cache": warm_cache, + "warm_cache_tiles": warm_cache_tiles, + "single_tile_mode": single_tile_mode, + } + ) + # Convert paths to appropriate format for response (relative for web server, absolute for standalone) + if "he_path" in payload: + payload["he_path"] = self._normalize_path_for_response(payload["he_path"]) + self._start_cache_warmer(enabled=warm_cache, tile_limit=warm_cache_tiles, warm_params=warm_params) + payload["cache_warm_status"] = self._cache_warm_status() + return self._json_response(payload) + + self._cleanup_staged_dir() + effective_paths = { + "he_path": source_he_path, + "labels_path": source_labels_path, + "adata_path": source_adata_path, + } + staging_info: Dict[str, object] = { + "enabled": stage_to_local, + "stage_root": stage_root, + } + if stage_to_local and stage_root: + staged_paths, staging_details = self._stage_dataset_files( + he_path=source_he_path, + labels_path=source_labels_path, + adata_path=source_adata_path, + stage_root=stage_root, + ) + effective_paths.update(staged_paths) + staging_info.update(staging_details) + + ctx = B2CContext( + he_image_path=effective_paths["he_path"], + labels_npz_path=effective_paths["labels_path"], + adata_path=effective_paths["adata_path"], + obsm_key=obsm_key, + ) + tiles = make_tiles(ctx, tile_h=tile_h, tile_w=tile_w, stride_h=stride_h, stride_w=stride_w) + + self.context = ctx + self.tiles = tiles + self.tile_cache.clear() + self.dataset_id = os.path.basename(effective_paths["adata_path"]) + + dataset_config = { + "he_path": effective_paths["he_path"], + "labels_path": effective_paths["labels_path"], + "adata_path": effective_paths["adata_path"], + "obsm_key": ctx.obsm_key, + "tile_h": tile_h, + "tile_w": tile_w, + "stride_h": stride_h, + "stride_w": stride_w, + "source_he_path": source_he_path, + "source_labels_path": source_labels_path, + "source_adata_path": source_adata_path, + "stage_to_local": stage_to_local, + "stage_root": stage_root, + "staging": staging_info, + "tile_cache_size": tile_cache_size, + "warm_cache": warm_cache, + "warm_cache_tiles": warm_cache_tiles, + "tile_workers": tile_workers, + "single_tile_mode": single_tile_mode, + } + + payload = { + "status": "ok", + "tile_count": len(tiles), + "tiles": [tile.to_dict() for tile in tiles], + "obsm_key": ctx.obsm_key, + "available_obsm": ctx.available_obsm, + "obs_columns": ctx.obs_columns, + "gene_count": len(ctx.gene_names), + "genes_preview": ctx.gene_names[:512], + "dataset_id": self.dataset_id, + "shape": ctx.shape, + **dataset_config, + } + # Convert paths to appropriate format for response (relative for web server, absolute for standalone) + if "he_path" in payload: + payload["he_path"] = self._normalize_path_for_response(payload["he_path"]) + self._state["dataset_config"] = dataset_config + try: + with open(self.state_path, "w") as fh: + json.dump(dataset_config, fh) + except Exception as exc: + LOGGER.warning("Failed to persist dataset config: %s", exc) + LOGGER.info("Dataset loaded successfully: %d tiles", len(tiles)) + self._start_cache_warmer(enabled=warm_cache, tile_limit=warm_cache_tiles, warm_params=warm_params) + payload["cache_warm_status"] = self._cache_warm_status() + return self._json_response(payload) + except Exception as exc: + return self._error_from_exception(exc, "Failed to load dataset") + finally: + self._state["loading"] = False + + def describe_dataset(self, params: Dict): + try: + ctx = self._ensure_context() + config = self._state.get("dataset_config") or {} + payload = { + "dataset_id": self.dataset_id, + "shape": ctx.shape, + "tile_count": len(self.tiles), + "obsm_key": ctx.obsm_key, + "available_obsm": ctx.available_obsm, + "obs_columns": ctx.obs_columns, + "gene_count": len(ctx.gene_names), + "tile_cache_size": self._state.get("tile_cache_size"), + "stage_to_local": config.get("stage_to_local"), + "stage_root": config.get("stage_root"), + "warm_cache": config.get("warm_cache"), + "warm_cache_tiles": config.get("warm_cache_tiles"), + "tile_workers": config.get("tile_workers") or self._state.get("worker_count"), + "single_tile_mode": config.get("single_tile_mode"), + "cache_warm_status": self._cache_warm_status(), + } + return self._json_response(payload) + except Exception as exc: + return self._error_from_exception(exc, "Failed to describe dataset") + + def list_tiles(self, params: Dict): + try: + self._ensure_context() + return self._json_response({"tiles": [tile.to_dict() for tile in self.tiles]}) + except Exception as exc: + return self._error_from_exception(exc, "Failed to list tiles") + + def list_obs_columns(self, params: Dict): + try: + ctx = self._ensure_context() + cols = ctx.obs_columns + prefer = [] + fallback = [] + for col in cols: + series = ctx.obs_vector(col) + if series.dtype.kind in {"O", "U", "S"} or str(series.dtype).startswith("category"): + prefer.append(col) + else: + fallback.append(col) + ordered = prefer + [c for c in fallback if c not in prefer] + return self._json_response({"columns": ordered}) + except Exception as exc: + return self._error_from_exception(exc, "Failed to list observation columns") + + def describe_obs_column(self, params: Dict): + try: + ctx = self._ensure_context() + obs_col = params.get("obs_col") + if not obs_col: + raise ValueError("Provide 'obs_col'.") + meta = ctx.obs_metadata(obs_col) + payload = { + "obs_col": obs_col, + "categories": meta.get("categories", []), + "color_map": meta.get("color_map", {}), + "category_limit_hit": meta.get("category_limit_hit", False), + } + return self._json_response(payload) + except Exception as exc: + return self._error_from_exception(exc, "Failed to describe observation column") + + def list_genes(self, params: Dict): + try: + ctx = self._ensure_context() + limit = int(params.get("limit", 0) or 0) + genes = ctx.gene_names + if limit > 0: + genes = genes[:limit] + return self._json_response({"genes": genes}) + except Exception as exc: + return self._error_from_exception(exc, "Failed to list genes") + + # Overlay core + + def _tile_entry( + self, + tile_id: int, + *, + b2c_mode: str, + max_bin_distance: float, + mpp: float, + bin_um: float, + volume_ratio: float, + pad_factor: int = 2, + cache_key: Optional[Tuple] = None, + ) -> Dict: + """ + Get or compute tile geometry (polygons, outlines, centroids). + + This function is PURE - it depends ONLY on geometry parameters. + Color parameters have no effect here. + + Uses GeometryCache for caching instead of the old TileCache. + """ + ctx = self._ensure_context() + if tile_id < 0 or tile_id >= len(self.tiles): + raise ValueError(f"Tile id {tile_id} out of range.") + tile = self.tiles[tile_id] + + # Get geometry cache + geometry_cache = self._state.get("geometry_cache") + if geometry_cache is None: + geometry_cache = GeometryCache(max_items=100) + self._state["geometry_cache"] = geometry_cache + + # Create geometry cache key (ONLY geometry parameters!) + geom_key = geometry_cache._make_key( + self.dataset_id, + tile_id, + b2c_mode, + max_bin_distance, + mpp, + bin_um, + volume_ratio, + pad_factor, + ) + + # Check geometry cache + cached = geometry_cache.get(geom_key) + if cached is not None: + LOGGER.debug(f"GeometryCache HIT for tile {tile_id}") + return cached + + LOGGER.info(f"GeometryCache MISS for tile {tile_id}, computing geometry...") + + import time + t0 = time.time() + + # Cache miss - do expensive geometry computation + _, lab_raw = ctx.crop_dense(tile.r0, tile.r1, tile.c0, tile.c1) + t1 = time.time() + LOGGER.info(f"⏱️ Tile {tile_id}: crop_dense took {(t1-t0)*1000:.1f}ms") + + # Only do expensive label expansion if actually needed + if b2c_mode == "none": + # Skip expansion entirely - huge time savings! + lab_exp = lab_raw # Use raw labels directly + dist_px = 0 + t2 = time.time() + LOGGER.info(f"⏱️ Tile {tile_id}: SKIPPED _expand_labels_tile (b2c_mode=none)") + else: + he_crop, lab_exp, dist_px = _expand_labels_tile( + ctx, + tile, + mode=b2c_mode, + max_bin_distance=max_bin_distance, + mpp=mpp, + bin_um=bin_um, + volume_ratio=volume_ratio, + pad_factor=pad_factor, + ) + t2 = time.time() + LOGGER.info(f"⏱️ Tile {tile_id}: _expand_labels_tile took {(t2-t1)*1000:.1f}ms") + + centroid_idx, rows_abs, cols_abs, local_rc = _centroids_for_tile(ctx, tile) + labels_at_centroids = lab_exp[local_rc[:, 0], local_rc[:, 1]] if local_rc.size else np.empty((0,), dtype=np.int32) + t3 = time.time() + LOGGER.info(f"⏱️ Tile {tile_id}: _centroids_for_tile took {(t3-t2)*1000:.1f}ms, found {len(centroid_idx)} centroids") + + import cv2 + from scipy import ndimage + + def generate_polygons_fast(lab_array, tile_r0, tile_c0): + """Generate polygons for all labels using bounding box + optimizations. + + Optimizations: + 1. Bounding box extraction (only process small regions) + 2. cv2.approxPolyDP for polygon simplification + 3. Integer coordinates (smaller JSON) + """ + slices = ndimage.find_objects(lab_array.astype(np.int32)) + + polygons = {} + + for lbl_idx, bbox in enumerate(slices): + if bbox is None: + continue + lbl = lbl_idx + 1 + + row_slice, col_slice = bbox + sub_array = lab_array[row_slice, col_slice] + + # Optimized mask creation + mask = (sub_array == lbl).astype(np.uint8) * 255 + + # findContours with CHAIN_APPROX_SIMPLE already reduces points + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + poly_list = [] + offset_x = col_slice.start + tile_c0 + offset_y = row_slice.start + tile_r0 + + for contour in contours: + if len(contour) < 3: + continue + + # Simplify polygon with approxPolyDP (epsilon=2.0 for faster browser rendering) + epsilon = 2.0 + simplified = cv2.approxPolyDP(contour, epsilon, True) + + if len(simplified) < 3: + continue + + # Reshape and add offsets + pts = simplified.reshape(-1, 2) + pts[:, 0] += offset_x + pts[:, 1] += offset_y + + # Use integers (smaller JSON, faster) + polygon = [[int(x), int(y)] for x, y in pts] + + # Close polygon if needed + if polygon and polygon[0] != polygon[-1]: + polygon.append(polygon[0]) + + if polygon: + poly_list.append(polygon) + + if poly_list: + polygons[lbl] = poly_list + + return polygons + + r0, c0 = tile.r0, tile.c0 + + # Only compute expanded polygons if expansion is enabled + need_expanded = (b2c_mode != "none") + + if need_expanded: + polygons_exp = generate_polygons_fast(lab_exp, r0, c0) + t4 = time.time() + LOGGER.info(f"⏱️ Tile {tile_id}: _polygons_from_labels(exp) VECTORIZED took {(t4-t3)*1000:.1f}ms, {len(polygons_exp)} labels") + else: + polygons_exp = {} + t4 = time.time() + LOGGER.info(f"⏱️ Tile {tile_id}: SKIPPED expanded polygons (b2c_mode=none)") + + polygons_raw = generate_polygons_fast(lab_raw, r0, c0) + t5 = time.time() + LOGGER.info(f"⏱️ Tile {tile_id}: _polygons_from_labels(raw) VECTORIZED took {(t5-t4)*1000:.1f}ms, {len(polygons_raw)} labels") + + # DERIVE outline paths from already-computed polygons (instead of recomputing) + # This reuses the polygon data we just generated - instant! + outline_exp = [] + if need_expanded: + for lbl, poly_list in polygons_exp.items(): + outline_exp.extend(poly_list) + t6 = time.time() + LOGGER.info(f"⏱️ Tile {tile_id}: outline_exp DERIVED from polygons ({len(outline_exp)} paths)") + + outline_raw = [] + for lbl, poly_list in polygons_raw.items(): + outline_raw.extend(poly_list) + t7 = time.time() + LOGGER.info(f"⏱️ Tile {tile_id}: outline_raw DERIVED from polygons ({len(outline_raw)} paths)") + + # Get unique labels for per-label outlines (skip pre-computation - use on-demand) + unique_labels = np.unique(labels_at_centroids) + unique_labels = unique_labels[unique_labels > 0].tolist() + + # Pre-compute per-label outlines too for selected labels mode + per_label_nuclei_outlines = {} + per_label_expanded_outlines = {} + + if unique_labels: + # Use the already-generated polygons as outlines (same geometry) + for lbl in unique_labels: + if lbl in polygons_raw: + per_label_nuclei_outlines[lbl] = polygons_raw[lbl] + if lbl in polygons_exp: + per_label_expanded_outlines[lbl] = polygons_exp[lbl] + + t9 = time.time() + LOGGER.info(f"⏱️ Tile {tile_id}: Per-label outlines from polygons ({len(unique_labels)} labels)") + LOGGER.info(f"⏱️ Tile {tile_id}: TOTAL geometry computation took {(t9-t0)*1000:.1f}ms") + + entry = { + "tile": tile, + "lab_exp": lab_exp, + "lab_raw": lab_raw, + "dist_px": dist_px, + "polygons_exp": polygons_exp, + "polygons_raw": polygons_raw, + "outline_exp": outline_exp, + "outline_raw": outline_raw, + "centroid_indices": centroid_idx, + "centroid_rows": rows_abs, + "centroid_cols": cols_abs, + "labels_at_centroids": labels_at_centroids, + "geometry_key": geom_key, # NEW: Store for ColorCache linking + # PRE-COMPUTED per-label outlines (for instant selected-only mode) + "per_label_nuclei_outlines": per_label_nuclei_outlines, + "per_label_expanded_outlines": per_label_expanded_outlines, + } + + # Cache in geometry cache + geometry_cache.set(geom_key, entry) + + # Also cache in old TileCache for backward compatibility with cache warmer + old_cache_key = cache_key or self._tile_cache_key( + tile_id, b2c_mode=b2c_mode, max_bin_distance=max_bin_distance, + mpp=mpp, bin_um=bin_um, volume_ratio=volume_ratio, pad_factor=pad_factor + ) + self.tile_cache.set(old_cache_key, entry) + + LOGGER.info(f"Geometry cached for tile {tile_id}") + return entry + + def get_overlay(self, params: Dict): + """ + Main API endpoint for getting overlay data. + + Flow: + 1. Parse parameters (geometry, color, visual) + 2. Get geometry from GeometryCache (via _tile_entry) + 3. Check ColorCache for colored overlay + 4. If cache miss, compute colors (fast: <50ms) + 5. Return compressed response + """ + try: + if self._state.get("loading"): + return self._error_response(409, "Dataset is still loading; try again in a moment.") + + # Parse overlay type and tile + overlay_type = params.get("overlay_type", "gene") + tile_id = int(params.get("tile_id", 0)) + + # Geometry parameters (affect polygon shapes) + b2c_mode = params.get("b2c_mode", "fixed") + max_bin_distance = float(params.get("max_bin_distance", 2.0) or 2.0) + mpp = float(params.get("mpp", 0.3) or 0.3) + bin_um = float(params.get("bin_um", 2.0) or 2.0) + volume_ratio = float(params.get("volume_ratio", 4.0) or 4.0) + pad_factor = int(params.get("pad_factor", 2) or 2) + + # Color parameters (affect which cells shown and their colors) + gene = params.get("gene") or params.get("genes") + obs_col = params.get("obs_col") + category = params.get("category") + color_mode = params.get("color_mode", "gradient") + gradient_color = params.get("gradient_color") + expr_quantile_param = params.get("expr_quantile") + expr_quantile = float(expr_quantile_param) if expr_quantile_param not in (None, "") else None + + # Visual parameters (client-side only, not used in backend caching) + overlay_alpha = float(params.get("overlay_alpha", 0.5) or 0.5) + render_mode = params.get("render_mode", "fill") + stroke_width = float(params.get("stroke_width", 1.0) or 1.0) + show_outlines = _as_bool(params.get("show_outlines", True)) + all_expanded_outline = _as_bool(params.get("all_expanded_outline", False)) + all_nuclei_outline = _as_bool(params.get("all_nuclei_outline", False)) + nuclei_outline_color = params.get("nuclei_outline_color", "#000000") + nuclei_outline_alpha = float(params.get("nuclei_outline_alpha", 0.6) or 0.6) + + # Legacy parameter + include_geometry = _as_bool(params.get("include_geometry", True)) + + # STEP 1: Get geometry (uses GeometryCache via _tile_entry) + entry_future = self._schedule_tile_job( + tile_id, + b2c_mode=b2c_mode, + max_bin_distance=max_bin_distance, + mpp=mpp, + bin_um=bin_um, + volume_ratio=volume_ratio, + pad_factor=pad_factor, + ) + geometry_entry = entry_future.result() + geometry_key = geometry_entry.get("geometry_key") + + # STEP 2: Check color cache + import time + t0 = time.time() + + color_cache = self._state.get("color_cache") + if color_cache is None: + color_cache = ColorCache(max_items=200) + self._state["color_cache"] = color_cache + + color_key = color_cache._make_key( + self.dataset_id, + tile_id, + overlay_type, + geometry_key, + gene=gene, + obs_col=obs_col, + category=category, + color_mode=color_mode, + gradient_color=gradient_color, + expr_quantile=expr_quantile, + ) + + cached_colors = color_cache.get(color_key) + if cached_colors is not None: + LOGGER.info(f"⚡ ColorCache HIT for tile {tile_id}, overlay_type={overlay_type}") + colored_overlay = cached_colors + else: + LOGGER.info(f"🔄 ColorCache MISS for tile {tile_id}, computing colors...") + t1 = time.time() + + # Compute colors (fast: <50ms) + if overlay_type == "gene": + colored_overlay = self._prepare_gene_overlay(geometry_entry, params, include_geometry=include_geometry) + elif overlay_type == "observation": + colored_overlay = self._prepare_obs_overlay(geometry_entry, params, include_geometry=include_geometry) + else: + raise ValueError(f"Unknown overlay_type '{overlay_type}'") + + t2 = time.time() + LOGGER.info(f"✅ Color computation took {(t2-t1)*1000:.1f}ms") + + # Cache the colored overlay + color_cache.set(color_key, colored_overlay) + LOGGER.info(f"ColorCache stored for tile {tile_id}") + + # STEP 3: Build final response with all parameters + payload = { + "status": "ok", + "overlay_type": overlay_type, + "tile": geometry_entry["tile"].to_dict(), + "dist_px": geometry_entry["dist_px"], + + # Geometry parameters (for client-side geometry hash) + "b2c_mode": b2c_mode, + "max_bin_distance": max_bin_distance, + "mpp": mpp, + "bin_um": bin_um, + "volume_ratio": volume_ratio, + "pad_factor": pad_factor, + + # Visual parameters (for client-side rendering) + "overlay_alpha": overlay_alpha, + "render_mode": render_mode, + "stroke_width": stroke_width, + "show_outlines": show_outlines, + "all_expanded_outline": all_expanded_outline, + "all_nuclei_outline": all_nuclei_outline, + "nuclei_outline_color": nuclei_outline_color, + "nuclei_outline_alpha": nuclei_outline_alpha, + + # Color parameters (for client-side color hash) + "color_mode": color_mode, + "gradient_color": gradient_color, + "expr_quantile": expr_quantile, + + # Legacy + "geometry_included": include_geometry, + + # Colored overlay data + **colored_overlay + } + + # STEP 4: Return compressed response + return self._json_response_compressed(payload) + + except Exception as exc: + return self._error_from_exception(exc, "Failed to build overlay") + + def _prepare_gene_overlay(self, entry: Dict, params: Dict, *, include_geometry: bool) -> Dict: + ctx = self._ensure_context() + genes_value = params.get("genes") or params.get("gene") + if not genes_value: + raise ValueError("Provide 'gene' or 'genes' for gene overlay.") + if isinstance(genes_value, str): + genes = [g.strip() for g in genes_value.split(",") if g.strip()] + else: + genes = list(genes_value) + + if not genes: + raise ValueError("No gene names provided.") + + color_mode = params.get("color_mode", "gradient") + gene_color = params.get("gene_color", "#ff6b6b") or "#ff6b6b" + gradient_color = params.get("gradient_color") + render_mode = params.get("render_mode", "fill") + expr_quantile = params.get("expr_quantile") + expr_quantile = float(expr_quantile) if expr_quantile not in (None, "") else None + top_n = int(params.get("top_n", 0) or 0) + overlay_alpha = float(params.get("overlay_alpha", 0.5) or 0.5) + show_centroids = str(params.get("show_centroids", "false")).lower() in ("1", "true", "yes", "on") + highlight_color = params.get("highlight_color", "#39ff14") + highlight_width = float(params.get("highlight_width", 2.0) or 2.0) + cmap_name = params.get("cmap_name", "viridis") + vmin_param = params.get("vmin") + vmax_param = params.get("vmax") + vmin = float(vmin_param) if vmin_param not in (None, "") else None + vmax = float(vmax_param) if vmax_param not in (None, "") else None + all_expanded_outline = str(params.get("all_expanded_outline", "false")).lower() in ("1", "true", "yes", "on") + all_nuclei_outline = str(params.get("all_nuclei_outline", "false")).lower() in ("1", "true", "yes", "on") + + try: + solid_rgba = colors.to_rgba(gene_color) + except ValueError: + gene_color = "#ff6b6b" + solid_rgba = colors.to_rgba(gene_color) + + custom_cmap = None + if color_mode == "gradient" and gradient_color: + try: + target_rgba = colors.to_rgba(gradient_color) + except ValueError: + target_rgba = colors.to_rgba("#4285f4") + custom_cmap = colors.LinearSegmentedColormap.from_list( + "bin2cell_custom_gradient", + [(0.0, (1.0, 1.0, 1.0, 0.0)), (1.0, target_rgba)], + ) + else: + target_rgba = None + + lab_exp: np.ndarray = entry["lab_exp"] + polygons_exp: Dict[int, List[List[List[float]]]] = entry["polygons_exp"] + polygons_raw: Dict[int, List[List[List[float]]]] = entry["polygons_raw"] + centroid_indices: np.ndarray = entry["centroid_indices"] + labels_at_centroids: np.ndarray = entry["labels_at_centroids"] + + overlays = [] + centroid_payload = [] + + for gene in genes: + values = ctx.gene_vector(gene) + tile_values = values[centroid_indices] if centroid_indices.size else np.empty((0,), dtype=float) + + selected_mask = np.ones_like(tile_values, dtype=bool) + threshold = None + # Validate expr_quantile is in valid range [0, 1] + if expr_quantile is not None and tile_values.size: + try: + q = float(expr_quantile) + if 0 <= q <= 1: + threshold = float(np.quantile(tile_values, q)) + selected_mask &= tile_values > threshold + else: + LOGGER.warning(f"expr_quantile {q} out of range [0,1], ignoring") + except (ValueError, TypeError) as e: + LOGGER.warning(f"Invalid expr_quantile value: {expr_quantile}, ignoring") + + selected_indices = centroid_indices[selected_mask] + selected_values = tile_values[selected_mask] + selected_labels = labels_at_centroids[selected_mask] + + label_expr: Dict[int, float] = {} + for lbl, val in zip(selected_labels, selected_values): + if lbl <= 0: + continue + if (lbl not in label_expr) or (val > label_expr[lbl]): + label_expr[int(lbl)] = float(val) + + if top_n and len(label_expr) > top_n: + top_labels = sorted(label_expr.keys(), key=lambda l: label_expr[l], reverse=True)[:top_n] + label_expr = {lbl: label_expr[lbl] for lbl in top_labels} + + if not label_expr: + overlays.append( + { + "gene": gene, + "features": [], + "legend": { + "type": "empty", + "gene": gene, + "message": "No labels passed filters in this tile.", + }, + } + ) + continue + + values_array = np.array(list(label_expr.values()), dtype=float) + vmin_local = float(values_array.min()) if vmin is None else vmin + vmax_local = float(values_array.max()) if vmax is None else vmax + if vmax_local == vmin_local: + vmax_local = vmin_local + 1e-6 + norm = colors.Normalize(vmin=vmin_local, vmax=vmax_local) + + # Use pre-computed polygons from geometry cache + # (on-demand generation removed - polygons now always pre-computed) + feature_polygons = polygons_exp if all_expanded_outline else polygons_raw + + # Generate per-label outlines for selected cells (if needed for selected-only mode) + per_label_nuclei_outlines: Dict[int, List] = {} + per_label_expanded_outlines: Dict[int, List] = {} + if include_geometry: + # Get labels that will be shown + shown_labels = set(label_expr.keys()) + + # Use PRE-CACHED per-label outlines from geometry (instant lookup!) + cached_nuclei = entry.get("per_label_nuclei_outlines", {}) + cached_expanded = entry.get("per_label_expanded_outlines", {}) + + # Filter to only shown labels (instant, just dict lookups) + per_label_nuclei_outlines = {lbl: cached_nuclei.get(lbl, []) for lbl in shown_labels if lbl in cached_nuclei} + per_label_expanded_outlines = {lbl: cached_expanded.get(lbl, []) for lbl in shown_labels if lbl in cached_expanded} + + LOGGER.debug(f"Using cached outlines: {len(per_label_nuclei_outlines)} nuclei, {len(per_label_expanded_outlines)} expanded") + + features = [] + for lbl, expr_value in label_expr.items(): + polygons = feature_polygons.get(lbl) or polygons_raw.get(lbl) or polygons_exp.get(lbl) + if color_mode == "solid": + # Use solid gene color for all expressing cells + fill_color = _rgba_to_css(solid_rgba, alpha_override=overlay_alpha) + stroke_color = gene_color + else: + color_fraction = float(norm(expr_value)) + cmap_source = custom_cmap or cmap_name + fill_color = _colormap_sample(cmap_source, color_fraction, alpha=overlay_alpha) + stroke_color = _colormap_sample(cmap_source, color_fraction, alpha=1.0) + + feature = { + "label": int(lbl), + "polygons": polygons if include_geometry else [], + "fill": fill_color if render_mode == "fill" else None, + "stroke": stroke_color, + "stroke_width": highlight_width, + "value": float(expr_value), + } + + # Add per-feature outlines for selected-only mode + if include_geometry: + feature["nuclei_outline_paths"] = per_label_nuclei_outlines.get(lbl, []) + feature["expanded_outline_paths"] = per_label_expanded_outlines.get(lbl, []) + + if include_geometry or polygons: + features.append(feature) + else: + # Preserve style/value for client-side geometry reuse + features.append({k: v for k, v in feature.items() if k not in ("polygons", "nuclei_outline_paths", "expanded_outline_paths")}) + + legend = { + "type": "binary" if color_mode == "binary" else ("solid" if color_mode == "solid" else "continuous"), + "gene": gene, + "color": gene_color if color_mode == "solid" else highlight_color, + "overlay_alpha": overlay_alpha, + } + if color_mode == "gradient": + cmap_source = custom_cmap or cmap_name + legend.update( + { + "min": vmin_local, + "max": vmax_local, + "cmap": cmap_name, + "gradient": _sample_gradient(cmap_source), + "gradient_color": gradient_color, + } + ) + + overlays.append( + { + "gene": gene, + "features": features, + "legend": legend, + "render_mode": render_mode, + "color_mode": color_mode, + "gene_color": gene_color, + "gradient_color": gradient_color, + } + ) + + if show_centroids and centroid_indices.size: + gene_centroids = [] + for lbl, idx_global, row, col, value in zip( + labels_at_centroids[selected_mask], + selected_indices, + entry["centroid_rows"][selected_mask], + entry["centroid_cols"][selected_mask], + selected_values, + ): + if lbl <= 0: + continue + gene_centroids.append( + { + "index": int(idx_global), + "label": int(lbl), + "x": float(col), + "y": float(row), + "value": float(value), + } + ) + centroid_payload.append({"gene": gene, "points": gene_centroids}) + + geometry_block = {} + if include_geometry: + geometry_block = { + "polygons_exp": polygons_exp, + "polygons_raw": polygons_raw, + "outline_exp": entry["outline_exp"], + "outline_raw": entry["outline_raw"], + # Per-label outlines for selected-only mode + "per_label_nuclei": entry.get("per_label_nuclei_outlines", {}), + "per_label_expanded": entry.get("per_label_expanded_outlines", {}), + } + + payload: Dict = { + "overlay_type": "gene", + "overlays": overlays, + "all_expanded_outline": all_expanded_outline, + "all_nuclei_outline": all_nuclei_outline, + "expanded_outline": entry["outline_exp"] if (all_expanded_outline and include_geometry) else [], + "nuclei_outline": entry["outline_raw"] if (all_nuclei_outline and include_geometry) else [], + } + if geometry_block: + payload["geometry"] = geometry_block + if show_centroids: + payload["centroids"] = centroid_payload + return payload + + def _prepare_obs_overlay(self, entry: Dict, params: Dict, *, include_geometry: bool) -> Dict: + ctx = self._ensure_context() + obs_col = params.get("obs_col") + if not obs_col: + raise ValueError("Provide 'obs_col' for observation overlay.") + + lab_exp: np.ndarray = entry["lab_exp"] + polygons_exp: Dict[int, List[List[List[float]]]] = entry["polygons_exp"] + polygons_raw: Dict[int, List[List[List[float]]]] = entry["polygons_raw"] + centroid_indices: np.ndarray = entry["centroid_indices"] + labels_at_centroids: np.ndarray = entry["labels_at_centroids"] + + category_filter = params.get("category") + render_mode = params.get("render_mode", "fill") + overlay_alpha = float(params.get("overlay_alpha", 0.5) or 0.5) + cmap_name = params.get("cmap_name", "tab20") + highlight_color = params.get("highlight_color", "#39ff14") + highlight_width = float(params.get("highlight_width", 2.0) or 2.0) + show_centroids = str(params.get("show_centroids", "false")).lower() in ("1", "true", "yes", "on") + all_expanded_outline = str(params.get("all_expanded_outline", "true")).lower() in ("1", "true", "yes", "on") + all_nuclei_outline = str(params.get("all_nuclei_outline", "false")).lower() in ("1", "true", "yes", "on") + legend_outside = str(params.get("legend_outside", "true")).lower() in ("1", "true", "yes", "on") + + obs_vals = ctx.obs_vector(obs_col) + tile_vals = obs_vals[centroid_indices] if centroid_indices.size else np.empty((0,), dtype=object) + + label_to_cat: Dict[int, str] = {} + counts: Dict[int, Counter] = defaultdict(Counter) + for lbl, val in zip(labels_at_centroids, tile_vals): + if lbl <= 0: + continue + key = "" if val is None or (isinstance(val, float) and math.isnan(val)) else str(val) + counts[int(lbl)][key] += 1 + for lbl, cnt in counts.items(): + if not cnt: + continue + label_to_cat[lbl] = cnt.most_common(1)[0][0] + + if not label_to_cat: + return { + "overlay_type": "observation", + "obs_col": obs_col, + "features": [], + "legend": {"type": "empty", "message": "No categories found in tile."}, + } + + meta = ctx.obs_metadata(obs_col) + ordered_categories = list(meta.get("categories") or []) + predefined_colors = dict(meta.get("color_map") or {}) + + if category_filter: + allowed_categories = {category_filter} + else: + allowed_categories = set(label_to_cat.values()) + + if ordered_categories: + categories_sorted = [cat for cat in ordered_categories if cat in allowed_categories] + remainder = [cat for cat in allowed_categories if cat not in categories_sorted] + categories_sorted.extend(sorted(remainder)) + else: + categories_sorted = sorted(allowed_categories) + if not categories_sorted and allowed_categories: + categories_sorted = sorted(allowed_categories) + + cmap_obj = cm.get_cmap(cmap_name, max(1, len(categories_sorted) or 1)) + denom = max(1, len(categories_sorted) - 1) + category_styles: Dict[str, Dict[str, Optional[str]]] = {} + for idx, cat in enumerate(categories_sorted): + base_color = predefined_colors.get(cat) + if base_color: + fill_css = _color_to_css(base_color, alpha_override=overlay_alpha) if render_mode == "fill" else None + stroke_css = _color_to_css(base_color, alpha_override=1.0) + legend_color = base_color + else: + fraction = idx / denom if denom else 0.0 + fill_css = _colormap_sample(cmap_obj, fraction, alpha=overlay_alpha) if render_mode == "fill" else None + stroke_css = _colormap_sample(cmap_obj, fraction, alpha=1.0) + legend_color = stroke_css + category_styles[cat] = { + "fill": fill_css, + "stroke": stroke_css, + "legend": legend_color, + } + + # Use pre-computed polygons from geometry cache + # (on-demand generation removed - polygons now always pre-computed) + feature_polygons = polygons_exp if all_expanded_outline else polygons_raw + + # Generate per-label outlines for selected cells (if needed for selected-only mode) + per_label_nuclei_outlines: Dict[int, List] = {} + per_label_expanded_outlines: Dict[int, List] = {} + if include_geometry: + # Get labels that will be shown + shown_labels = set(label_to_cat.keys()) + + # Use PRE-CACHED per-label outlines from geometry (instant lookup!) + cached_nuclei = entry.get("per_label_nuclei_outlines", {}) + cached_expanded = entry.get("per_label_expanded_outlines", {}) + + # Filter to only shown labels (instant, just dict lookups) + per_label_nuclei_outlines = {lbl: cached_nuclei.get(lbl, []) for lbl in shown_labels if lbl in cached_nuclei} + per_label_expanded_outlines = {lbl: cached_expanded.get(lbl, []) for lbl in shown_labels if lbl in cached_expanded} + + LOGGER.debug(f"[OBS] Using cached outlines: {len(per_label_nuclei_outlines)} nuclei, {len(per_label_expanded_outlines)} expanded") + + features = [] + for lbl, cat in label_to_cat.items(): + if category_filter and cat != category_filter: + continue + polygons = feature_polygons.get(lbl) or polygons_raw.get(lbl) or polygons_exp.get(lbl) + style = category_styles.get(cat) + if not style: + continue + fill_color = style["fill"] if render_mode == "fill" else None + stroke_color = style["stroke"] or highlight_color + + feature_entry = { + "label": int(lbl), + "category": cat, + "polygons": polygons if include_geometry else [], + "fill": fill_color, + "stroke": stroke_color, + "stroke_width": highlight_width, + } + + # Add per-feature outlines for selected-only mode + if include_geometry: + feature_entry["nuclei_outline_paths"] = per_label_nuclei_outlines.get(lbl, []) + feature_entry["expanded_outline_paths"] = per_label_expanded_outlines.get(lbl, []) + + if include_geometry or polygons: + features.append(feature_entry) + else: + features.append({k: v for k, v in feature_entry.items() if k != "polygons"}) + + legend = { + "type": "categorical", + "obs_col": obs_col, + "items": [ + {"label": cat, "color": (category_styles.get(cat) or {}).get("legend", highlight_color)} + for cat in categories_sorted + ], + "legend_outside": legend_outside, + } + + geometry_block = {} + if include_geometry: + geometry_block = { + "polygons_exp": polygons_exp, + "polygons_raw": polygons_raw, + "outline_exp": entry["outline_exp"], + "outline_raw": entry["outline_raw"], + # Per-label outlines for selected-only mode + "per_label_nuclei": entry.get("per_label_nuclei_outlines", {}), + "per_label_expanded": entry.get("per_label_expanded_outlines", {}), + } + + payload: Dict = { + "overlay_type": "observation", + "obs_col": obs_col, + "category_filter": category_filter, + "features": features, + "legend": legend, + "render_mode": render_mode, + "all_expanded_outline": all_expanded_outline, + "all_nuclei_outline": all_nuclei_outline, + "expanded_outline": entry["outline_exp"] if (all_expanded_outline and include_geometry) else [], + "nuclei_outline": entry["outline_raw"] if (all_nuclei_outline and include_geometry) else [], + } + + if geometry_block: + payload["geometry"] = geometry_block + + if show_centroids and centroid_indices.size: + points = [] + for lbl, idx_global, row, col, obs_val in zip( + labels_at_centroids, + centroid_indices, + entry["centroid_rows"], + entry["centroid_cols"], + tile_vals, + ): + if lbl <= 0: + continue + label_str = label_to_cat.get(int(lbl)) + points.append( + { + "index": int(idx_global), + "label": int(lbl), + "category": label_str, + "x": float(col), + "y": float(row), + } + ) + payload["centroids"] = [{"points": points}] + + return payload + + def export_overlay(self, params: Dict): + try: + response = self.get_overlay(params) + if response.status_code and response.status_code >= 400: + return response + payload = json.loads(response.get_data(as_text=True)) + + tile_id = payload["tile"]["id"] + overlay_type = payload["overlay_type"] + name = params.get("name") + if not name: + suffix = overlay_type + if overlay_type == "gene" and payload.get("overlays"): + suffix = "_".join(ov["gene"] for ov in payload["overlays"]) + if overlay_type == "observation": + suffix = payload.get("obs_col", "observation") + name = f"tile{tile_id}_{suffix}" + + safe_name = "".join(ch if ch.isalnum() or ch in ("_", "-", ".") else "_" for ch in name) + out_path = os.path.join(self.out_dir, f"{safe_name}.geojson") + + features = [] + if overlay_type == "gene": + for gene_overlay in payload.get("overlays", []): + gene_name = gene_overlay.get("gene") + for feature in gene_overlay.get("features", []): + geom = _feature_polygons_to_geojson(feature["polygons"]) + props = { + "tile_id": tile_id, + "overlay_type": "gene", + "gene": gene_name, + "value": feature.get("value"), + } + features.append({"type": "Feature", "geometry": geom, "properties": props}) + else: + obs_col = payload.get("obs_col") + for feature in payload.get("features", []): + geom = _feature_polygons_to_geojson(feature["polygons"]) + props = { + "tile_id": tile_id, + "overlay_type": "observation", + "obs_col": obs_col, + "category": feature.get("category"), + } + features.append({"type": "Feature", "geometry": geom, "properties": props}) + + geojson = {"type": "FeatureCollection", "features": features} + with open(out_path, "w") as fh: + json.dump(geojson, fh) + + return self._json_response({"status": "ok", "path": out_path}) + except Exception as exc: + return self._error_from_exception(exc, "Failed to export overlay") + + def save_preset(self, params: Dict): + try: + name = params.get("name") + if not name: + raise ValueError("Preset requires 'name'.") + config = params.get("config") + if not isinstance(config, dict): + raise ValueError("Preset requires 'config' JSON object.") + self.presets[name] = config + self._save_presets() + return self._json_response({"status": "ok", "name": name}) + except Exception as exc: + return self._error_from_exception(exc, "Failed to save preset") + + def list_presets(self, params: Dict): + try: + return self._json_response({"presets": self.presets}) + except Exception as exc: + return self._error_from_exception(exc, "Failed to list presets") + + def delete_preset(self, params: Dict): + try: + name = params.get("name") + if not name or name not in self.presets: + raise KeyError("Preset not found.") + del self.presets[name] + self._save_presets() + return self._json_response({"status": "ok", "deleted": name}) + except Exception as exc: + return self._error_from_exception(exc, "Failed to delete preset") + + +def _feature_polygons_to_geojson(polygons: List[List[List[float]]]) -> Dict: + if not polygons: + return {"type": "GeometryCollection", "geometries": []} + if len(polygons) == 1: + return {"type": "Polygon", "coordinates": [polygons[0]]} + return {"type": "MultiPolygon", "coordinates": [[poly] for poly in polygons]} + + +def _json_default(obj): + if isinstance(obj, Tile): + return obj.to_dict() + if isinstance(obj, np.generic): + return obj.item() + if isinstance(obj, np.ndarray): + return obj.tolist() + raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") +class FileDialogHelper(QObject): + def __init__(self) -> None: + super().__init__() + self._result: Tuple[str, str] = ("", "") + + @Slot(str, str, str) + def _open_dialog(self, caption: str, start_dir: str, filters: str) -> None: + self._result = QFileDialog.getOpenFileName(None, caption, start_dir, filters) + + def get_open_file_name(self, caption: str, start_dir: str, filters: str) -> Tuple[str, str]: + self._result = ("", "") + QMetaObject.invokeMethod( + self, + "_open_dialog", + Qt.BlockingQueuedConnection, + Q_ARG(str, caption), + Q_ARG(str, start_dir), + Q_ARG(str, filters), + ) + return self._result diff --git a/plugins/CellExplorer.yml b/plugins/CellExplorer.yml new file mode 100644 index 0000000..554e04e --- /dev/null +++ b/plugins/CellExplorer.yml @@ -0,0 +1,4 @@ +name: Cell Explorer +version: 0.1.0 +author: Joel Joseph +description: "Interactive Cell Explorer overlays for TissUUmaps with gene and observation controls." diff --git a/plugins/CellExplorer/CellExplorer.log b/plugins/CellExplorer/CellExplorer.log new file mode 100644 index 0000000..d29c09c --- /dev/null +++ b/plugins/CellExplorer/CellExplorer.log @@ -0,0 +1,690 @@ +2025-11-27 01:07:02,095 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 01:07:02,095 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 01:07:06,277 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 01:07:06,996 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 01:07:09,744 [INFO] Dataset loaded successfully: 224 tiles +2025-11-27 01:07:09,828 [INFO] Rehydrating dataset from cached config. +2025-11-27 01:07:09,829 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 01:07:14,773 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 01:07:15,285 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 01:07:24,735 [INFO] Rehydrating dataset from cached config. +2025-11-27 01:07:24,736 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 01:07:28,007 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 01:07:28,360 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 01:07:37,731 [INFO] Rehydrating dataset from cached config. +2025-11-27 01:07:37,731 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 01:07:41,013 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 01:07:41,539 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 01:11:07,717 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 01:11:07,718 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 01:11:10,556 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 01:11:10,910 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 01:11:13,209 [INFO] Dataset loaded successfully: 224 tiles +2025-11-27 01:11:13,275 [INFO] Rehydrating dataset from cached config. +2025-11-27 01:11:13,275 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 01:11:16,779 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 01:11:17,359 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 01:11:26,289 [INFO] Rehydrating dataset from cached config. +2025-11-27 01:11:26,290 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 01:11:29,702 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 01:11:30,049 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 01:11:33,550 [INFO] Rehydrating dataset from cached config. +2025-11-27 01:11:33,550 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 01:11:37,162 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 01:11:37,641 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 01:12:30,150 [INFO] Rehydrating dataset from cached config. +2025-11-27 01:12:30,151 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 01:12:32,304 [INFO] Rehydrating dataset from cached config. +2025-11-27 01:12:32,304 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 01:12:34,806 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 01:12:36,111 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 01:12:37,745 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 01:12:38,223 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:45:58,559 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 02:45:58,559 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 02:45:58,559 [INFO] Rehydrating dataset from cached config. +2025-11-27 02:45:58,559 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:46:01,968 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:46:02,424 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:46:05,190 [INFO] Rehydrating dataset from cached config. +2025-11-27 02:46:05,203 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:46:08,848 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:46:09,318 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:46:43,787 [INFO] Rehydrating dataset from cached config. +2025-11-27 02:46:43,788 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:46:47,815 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:46:48,312 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:46:49,945 [INFO] Rehydrating dataset from cached config. +2025-11-27 02:46:49,946 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:46:54,563 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:46:54,992 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:46:58,176 [INFO] GeometryCache MISS for tile 184, computing geometry... +2025-11-27 02:47:28,928 [INFO] Geometry cached for tile 184 +2025-11-27 02:47:57,211 [INFO] Rehydrating dataset from cached config. +2025-11-27 02:47:57,212 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:48:01,324 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:48:01,726 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:48:04,176 [INFO] GeometryCache MISS for tile 184, computing geometry... +2025-11-27 02:48:35,008 [INFO] Geometry cached for tile 184 +2025-11-27 02:48:57,491 [INFO] Rehydrating dataset from cached config. +2025-11-27 02:48:57,492 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:49:02,271 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:49:02,650 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:49:05,226 [INFO] GeometryCache MISS for tile 184, computing geometry... +2025-11-27 02:49:35,956 [INFO] Geometry cached for tile 184 +2025-11-27 02:49:57,256 [INFO] Rehydrating dataset from cached config. +2025-11-27 02:49:57,256 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:50:01,538 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:50:01,919 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:50:04,582 [INFO] GeometryCache MISS for tile 184, computing geometry... +2025-11-27 02:50:35,360 [INFO] Geometry cached for tile 184 +2025-11-27 02:50:36,768 [ERROR] Quantiles must be in the range [0, 1] +Traceback (most recent call last): + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 1917, in get_overlay + colored_overlay = self._prepare_gene_overlay(geometry_entry, params, include_geometry=include_geometry) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 2033, in _prepare_gene_overlay + threshold = float(np.quantile(tile_values, expr_quantile)) + File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py", line 4650, in quantile + raise ValueError("Quantiles must be in the range [0, 1]") +ValueError: Quantiles must be in the range [0, 1] +2025-11-27 02:50:48,393 [INFO] Rehydrating dataset from cached config. +2025-11-27 02:50:48,393 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:50:52,968 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:50:53,426 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:50:56,213 [INFO] GeometryCache MISS for tile 184, computing geometry... +2025-11-27 02:51:28,687 [INFO] Geometry cached for tile 184 +2025-11-27 02:51:45,853 [INFO] Rehydrating dataset from cached config. +2025-11-27 02:51:45,854 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:51:49,997 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:51:50,434 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:56:32,139 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 02:56:32,139 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 02:56:32,139 [INFO] Rehydrating dataset from cached config. +2025-11-27 02:56:32,139 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:56:35,092 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:56:35,713 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:56:37,995 [INFO] Rehydrating dataset from cached config. +2025-11-27 02:56:37,996 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:56:41,959 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:56:42,493 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:56:48,776 [INFO] Rehydrating dataset from cached config. +2025-11-27 02:56:48,776 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:56:51,639 [INFO] Rehydrating dataset from cached config. +2025-11-27 02:56:51,640 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:56:52,367 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:56:53,820 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:56:57,204 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:56:57,884 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:57:02,386 [INFO] GeometryCache MISS for tile 183, computing geometry... +2025-11-27 02:57:10,438 [INFO] Geometry cached for tile 183 +2025-11-27 02:57:22,309 [INFO] Rehydrating dataset from cached config. +2025-11-27 02:57:22,309 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:57:23,965 [INFO] Rehydrating dataset from cached config. +2025-11-27 02:57:23,965 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:57:27,403 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:57:28,616 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:57:29,288 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:57:30,361 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:57:36,378 [INFO] GeometryCache MISS for tile 183, computing geometry... +2025-11-27 02:57:44,749 [INFO] Geometry cached for tile 183 +2025-11-27 02:59:48,340 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 02:59:48,341 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 02:59:48,341 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 02:59:48,341 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:59:51,606 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:59:52,070 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 02:59:54,670 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 02:59:54,775 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 02:59:54,775 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 02:59:58,896 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 02:59:59,352 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 03:00:02,214 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 03:00:04,910 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 03:00:04,910 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 03:00:06,468 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 03:00:06,469 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 03:00:10,957 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 03:00:12,537 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 03:00:12,574 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 03:00:13,504 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 03:00:20,271 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 03:00:20,274 [INFO] GeometryCache MISS for tile 167, computing geometry... +2025-11-27 03:00:20,275 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 03:00:32,523 [INFO] Geometry cached for tile 167 +2025-11-27 03:00:39,653 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 03:00:39,653 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 03:00:41,232 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 03:00:41,233 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 03:00:45,470 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 03:00:46,932 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 03:00:47,342 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 03:00:48,204 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 03:00:53,102 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 03:00:55,007 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 03:00:55,009 [INFO] GeometryCache MISS for tile 167, computing geometry... +2025-11-27 03:01:07,257 [INFO] Geometry cached for tile 167 +2025-11-27 03:01:35,294 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 03:01:35,294 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 03:01:39,255 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 03:01:39,695 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 03:01:42,558 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 03:01:42,562 [INFO] GeometryCache MISS for tile 167, computing geometry... +2025-11-27 03:01:54,467 [INFO] Geometry cached for tile 167 +2025-11-27 03:03:27,893 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 03:03:27,893 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 03:03:27,893 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 03:03:27,893 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 03:03:31,543 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 03:03:32,047 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 03:03:34,455 [INFO] 🟢 context setter: Storing context (id: 13779709184, _GLOBAL_STATE id: 13607457856) +2025-11-27 03:03:34,455 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 03:03:34,533 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 03:03:34,533 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 03:03:38,341 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 03:03:38,798 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 03:03:41,281 [INFO] 🟢 context setter: Storing context (id: 13793831616, _GLOBAL_STATE id: 13806876864) +2025-11-27 03:03:41,284 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 03:03:44,517 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 03:03:44,517 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 03:03:47,100 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 03:03:47,102 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 03:03:49,303 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 03:03:50,910 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 03:03:53,872 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 03:03:54,208 [INFO] 🟢 context setter: Storing context (id: 13863803920, _GLOBAL_STATE id: 13818386624) +2025-11-27 03:03:54,209 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 03:03:54,602 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 03:04:00,050 [INFO] 🟢 context setter: Storing context (id: 13762638176, _GLOBAL_STATE id: 13762564800) +2025-11-27 03:04:00,051 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 03:04:00,053 [INFO] GeometryCache MISS for tile 103, computing geometry... +2025-11-27 03:04:30,677 [INFO] Geometry cached for tile 103 +2025-11-27 03:04:42,986 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 03:04:42,986 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 03:04:45,602 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 03:04:45,603 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 03:04:47,594 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 03:04:49,289 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 03:04:50,963 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 03:04:51,475 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 03:04:54,872 [INFO] 🟢 context setter: Storing context (id: 15607812544, _GLOBAL_STATE id: 16223036160) +2025-11-27 03:04:54,875 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 03:04:57,852 [INFO] 🟢 context setter: Storing context (id: 30984131296, _GLOBAL_STATE id: 30968853248) +2025-11-27 03:04:57,855 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 03:04:57,857 [INFO] GeometryCache MISS for tile 103, computing geometry... +2025-11-27 03:05:28,180 [INFO] Geometry cached for tile 103 +2025-11-27 03:07:59,161 [INFO] 🆕 Initializing NEW app-level state (first time or after app restart) +2025-11-27 03:07:59,162 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 03:07:59,162 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 03:07:59,162 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 03:07:59,162 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 03:08:02,498 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 03:08:02,928 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 03:08:05,525 [INFO] 🟢 context setter: Storing context (ctx id: 13815151872, state id: 13808829696) +2025-11-27 03:08:05,526 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 03:08:05,571 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:08:05,571 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:08:32,788 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:08:34,964 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:08:34,965 [INFO] GeometryCache MISS for tile 136, computing geometry... +2025-11-27 03:09:06,805 [INFO] Geometry cached for tile 136 +2025-11-27 03:10:13,574 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:10:15,209 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:10:23,105 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:10:25,326 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:10:49,782 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:11:03,011 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:11:06,843 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:11:15,108 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:11:16,459 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:11:27,944 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:11:43,458 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:11:45,825 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:11:59,226 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:12:08,560 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:12:12,975 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:12:25,260 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:12:34,376 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:12:43,345 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:12:53,893 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:13:09,393 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:13:23,501 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:13:37,743 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:13:48,977 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:13:58,625 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:14:07,609 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:14:21,294 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:14:21,294 [ERROR] Provide 'gene' or 'genes' for gene overlay. +Traceback (most recent call last): + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 1936, in get_overlay + colored_overlay = self._prepare_gene_overlay(geometry_entry, params, include_geometry=include_geometry) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 1989, in _prepare_gene_overlay + raise ValueError("Provide 'gene' or 'genes' for gene overlay.") +ValueError: Provide 'gene' or 'genes' for gene overlay. +2025-11-27 03:14:32,371 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:14:32,372 [ERROR] "Gene 'COL' not found in AnnData." +Traceback (most recent call last): + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 1936, in get_overlay + colored_overlay = self._prepare_gene_overlay(geometry_entry, params, include_geometry=include_geometry) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 2046, in _prepare_gene_overlay + values = ctx.gene_vector(gene) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 295, in gene_vector + raise KeyError(f"Gene '{gene}' not found in AnnData.") +KeyError: "Gene 'COL' not found in AnnData." +2025-11-27 03:14:39,041 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:14:39,042 [ERROR] "Gene 'COLA' not found in AnnData." +Traceback (most recent call last): + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 1936, in get_overlay + colored_overlay = self._prepare_gene_overlay(geometry_entry, params, include_geometry=include_geometry) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 2046, in _prepare_gene_overlay + values = ctx.gene_vector(gene) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 295, in gene_vector + raise KeyError(f"Gene '{gene}' not found in AnnData.") +KeyError: "Gene 'COLA' not found in AnnData." +2025-11-27 03:14:44,928 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:14:44,929 [INFO] GeometryCache MISS for tile 136, computing geometry... +2025-11-27 03:15:19,727 [INFO] Geometry cached for tile 136 +2025-11-27 03:15:19,728 [ERROR] "Gene 'COLA' not found in AnnData." +Traceback (most recent call last): + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 1936, in get_overlay + colored_overlay = self._prepare_gene_overlay(geometry_entry, params, include_geometry=include_geometry) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 2046, in _prepare_gene_overlay + values = ctx.gene_vector(gene) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 295, in gene_vector + raise KeyError(f"Gene '{gene}' not found in AnnData.") +KeyError: "Gene 'COLA' not found in AnnData." +2025-11-27 03:27:43,668 [INFO] 🆕 Initializing NEW app-level state (first time or after app restart) +2025-11-27 03:27:49,117 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 03:27:57,264 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 03:28:08,748 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 03:28:08,748 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 03:28:08,749 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 03:28:08,749 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 03:28:08,749 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 03:28:12,050 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 03:28:12,528 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 03:28:15,035 [INFO] 🟢 context setter: Storing context (ctx id: 13219852640, state id: 14041579392) +2025-11-27 03:28:15,036 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 03:28:15,159 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:28:15,159 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:28:20,519 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:28:20,520 [INFO] GeometryCache MISS for tile 119, computing geometry... +2025-11-27 03:28:41,570 [INFO] Geometry cached for tile 119 +2025-11-27 03:32:12,126 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:32:21,878 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:32:23,030 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:33:49,289 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 03:33:51,298 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:36:08,461 [INFO] 🆕 Initializing NEW app-level state (first time or after app restart) +2025-11-27 12:36:13,300 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 12:36:19,162 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 12:36:35,965 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 12:36:35,966 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 12:36:35,967 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 12:36:35,967 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 12:36:35,967 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 12:36:39,817 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 12:36:40,317 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 12:36:42,678 [INFO] 🟢 context setter: Storing context (ctx id: 13064516704, state id: 14013172160) +2025-11-27 12:36:42,680 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 12:36:42,868 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:36:42,868 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:37:16,368 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:37:16,370 [INFO] GeometryCache MISS for tile 183, computing geometry... +2025-11-27 12:37:24,697 [INFO] Geometry cached for tile 183 +2025-11-27 12:37:49,357 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:37:51,014 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:40:19,853 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:40:21,353 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:40:53,533 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:41:34,677 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:41:38,229 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:41:53,151 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:41:55,495 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:42:13,998 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:42:26,653 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:42:35,762 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:55:15,573 [INFO] 🆕 Initializing NEW app-level state (first time or after app restart) +2025-11-27 12:55:20,947 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 12:55:26,499 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 12:55:35,529 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 12:55:35,530 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 12:55:35,530 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 12:55:35,530 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 12:55:35,531 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 12:55:39,678 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 12:55:40,193 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 12:55:42,535 [INFO] 🟢 context setter: Storing context (ctx id: 13338591920, state id: 14002685440) +2025-11-27 12:55:42,537 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 12:55:42,715 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:55:42,715 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:58:35,607 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 12:58:35,609 [INFO] GeometryCache MISS for tile 151, computing geometry... +2025-11-27 12:58:54,584 [INFO] Geometry cached for tile 151 +2025-11-27 13:01:26,102 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:01:26,103 [INFO] GeometryCache MISS for tile 151, computing geometry... +2025-11-27 13:01:45,066 [INFO] Geometry cached for tile 151 +2025-11-27 13:02:21,711 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:02:33,495 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:03:41,445 [INFO] 🆕 Initializing NEW app-level state (first time or after app restart) +2025-11-27 13:03:46,121 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:03:51,086 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:04:03,921 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:04:03,921 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 13:04:03,921 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 13:04:03,921 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 13:04:03,921 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 13:04:07,230 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 13:04:07,782 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 13:04:10,622 [INFO] 🟢 context setter: Storing context (ctx id: 13261939376, state id: 13991620544) +2025-11-27 13:04:10,623 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 13:04:10,708 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:04:10,708 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:09:08,812 [INFO] 🆕 Initializing NEW app-level state (first time or after app restart) +2025-11-27 13:09:18,151 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:09:25,984 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:09:25,985 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 13:09:25,985 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 13:09:29,639 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 13:09:30,244 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 13:09:32,970 [INFO] 🟢 context setter: Storing context (ctx id: 13183802960, state id: 13758537152) +2025-11-27 13:09:32,973 [INFO] Dataset loaded successfully: 224 tiles +2025-11-27 13:09:32,978 [INFO] GeometryCache MISS for tile 0, computing geometry... +2025-11-27 13:09:32,978 [INFO] GeometryCache MISS for tile 1, computing geometry... +2025-11-27 13:09:32,979 [INFO] GeometryCache MISS for tile 2, computing geometry... +2025-11-27 13:09:32,979 [INFO] GeometryCache MISS for tile 3, computing geometry... +2025-11-27 13:09:32,980 [INFO] GeometryCache MISS for tile 4, computing geometry... +2025-11-27 13:09:32,981 [INFO] GeometryCache MISS for tile 5, computing geometry... +2025-11-27 13:09:32,981 [INFO] GeometryCache MISS for tile 6, computing geometry... +2025-11-27 13:09:32,986 [INFO] GeometryCache MISS for tile 7, computing geometry... +2025-11-27 13:09:33,318 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:09:33,321 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:09:33,668 [INFO] Geometry cached for tile 5 +2025-11-27 13:09:33,668 [INFO] GeometryCache MISS for tile 8, computing geometry... +2025-11-27 13:09:33,683 [INFO] Geometry cached for tile 1 +2025-11-27 13:09:33,683 [INFO] GeometryCache MISS for tile 9, computing geometry... +2025-11-27 13:09:33,710 [INFO] Geometry cached for tile 4 +2025-11-27 13:09:33,726 [INFO] Geometry cached for tile 0 +2025-11-27 13:09:33,726 [INFO] Geometry cached for tile 3 +2025-11-27 13:09:33,726 [INFO] Geometry cached for tile 2 +2025-11-27 13:09:33,727 [INFO] GeometryCache MISS for tile 10, computing geometry... +2025-11-27 13:09:33,727 [INFO] GeometryCache MISS for tile 11, computing geometry... +2025-11-27 13:09:33,728 [INFO] GeometryCache MISS for tile 12, computing geometry... +2025-11-27 13:09:33,728 [INFO] GeometryCache MISS for tile 13, computing geometry... +2025-11-27 13:09:33,745 [INFO] Geometry cached for tile 6 +2025-11-27 13:09:33,746 [INFO] Geometry cached for tile 7 +2025-11-27 13:09:33,749 [INFO] GeometryCache MISS for tile 14, computing geometry... +2025-11-27 13:09:33,751 [INFO] GeometryCache MISS for tile 15, computing geometry... +2025-11-27 13:09:33,831 [INFO] Geometry cached for tile 15 +2025-11-27 13:09:33,832 [INFO] GeometryCache MISS for tile 16, computing geometry... +2025-11-27 13:09:34,041 [INFO] Geometry cached for tile 14 +2025-11-27 13:09:34,043 [INFO] GeometryCache MISS for tile 17, computing geometry... +2025-11-27 13:09:34,198 [INFO] Geometry cached for tile 16 +2025-11-27 13:09:34,227 [INFO] GeometryCache MISS for tile 18, computing geometry... +2025-11-27 13:09:34,509 [INFO] Geometry cached for tile 13 +2025-11-27 13:09:34,511 [INFO] GeometryCache MISS for tile 19, computing geometry... +2025-11-27 13:09:34,728 [INFO] Geometry cached for tile 17 +2025-11-27 13:09:34,729 [INFO] GeometryCache MISS for tile 20, computing geometry... +2025-11-27 13:09:34,857 [INFO] Geometry cached for tile 18 +2025-11-27 13:09:34,864 [INFO] GeometryCache MISS for tile 21, computing geometry... +2025-11-27 13:09:35,566 [INFO] Geometry cached for tile 21 +2025-11-27 13:09:35,568 [INFO] Geometry cached for tile 20 +2025-11-27 13:09:35,568 [INFO] GeometryCache MISS for tile 22, computing geometry... +2025-11-27 13:09:35,585 [INFO] GeometryCache MISS for tile 23, computing geometry... +2025-11-27 13:09:37,058 [INFO] Geometry cached for tile 22 +2025-11-27 13:09:37,060 [INFO] GeometryCache MISS for tile 24, computing geometry... +2025-11-27 13:09:47,512 [INFO] Geometry cached for tile 19 +2025-11-27 13:09:47,537 [INFO] GeometryCache MISS for tile 25, computing geometry... +2025-11-27 13:10:05,056 [INFO] Geometry cached for tile 23 +2025-11-27 13:10:05,093 [INFO] GeometryCache MISS for tile 26, computing geometry... +2025-11-27 13:10:10,531 [INFO] Geometry cached for tile 11 +2025-11-27 13:10:10,573 [INFO] GeometryCache MISS for tile 27, computing geometry... +2025-11-27 13:10:25,595 [INFO] Geometry cached for tile 12 +2025-11-27 13:10:25,596 [INFO] GeometryCache MISS for tile 28, computing geometry... +2025-11-27 13:10:28,879 [INFO] Geometry cached for tile 10 +2025-11-27 13:10:28,880 [INFO] GeometryCache MISS for tile 29, computing geometry... +2025-11-27 13:11:00,918 [INFO] Geometry cached for tile 8 +2025-11-27 13:11:00,920 [INFO] GeometryCache MISS for tile 30, computing geometry... +2025-11-27 13:12:08,150 [INFO] Geometry cached for tile 30 +2025-11-27 13:12:08,235 [INFO] GeometryCache MISS for tile 31, computing geometry... +2025-11-27 13:12:09,757 [INFO] Geometry cached for tile 31 +2025-11-27 13:12:15,404 [INFO] Geometry cached for tile 9 +2025-11-27 13:12:38,119 [INFO] Geometry cached for tile 24 +2025-11-27 13:12:57,443 [INFO] Geometry cached for tile 28 +2025-11-27 13:13:13,159 [INFO] Geometry cached for tile 26 +2025-11-27 13:13:13,995 [INFO] Geometry cached for tile 25 +2025-11-27 13:13:41,063 [INFO] 🆕 Initializing NEW app-level state (first time or after app restart) +2025-11-27 13:13:49,913 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:13:58,566 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:13:58,567 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 13:13:58,567 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 13:14:01,638 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 13:14:02,122 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 13:14:04,587 [INFO] 🟢 context setter: Storing context (ctx id: 13249428640, state id: 13773886208) +2025-11-27 13:14:04,589 [INFO] Dataset loaded successfully: 224 tiles +2025-11-27 13:14:04,642 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:14:04,642 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:14:25,533 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:14:25,534 [INFO] GeometryCache MISS for tile 101, computing geometry... +2025-11-27 13:14:47,627 [INFO] Geometry cached for tile 101 +2025-11-27 13:15:24,984 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:15:24,986 [ERROR] Quantiles must be in the range [0, 1] +Traceback (most recent call last): + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 1940, in get_overlay + colored_overlay = self._prepare_gene_overlay(geometry_entry, params, include_geometry=include_geometry) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 2060, in _prepare_gene_overlay + threshold = float(np.quantile(tile_values, expr_quantile)) + File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py", line 4650, in quantile + raise ValueError("Quantiles must be in the range [0, 1]") +ValueError: Quantiles must be in the range [0, 1] +2025-11-27 13:15:33,275 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:15:33,276 [ERROR] Quantiles must be in the range [0, 1] +Traceback (most recent call last): + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 1940, in get_overlay + colored_overlay = self._prepare_gene_overlay(geometry_entry, params, include_geometry=include_geometry) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 2060, in _prepare_gene_overlay + threshold = float(np.quantile(tile_values, expr_quantile)) + File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py", line 4650, in quantile + raise ValueError("Quantiles must be in the range [0, 1]") +ValueError: Quantiles must be in the range [0, 1] +2025-11-27 13:15:41,696 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:15:41,697 [ERROR] Quantiles must be in the range [0, 1] +Traceback (most recent call last): + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 1940, in get_overlay + colored_overlay = self._prepare_gene_overlay(geometry_entry, params, include_geometry=include_geometry) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 2060, in _prepare_gene_overlay + threshold = float(np.quantile(tile_values, expr_quantile)) + File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py", line 4650, in quantile + raise ValueError("Quantiles must be in the range [0, 1]") +ValueError: Quantiles must be in the range [0, 1] +2025-11-27 13:16:14,264 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:16:14,265 [ERROR] Failed to build overlay: _rgba_to_css() takes 1 positional argument but 2 were given +Traceback (most recent call last): + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 1940, in get_overlay + colored_overlay = self._prepare_gene_overlay(geometry_entry, params, include_geometry=include_geometry) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 2104, in _prepare_gene_overlay + fill_color = _rgba_to_css(colors.to_rgba(highlight_color), overlay_alpha) +TypeError: _rgba_to_css() takes 1 positional argument but 2 were given +2025-11-27 13:16:29,096 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:17:57,049 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:17:59,729 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:19:15,602 [INFO] 🆕 Initializing NEW app-level state (first time or after app restart) +2025-11-27 13:19:21,044 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:19:26,760 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:19:35,042 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:19:35,042 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 13:19:35,043 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 13:19:35,043 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 13:19:35,043 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 13:19:38,833 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 13:19:39,382 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 13:19:42,121 [INFO] 🟢 context setter: Storing context (ctx id: 13321393680, state id: 13593144320) +2025-11-27 13:19:42,122 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 13:19:42,194 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:19:42,194 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:23:02,795 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:23:02,797 [INFO] GeometryCache MISS for tile 86, computing geometry... +2025-11-27 13:23:29,648 [INFO] Geometry cached for tile 86 +2025-11-27 13:23:31,286 [ERROR] Quantiles must be in the range [0, 1] +Traceback (most recent call last): + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 1940, in get_overlay + colored_overlay = self._prepare_gene_overlay(geometry_entry, params, include_geometry=include_geometry) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 2060, in _prepare_gene_overlay + threshold = float(np.quantile(tile_values, expr_quantile)) + File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py", line 4650, in quantile + raise ValueError("Quantiles must be in the range [0, 1]") +ValueError: Quantiles must be in the range [0, 1] +2025-11-27 13:23:48,101 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:23:48,102 [ERROR] Quantiles must be in the range [0, 1] +Traceback (most recent call last): + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 1940, in get_overlay + colored_overlay = self._prepare_gene_overlay(geometry_entry, params, include_geometry=include_geometry) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 2060, in _prepare_gene_overlay + threshold = float(np.quantile(tile_values, expr_quantile)) + File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py", line 4650, in quantile + raise ValueError("Quantiles must be in the range [0, 1]") +ValueError: Quantiles must be in the range [0, 1] +2025-11-27 13:24:00,400 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:24:00,401 [ERROR] Quantiles must be in the range [0, 1] +Traceback (most recent call last): + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 1940, in get_overlay + colored_overlay = self._prepare_gene_overlay(geometry_entry, params, include_geometry=include_geometry) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 2060, in _prepare_gene_overlay + threshold = float(np.quantile(tile_values, expr_quantile)) + File "/Users/jjoseph/.tissuumaps/.venv/lib/python3.9/site-packages/numpy/lib/_function_base_impl.py", line 4650, in quantile + raise ValueError("Quantiles must be in the range [0, 1]") +ValueError: Quantiles must be in the range [0, 1] +2025-11-27 13:24:06,781 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:24:06,784 [ERROR] Failed to build overlay: _rgba_to_css() takes 1 positional argument but 2 were given +Traceback (most recent call last): + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 1940, in get_overlay + colored_overlay = self._prepare_gene_overlay(geometry_entry, params, include_geometry=include_geometry) + File "/Users/jjoseph/.tissuumaps/plugins/CellExplorer.py", line 2104, in _prepare_gene_overlay + fill_color = _rgba_to_css(colors.to_rgba(highlight_color), overlay_alpha) +TypeError: _rgba_to_css() takes 1 positional argument but 2 were given +2025-11-27 13:24:15,965 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:32:52,898 [INFO] 🆕 Initializing NEW app-level state (first time or after app restart) +2025-11-27 13:32:57,429 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:33:03,876 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:33:14,878 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:33:14,878 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 13:33:14,878 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 13:33:14,878 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 13:33:14,878 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 13:33:18,332 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 13:33:18,845 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 13:33:21,552 [INFO] 🟢 context setter: Storing context (ctx id: 13472843808, state id: 13661229312) +2025-11-27 13:33:21,588 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 13:33:21,723 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:33:21,723 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:33:33,596 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:33:33,597 [INFO] GeometryCache MISS for tile 152, computing geometry... +2025-11-27 13:34:11,759 [INFO] Geometry cached for tile 152 +2025-11-27 13:39:48,153 [INFO] 🆕 Initializing NEW app-level state (first time or after app restart) +2025-11-27 13:39:52,790 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:39:57,336 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:40:10,487 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:40:10,487 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 13:40:10,488 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 13:40:10,488 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 13:40:10,488 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 13:40:14,026 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 13:40:14,489 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 13:40:17,024 [INFO] 🟢 context setter: Storing context (ctx id: 6105856032, state id: 13975846784) +2025-11-27 13:40:17,025 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 13:40:17,138 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:40:17,138 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:40:33,511 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:40:33,513 [INFO] GeometryCache MISS for tile 70, computing geometry... +2025-11-27 13:40:39,425 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:40:41,636 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:40:53,416 [INFO] Geometry cached for tile 70 +2025-11-27 13:41:00,006 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:41:58,263 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:41:58,264 [INFO] GeometryCache MISS for tile 70, computing geometry... +2025-11-27 13:42:17,493 [INFO] Geometry cached for tile 70 +2025-11-27 13:45:50,594 [INFO] 🆕 Initializing NEW app-level state (first time or after app restart) +2025-11-27 13:45:54,894 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:46:00,741 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:46:10,958 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:46:10,958 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 13:46:10,959 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 13:46:10,959 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 13:46:10,959 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 13:46:15,047 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 13:46:15,629 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 13:46:18,284 [INFO] 🟢 context setter: Storing context (ctx id: 6441694208, state id: 13901596544) +2025-11-27 13:46:18,285 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 13:46:18,374 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:46:18,374 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:46:22,316 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:46:22,317 [INFO] GeometryCache MISS for tile 117, computing geometry... +2025-11-27 13:46:46,717 [INFO] Geometry cached for tile 117 +2025-11-27 13:46:51,142 [INFO] Generated nuclei outlines for 307 labels +2025-11-27 13:46:54,126 [INFO] Generated expanded outlines for 310 labels +2025-11-27 13:53:10,556 [INFO] 🆕 Initializing NEW app-level state (first time or after app restart) +2025-11-27 13:53:15,833 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:53:20,382 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:53:30,281 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 13:53:30,281 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 13:53:30,281 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 13:53:30,281 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 13:53:30,281 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 13:53:33,517 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 13:53:34,069 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 13:53:36,605 [INFO] 🟢 context setter: Storing context (ctx id: 13393061728, state id: 14056384960) +2025-11-27 13:53:36,606 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 13:53:36,715 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:53:36,715 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:53:46,002 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:53:46,004 [INFO] GeometryCache MISS for tile 118, computing geometry... +2025-11-27 13:54:05,372 [INFO] Geometry cached for tile 118 +2025-11-27 13:54:10,438 [INFO] Generated nuclei outlines for 359 labels +2025-11-27 13:54:13,851 [INFO] Generated expanded outlines for 360 labels +2025-11-27 13:56:15,925 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:56:20,939 [INFO] Generated nuclei outlines for 359 labels +2025-11-27 13:56:24,823 [INFO] Generated expanded outlines for 360 labels +2025-11-27 13:56:43,653 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:56:45,169 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:57:06,742 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 13:57:08,210 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 14:01:27,646 [INFO] 🆕 Initializing NEW app-level state (first time or after app restart) +2025-11-27 14:01:32,029 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 14:01:36,777 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 14:01:46,227 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 14:01:46,227 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 14:01:46,227 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 14:01:46,227 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 14:01:46,227 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 14:01:49,740 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 14:01:50,348 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 14:01:52,912 [INFO] 🟢 context setter: Storing context (ctx id: 13177292640, state id: 13944407744) +2025-11-27 14:01:52,914 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 14:01:53,014 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 14:01:53,014 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 14:01:58,314 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 14:01:58,317 [INFO] GeometryCache MISS for tile 102, computing geometry... +2025-11-27 14:02:22,834 [INFO] Geometry cached for tile 102 +2025-11-27 14:02:28,910 [INFO] Generated nuclei outlines for 476 labels +2025-11-27 14:02:33,896 [INFO] Generated expanded outlines for 478 labels +2025-11-27 14:03:06,702 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 14:03:08,025 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 14:06:48,471 [INFO] 🆕 Initializing NEW app-level state (first time or after app restart) +2025-11-27 14:06:53,075 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 14:06:58,288 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 14:07:07,072 [INFO] ♻️ Reusing EXISTING app-level state (context exists: False) +2025-11-27 14:07:07,073 [INFO] load_dataset request paths: he_path='/Users/jjoseph/Desktop/he.tiff', labels_path='/Users/jjoseph/Desktop/he.npz', adata_path='/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad' +2025-11-27 14:07:07,074 [INFO] Dataset already loaded; reusing cached context. +2025-11-27 14:07:07,074 [WARNING] ⚠️ Context is None, rehydrating dataset (this should only happen once per session!) +2025-11-27 14:07:07,074 [INFO] Loading H&E image: /Users/jjoseph/Desktop/he.tiff +2025-11-27 14:07:11,520 [INFO] Loading sparse labels: /Users/jjoseph/Desktop/he.npz +2025-11-27 14:07:12,144 [INFO] Loading AnnData: /Users/jjoseph/Desktop/P2_CRC_annotated.h5ad +2025-11-27 14:07:14,781 [INFO] 🟢 context setter: Storing context (ctx id: 6108989472, state id: 14029880512) +2025-11-27 14:07:14,783 [INFO] ✅ Context rehydrated. Dataset ID: P2_CRC_annotated.h5ad +2025-11-27 14:07:14,985 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 14:07:14,985 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 14:07:20,830 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 14:07:24,166 [INFO] ♻️ Reusing EXISTING app-level state (context exists: True) +2025-11-27 14:07:24,168 [INFO] GeometryCache MISS for tile 137, computing geometry... +2025-11-27 14:07:58,567 [INFO] Geometry cached for tile 137 +2025-11-27 14:08:05,609 [INFO] [OBS] Generated nuclei outlines for 699 labels +2025-11-27 14:08:12,401 [INFO] [OBS] Generated expanded outlines for 701 labels diff --git a/plugins/CellExplorer/dataset_state.json b/plugins/CellExplorer/dataset_state.json new file mode 100644 index 0000000..9566457 --- /dev/null +++ b/plugins/CellExplorer/dataset_state.json @@ -0,0 +1 @@ +{"he_path": "/Users/jjoseph/Desktop/he.tiff", "labels_path": "/Users/jjoseph/Desktop/he.npz", "adata_path": "/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad", "obsm_key": "spatial_cropped_150_buffer", "tile_h": 1500, "tile_w": 1500, "stride_h": null, "stride_w": null, "source_he_path": "/Users/jjoseph/Desktop/he.tiff", "source_labels_path": "/Users/jjoseph/Desktop/he.npz", "source_adata_path": "/Users/jjoseph/Desktop/P2_CRC_annotated.h5ad", "stage_to_local": false, "stage_root": null, "staging": {"enabled": false, "stage_root": null}, "tile_cache_size": 1, "warm_cache": false, "warm_cache_tiles": 0, "tile_workers": 8, "single_tile_mode": true} \ No newline at end of file diff --git a/plugins/plugins_manifest.json b/plugins/plugins_manifest.json index 25bfb54..9b3a15d 100644 --- a/plugins/plugins_manifest.json +++ b/plugins/plugins_manifest.json @@ -1,12 +1,8 @@ { "modules": { - "Bin2CellExplorer": { - "path": "/Users/jjoseph/.tissuumaps/plugins/Bin2CellExplorer.js", - "name": "Bin2CellExplorer" - }, - "CircleSeg_Overlay": { - "path": "/Users/jjoseph/.tissuumaps/plugins/CircleSeg_Overlay.js", - "name": "CircleSeg_Overlay" + "CellExplorer": { + "path": "/nfs/home/jjoseph/.tissuumaps/plugins/CellExplorer.js", + "name": "CellExplorer" } } -} \ No newline at end of file +} diff --git a/plugins_manifest.json b/plugins_manifest.json new file mode 100644 index 0000000..9b3a15d --- /dev/null +++ b/plugins_manifest.json @@ -0,0 +1,8 @@ +{ + "modules": { + "CellExplorer": { + "path": "/nfs/home/jjoseph/.tissuumaps/plugins/CellExplorer.js", + "name": "CellExplorer" + } + } +}