senoquant 1.0.0b2__py3-none-any.whl → 1.0.0b4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. senoquant/__init__.py +6 -2
  2. senoquant/_reader.py +1 -1
  3. senoquant/_widget.py +9 -1
  4. senoquant/reader/core.py +201 -18
  5. senoquant/tabs/__init__.py +2 -0
  6. senoquant/tabs/batch/backend.py +76 -27
  7. senoquant/tabs/batch/frontend.py +127 -25
  8. senoquant/tabs/quantification/features/marker/dialog.py +26 -6
  9. senoquant/tabs/quantification/features/marker/export.py +97 -24
  10. senoquant/tabs/quantification/features/marker/rows.py +2 -2
  11. senoquant/tabs/quantification/features/spots/dialog.py +41 -11
  12. senoquant/tabs/quantification/features/spots/export.py +163 -10
  13. senoquant/tabs/quantification/frontend.py +2 -2
  14. senoquant/tabs/segmentation/frontend.py +46 -9
  15. senoquant/tabs/segmentation/models/cpsam/model.py +1 -1
  16. senoquant/tabs/segmentation/models/default_2d/model.py +22 -77
  17. senoquant/tabs/segmentation/models/default_3d/model.py +8 -74
  18. senoquant/tabs/segmentation/stardist_onnx_utils/_csbdeep/tools/create_zip_contents.py +0 -0
  19. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/inspect/probe.py +13 -13
  20. senoquant/tabs/segmentation/stardist_onnx_utils/onnx_framework/stardist_libs.py +171 -0
  21. senoquant/tabs/spots/frontend.py +96 -5
  22. senoquant/tabs/spots/models/rmp/details.json +3 -9
  23. senoquant/tabs/spots/models/rmp/model.py +341 -266
  24. senoquant/tabs/spots/models/ufish/details.json +32 -0
  25. senoquant/tabs/spots/models/ufish/model.py +327 -0
  26. senoquant/tabs/spots/ufish_utils/__init__.py +13 -0
  27. senoquant/tabs/spots/ufish_utils/core.py +387 -0
  28. senoquant/tabs/visualization/__init__.py +1 -0
  29. senoquant/tabs/visualization/backend.py +306 -0
  30. senoquant/tabs/visualization/frontend.py +1113 -0
  31. senoquant/tabs/visualization/plots/__init__.py +80 -0
  32. senoquant/tabs/visualization/plots/base.py +152 -0
  33. senoquant/tabs/visualization/plots/double_expression.py +187 -0
  34. senoquant/tabs/visualization/plots/spatialplot.py +156 -0
  35. senoquant/tabs/visualization/plots/umap.py +140 -0
  36. senoquant/utils.py +1 -1
  37. senoquant-1.0.0b4.dist-info/METADATA +162 -0
  38. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/RECORD +53 -30
  39. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/top_level.txt +1 -0
  40. ufish/__init__.py +1 -0
  41. ufish/api.py +778 -0
  42. ufish/model/__init__.py +0 -0
  43. ufish/model/loss.py +62 -0
  44. ufish/model/network/__init__.py +0 -0
  45. ufish/model/network/spot_learn.py +50 -0
  46. ufish/model/network/ufish_net.py +204 -0
  47. ufish/model/train.py +175 -0
  48. ufish/utils/__init__.py +0 -0
  49. ufish/utils/img.py +418 -0
  50. ufish/utils/log.py +8 -0
  51. ufish/utils/spot_calling.py +115 -0
  52. senoquant/tabs/spots/models/udwt/details.json +0 -103
  53. senoquant/tabs/spots/models/udwt/model.py +0 -482
  54. senoquant-1.0.0b2.dist-info/METADATA +0 -193
  55. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/WHEEL +0 -0
  56. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/entry_points.txt +0 -0
  57. {senoquant-1.0.0b2.dist-info → senoquant-1.0.0b4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,80 @@
1
+ """Visualization plot UI components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import pkgutil
7
+ from typing import Iterable
8
+
9
+ from .base import PlotConfig, PlotData, SenoQuantPlot
10
+ from .spatialplot import SpatialPlotData
11
+ from .umap import UMAPData
12
+
13
+
14
+ def _iter_subclasses(cls: type[SenoQuantPlot]) -> Iterable[type[SenoQuantPlot]]:
15
+ """Yield all subclasses of a plot class recursively.
16
+
17
+ Parameters
18
+ ----------
19
+ cls : type[SenoQuantPlot]
20
+ Base class whose subclasses should be discovered.
21
+
22
+ Yields
23
+ ------
24
+ type[SenoQuantPlot]
25
+ Plot subclass types.
26
+ """
27
+ for subclass in cls.__subclasses__():
28
+ yield subclass
29
+ yield from _iter_subclasses(subclass)
30
+
31
+
32
+ def get_feature_registry() -> dict[str, type[SenoQuantPlot]]:
33
+ """Discover plot classes and return a registry by name."""
34
+ for module in pkgutil.walk_packages(__path__, f"{__name__}."):
35
+ importlib.import_module(module.name)
36
+
37
+ registry: dict[str, type[SenoQuantPlot]] = {}
38
+ for plot_cls in _iter_subclasses(SenoQuantPlot):
39
+ feature_type = getattr(plot_cls, "feature_type", "")
40
+ if not feature_type:
41
+ continue
42
+ registry[feature_type] = plot_cls
43
+
44
+ return dict(
45
+ sorted(
46
+ registry.items(),
47
+ key=lambda item: getattr(item[1], "order", 0),
48
+ )
49
+ )
50
+
51
+ FEATURE_DATA_FACTORY: dict[str, type[PlotData]] = {
52
+ "UMAP": UMAPData,
53
+ "Spatial Plot": SpatialPlotData,
54
+ }
55
+
56
+
57
+ def build_feature_data(feature_type: str) -> PlotData:
58
+ """Create a plot data instance for the specified plot type.
59
+
60
+ Parameters
61
+ ----------
62
+ feature_type : str
63
+ Plot type name.
64
+
65
+ Returns
66
+ -------
67
+ PlotData
68
+ Plot-specific configuration instance.
69
+ """
70
+ data_cls = FEATURE_DATA_FACTORY.get(feature_type, PlotData)
71
+ return data_cls()
72
+
73
+
74
+ __all__ = [
75
+ "PlotConfig",
76
+ "PlotData",
77
+ "SenoQuantPlot",
78
+ "build_feature_data",
79
+ "get_feature_registry",
80
+ ]
@@ -0,0 +1,152 @@
1
+ """Feature UI base classes for visualization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Iterable
8
+ import uuid
9
+
10
+ from qtpy.QtWidgets import QComboBox
11
+
12
+ if TYPE_CHECKING:
13
+ from ..frontend import VisualizationTab
14
+ from ..frontend import PlotUIContext
15
+
16
+
17
+ class PlotData:
18
+ """Base class for plot-specific configuration data.
19
+
20
+ Notes
21
+ -----
22
+ Concrete plot data classes should inherit from this class so they can
23
+ be stored on :class:`PlotConfig`.
24
+ """
25
+
26
+
27
+ @dataclass
28
+ class PlotConfig:
29
+ """Configuration for a single visualization plot.
30
+
31
+ Attributes
32
+ ----------
33
+ plot_id : str
34
+ Unique identifier for the plot instance.
35
+ type_name : str
36
+ Plot type name (e.g., ``"UMAP"``).
37
+ data : PlotData
38
+ Plot-specific configuration payload.
39
+ """
40
+
41
+ plot_id: str = field(default_factory=lambda: uuid.uuid4().hex)
42
+ type_name: str = ""
43
+ data: PlotData = field(default_factory=PlotData)
44
+
45
+
46
+ class SenoQuantPlot:
47
+ """Base class for visualization plot UI."""
48
+
49
+ feature_type: str = ""
50
+ order: int = 0
51
+
52
+ def __init__(self, tab: "VisualizationTab", context: "PlotUIContext") -> None:
53
+ """Initialize a plot with shared tab context.
54
+
55
+ Parameters
56
+ ----------
57
+ tab : VisualizationTab
58
+ Parent visualization tab instance.
59
+ context : PlotUIContext
60
+ Plot UI context with configuration state.
61
+ """
62
+ self._tab = tab
63
+ self._context = context
64
+ self._state = context.state
65
+ self._ui: dict[str, object] = {}
66
+
67
+ def build(self) -> None:
68
+ """Build the UI for this plot."""
69
+ raise NotImplementedError
70
+
71
+ def plot(
72
+ self,
73
+ temp_dir: Path,
74
+ input_path: Path,
75
+ export_format: str,
76
+ markers: list[str] | None = None,
77
+ thresholds: dict[str, float] | None = None,
78
+ ) -> Iterable[Path]:
79
+ """Generate plot outputs into a temporary directory.
80
+
81
+ Parameters
82
+ ----------
83
+ temp_dir : Path
84
+ Temporary directory where outputs should be written.
85
+ input_path : Path
86
+ Path to the folder containing CSV or Excel files for plotting.
87
+ export_format : str
88
+ File format requested by the user (``"png"``, ``"svg"``, or ``"pdf"``).
89
+ markers : list of str, optional
90
+ List of selected markers to include.
91
+ thresholds : dict, optional
92
+ Dictionary of {marker_name: threshold_value} for filtering.
93
+
94
+ Returns
95
+ -------
96
+ iterable of Path
97
+ Paths to files produced by the plot routine.
98
+
99
+ Notes
100
+ -----
101
+ Implementations may either return explicit file paths or simply
102
+ write outputs into ``temp_dir`` and return an empty iterable.
103
+ """
104
+ return []
105
+
106
+ def on_features_changed(self, configs: list["PlotUIContext"]) -> None:
107
+ """Handle updates when the plot list changes.
108
+
109
+ Parameters
110
+ ----------
111
+ configs : list of PlotUIContext
112
+ Current plot contexts.
113
+ """
114
+ return
115
+
116
+ @classmethod
117
+ def update_type_options(
118
+ cls, tab: "VisualizationTab", configs: list["PlotUIContext"]
119
+ ) -> None:
120
+ """Update type availability in plot selectors.
121
+
122
+ Parameters
123
+ ----------
124
+ tab : VisualizationTab
125
+ Parent visualization tab instance.
126
+ configs : list of PlotUIContext
127
+ Current plot contexts.
128
+ """
129
+ return
130
+
131
+
132
+ class RefreshingComboBox(QComboBox):
133
+ """Combo box that refreshes its items when opened."""
134
+
135
+ def __init__(self, refresh_callback=None, parent=None) -> None:
136
+ """Create a combo box that refreshes before showing its popup.
137
+
138
+ Parameters
139
+ ----------
140
+ refresh_callback : callable or None
141
+ Callback invoked before showing the popup.
142
+ parent : QWidget or None
143
+ Optional parent widget.
144
+ """
145
+ super().__init__(parent)
146
+ self._refresh_callback = refresh_callback
147
+
148
+ def showPopup(self) -> None:
149
+ """Refresh items before showing the popup."""
150
+ if self._refresh_callback is not None:
151
+ self._refresh_callback()
152
+ super().showPopup()
@@ -0,0 +1,187 @@
1
+ """Double expression plot handler for visualization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+ try:
9
+ from napari.utils.notifications import show_error
10
+ except ImportError:
11
+ def show_error(message: str) -> None:
12
+ pass
13
+
14
+ from .base import PlotData, SenoQuantPlot
15
+
16
+
17
+ class DoubleExpressionData(PlotData):
18
+ """Configuration data for double expression plot."""
19
+
20
+ pass
21
+
22
+
23
+ class DoubleExpressionPlot(SenoQuantPlot):
24
+ """Spatial scatter plot highlighting double positive cells."""
25
+
26
+ feature_type = "Double Expression"
27
+ order = 2
28
+
29
+ def build(self) -> None:
30
+ """Build the UI for double expression plot configuration."""
31
+ pass
32
+
33
+ def plot(
34
+ self,
35
+ temp_dir: Path,
36
+ input_path: Path,
37
+ export_format: str,
38
+ markers: list[str] | None = None,
39
+ thresholds: dict[str, float] | None = None,
40
+ ) -> Iterable[Path]:
41
+ """Generate double expression plot from input CSV.
42
+
43
+ Parameters
44
+ ----------
45
+ temp_dir : Path
46
+ Temporary directory to write plot output.
47
+ input_path : Path
48
+ Path to input CSV file or folder containing CSV files.
49
+ export_format : str
50
+ Output format ("png", "svg", or "pdf").
51
+ markers : list of str, optional
52
+ List of selected markers. Must contain exactly 2 markers.
53
+ thresholds : dict, optional
54
+ Dictionary of {marker_name: threshold_value}.
55
+
56
+ Returns
57
+ -------
58
+ iterable of Path
59
+ Paths to generated plot files.
60
+ """
61
+ try:
62
+ try:
63
+ import pandas as pd
64
+ except ImportError:
65
+ msg = (
66
+ "[DoubleExpressionPlot] pandas is not installed; "
67
+ "skipping plot generation."
68
+ )
69
+ print(msg)
70
+ show_error(msg)
71
+ return []
72
+ try:
73
+ import matplotlib.pyplot as plt
74
+ except ImportError:
75
+ msg = (
76
+ "[DoubleExpressionPlot] matplotlib is not installed; "
77
+ "skipping plot generation."
78
+ )
79
+ print(msg)
80
+ show_error(msg)
81
+ return []
82
+
83
+ print(f"[DoubleExpressionPlot] Starting with input_path={input_path}")
84
+
85
+ if not markers or len(markers) != 2:
86
+ msg = f"Double Expression Plot requires exactly 2 markers. Got {len(markers) if markers else 0}."
87
+ print(f"[DoubleExpressionPlot] {msg}")
88
+ show_error(msg)
89
+ return []
90
+
91
+ # Find data file
92
+ data_files = list(input_path.glob("*.csv")) + list(input_path.glob("*.xlsx")) + list(input_path.glob("*.xls"))
93
+ if not data_files:
94
+ print(f"[DoubleExpressionPlot] No data files found")
95
+ return []
96
+
97
+ data_file = data_files[0]
98
+ if data_file.suffix.lower() in ('.xlsx', '.xls'):
99
+ df = pd.read_excel(data_file)
100
+ else:
101
+ df = pd.read_csv(data_file)
102
+
103
+ if df.empty:
104
+ return []
105
+
106
+ # Identify columns (alphabetical order from frontend)
107
+ m1, m2 = markers[0], markers[1]
108
+ col1 = f"{m1}_mean_intensity"
109
+ col2 = f"{m2}_mean_intensity"
110
+
111
+ if col1 not in df.columns or col2 not in df.columns:
112
+ msg = f"Missing columns for markers: {m1}, {m2}"
113
+ print(f"[DoubleExpressionPlot] {msg}")
114
+ show_error(msg)
115
+ return []
116
+
117
+ # Get thresholds
118
+ t1 = thresholds.get(m1, 0.0) if thresholds else 0.0
119
+ t2 = thresholds.get(m2, 0.0) if thresholds else 0.0
120
+
121
+ print(f"[DoubleExpressionPlot] Using thresholds: {m1}>{t1}, {m2}>{t2}")
122
+
123
+ # Find X, Y
124
+ x_col = None
125
+ y_col = None
126
+ for col in df.columns:
127
+ col_lower = col.lower()
128
+ if "x" in col_lower and x_col is None:
129
+ x_col = col
130
+ elif "y" in col_lower and y_col is None:
131
+ y_col = col
132
+
133
+ if x_col is None or y_col is None:
134
+ msg = "[DoubleExpressionPlot] Could not find X/Y columns in the data file."
135
+ print(msg)
136
+ show_error(msg)
137
+ return []
138
+
139
+ # Plotting
140
+ fig, ax = plt.subplots(figsize=(10, 10))
141
+
142
+ # 1. Background (All cells - Negative appearance)
143
+ ax.scatter(df[x_col], df[y_col], c="#f0f0f0", s=1, label="Negative")
144
+
145
+ # 2. Layer 1: M1 ONLY (Red)
146
+ # Logic: (M1 > T1) AND (M2 <= T2)
147
+ m1_only = df[(df[col1] > t1) & (df[col2] <= t2)]
148
+ ax.scatter(m1_only[x_col], m1_only[y_col], c="red", s=3, alpha=0.8, label=f"{m1}+ only")
149
+
150
+ # 3. Layer 2: M2 ONLY (Blue)
151
+ # Logic: (M2 > T2) AND (M1 <= T1)
152
+ m2_only = df[(df[col2] > t2) & (df[col1] <= t1)]
153
+ ax.scatter(m2_only[x_col], m2_only[y_col], c="blue", s=3, alpha=0.8, label=f"{m2}+ only")
154
+
155
+ # 4. Layer 3: DOUBLE POSITIVE (Green)
156
+ # Logic: (M1 > T1) AND (M2 > T2)
157
+ both_pos = df[(df[col1] > t1) & (df[col2] > t2)]
158
+ ax.scatter(both_pos[x_col], both_pos[y_col], c="green", s=4, alpha=1.0, label="Double Positive")
159
+
160
+ ax.set_aspect('equal')
161
+ ax.set_title(f"Spatial Distribution\n{m1} (Red) | {m2} (Blue) | Both (Green)", fontsize=15)
162
+ ax.set_xlabel(x_col)
163
+ ax.set_ylabel(y_col)
164
+
165
+ # Custom Legend
166
+ ax.legend(markerscale=4, loc='upper right', frameon=False)
167
+
168
+ # Print Counts
169
+ print(f"[DoubleExpressionPlot] {m1}+ only: {len(m1_only)}")
170
+ print(f"[DoubleExpressionPlot] {m2}+ only: {len(m2_only)}")
171
+ print(f"[DoubleExpressionPlot] Double + : {len(both_pos)}")
172
+
173
+ # Save
174
+ safe_name = f"{m1}_{m2}_double_expression"
175
+ safe_name = "".join(c if c.isalnum() else "_" for c in safe_name)
176
+ output_file = temp_dir / f"{safe_name}.{export_format}"
177
+ fig.savefig(str(output_file), dpi=150, bbox_inches="tight")
178
+ plt.close(fig)
179
+
180
+ return [output_file]
181
+
182
+ except Exception as e:
183
+ import traceback
184
+ print(f"[DoubleExpressionPlot] Error: {e}")
185
+ print(traceback.format_exc())
186
+ show_error(f"Error in Double Expression Plot: {e}")
187
+ return []
@@ -0,0 +1,156 @@
1
+ """Spatial plot handler for visualization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+ from .base import PlotData, SenoQuantPlot
9
+
10
+
11
+ class SpatialPlotData(PlotData):
12
+ """Configuration data for spatial plot."""
13
+
14
+ pass
15
+
16
+
17
+ class SpatialPlot(SenoQuantPlot):
18
+ """Spatial scatter plot handler for coordinate and intensity data."""
19
+
20
+ feature_type = "Spatial Plot"
21
+ order = 0
22
+
23
+ def build(self) -> None:
24
+ """Build the UI for spatial plot configuration."""
25
+ # Minimal UI for now; can add controls for marker selection, colormaps, etc. later
26
+ pass
27
+
28
+ def plot(
29
+ self,
30
+ temp_dir: Path,
31
+ input_path: Path,
32
+ export_format: str,
33
+ markers: list[str] | None = None,
34
+ thresholds: dict[str, float] | None = None,
35
+ ) -> Iterable[Path]:
36
+ """Generate spatial plot from input CSV.
37
+
38
+ Parameters
39
+ ----------
40
+ temp_dir : Path
41
+ Temporary directory to write plot output.
42
+ input_path : Path
43
+ Path to input CSV file or folder containing CSV files.
44
+ export_format : str
45
+ Output format ("png", "svg", or "pdf").
46
+ markers : list of str, optional
47
+ List of selected markers to include.
48
+ thresholds : dict, optional
49
+ Dictionary of {marker_name: threshold_value} for filtering.
50
+
51
+ Returns
52
+ -------
53
+ iterable of Path
54
+ Paths to generated plot files.
55
+ """
56
+ try:
57
+ try:
58
+ import pandas as pd
59
+ except ImportError:
60
+ print("[SpatialPlot] pandas is not installed; skipping plot generation.")
61
+ return []
62
+ try:
63
+ import matplotlib.pyplot as plt
64
+ except ImportError:
65
+ print(
66
+ "[SpatialPlot] matplotlib is not installed; skipping plot generation."
67
+ )
68
+ return []
69
+
70
+ print(f"[SpatialPlot] Starting with input_path={input_path}")
71
+ # Find the first data file (CSV or Excel) in the input folder
72
+ data_files = list(input_path.glob("*.csv")) + list(input_path.glob("*.xlsx")) + list(input_path.glob("*.xls"))
73
+ print(f"[SpatialPlot] Found {len(data_files)} data files")
74
+ if not data_files:
75
+ print(f"[SpatialPlot] No CSV/Excel files found in {input_path}")
76
+ return []
77
+
78
+ data_file = data_files[0]
79
+ print(f"[SpatialPlot] Reading {data_file}")
80
+ if data_file.suffix.lower() in ('.xlsx', '.xls'):
81
+ df = pd.read_excel(data_file)
82
+ else:
83
+ df = pd.read_csv(data_file)
84
+ print(f"[SpatialPlot] Loaded dataframe with shape {df.shape}")
85
+ if df.empty:
86
+ print(f"[SpatialPlot] DataFrame is empty")
87
+ return []
88
+
89
+ print(df.head())
90
+
91
+ # Apply thresholds if provided
92
+ if thresholds:
93
+ for marker, thresh in thresholds.items():
94
+ col_name = f"{marker}_mean_intensity"
95
+ if col_name in df.columns:
96
+ # Clip values below threshold to 0
97
+ df.loc[df[col_name] < thresh, col_name] = 0
98
+
99
+ # Filter columns based on selected markers (optional, but good for cleanup)
100
+ if markers is not None:
101
+ # We want to ensure we don't pick a deselected marker as the intensity column
102
+ valid_marker_cols = [f"{m}_mean_intensity" for m in markers]
103
+ # Keep non-marker columns (like coords) + valid marker columns
104
+ cols_to_keep = [c for c in df.columns if "_mean_intensity" not in c or c in valid_marker_cols]
105
+ df = df[cols_to_keep]
106
+ print(f"[SpatialPlot] Filtered columns using {len(valid_marker_cols)} selected markers")
107
+
108
+ # Look for X, Y coordinate columns
109
+ x_col = None
110
+ y_col = None
111
+ for col in df.columns:
112
+ col_lower = col.lower()
113
+ if "x" in col_lower and x_col is None:
114
+ x_col = col
115
+ elif "y" in col_lower and y_col is None:
116
+ y_col = col
117
+
118
+ if x_col is None or y_col is None:
119
+ return []
120
+
121
+ x = df[x_col].values
122
+ y = df[y_col].values
123
+
124
+ # Get first numeric column (intensity) for coloring
125
+ numeric_cols = df.select_dtypes(include=["number"]).columns
126
+ intensity_col = None
127
+ for col in numeric_cols:
128
+ if col not in [x_col, y_col]:
129
+ intensity_col = col
130
+ break
131
+
132
+ # Create plot
133
+ fig, ax = plt.subplots(figsize=(8, 6))
134
+ if intensity_col is not None:
135
+ c = df[intensity_col].values
136
+ scatter = ax.scatter(x, y, c=c, cmap="viridis", alpha=0.6, s=20)
137
+ plt.colorbar(scatter, ax=ax, label=intensity_col)
138
+ else:
139
+ ax.scatter(x, y, alpha=0.6, s=20)
140
+
141
+ ax.set_xlabel(x_col)
142
+ ax.set_ylabel(y_col)
143
+ ax.set_title("Spatial Distribution")
144
+
145
+ # Save plot
146
+ output_file = temp_dir / f"spatial_plot.{export_format}"
147
+ fig.savefig(str(output_file), dpi=150, bbox_inches="tight")
148
+ plt.close(fig)
149
+
150
+ return [output_file]
151
+
152
+ except Exception as e:
153
+ import traceback
154
+ print(f"[SpatialPlot] ERROR generating spatial plot: {e}")
155
+ print(traceback.format_exc())
156
+ return []