lavlab-pythomics 0.1.0__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.
- lavlab_pythomics-0.1.0.dist-info/METADATA +62 -0
- lavlab_pythomics-0.1.0.dist-info/RECORD +9 -0
- lavlab_pythomics-0.1.0.dist-info/WHEEL +4 -0
- lavlab_pythomics-0.1.0.dist-info/entry_points.txt +2 -0
- lavlab_pythomics-0.1.0.dist-info/licenses/LICENSE.txt +9 -0
- pythomics/__init__.py +5 -0
- pythomics/__main__.py +3 -0
- pythomics/cli.py +88 -0
- pythomics/core.py +253 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lavlab-pythomics
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Feature extraction from histology label maps
|
|
5
|
+
Project-URL: Documentation, https://github.com/laviolette-lab/pythomics#readme
|
|
6
|
+
Project-URL: Issues, https://github.com/laviolette-lab/pythomics/issues
|
|
7
|
+
Project-URL: Source, https://github.com/laviolette-lab/pythomics
|
|
8
|
+
Author-email: barrettMCW <mjbarrett@mcw.edu>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE.txt
|
|
11
|
+
Keywords: feature-extraction,histology,image-analysis
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
20
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: numpy
|
|
24
|
+
Requires-Dist: pandas
|
|
25
|
+
Requires-Dist: scikit-image
|
|
26
|
+
Requires-Dist: scipy
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: coverage[toml]>=6.2; extra == 'test'
|
|
29
|
+
Requires-Dist: pytest; extra == 'test'
|
|
30
|
+
Requires-Dist: pytest-cov; extra == 'test'
|
|
31
|
+
Provides-Extra: visualization
|
|
32
|
+
Requires-Dist: glymur; extra == 'visualization'
|
|
33
|
+
Requires-Dist: matplotlib; extra == 'visualization'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# Standalone pythomics feature extraction
|
|
37
|
+
|
|
38
|
+
Extract per-object lumen and epithelium measurements and block-summed density maps from an integer label-map image. This folder is self-contained and does not import the repository's model/inference code.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
From this directory, install the listed dependencies into your environment:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python -m pip install -r requirements.txt
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Image readers are provided by scikit-image. Parquet output is deliberately not required; object-level tables are CSV, density arrays are compressed NPZ, and MAT output is optional.
|
|
49
|
+
|
|
50
|
+
## CLI
|
|
51
|
+
|
|
52
|
+
Run from the repository root:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python -m pythomics_standalone path/to/labels.png --output-dir output --save-mat
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The default label values are lumen=1, epithelium=2, stroma=4, epithelial-cells=5, stromal-cells=6. Override with `--lumen-label`, `--epithelium-label`, `--stroma-label`, `--epithelial-cells-label`, and `--stromal-cells-label`. `--min-area` sets the minimum connected component size in source pixels (default 16); `--block-size HEIGHT WIDTH` sets density aggregation blocks (default 20 20).
|
|
59
|
+
|
|
60
|
+
Epithelium regions include both epithelium and epithelial-cell pixels. Stroma density includes stromal-cell pixels. Connected components are labeled independently in each tissue mask. Tables contain source-pixel area, circularity (`roundness`), and for epithelium average skeleton thickness and epithelial-cell fraction. Density NPZ arrays are block sums, with edge blocks padded with zeros. The optional MAT file includes densities plus feature maps projected across every source-resolution pixel.
|
|
61
|
+
|
|
62
|
+
Outputs are `<input-stem>_lumen_features.csv`, `<input-stem>_epithelium_features.csv`, and `<input-stem>_densities.npz`; with `--save-mat`, also `<input-stem>_features.mat`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pythomics/__init__.py,sha256=LXOqwZTg7-j2pXao-TDzny4v6UV4ZISeJLH8tXmSKr4,199
|
|
2
|
+
pythomics/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
pythomics/cli.py,sha256=5N_R1iSM7-qks2Vyntkss7RhyJz6Ob0Vxf2zvvCsvNg,2697
|
|
4
|
+
pythomics/core.py,sha256=oK-Y_MBgF6o6p1UnssJJghkt50I6Pr6wvL_7h8M2o7k,9374
|
|
5
|
+
lavlab_pythomics-0.1.0.dist-info/METADATA,sha256=pXFIQlzJfQpFKtUtO31cP87ckLb-WGfA47No-xNQTYs,3240
|
|
6
|
+
lavlab_pythomics-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
lavlab_pythomics-0.1.0.dist-info/entry_points.txt,sha256=n-awG9x42tNICPOLkJHwKnbBHeDkLlxLkdhtyOvud58,49
|
|
8
|
+
lavlab_pythomics-0.1.0.dist-info/licenses/LICENSE.txt,sha256=g-g_MSYD9bY-dMaZqJP6LzxscgMsRtIX3wERzYAYc6Q,1095
|
|
9
|
+
lavlab_pythomics-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-present barrettMCW <mjbarrett@mcw.edu>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
pythomics/__init__.py
ADDED
pythomics/__main__.py
ADDED
pythomics/cli.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Command-line interface for standalone pythomics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .core import DEFAULT_LABELS, extract_features, load_label_map, save_outputs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
description="Calculate lumen and epithelium features from an integer label map."
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"label_map", type=Path, help="Input 2-D integer label map image"
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"-o", "--output-dir", type=Path, default=Path("pythomics-output")
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--min-area",
|
|
24
|
+
type=int,
|
|
25
|
+
default=16,
|
|
26
|
+
help="Minimum connected-region area in pixels",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--block-size", type=int, nargs=2, metavar=("HEIGHT", "WIDTH"), default=(20, 20)
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--save-mat",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="Also write a full-resolution feature-map MAT file",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--log-level", choices=("DEBUG", "INFO", "WARNING", "ERROR"), default="INFO"
|
|
38
|
+
)
|
|
39
|
+
for name, default in DEFAULT_LABELS.items():
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
f"--{name.replace('_', '-')}-label",
|
|
42
|
+
type=int,
|
|
43
|
+
default=default,
|
|
44
|
+
help=f"Label value for {name}",
|
|
45
|
+
)
|
|
46
|
+
return parser
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main(argv: list[str] | None = None) -> int:
|
|
50
|
+
args = build_parser().parse_args(argv)
|
|
51
|
+
logging.basicConfig(
|
|
52
|
+
level=getattr(logging, args.log_level), format="%(levelname)s: %(message)s"
|
|
53
|
+
)
|
|
54
|
+
if args.min_area < 0 or min(args.block_size) <= 0:
|
|
55
|
+
build_parser().error(
|
|
56
|
+
"--min-area must be non-negative and --block-size values must be positive"
|
|
57
|
+
)
|
|
58
|
+
if not args.label_map.is_file():
|
|
59
|
+
build_parser().error(f"label map does not exist: {args.label_map}")
|
|
60
|
+
|
|
61
|
+
labels = {name: getattr(args, f"{name}_label") for name in DEFAULT_LABELS}
|
|
62
|
+
if len(set(labels.values())) != len(labels):
|
|
63
|
+
build_parser().error("label values must be distinct")
|
|
64
|
+
|
|
65
|
+
label_map = load_label_map(args.label_map)
|
|
66
|
+
densities, lumen, epithelium = extract_features(
|
|
67
|
+
label_map,
|
|
68
|
+
labels=labels,
|
|
69
|
+
min_area=args.min_area,
|
|
70
|
+
block_size=tuple(args.block_size),
|
|
71
|
+
)
|
|
72
|
+
save_outputs(
|
|
73
|
+
label_map,
|
|
74
|
+
densities,
|
|
75
|
+
lumen,
|
|
76
|
+
epithelium,
|
|
77
|
+
args.output_dir,
|
|
78
|
+
args.label_map.stem,
|
|
79
|
+
save_mat=args.save_mat,
|
|
80
|
+
labels=labels,
|
|
81
|
+
block_size=tuple(args.block_size),
|
|
82
|
+
)
|
|
83
|
+
logging.info("Wrote outputs to %s", args.output_dir)
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
raise SystemExit(main())
|
pythomics/core.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Feature calculations for integer-valued histology label maps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
import scipy.io
|
|
12
|
+
import scipy.ndimage
|
|
13
|
+
import skimage.io
|
|
14
|
+
import skimage.measure
|
|
15
|
+
import skimage.morphology
|
|
16
|
+
|
|
17
|
+
DEFAULT_LABELS = {
|
|
18
|
+
"lumen": 1,
|
|
19
|
+
"epithelium": 2,
|
|
20
|
+
"stroma": 4,
|
|
21
|
+
"epithelial_cells": 5,
|
|
22
|
+
"stromal_cells": 6,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_label_map(path: str | Path) -> np.ndarray:
|
|
27
|
+
"""Read a 2-D label map; RGB representations are accepted only if channels match."""
|
|
28
|
+
image = np.asarray(skimage.io.imread(str(path)))
|
|
29
|
+
if image.ndim == 3:
|
|
30
|
+
if image.shape[-1] not in (3, 4) or not np.all(
|
|
31
|
+
image[..., :3] == image[..., :1]
|
|
32
|
+
):
|
|
33
|
+
raise ValueError(
|
|
34
|
+
"Expected a single-channel integer label map, not a color image"
|
|
35
|
+
)
|
|
36
|
+
image = image[..., 0]
|
|
37
|
+
if image.ndim != 2:
|
|
38
|
+
raise ValueError(f"Expected a 2-D label map, got shape {image.shape}")
|
|
39
|
+
if not np.issubdtype(image.dtype, np.integer):
|
|
40
|
+
raise ValueError(f"Expected integer label values, got dtype {image.dtype}")
|
|
41
|
+
return image
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def summify(image: np.ndarray, block_size: tuple[int, int] = (20, 20)) -> np.ndarray:
|
|
45
|
+
"""Sum image pixels in padded, non-overlapping blocks."""
|
|
46
|
+
arr = np.asarray(image, dtype=np.float32)
|
|
47
|
+
if arr.ndim != 2:
|
|
48
|
+
raise ValueError("summify expects a 2-D image")
|
|
49
|
+
if not arr.size:
|
|
50
|
+
return np.empty((0, 0), dtype=np.float32)
|
|
51
|
+
bh, bw = block_size
|
|
52
|
+
if bh <= 0 or bw <= 0:
|
|
53
|
+
raise ValueError("block_size values must be positive")
|
|
54
|
+
pad_h, pad_w = (-arr.shape[0]) % bh, (-arr.shape[1]) % bw
|
|
55
|
+
arr = np.pad(arr, ((0, pad_h), (0, pad_w)), mode="constant")
|
|
56
|
+
return skimage.measure.block_reduce(arr, block_size=block_size, func=np.sum).astype(
|
|
57
|
+
np.float32
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def calculate_tortuosity(area: float, perimeter: float) -> float:
|
|
62
|
+
"""Return circularity (1 for an ideal circle).
|
|
63
|
+
|
|
64
|
+
skimage's pixel-based perimeter underestimates small objects, which can push
|
|
65
|
+
the raw ratio above 1, so the result is clipped to [0, 1].
|
|
66
|
+
"""
|
|
67
|
+
if area <= 0 or perimeter <= 0:
|
|
68
|
+
return 0.0
|
|
69
|
+
return float(min(1.0, 4 * np.pi * area / perimeter**2))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def average_thickness(region_mask: np.ndarray) -> float:
|
|
73
|
+
area = int(np.sum(region_mask))
|
|
74
|
+
if area == 0:
|
|
75
|
+
return 0.0
|
|
76
|
+
length = int(np.sum(skimage.morphology.skeletonize(region_mask)))
|
|
77
|
+
return float(area / length) if length else 0.0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _empty(columns: list[str]) -> pd.DataFrame:
|
|
81
|
+
return pd.DataFrame(columns=columns)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def calculate_lumen_features(
|
|
85
|
+
labeled_lumen: np.ndarray, min_area: int = 16
|
|
86
|
+
) -> pd.DataFrame:
|
|
87
|
+
columns = ["label", "area", "roundness"]
|
|
88
|
+
if not np.any(labeled_lumen):
|
|
89
|
+
return _empty(columns)
|
|
90
|
+
props = skimage.measure.regionprops_table(
|
|
91
|
+
labeled_lumen, properties=("label", "area", "perimeter")
|
|
92
|
+
)
|
|
93
|
+
frame = pd.DataFrame(props)
|
|
94
|
+
frame["roundness"] = [
|
|
95
|
+
calculate_tortuosity(a, p)
|
|
96
|
+
for a, p in zip(frame.area, frame.perimeter, strict=False)
|
|
97
|
+
]
|
|
98
|
+
frame = frame.loc[frame.area >= min_area, columns]
|
|
99
|
+
return frame.reset_index(drop=True)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def calculate_epithelium_features(
|
|
103
|
+
labeled_epithelium: np.ndarray, epithelial_cells: np.ndarray, min_area: int = 16
|
|
104
|
+
) -> pd.DataFrame:
|
|
105
|
+
columns = ["label", "area", "roundness", "average_thickness", "cell_fraction"]
|
|
106
|
+
if not np.any(labeled_epithelium):
|
|
107
|
+
return _empty(columns)
|
|
108
|
+
props = skimage.measure.regionprops_table(
|
|
109
|
+
labeled_epithelium,
|
|
110
|
+
intensity_image=epithelial_cells.astype(np.uint8),
|
|
111
|
+
properties=("label", "area", "perimeter"),
|
|
112
|
+
extra_properties=[average_thickness],
|
|
113
|
+
)
|
|
114
|
+
frame = pd.DataFrame(props)
|
|
115
|
+
frame["roundness"] = [
|
|
116
|
+
calculate_tortuosity(a, p)
|
|
117
|
+
for a, p in zip(frame.area, frame.perimeter, strict=False)
|
|
118
|
+
]
|
|
119
|
+
cell_counts = np.bincount(
|
|
120
|
+
labeled_epithelium.ravel(), weights=epithelial_cells.astype(np.uint8).ravel()
|
|
121
|
+
)
|
|
122
|
+
counts = cell_counts[frame.label.to_numpy(dtype=np.int64)]
|
|
123
|
+
frame["cell_fraction"] = np.divide(
|
|
124
|
+
counts,
|
|
125
|
+
frame.area,
|
|
126
|
+
out=np.zeros_like(counts, dtype=float),
|
|
127
|
+
where=frame.area.to_numpy() > 0,
|
|
128
|
+
)
|
|
129
|
+
frame = frame.loc[frame.area >= min_area, columns]
|
|
130
|
+
return frame.reset_index(drop=True)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def extract_features(
|
|
134
|
+
label_map: np.ndarray,
|
|
135
|
+
labels: Mapping[str, int] | None = None,
|
|
136
|
+
min_area: int = 16,
|
|
137
|
+
block_size: tuple[int, int] = (20, 20),
|
|
138
|
+
) -> tuple[dict[str, np.ndarray], pd.DataFrame, pd.DataFrame]:
|
|
139
|
+
"""Calculate density maps and per-object lumen/epithelium measurements.
|
|
140
|
+
|
|
141
|
+
Epithelium includes both ``epithelium`` and ``epithelial_cells`` labels;
|
|
142
|
+
stromal cells are included in the stroma density. All feature areas and
|
|
143
|
+
thresholds are in source-map pixels.
|
|
144
|
+
"""
|
|
145
|
+
palette = np.asarray(label_map)
|
|
146
|
+
if palette.ndim != 2 or not np.issubdtype(palette.dtype, np.integer):
|
|
147
|
+
raise ValueError("label_map must be a 2-D integer array")
|
|
148
|
+
label_values = dict(DEFAULT_LABELS)
|
|
149
|
+
if labels:
|
|
150
|
+
unknown = set(labels) - set(label_values)
|
|
151
|
+
if unknown:
|
|
152
|
+
raise ValueError(f"Unknown label names: {', '.join(sorted(unknown))}")
|
|
153
|
+
label_values.update(labels)
|
|
154
|
+
if len(set(label_values.values())) != len(label_values):
|
|
155
|
+
raise ValueError("label values must be distinct")
|
|
156
|
+
lumen = palette == label_values["lumen"]
|
|
157
|
+
epithelial_cells = palette == label_values["epithelial_cells"]
|
|
158
|
+
epithelium = (palette == label_values["epithelium"]) | epithelial_cells
|
|
159
|
+
stroma = (palette == label_values["stroma"]) | (
|
|
160
|
+
palette == label_values["stromal_cells"]
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
densities = {
|
|
164
|
+
"lumen_density": summify(lumen, block_size),
|
|
165
|
+
"stroma_density": summify(stroma, block_size),
|
|
166
|
+
"epithelium_density": summify(epithelium, block_size),
|
|
167
|
+
"epithelial_cells_density": summify(epithelial_cells, block_size),
|
|
168
|
+
}
|
|
169
|
+
labeled_lumen = scipy.ndimage.label(lumen)[0]
|
|
170
|
+
labeled_epithelium = scipy.ndimage.label(epithelium)[0]
|
|
171
|
+
lumen_features = calculate_lumen_features(labeled_lumen, min_area)
|
|
172
|
+
epithelium_features = calculate_epithelium_features(
|
|
173
|
+
labeled_epithelium, epithelial_cells, min_area
|
|
174
|
+
)
|
|
175
|
+
logging.info(
|
|
176
|
+
"Found %d lumens and %d epithelial regions",
|
|
177
|
+
len(lumen_features),
|
|
178
|
+
len(epithelium_features),
|
|
179
|
+
)
|
|
180
|
+
return densities, lumen_features, epithelium_features
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _paint_features(
|
|
184
|
+
labeled: np.ndarray, features: pd.DataFrame, column: str
|
|
185
|
+
) -> np.ndarray:
|
|
186
|
+
if column not in features.columns:
|
|
187
|
+
raise ValueError(f"Feature column {column!r} not found")
|
|
188
|
+
max_label = int(labeled.max()) if labeled.size else 0
|
|
189
|
+
lookup = np.zeros(max_label + 1, dtype=np.float32)
|
|
190
|
+
if not features.empty:
|
|
191
|
+
ids = features["label"].to_numpy(dtype=np.int64)
|
|
192
|
+
values = features[column].to_numpy(dtype=np.float32)
|
|
193
|
+
valid = (ids > 0) & (ids <= max_label)
|
|
194
|
+
values = np.nan_to_num(values, nan=0.0)
|
|
195
|
+
lookup[ids[valid]] = values[valid]
|
|
196
|
+
return lookup[labeled]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def save_outputs(
|
|
200
|
+
label_map: np.ndarray,
|
|
201
|
+
densities: Mapping[str, np.ndarray],
|
|
202
|
+
lumen_features: pd.DataFrame,
|
|
203
|
+
epithelium_features: pd.DataFrame,
|
|
204
|
+
output_dir: str | Path,
|
|
205
|
+
stem: str,
|
|
206
|
+
save_mat: bool = False,
|
|
207
|
+
labels: Mapping[str, int] | None = None,
|
|
208
|
+
block_size: tuple[int, int] = (20, 20),
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Write per-object CSV tables, density arrays, and optionally a MAT feature map."""
|
|
211
|
+
output = Path(output_dir)
|
|
212
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
lumen_features.to_csv(output / f"{stem}_lumen_features.csv", index=False)
|
|
214
|
+
epithelium_features.to_csv(output / f"{stem}_epithelium_features.csv", index=False)
|
|
215
|
+
np.savez_compressed(output / f"{stem}_densities.npz", **densities)
|
|
216
|
+
if not save_mat:
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
values = dict(DEFAULT_LABELS)
|
|
220
|
+
if labels:
|
|
221
|
+
values.update(labels)
|
|
222
|
+
lumen = label_map == values["lumen"]
|
|
223
|
+
epithelial_cells = label_map == values["epithelial_cells"]
|
|
224
|
+
epithelium = (label_map == values["epithelium"]) | epithelial_cells
|
|
225
|
+
labeled_lumen = scipy.ndimage.label(lumen)[0]
|
|
226
|
+
labeled_epithelium = scipy.ndimage.label(epithelium)[0]
|
|
227
|
+
maps = {
|
|
228
|
+
**{
|
|
229
|
+
key: summify(mask, block_size)
|
|
230
|
+
for key, mask in {
|
|
231
|
+
"lumen_density": lumen,
|
|
232
|
+
"stroma_density": (label_map == values["stroma"])
|
|
233
|
+
| (label_map == values["stromal_cells"]),
|
|
234
|
+
"epithelium_density": epithelium,
|
|
235
|
+
"epithelial_cells_density": epithelial_cells,
|
|
236
|
+
}.items()
|
|
237
|
+
},
|
|
238
|
+
"lumen_roundness": _paint_features(labeled_lumen, lumen_features, "roundness"),
|
|
239
|
+
"lumen_area": _paint_features(labeled_lumen, lumen_features, "area"),
|
|
240
|
+
"epithelium_roundness": _paint_features(
|
|
241
|
+
labeled_epithelium, epithelium_features, "roundness"
|
|
242
|
+
),
|
|
243
|
+
"epithelium_area": _paint_features(
|
|
244
|
+
labeled_epithelium, epithelium_features, "area"
|
|
245
|
+
),
|
|
246
|
+
"epithelium_thickness": _paint_features(
|
|
247
|
+
labeled_epithelium, epithelium_features, "average_thickness"
|
|
248
|
+
),
|
|
249
|
+
"cell_fraction": _paint_features(
|
|
250
|
+
labeled_epithelium, epithelium_features, "cell_fraction"
|
|
251
|
+
),
|
|
252
|
+
}
|
|
253
|
+
scipy.io.savemat(output / f"{stem}_features.mat", maps)
|