imc-histotools 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.
@@ -0,0 +1,24 @@
1
+ """
2
+ IMC-HistoTools — digital pathology analysis toolkit for the Selenium (Se) IMC paper.
3
+
4
+ Provides:
5
+ * virtual_IHC : Beer-Lambert virtual IHC (brightfield-like) rendering
6
+ * virtual_IHC_ROI : local-region rendering that keeps original pixel coords
7
+ * virtual_IHC_annotation : Qupath geojson -> ROI masks
8
+ * Hscore_threshold : median-intensity thresholds from Np/Lp/Mp/Hp reference ROIs
9
+ * pix_Hscore_map : pixel-wise H-score classification + CSV + figures
10
+ """
11
+
12
+ from .virtual_IHC import virtual_IHC, virtual_IHC_ROI
13
+ from .annotation_region import virtual_IHC_annotation, Hscore_threshold
14
+ from .pix_Hscore import pix_Hscore_map
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ __all__ = [
19
+ "virtual_IHC",
20
+ "virtual_IHC_ROI",
21
+ "virtual_IHC_annotation",
22
+ "Hscore_threshold",
23
+ "pix_Hscore_map",
24
+ ]
@@ -0,0 +1,183 @@
1
+ """
2
+ annotation_region.py — Qupath 圈画 ROI 与 H-score 阈值模块 (IMC-HistoTools)
3
+
4
+ 提供:
5
+ * ``virtual_IHC_annotation`` : 读取 Qupath geojson 圈画文件 → 栅格化为 ROI mask
6
+ * ``Hscore_threshold`` : 根据参考 ROI (Np / Lp / Mp / Hp) 计算各层
7
+ 像素中位值, 作为 pixel-wise H-score 分层阈值
8
+
9
+ 对应 2610/261003_Pix_Hscore_histopathology.ipynb 中的 geojson → mask 逻辑,
10
+ 并扩展为可直接复用的包函数。
11
+ """
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+ import cv2
18
+
19
+
20
+ # ============================================================
21
+ # helper
22
+ # ============================================================
23
+
24
+ def _as_mask(roi, shape):
25
+ """把「单个 mask 或 mask 列表」归一化为一个 bool ndarray (列表取并集)."""
26
+ if roi is None:
27
+ return None
28
+ if isinstance(roi, (list, tuple)):
29
+ m = np.zeros(shape, dtype=bool)
30
+ for r in roi:
31
+ if r is None:
32
+ continue
33
+ m |= np.asarray(r, dtype=bool)
34
+ return m
35
+ return np.asarray(roi, dtype=bool)
36
+
37
+
38
+ # ============================================================
39
+ # 1. Qupath geojson → ROI mask
40
+ # ============================================================
41
+
42
+ def virtual_IHC_annotation(image, geojson_path, region_order=None, name_key='name'):
43
+ """
44
+ 读取 Qupath geojson 圈画文件, 栅格化为 ROI 区域的布尔 mask。
45
+
46
+ 仅取每个 polygon 的外环 (第一个坐标环), 用 ``cv2.fillPoly`` 填充;
47
+ 支持 Polygon 与 MultiPolygon。
48
+
49
+ Parameters
50
+ ----------
51
+ image : 2D ndarray 或 (H, W) tuple
52
+ 用于确定栅格尺寸 H, W (通常传入对应的 virtual IHC / marker 图像)。
53
+ geojson_path : str or Path
54
+ Qupath 导出的 .geojson 文件路径。
55
+ region_order : list of str or None
56
+ 返回结果的 region 顺序; None → 按 geojson 中出现顺序去重。
57
+ name_key : str
58
+ feature['properties'] 中用于区分区域名称的键 (默认 'name')。
59
+
60
+ Returns
61
+ -------
62
+ dict
63
+ {
64
+ 'polys' : {name: [xy ndarray (N,2), ...]}, # 每个区域的外环顶点 (x, y)
65
+ 'masks' : {name: bool ndarray (H, W)}, # 每个区域的栅格 mask
66
+ 'names' : [按顺序的 region 名],
67
+ }
68
+ """
69
+ image = np.asarray(image)
70
+ if image.ndim == 2:
71
+ H, W = image.shape
72
+ else:
73
+ H, W = int(image[0]), int(image[1])
74
+
75
+ with open(geojson_path) as f:
76
+ gj = json.load(f)
77
+
78
+ region_polys = {} # name -> [exterior xy array, ...]
79
+ for feat in gj['features']:
80
+ name = feat['properties'].get(name_key, 'unknown')
81
+ geom = feat['geometry']
82
+ if geom['type'] == 'MultiPolygon':
83
+ polys = geom['coordinates']
84
+ else:
85
+ polys = [geom['coordinates']]
86
+ for poly in polys:
87
+ # 外环 (x, y)
88
+ region_polys.setdefault(name, []).append(
89
+ np.asarray(poly[0], dtype=np.float64))
90
+
91
+ if region_order is None:
92
+ region_order = list(region_polys.keys())
93
+
94
+ region_masks = {}
95
+ for name in region_order:
96
+ m = np.zeros((H, W), np.uint8)
97
+ for xy in region_polys.get(name, []):
98
+ pts = np.round(xy).astype(np.int32).reshape(-1, 1, 2) # cv2: (x, y)
99
+ cv2.fillPoly(m, [pts], 1)
100
+ region_masks[name] = m.astype(bool)
101
+
102
+ return {
103
+ 'polys': {n: region_polys.get(n, []) for n in region_order},
104
+ 'masks': region_masks,
105
+ 'names': region_order,
106
+ }
107
+
108
+
109
+ # ============================================================
110
+ # 2. 参考 ROI → H-score 分层阈值 (中位值)
111
+ # ============================================================
112
+
113
+ def Hscore_threshold(marker_image, Np=None, Lp=None, Mp=None, Hp=None,
114
+ tissue_mask=None, exclude_zero=False, stat='median'):
115
+ """
116
+ 根据参考 ROI (Np / Lp / Mp / Hp) 统计像素强度, 作为返回的阈值。
117
+
118
+ 对每个参考 ROI 分别统计:
119
+ - mean : 平均值
120
+ - median : 中位值
121
+ - geomean : 几何平均值 (仅正像素)
122
+ - all : mean / median / geomean 三者的平均
123
+
124
+ Np / Lp / Mp / Hp 分别对应单个或多个设置 ROI 区域 (bool mask 或 mask 列表,
125
+ 列表取并集); 可传 ``tissue_mask`` 以仅统计落在组织内的像素。
126
+
127
+ Parameters
128
+ ----------
129
+ marker_image : 2D ndarray
130
+ marker 强度图像 (通常为背景扣除后的图像)。
131
+ Np, Lp, Mp, Hp : bool ndarray 或 list of bool ndarray, 或 None
132
+ 负 / 低 / 中 / 高 表达的参考 ROI 区域。
133
+ tissue_mask : bool ndarray or None
134
+ 组织 mask (True=组织); 提供时各 ROI 会与组织求交。
135
+ exclude_zero : bool
136
+ 是否在统计前排除强度为 0 的像素 (默认 False)。
137
+ stat : str, {'mean','median','geomean','all'}
138
+ 选择用于 ``thresholds`` 的统计量 (默认 'median')。
139
+
140
+ Returns
141
+ -------
142
+ dict
143
+ {
144
+ 'Np' : {'mean','median','geomean','all'} 或 None,
145
+ 'Lp' : {...}, 'Mp' : {...}, 'Hp' : {...},
146
+ 'thresholds' : (T1, T2, T3), # 由 stat 决定, 对应 Lp/Mp/Hp
147
+ 'stat' : str,
148
+ }
149
+ """
150
+ if stat not in ('mean', 'median', 'geomean', 'all'):
151
+ raise ValueError(f"stat 必须是 'mean'/'median'/'geomean'/'all', 得到 {stat!r}")
152
+
153
+ img = np.asarray(marker_image, dtype=np.float32)
154
+ shape = img.shape
155
+
156
+ def _stats(roi):
157
+ m = _as_mask(roi, shape)
158
+ if m is None:
159
+ return None
160
+ if tissue_mask is not None:
161
+ m = m & np.asarray(tissue_mask, dtype=bool)
162
+ vals = img[m].astype(np.float64)
163
+ if exclude_zero:
164
+ vals = vals[vals > 0]
165
+ if vals.size == 0:
166
+ return None
167
+ mean = float(np.mean(vals))
168
+ median = float(np.median(vals))
169
+ pos = vals[vals > 0]
170
+ geomean = float(np.exp(np.mean(np.log(pos)))) if pos.size else 0.0
171
+ all_ = (mean + median + geomean) / 3.0
172
+ return {'mean': mean, 'median': median, 'geomean': geomean, 'all': all_}
173
+
174
+ stats = {'Np': _stats(Np), 'Lp': _stats(Lp),
175
+ 'Mp': _stats(Mp), 'Hp': _stats(Hp)}
176
+
177
+ thresholds = tuple(stats[k][stat] if stats[k] is not None else None
178
+ for k in ('Lp', 'Mp', 'Hp'))
179
+
180
+ result = dict(stats)
181
+ result['thresholds'] = thresholds
182
+ result['stat'] = stat
183
+ return result
@@ -0,0 +1,427 @@
1
+ """
2
+ pix_Hscore.py — pixel-wise H-score 分类与可视化模块 (IMC-HistoTools)
3
+
4
+ 核心函数 ``pix_Hscore_map``:
5
+ * 输入 marker 图像与参考 ROI (Np / Lp / Mp / Hp), 调取 ``Hscore_threshold``
6
+ 得到分层阈值
7
+ * 对每个像素分类为 0/1/2/3 (NP / LP / MP / HP)
8
+ * 标准返回: 逐像素分类 CSV 文件 + 分类着色图 (colorbar 在图外右侧)
9
+ * 若 ``tissue_annotation`` 非空, 额外返回各区域 pix H-score 柱状图 + 比例/面积表
10
+
11
+ 对应 2610/261001_CRC_Pix_Hscore.ipynb 与
12
+ 2610/261003_Pix_Hscore_histopathology.ipynb 的核心逻辑。
13
+ """
14
+
15
+ import os
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+ import cv2
20
+ import matplotlib.pyplot as plt
21
+ from matplotlib.colors import ListedColormap, BoundaryNorm, to_rgba
22
+ from matplotlib.patches import Rectangle
23
+ from matplotlib.ticker import MultipleLocator
24
+ import tifffile
25
+
26
+ from .annotation_region import Hscore_threshold, _as_mask
27
+
28
+
29
+ # ============================================================
30
+ # 常量
31
+ # ============================================================
32
+ SCORE_COLORS = ['#FFFFFF', '#2166AC', '#67AB49', '#FDBF2C', '#D73027']
33
+ SCORE_LABELS = ['0 (Neg)', '1 (Low)', '2 (Mod)', '3 (High)']
34
+ CATEGORY_NAMES = {0: 'NP', 1: 'LP', 2: 'MP', 3: 'HP'}
35
+ _CAT_ARR = np.array(['NP', 'LP', 'MP', 'HP'])
36
+
37
+
38
+ # ============================================================
39
+ # helper
40
+ # ============================================================
41
+
42
+ def _load(img):
43
+ """ndarray 原样返回; str/Path 视为 tiff 路径读取."""
44
+ if isinstance(img, (str, Path)):
45
+ return np.asarray(tifffile.imread(str(img))).astype(np.float32)
46
+ return np.asarray(img)
47
+
48
+
49
+ def _classify(marker_image, thresholds, blur_sigma=None, blur_kernel=None):
50
+ """阈值分层 (0/1/2/3)。可选高斯模糊。
51
+
52
+ thresholds = (T1, T2, T3) = (Lp, Mp, Hp):
53
+ 0 if data < T1
54
+ 1 if T1 <= data < T2
55
+ 2 if T2 <= data < T3
56
+ 3 if data >= T3
57
+ """
58
+ data_f = np.asarray(marker_image, dtype=np.float32)
59
+ if blur_kernel is None or blur_sigma in (None, 0):
60
+ data_blur = data_f
61
+ else:
62
+ if isinstance(blur_kernel, int):
63
+ blur_kernel = (blur_kernel, blur_kernel)
64
+ data_blur = cv2.GaussianBlur(data_f, blur_kernel, sigmaX=blur_sigma)
65
+ T1, T2, T3 = thresholds # (Lp, Mp, Hp)
66
+ scores_2d = np.select(
67
+ [data_blur >= T3, data_blur >= T2, data_blur >= T1],
68
+ [3, 2, 1], default=0,
69
+ ).astype(np.uint8)
70
+ return scores_2d
71
+
72
+
73
+ def _hscore_from_scores(scores_2d, sel, thresholds=(0, 0, 0)):
74
+ """在布尔子集 sel 上计算 pix H-Score 与各层计数/比例。"""
75
+ sc = scores_2d[sel]
76
+ total = int(sc.size)
77
+ counts = {k: int((sc == k).sum()) for k in (0, 1, 2, 3)}
78
+ NP, LP, MP, HP = (counts[k] for k in (0, 1, 2, 3))
79
+ pix_h = 100.0 * (3 * HP + 2 * MP + LP) / total if total > 0 else 0.0
80
+ percents = {k: (100.0 * v / total if total > 0 else 0.0)
81
+ for k, v in counts.items()}
82
+ return {'pix_h': pix_h, 'counts': counts, 'percents': percents,
83
+ 'total': total, 'thresholds': thresholds}
84
+
85
+
86
+ # ============================================================
87
+ # 主函数
88
+ # ============================================================
89
+
90
+ def pix_Hscore_map(marker_image,
91
+ Hscore_threshold_annotation=None,
92
+ tissue_annotation=None,
93
+ tissue_mask=None,
94
+ thresholds=None,
95
+ stat='median',
96
+ delta=None,
97
+ kernel=None,
98
+ # --- 输出 ---
99
+ save_dir='.',
100
+ base_name='pix_Hscore',
101
+ # --- 分类着色图 (map) 样式 ---
102
+ map_figsize=(12, 9),
103
+ map_title=None,
104
+ map_cmap=SCORE_COLORS,
105
+ map_show=True,
106
+ map_colorbar=True,
107
+ map_colorbar_label='Score',
108
+ map_colorbar_labels=SCORE_LABELS,
109
+ map_tick_step=500,
110
+ map_dpi=150,
111
+ map_save=True,
112
+ xlabel='5μm / pixel',
113
+ ylabel='5μm / pixel',
114
+ axis_label_fontsize=34,
115
+ tick_fontsize=24,
116
+ # --- 柱状图 + 表 样式 ---
117
+ bar_order=None,
118
+ region_colors=None,
119
+ pixel_area_um2=25,
120
+ bar_figsize=(6, 6.5),
121
+ bar_show=True,
122
+ bar_save=True,
123
+ bar_dpi=150,
124
+ # --- CSV ---
125
+ csv_format='long'):
126
+ """
127
+ Pixel-wise H-score 分类 + 可视化 + 逐像素分类 CSV 导出。
128
+
129
+ Parameters
130
+ ----------
131
+ marker_image : 2D ndarray 或 tiff 路径
132
+ marker 强度图像 (建议先做背景扣除)。
133
+ Hscore_threshold_annotation : dict or None
134
+ 参考 ROI 区域, 用于调取 ``Hscore_threshold`` 计算阈值, 形如
135
+ ``{'Np': mask, 'Lp': mask, 'Mp': mask, 'Hp': mask}``;
136
+ 每个 value 可为单个 bool mask 或 mask 列表。
137
+ tissue_annotation : dict or None
138
+ 待统计的区域标注 ``{name: mask 或 mask 列表}``; 非空时返回各区域
139
+ pix H-score 柱状图 + 比例/面积表。
140
+ tissue_mask : bool ndarray or None
141
+ 组织 mask (True=组织), 用于排除背景信号。
142
+ thresholds : (T1, T2, T3) or None
143
+ 显式分层阈值, 顺序为 (Lp, Mp, Hp); None → 由
144
+ ``Hscore_threshold_annotation`` 计算。
145
+ 分类规则: <T1→0, [T1,T2)→1, [T2,T3)→2, ≥T3→3。
146
+ stat : str, {'mean','median','geomean','all'}
147
+ 选择用于阈值的统计量 (默认 'median')。
148
+ delta : float or None
149
+ 分类着色图 (map) 显示前的高斯模糊 sigma (默认 None 不模糊)。
150
+ kernel : int or tuple or None
151
+ 分类着色图 (map) 显示前的高斯模糊核大小 (默认 None)。
152
+ 统计柱状图/CSV 始终使用非高斯 (非模糊) 分类。
153
+ save_dir : str, 输出目录。
154
+ base_name : str, 输出文件前缀。
155
+ map_* : 分类着色图相关样式 / 是否显示 / 保存。
156
+ bar_* : 柱状图 + 表相关样式 / 是否显示 / 保存。
157
+ pixel_area_um2 : float, 每像素面积 (µm²), 默认 25 (5µm × 5µm)。
158
+ csv_format : str, 'long' (逐像素行 x,y,intensity,score,category)
159
+ 或 'matrix' (2D score 矩阵 CSV)。
160
+
161
+ Returns
162
+ -------
163
+ dict
164
+ {
165
+ 'thresholds' : (T1, T2, T3),
166
+ 'stat' : 所选统计量 ('mean'/'median'/'geomean'/'all'),
167
+ 'stats' : Hscore_threshold 返回的完整统计 dict 或 None,
168
+ 'scores_2d' : 非高斯分类 uint8 ndarray (H, W) (bargraph/CSV 用),
169
+ 'scores_2d_blur' : 高斯模糊分类 uint8 ndarray (H, W) (map 显示用),
170
+ 'csv_path' : str,
171
+ 'score_tiff_path' : str,
172
+ 'fig_map' : matplotlib Figure or None,
173
+ 'results' : {region: {...}} or None (tissue_annotation 非空时),
174
+ 'fig_bar' : matplotlib Figure or None,
175
+ 'categories' : {0:'NP',1:'LP',2:'MP',3:'HP'},
176
+ }
177
+ """
178
+ marker_image = _load(marker_image)
179
+ os.makedirs(save_dir, exist_ok=True)
180
+
181
+ # ---- 1) 阈值 ----
182
+ stats = None
183
+ if thresholds is None:
184
+ if not Hscore_threshold_annotation:
185
+ raise ValueError(
186
+ "必须提供 thresholds 或 Hscore_threshold_annotation "
187
+ "(包含 Np/Lp/Mp/Hp 参考 ROI)。")
188
+ stats = Hscore_threshold(
189
+ marker_image,
190
+ Np=Hscore_threshold_annotation.get('Np'),
191
+ Lp=Hscore_threshold_annotation.get('Lp'),
192
+ Mp=Hscore_threshold_annotation.get('Mp'),
193
+ Hp=Hscore_threshold_annotation.get('Hp'),
194
+ tissue_mask=tissue_mask,
195
+ stat=stat)
196
+ thresholds = stats['thresholds']
197
+
198
+ thresholds = tuple(thresholds)
199
+ if len(thresholds) != 3 or any(t is None for t in thresholds):
200
+ raise ValueError(f"thresholds 必须为 (T1, T2, T3) 三个非 None 值, 得到 {thresholds}")
201
+
202
+ # ---- 2) 分类 ----
203
+ # 非高斯分类: 用于 bargraph 统计与逐像素 CSV
204
+ scores_2d = _classify(marker_image, thresholds,
205
+ blur_sigma=None, blur_kernel=None)
206
+ # 高斯模糊分类: 仅用于 map 显示 (delta=sigma, kernel=核大小)
207
+ if kernel is not None and delta not in (None, 0):
208
+ scores_2d_blur = _classify(marker_image, thresholds,
209
+ blur_sigma=delta, blur_kernel=kernel)
210
+ else:
211
+ scores_2d_blur = scores_2d
212
+
213
+ # ---- 3) 逐像素分类 CSV + score tiff ----
214
+ csv_path = os.path.join(save_dir, f"{base_name}_pixels.csv")
215
+ score_tiff_path = os.path.join(save_dir, f"{base_name}_scores.tiff")
216
+
217
+ if csv_format == 'matrix':
218
+ np.savetxt(csv_path, scores_2d, delimiter=',', fmt='%d')
219
+ else: # 'long'
220
+ if tissue_mask is not None:
221
+ valid = np.asarray(tissue_mask, dtype=bool)
222
+ else:
223
+ valid = np.ones_like(scores_2d, dtype=bool)
224
+ ys, xs = np.nonzero(valid)
225
+ ints = marker_image[valid]
226
+ scs = scores_2d[valid]
227
+ cats = _CAT_ARR[scs]
228
+ # 结构化数组一次性写出 (快速, 适合数百万像素)
229
+ rec = np.empty(len(xs), dtype=[
230
+ ('x', np.int64), ('y', np.int64),
231
+ ('intensity', np.float64), ('score', np.int64),
232
+ ('category', 'U2')])
233
+ rec['x'] = xs
234
+ rec['y'] = ys
235
+ rec['intensity'] = ints
236
+ rec['score'] = scs
237
+ rec['category'] = cats
238
+ np.savetxt(csv_path, rec, delimiter=',',
239
+ fmt=['%d', '%d', '%.4f', '%d', '%s'],
240
+ header='x,y,intensity,score,category', comments='')
241
+
242
+ tifffile.imwrite(score_tiff_path, scores_2d.astype(np.uint8), compression='lzw')
243
+
244
+ # ---- 4) 分类着色图 (colorbar 在图外右侧) ----
245
+ fig_map = None
246
+ if map_show or map_save:
247
+ cmap = ListedColormap(map_cmap) if isinstance(map_cmap, (list, tuple)) else map_cmap
248
+ norm = BoundaryNorm([-1.5, -0.5, 0.5, 1.5, 2.5, 3.5], ncolors=5)
249
+ disp = scores_2d_blur.astype(np.int8)
250
+ if tissue_mask is not None:
251
+ disp[~np.asarray(tissue_mask, dtype=bool)] = -1 # 背景 → 白
252
+
253
+ fig_map, ax = plt.subplots(figsize=map_figsize)
254
+ im = ax.imshow(disp, cmap=cmap, norm=norm, interpolation='nearest')
255
+
256
+ ax.xaxis.set_major_locator(MultipleLocator(map_tick_step))
257
+ ax.yaxis.set_major_locator(MultipleLocator(map_tick_step))
258
+ ax.tick_params(axis='both', labelsize=tick_fontsize)
259
+ ax.set_xlabel(xlabel, fontsize=axis_label_fontsize, fontweight='bold')
260
+ ax.set_ylabel(ylabel, fontsize=axis_label_fontsize, fontweight='bold')
261
+ ax.grid(True, alpha=0.2, linewidth=2.0)
262
+ if map_title is not None:
263
+ ax.set_title(map_title, fontsize=46, fontweight='bold')
264
+
265
+ if map_colorbar:
266
+ cbar = fig_map.colorbar(im, ax=ax, fraction=0.035, pad=0.02,
267
+ ticks=[0, 1, 2, 3])
268
+ cbar.ax.set_yticklabels(map_colorbar_labels)
269
+ cbar.set_label(map_colorbar_label, fontsize=34, fontweight='bold',
270
+ labelpad=10)
271
+ cbar.ax.tick_params(labelsize=24)
272
+
273
+ fig_map.tight_layout()
274
+ if map_show:
275
+ plt.show()
276
+ if map_save:
277
+ map_path = os.path.join(save_dir, f"{base_name}_map.png")
278
+ fig_map.savefig(map_path, dpi=map_dpi, bbox_inches='tight',
279
+ facecolor='white')
280
+ print(f"分类着色图已保存到: {map_path}")
281
+
282
+ # ---- 5) 各区域 pix H-score 柱状图 + 表 (tissue_annotation 非空时) ----
283
+ fig_bar = None
284
+ results = None
285
+ if tissue_annotation:
286
+ region_names = bar_order if bar_order is not None else list(tissue_annotation.keys())
287
+ results = {}
288
+ for name in region_names:
289
+ roi = _as_mask(tissue_annotation[name], marker_image.shape)
290
+ sel = roi & (np.asarray(tissue_mask, dtype=bool)
291
+ if tissue_mask is not None else np.ones_like(roi, bool))
292
+ res = _hscore_from_scores(scores_2d, sel, thresholds)
293
+ res['roi_px'] = int(roi.sum()) if roi is not None else 0
294
+ res['valid_px'] = int(sel.sum())
295
+ results[name] = res
296
+
297
+ fig_bar = _bargraph_table(
298
+ region_names, results, pixel_area_um2=pixel_area_um2,
299
+ region_colors=region_colors,
300
+ figsize=bar_figsize,
301
+ overall_sel=(np.asarray(tissue_mask, dtype=bool)
302
+ if tissue_mask is not None else None),
303
+ scores_2d=scores_2d, thresholds=thresholds)
304
+ if bar_show:
305
+ plt.show()
306
+ if bar_save:
307
+ bar_path = os.path.join(save_dir, f"{base_name}_bargraph.png")
308
+ fig_bar.savefig(bar_path, dpi=bar_dpi, bbox_inches='tight',
309
+ facecolor='white')
310
+ print(f"柱状图 + 表已保存到: {bar_path}")
311
+
312
+ print(f"逐像素分类 CSV 已保存到: {csv_path}")
313
+ print(f"score 图已保存到: {score_tiff_path}")
314
+
315
+ return {
316
+ 'thresholds': thresholds,
317
+ 'stat': stat,
318
+ 'stats': stats,
319
+ 'scores_2d': scores_2d,
320
+ 'scores_2d_blur': scores_2d_blur,
321
+ 'csv_path': csv_path,
322
+ 'score_tiff_path': score_tiff_path,
323
+ 'fig_map': fig_map,
324
+ 'results': results,
325
+ 'fig_bar': fig_bar,
326
+ 'categories': dict(CATEGORY_NAMES),
327
+ }
328
+
329
+
330
+ # ============================================================
331
+ # 柱状图 + 比例/面积表 (publication figure)
332
+ # ============================================================
333
+
334
+ def _bargraph_table(region_names, results, pixel_area_um2=25,
335
+ region_colors=None, figsize=(6, 6.5),
336
+ overall_sel=None, scores_2d=None, thresholds=(0, 0, 0)):
337
+ """柱状图 (上) + 各区域 NP/LP/MP/HP 比例与面积表 (下) 的复合图。"""
338
+ if region_colors is None:
339
+ default_colors = ['#e8481e', '#808080', '#cc66e6', '#1f77b4',
340
+ '#2ca02c', '#ff7f0e', '#d62728', '#17becf']
341
+ region_colors = {n: default_colors[i % len(default_colors)]
342
+ for i, n in enumerate(region_names)}
343
+
344
+ bar_width = 0.62
345
+ tick_fontsize = 15
346
+ value_fontsize = 16
347
+ table_fontsize = 14
348
+ axis_label_fontsize = 18
349
+
350
+ names = region_names
351
+ scores = [results[n]['pix_h'] for n in names]
352
+ percs = [[results[n]['percents'][k] for k in (0, 1, 2, 3)] for n in names]
353
+ areas = [results[n]['valid_px'] * pixel_area_um2 / 1e6 for n in names]
354
+ colors = [region_colors[n] for n in names]
355
+ n = len(names)
356
+ x = np.arange(n)
357
+
358
+ overall_hscore = None
359
+ if scores_2d is not None and overall_sel is not None:
360
+ overall_hscore = _hscore_from_scores(scores_2d, overall_sel, thresholds)['pix_h']
361
+
362
+ fig = plt.figure(figsize=figsize)
363
+ ax_bar = fig.add_axes([0.13, 0.54, 0.84, 0.41])
364
+ ax_tab = fig.add_axes([0.13, 0.06, 0.84, 0.40])
365
+
366
+ # ---- 上: 柱状图 ----
367
+ ax_bar.bar(x, scores, width=bar_width, color=colors,
368
+ edgecolor='white', linewidth=0.8, zorder=3)
369
+ for xi, s in zip(x, scores):
370
+ ax_bar.text(xi, s + max(scores) * 0.02, f'{s:.1f}',
371
+ ha='center', va='bottom', fontsize=value_fontsize,
372
+ fontweight='bold')
373
+ if overall_hscore is not None:
374
+ ax_bar.axhline(overall_hscore, color='#444444', ls='--', lw=1.4, zorder=2)
375
+ ax_bar.text(n - 0.5, overall_hscore + max(scores) * 0.015,
376
+ f'Overall {overall_hscore:.1f}', ha='right', va='bottom',
377
+ fontsize=11, color='#444444', style='italic')
378
+ ax_bar.set_ylabel('pix H-Score', fontsize=axis_label_fontsize,
379
+ fontweight='bold')
380
+ ax_bar.set_ylim(0, max(scores) * 1.28)
381
+ ax_bar.set_xlim(-0.5, n - 0.5)
382
+ ax_bar.set_xticks([])
383
+ ax_bar.tick_params(axis='y', labelsize=tick_fontsize)
384
+ for lb in ax_bar.get_yticklabels():
385
+ lb.set_fontweight('bold')
386
+ ax_bar.grid(True, axis='y', ls='--', alpha=0.45)
387
+ ax_bar.set_axisbelow(True)
388
+ for sp in ax_bar.spines.values():
389
+ sp.set_linewidth(0.8)
390
+ ax_bar.spines['top'].set_visible(False)
391
+ ax_bar.spines['right'].set_visible(False)
392
+
393
+ # ---- 下: 比例 / 面积表 ----
394
+ ROW_LABELS = [r'$N_{P}$', r'$L_{P}$', r'$M_{P}$', r'$H_{P}$', r'Area']
395
+ n_rows = 5
396
+ ax_tab.set_xlim(-0.5, n - 0.5)
397
+ ax_tab.set_ylim(-0.55, n_rows)
398
+ ax_tab.axis('off')
399
+ amax = max(areas)
400
+ for j, name in enumerate(names):
401
+ rgbc = to_rgba(region_colors[name])[:3]
402
+ for i in range(n_rows):
403
+ y = n_rows - 1 - i
404
+ if i < 4:
405
+ val = percs[j][i]
406
+ alpha = 0.10 + 0.80 * (val / 100.0)
407
+ fc = (*rgbc, alpha)
408
+ txt = f'{val:.1f}'
409
+ tcol = 'black' if alpha < 0.6 else 'white'
410
+ else:
411
+ alpha = 0.10 + 0.55 * (areas[j] / amax)
412
+ fc = (0.4, 0.4, 0.4, alpha)
413
+ txt = f'{areas[j]:.1f}'
414
+ tcol = 'black' if alpha < 0.55 else 'white'
415
+ ax_tab.add_patch(Rectangle((j - 0.5, y), 1.0, 1.0, facecolor=fc,
416
+ edgecolor='white', linewidth=1.2))
417
+ ax_tab.text(j, y + 0.5, txt, ha='center', va='center',
418
+ fontsize=table_fontsize, fontweight='bold', color=tcol)
419
+ for i, lab in enumerate(ROW_LABELS):
420
+ ax_tab.text(-0.62, n_rows - 1 - i + 0.5, lab, ha='right', va='center',
421
+ fontsize=table_fontsize + 2, fontweight='bold')
422
+ for j, name in enumerate(names):
423
+ ax_tab.text(j, -0.12, name.replace(' ', '\n'), ha='center', va='top',
424
+ fontsize=table_fontsize, fontweight='bold',
425
+ color=region_colors[name])
426
+
427
+ return fig
@@ -0,0 +1,400 @@
1
+ """
2
+ virtual_IHC.py — 虚拟 IHC 图像生成模块 (IMC-HistoTools)
3
+
4
+ 用 Beer-Lambert 模型将 IMC 多通道数据合成为模拟 IHC 染色的 RGB 图像。
5
+ 支持 per-channel gamma 校正以匹配真实 IHC 的颜色深浅对比。
6
+
7
+ 对应 Se_IMC/Pseudo_brightfield.py,调整如下:
8
+ * ``Virtual_IHC_show_save`` → ``virtual_IHC`` (功能不变)
9
+ * colorbar ``cbar.set_label(...)`` 的 label / fontsize / fontweight
10
+ 提升为函数输入参数, 便于作图时直接修改
11
+ * 新增 ``virtual_IHC_ROI``: 显示局部区域但保留原始像素坐标 (不从 0 重排)
12
+
13
+ 用法:
14
+ from IMC_HistoTools.virtual_IHC import virtual_IHC, virtual_IHC_ROI
15
+ virtual_IHC(Blue_image=DNA, Brown_image=Se, ...)
16
+ """
17
+
18
+ import os
19
+ import numpy as np
20
+ import cv2
21
+ import matplotlib.pyplot as plt
22
+ from matplotlib.colors import LinearSegmentedColormap, Normalize
23
+ from matplotlib.ticker import MultipleLocator
24
+ from matplotlib.cm import ScalarMappable
25
+ import tifffile
26
+
27
+
28
+ # ============================================================
29
+ # 颜色常量 (Beer-Lambert 吸收系数)
30
+ # ============================================================
31
+ BLK = [0.0, 0.0, 0.0] # 背景 (黑色)
32
+
33
+ BLUE = [9.147, 6.9215, 1.0] # 蓝色通道 (细胞核 / DNA)
34
+ RED = [1.0, 15.8, 6.0] # 红色通道
35
+ BROWN = [3.5, 5.3, 9.0] # 棕色通道 (Ecadherin 等膜染色)
36
+
37
+
38
+ # ============================================================
39
+ # helper
40
+ # ============================================================
41
+
42
+ def _apply_gamma(img, gamma, ref_percentile=99.9):
43
+ """对图像做 gamma 校正, 保持强度尺度大致不变.
44
+
45
+ 归一化到 ref_percentile → 应用 gamma → 缩放回原尺度.
46
+ gamma=1.0 不做任何改变.
47
+ """
48
+ if gamma == 1.0 or img is None or np.all(img == 0):
49
+ return img
50
+ ref = np.percentile(img[img > 0], ref_percentile) if np.any(img > 0) else 1.0
51
+ if ref <= 0:
52
+ return img
53
+ img_norm = img / ref
54
+ img_norm = np.clip(img_norm, 0, None) # 确保非负
55
+ return (img_norm ** gamma) * ref
56
+
57
+
58
+ # ============================================================
59
+ # 主函数
60
+ # ============================================================
61
+
62
+ def virtual_IHC(Blue_image, Red_image, Brown_image,
63
+ blue_name="Blue", red_name="Red", brown_name="Brown",
64
+ title='',
65
+ k1=0.001, k2=0.5, k3=0.0075,
66
+ blue_gamma=1.0, red_gamma=1.0, brown_gamma=1.0,
67
+ blue_threshold=0.0, red_threshold=0.0, brown_threshold=0.0,
68
+ blue_lim=(0, 100),
69
+ red_lim=(0, 100),
70
+ brown_lim=(0, 100),
71
+ sigma=1, kernel_size=(3, 3),
72
+ save_dir='.', figsize=(24, 18),
73
+ save_dpi=150,
74
+ Save_raw=False,
75
+ x_ticks=None, y_ticks=None,
76
+ tick_step=None,
77
+ xlim=None, ylim=None,
78
+ tick_fontsize=10,
79
+ tick_fontweight='normal',
80
+ xlabel='5μm / pixel',
81
+ ylabel='5μm / pixel',
82
+ axis_label_fontsize=12,
83
+ axis_label_fontweight='normal',
84
+ title_fontsize=18,
85
+ title_fontweight='bold',
86
+ title_pad=None,
87
+ grid=False,
88
+ grid_linewidth=0.5,
89
+ spine_linewidth=1.5,
90
+ # --- colorbar ---
91
+ blue_bar=False,
92
+ red_bar=False,
93
+ brown_bar=False,
94
+ blue_bar_alpha=1.0,
95
+ red_bar_alpha=1.0,
96
+ brown_bar_alpha=1.0,
97
+ blue_bar_depth=1.0,
98
+ red_bar_depth=1.0,
99
+ brown_bar_depth=1.0,
100
+ bar_position='right',
101
+ bar_pad=0.02,
102
+ bar_tick_fontsize=9,
103
+ bar_tick_fontweight='normal',
104
+ bar_label='Normalized Intensity',
105
+ bar_label_fontsize=None,
106
+ bar_label_fontweight=None):
107
+ """
108
+ 生成虚拟 IHC 图像,显示并保存。
109
+
110
+ Beer-Lambert 模型:
111
+ RGB[c] = BLK[c] + (1-BLK[c]) * exp(
112
+ - k1*BLUE[c]*Blue_gamma - k2*RED[c]*Red_gamma - k3*BROWN[c]*Brown_gamma
113
+ )
114
+
115
+ Parameters
116
+ ----------
117
+ Blue_image : 2D ndarray, 蓝色通道 (细胞核, 不可为 None)
118
+ Red_image : 2D ndarray or None, 红色通道
119
+ Brown_image : 2D ndarray or None, 棕色通道 (膜染色)
120
+ blue_name, red_name, brown_name : str, 各通道名称
121
+ title : str, 图像标题 (默认 '' 不显示)
122
+ k1, k2, k3 : float
123
+ 全局吸收系数, 控制各通道整体强度.
124
+ blue_gamma, red_gamma, brown_gamma : float
125
+ 各通道 Gamma 校正 (默认 1.0).
126
+ blue_threshold, red_threshold, brown_threshold : float
127
+ 各通道背景扣除阈值: 强度 < threshold 的像素置 0 (默认 0.0 不扣除).
128
+ 例如 Brown_threshold=0.1 等价于 np.where(Brown_image < 0.1, 0.0, Brown_image).
129
+ sigma : float, 高斯模糊 sigma
130
+ kernel_size : tuple, 高斯核大小
131
+ save_dir : str, 输出目录或完整路径 (.png/.tiff/.pdf/.svg)
132
+ figsize : tuple, 显示尺寸
133
+ save_dpi : int, 保存 figure 的分辨率 (默认 150)
134
+ x_ticks, y_ticks : array-like or None
135
+ 显式坐标轴刻度 (优先于 tick_step). 例如 np.arange(0, 2001, 250).
136
+ tick_step : int or None
137
+ 坐标轴刻度间隔 (像素). 当 x_ticks/y_ticks 为 None 时, 用
138
+ MultipleLocator(tick_step) 自动生成刻度; 默认 None 不设置.
139
+ xlim, ylim : tuple or None
140
+ 坐标轴显示范围 (xmin, xmax) / (ymin, ymax). imshow 默认范围是
141
+ -0.5 ~ W-0.5, 导致边界刻度(如 2000)被裁掉; 设为 (0, W) 即可显示.
142
+ tick_fontsize : int, 坐标轴刻度字号
143
+ tick_fontweight : str, 坐标轴刻度粗细
144
+ xlabel : str, X 轴标签 (默认 '5μm / pixel')
145
+ ylabel : str, Y 轴标签 (默认 '5μm / pixel')
146
+ axis_label_fontsize : int, 坐标轴标签字号
147
+ axis_label_fontweight : str, 坐标轴标签粗细
148
+ title_fontsize : int, 标题字号
149
+ title_fontweight : str, 标题粗细
150
+ title_pad : float or None
151
+ 标题与图像之间的间距; 默认 None 使用 matplotlib 默认值.
152
+ grid : bool, 是否显示网格
153
+ grid_linewidth : float, 网格线宽度 (默认 0.5)
154
+ spine_linewidth : float, 外边框线宽度 (默认 1.5)
155
+ blue_bar, red_bar, brown_bar : bool
156
+ 是否显示各通道的 Normalized Intensity [0,1] colorbar.
157
+ bar_position : str, 'left' / 'right' / 'top' / 'bottom'
158
+ colorbar 放置位置.
159
+ bar_pad : float
160
+ colorbar 与图像的间距 (默认 0.02).
161
+ bar_tick_fontsize : int
162
+ colorbar 刻度字号.
163
+ bar_tick_fontweight : str
164
+ colorbar 刻度粗细.
165
+ bar_label : str
166
+ colorbar 标签文字 (默认 'Normalized Intensity').
167
+ bar_label_fontsize : int or None
168
+ colorbar 标签字号; None → bar_tick_fontsize + 8.
169
+ bar_label_fontweight : str or None
170
+ colorbar 标签粗细; None → bar_tick_fontweight.
171
+ """
172
+ # ---- 背景扣除 (threshold): 低于阈值的像素置 0 ----
173
+ Blue_image = np.where(Blue_image < blue_threshold, 0.0, Blue_image) if Blue_image is not None else None
174
+ Red_image = np.where(Red_image < red_threshold, 0.0, Red_image) if Red_image is not None else None
175
+ Brown_image = np.where(Brown_image < brown_threshold, 0.0, Brown_image) if Brown_image is not None else None
176
+
177
+ # ---- gamma 校正 ----
178
+ Blue_image = _apply_gamma(Blue_image, blue_gamma) if Blue_image is not None else None
179
+ Red_image = _apply_gamma(Red_image, red_gamma) if Red_image is not None else None
180
+ Brown_image = _apply_gamma(Brown_image, brown_gamma) if Brown_image is not None else None
181
+
182
+ # colorbar 用: 从 (阈值扣除 + gamma 校正后) 图像取 lim 百分位作为 [0,1] 映射区间.
183
+ # lim=(0, 100) (默认) 时 lo = 最小正像素 (阈值扣除后最小值), hi = 最大正像素,
184
+ # 即 colorbar 的 0 端 = 阈值扣除后最小像素的颜色, 1 端 = 最大像素的颜色.
185
+ def _get_lo_hi(img, lim):
186
+ if img is None or np.all(img == 0):
187
+ return 0.0, 1.0
188
+ lo = np.percentile(img[img > 0], lim[0]) if np.any(img > 0) else 0.0
189
+ hi = np.percentile(img[img > 0], lim[1]) if np.any(img > 0) else 1.0
190
+ if hi <= lo:
191
+ hi = lo + 1.0
192
+ return lo, hi
193
+
194
+ _lo_hi = {}
195
+ _lo_hi['blue'] = _get_lo_hi(Blue_image, blue_lim)
196
+ _lo_hi['red'] = _get_lo_hi(Red_image, red_lim)
197
+ _lo_hi['brown'] = _get_lo_hi(Brown_image, brown_lim)
198
+
199
+ # ---- 输入校验: 从可用图像确定 height/width (优先级 Blue > Red > Brown) ----
200
+ if Blue_image is not None:
201
+ height, width = Blue_image.shape
202
+ elif Red_image is not None:
203
+ height, width = Red_image.shape
204
+ elif Brown_image is not None:
205
+ height, width = Brown_image.shape
206
+ else:
207
+ raise ValueError("至少需要提供一个非 None 的图像 (Blue / Red / Brown).")
208
+
209
+ if Blue_image is not None:
210
+ print(f"Blue image: {blue_name}, shape: {Blue_image.shape}, dtype: {Blue_image.dtype}")
211
+ else:
212
+ Blue_image = np.zeros((height, width))
213
+ print(f"Blue image: None → 已用全零数组替代")
214
+
215
+ if Red_image is None:
216
+ Red_image = np.zeros((height, width))
217
+ print(f"Red image: None → 已用全零数组替代")
218
+ else:
219
+ print(f"Red image: {red_name}, shape: {Red_image.shape}, dtype: {Red_image.dtype}")
220
+
221
+ if Brown_image is None:
222
+ Brown_image = np.zeros((height, width))
223
+ print(f"Brown image: None → 已用全零数组替代")
224
+ else:
225
+ print(f"Brown image: {brown_name}, shape: {Brown_image.shape}, dtype: {Brown_image.dtype}")
226
+
227
+ # ---- Beer-Lambert 颜色合成 ----
228
+ new_image = np.empty([height, width, 3], dtype=np.float64)
229
+
230
+ for c in range(3):
231
+ absorbance = (k1 * BLUE[c] * Blue_image +
232
+ k2 * RED[c] * Red_image +
233
+ k3 * BROWN[c] * Brown_image)
234
+ new_image[:, :, c] = BLK[c] + (1.0 - BLK[c]) * np.exp(-absorbance)
235
+
236
+ # 裁剪到 [0, 1]
237
+ new_image = np.clip(new_image, 0.0, 1.0)
238
+
239
+ # ---- 高斯模糊 ----
240
+ new_image_blur = cv2.GaussianBlur(
241
+ new_image, ksize=kernel_size,
242
+ sigmaX=sigma, sigmaY=sigma,
243
+ borderType=cv2.BORDER_REFLECT
244
+ )
245
+
246
+ # ---- 显示 ----
247
+ fig = plt.figure(figsize=figsize)
248
+ ax = fig.add_subplot(111)
249
+ ax.imshow(new_image_blur)
250
+ if title:
251
+ # title_pad=None → 用 matplotlib 默认间距; 否则显式设定
252
+ if title_pad is None:
253
+ ax.set_title(title, fontsize=title_fontsize, fontweight=title_fontweight)
254
+ else:
255
+ ax.set_title(title, fontsize=title_fontsize, fontweight=title_fontweight,
256
+ pad=title_pad)
257
+ # 坐标轴刻度: 显式 x_ticks/y_ticks 优先; 否则用 tick_step 自动生成
258
+ if x_ticks is not None:
259
+ ax.set_xticks(x_ticks)
260
+ elif tick_step is not None:
261
+ ax.xaxis.set_major_locator(MultipleLocator(tick_step))
262
+ if y_ticks is not None:
263
+ ax.set_yticks(y_ticks)
264
+ elif tick_step is not None:
265
+ ax.yaxis.set_major_locator(MultipleLocator(tick_step))
266
+ # 坐标轴范围: 让边界刻度(如 2000)可见 —— imshow 默认范围是 -0.5~W-0.5
267
+ # xlim=(xmin, xmax); ylim=(ymin, ymax) —— 图像 y 轴自上而下,
268
+ # 故 ylim 反转传入 set_ylim, 保证 (0, 2000) 时 0 在上、2000 在下。
269
+ if xlim is not None:
270
+ ax.set_xlim(xlim)
271
+ if ylim is not None:
272
+ ax.set_ylim(ylim[1], ylim[0])
273
+ ax.tick_params(axis='both', labelsize=tick_fontsize)
274
+ for label in ax.get_xticklabels() + ax.get_yticklabels():
275
+ label.set_fontweight(tick_fontweight)
276
+ ax.set_xlabel(xlabel, fontsize=axis_label_fontsize,
277
+ fontweight=axis_label_fontweight)
278
+ ax.set_ylabel(ylabel, fontsize=axis_label_fontsize,
279
+ fontweight=axis_label_fontweight)
280
+ if grid:
281
+ ax.grid(True, alpha=0.3, linewidth=grid_linewidth)
282
+ for spine in ax.spines.values():
283
+ spine.set_linewidth(spine_linewidth)
284
+
285
+ # ---- colorbar (Normalized Intensity [0,1] per channel) ----
286
+ # x → x^gamma → 映射到 [lo, hi] → Beer-Lambert
287
+ def _make_stain_cmap(absorption, k_val, gamma_val, lo, hi,
288
+ bar_alpha=1.0, bar_depth=1.0, n=256):
289
+ """gamma + lim → Beer-Lambert → 白→最深染色 colormap."""
290
+ x = np.linspace(0, 1, n)
291
+ x_eff = x ** gamma_val
292
+ intensity = lo + x_eff * (hi - lo)
293
+ absorbance = k_val * bar_depth * np.outer(intensity,
294
+ np.array(absorption, dtype=np.float64))
295
+ rgb = np.clip(np.exp(-absorbance), 0.0, 1.0)
296
+ rgba = np.concatenate([rgb, np.full((n, 1), bar_alpha)], axis=1)
297
+ return LinearSegmentedColormap.from_list('stain', rgba, N=n)
298
+
299
+ channel_configs = [
300
+ (blue_bar, Blue_image, _make_stain_cmap(BLUE, k1, blue_gamma, *_lo_hi['blue'], blue_bar_alpha, blue_bar_depth), blue_name),
301
+ (red_bar, Red_image, _make_stain_cmap(RED, k2, red_gamma, *_lo_hi['red'], red_bar_alpha, red_bar_depth), red_name),
302
+ (brown_bar, Brown_image, _make_stain_cmap(BROWN, k3, brown_gamma, *_lo_hi['brown'], brown_bar_alpha, brown_bar_depth), brown_name),
303
+ ]
304
+
305
+ # colorbar label 样式: None → 使用默认 (bar_tick_fontsize + 8 / bar_tick_fontweight)
306
+ if bar_label_fontsize is None:
307
+ bar_label_fontsize = bar_tick_fontsize + 8
308
+ if bar_label_fontweight is None:
309
+ bar_label_fontweight = bar_tick_fontweight
310
+
311
+ # 累加 pad 偏移, 使多个 bar 不重叠
312
+ pad_offset = 0.0
313
+ for show, img, cmap, label in channel_configs:
314
+ if not show or img is None or np.all(img == 0):
315
+ continue
316
+
317
+ sm = ScalarMappable(Normalize(vmin=0, vmax=1), cmap=cmap)
318
+ sm.set_array([])
319
+
320
+ cbar = fig.colorbar(sm, ax=ax, location=bar_position,
321
+ pad=bar_pad + pad_offset,
322
+ fraction=0.035, shrink=0.85)
323
+ cbar.set_label(bar_label,
324
+ fontsize=bar_label_fontsize,
325
+ fontweight=bar_label_fontweight)
326
+ cbar.ax.tick_params(labelsize=bar_tick_fontsize)
327
+ for t in cbar.ax.get_yticklabels():
328
+ t.set_fontweight(bar_tick_fontweight)
329
+ pad_offset += 0.12 # 下一个 bar 向外偏移
330
+
331
+ plt.tight_layout()
332
+ plt.show()
333
+
334
+ # ---- 保存完整 figure (含 title, colorbar, 标注) ----
335
+ if save_dir.lower().endswith(('.png', '.tiff', '.tif', '.jpg', '.jpeg', '.pdf', '.svg')):
336
+ filepath = save_dir
337
+ else:
338
+ parts = [n for n in [blue_name, red_name, brown_name] if n]
339
+ filename = '_'.join(parts) + '.png'
340
+ filepath = os.path.join(save_dir, filename)
341
+
342
+ os.makedirs(os.path.dirname(filepath) or '.', exist_ok=True)
343
+ fig.savefig(filepath, dpi=save_dpi, bbox_inches='tight',
344
+ facecolor='white', edgecolor='none')
345
+ print(f"Figure 已保存到: {filepath} (dpi={save_dpi})")
346
+
347
+ # ---- 额外保存 raw 合成图 (无任何标注: 无 title/colorbar/坐标轴/网格) ----
348
+ if Save_raw:
349
+ base = os.path.splitext(filepath)[0]
350
+ raw_filepath = base + "_raw.tiff"
351
+ image_to_save = (new_image_blur * 65535).astype(np.uint16)
352
+ tifffile.imwrite(raw_filepath, image_to_save, compression='lzw')
353
+ print(f"Raw 图像已保存到: {raw_filepath}")
354
+
355
+ return fig
356
+
357
+
358
+ # ============================================================
359
+ # ROI 版本: 仅显示局部区域, 但保留原始像素坐标 (不从 0 重新开始)
360
+ # ============================================================
361
+
362
+ def virtual_IHC_ROI(Blue_image, Red_image, Brown_image,
363
+ X0=2250, X1=3000,
364
+ Y0=1250, Y1=2000,
365
+ TICK=250,
366
+ **kwargs):
367
+ """
368
+ 生成虚拟 IHC 图像的局部 ROI 区域,显示并保存。
369
+
370
+ 在 ``virtual_IHC`` 基础上, 通过 ``xlim``/``ylim`` 仅显示矩形区域
371
+ [X0, X1) x [Y0, Y1), 但坐标轴刻度保留原始像素坐标 (不从 0 重新开始):
372
+ X0, X1 : x 像素范围 [X0, X1)
373
+ Y0, Y1 : y 像素范围 [Y0, Y1)
374
+ TICK : 坐标刻度间隔
375
+
376
+ Beer-Lambert 模型与 ``virtual_IHC`` 完全一致; 其余参数 (gamma / colorbar /
377
+ 保存选项等) 经 **kwargs 透传给 ``virtual_IHC``。
378
+
379
+ Parameters
380
+ ----------
381
+ Blue_image : 2D ndarray, 蓝色通道 (细胞核, 不可为 None)
382
+ Red_image : 2D ndarray or None, 红色通道
383
+ Brown_image : 2D ndarray or None, 棕色通道 (膜染色)
384
+ X0, X1 : int, x 像素范围 [X0, X1) (默认 2250, 3000)
385
+ Y0, Y1 : int, y 像素范围 [Y0, Y1) (默认 1250, 2000)
386
+ TICK : int, 坐标刻度间隔 (默认 250)
387
+ **kwargs : 其余参数透传给 ``virtual_IHC`` (title, k1/k2/k3, gamma, lim,
388
+ sigma, save_dir, figsize, save_dpi, Save_raw, tick/axis 样式,
389
+ colorbar 样式等)。
390
+ """
391
+ # 显式坐标刻度: 保留原始像素坐标 (2250, 2500, 2750, 3000 ...)
392
+ x_ticks = np.arange(X0, X1 + 1, TICK)
393
+ y_ticks = np.arange(Y0, Y1 + 1, TICK)
394
+
395
+ return virtual_IHC(Blue_image, Red_image, Brown_image,
396
+ xlim=(X0, X1),
397
+ ylim=(Y0, Y1),
398
+ x_ticks=x_ticks,
399
+ y_ticks=y_ticks,
400
+ **kwargs)
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: imc-histotools
3
+ Version: 0.1.0
4
+ Summary: Digital pathology analysis tools for the Selenium (Se) IMC paper: virtual IHC rendering, Qupath ROI annotation, and pixel-wise H-score classification.
5
+ Author: Guojun Han Laboratory @PKU
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/gjhan100/IMC-HistoTools
8
+ Project-URL: Repository, https://github.com/gjhan100/IMC-HistoTools
9
+ Project-URL: Issues, https://github.com/gjhan100/IMC-HistoTools/issues
10
+ Keywords: IMC,imaging mass cytometry,digital pathology,virtual IHC,H-score,pixel-wise H-score,Qupath,geojson,selenium
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
16
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: numpy>=1.20
21
+ Requires-Dist: opencv-python>=4.5
22
+ Requires-Dist: matplotlib>=3.4
23
+ Requires-Dist: tifffile>=2021.0
24
+ Dynamic: license-file
25
+
26
+ <p align="center"><img src="https://raw.githubusercontent.com/gjhan100/IMC-HistoTools/main/assets/261013_Github.png" width="700"></p>
27
+
28
+ [![Python Versions](https://img.shields.io/badge/python-3.8+-brightgreen.svg)](https://pypi.org/project/imc-histotools)
29
+ [![PyPI](https://img.shields.io/pypi/v/imc-histotools.svg?color=brightgreen&style=flat)](https://pypi.org/project/imc-histotools)
30
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
31
+
32
+ **IMC-HistoTools** is a digital-pathology toolkit that bridges **imaging mass cytometry (IMC)** and **histopathology**, developed for the Selenium (Se) IMC study. It turns raw IMC channels into brightfield-like virtual IHC images, quantifies marker expression as **pixel-wise H-scores** on Qupath annotations, and renders **virtual multiplex** images from consecutive sections.
33
+
34
+ # Functionality
35
+
36
+ | Function | Description |
37
+ | --- | --- |
38
+ | `virtual_IHC` | Synthesise an IHC / brightfield-like RGB image from IMC channels using the Beer–Lambert absorbance model (per-channel gamma, background threshold, stain colour bar). |
39
+ | `virtual_IHC_ROI` | Same as above but zooms into a local region while **keeping the original pixel coordinates**. |
40
+ | `virtual_IHC_annotation` | Rasterise Qupath `.geojson` polygon annotations into ROI boolean masks. |
41
+ | `Hscore_threshold` | Build classification thresholds from reference ROIs (Negative / Low / Medium / High) using **mean / median / geometric-mean / all**. |
42
+ | `pix_Hscore_map` | Classify every pixel into NP / LP / MP / HP, export a per-pixel CSV, and produce publication figures (classification map + bar chart & table). |
43
+
44
+ # Install IMC-HistoTools
45
+
46
+ ### Using pip [![PyPI](https://img.shields.io/pypi/v/imc-histotools.svg?color=brightgreen&style=flat)](https://pypi.org/project/imc-histotools)
47
+ ```console
48
+ pip install imc-histotools
49
+ ```
50
+
51
+ ### From GitHub [![GitHub](https://img.shields.io/badge/github-source-blue.svg)](https://github.com/gjhan100/IMC-HistoTools)
52
+ ```console
53
+ git clone https://github.com/gjhan100/IMC-HistoTools.git
54
+ cd IMC-HistoTools
55
+ pip install -e .
56
+ ```
57
+
58
+ # Usage
59
+
60
+ <details>
61
+ <summary><strong>1. Virtual IHC from IMC raw tiff</strong></summary>
62
+
63
+ Render a brightfield-like IHC image from raw (or [0,1]-normalised) IMC channels.
64
+ `Blue` is typically the nuclear marker (DNA); `Brown`/`Red` are membrane / lineage markers.
65
+
66
+ ```python
67
+ import numpy as np
68
+ import tifffile
69
+ from IMC_HistoTools import virtual_IHC, virtual_IHC_ROI
70
+
71
+ # read IMC channels
72
+ dna = tifffile.imread("ROI_001_193Ir-DNA_cor.tiff").astype(np.float32) # nuclear
73
+ se = tifffile.imread("ROI_001_Se-Ecadherin_cor.tiff").astype(np.float32) # membrane
74
+
75
+ # full-image virtual IHC (Blue = DNA, Brown = E-cadherin-Se)
76
+ virtual_IHC(
77
+ Blue_image=dna, Red_image=None, Brown_image=se,
78
+ blue_name="DNA", brown_name="E-cadherin (Se)",
79
+ brown_threshold=0.1, # background removal (< threshold -> 0)
80
+ brown_bar=True, # show stain colour bar
81
+ save_dir="out/virtual_IHC.png",
82
+ )
83
+
84
+ # local region, keeping original pixel coordinates
85
+ virtual_IHC_ROI(
86
+ Blue_image=dna, Red_image=None, Brown_image=se,
87
+ X0=2250, X1=3000, Y0=1250, Y1=2000, TICK=250,
88
+ save_dir="out/virtual_IHC_ROI.png",
89
+ )
90
+ ```
91
+ </details>
92
+
93
+ <details>
94
+ <summary><strong>2. Pixel-wise H-score with Qupath annotation</strong></summary>
95
+
96
+ Load tissue-region and reference-ROI annotations exported from Qupath, calibrate the
97
+ thresholds from the Negative / Low / Medium / High reference ROIs, then classify every
98
+ pixel and export a CSV + figures.
99
+
100
+ ```python
101
+ from IMC_HistoTools import virtual_IHC_annotation, Hscore_threshold, pix_Hscore_map
102
+
103
+ marker = tifffile.imread("Normalize/ROI_001_Se_crop_normal.tiff").astype(np.float32)
104
+ tissue = tifffile.imread("Mask/mask_crop.tiff") > 0
105
+
106
+ # 1) tissue-region annotations (Normal mucosa / Tumor center / Invasive front / ...)
107
+ ann = virtual_IHC_annotation(marker, "tissue_regions.geojson")
108
+
109
+ # 2) reference ROIs (Negative / Low / Medium / High) for threshold calibration
110
+ ref = virtual_IHC_annotation(marker, "reference_ROIs.geojson",
111
+ region_order=["Negative", "Low", "Medium", "High"])
112
+
113
+ # 3) thresholds from the reference ROIs (stat: 'mean'/'median'/'geomean'/'all')
114
+ thr = Hscore_threshold(
115
+ marker,
116
+ Np=ref["masks"]["Negative"], Lp=ref["masks"]["Low"],
117
+ Mp=ref["masks"]["Medium"], Hp=ref["masks"]["High"],
118
+ tissue_mask=tissue, stat="median",
119
+ )
120
+ print(thr["thresholds"]) # (Lp, Mp, Hp)
121
+
122
+ # 4) per-pixel H-score map + CSV + bar chart / table
123
+ res = pix_Hscore_map(
124
+ marker,
125
+ Hscore_threshold_annotation=ref["masks"], # Lp / Mp / Hp reference ROIs
126
+ tissue_annotation=ann["masks"], # non-empty -> bar chart + table
127
+ tissue_mask=tissue,
128
+ stat="median",
129
+ delta=2, kernel=(5, 5), # Gaussian blur for the map display only
130
+ save_dir="out/",
131
+ )
132
+ res["csv_path"] # per-pixel classification: x, y, intensity, score, category
133
+ ```
134
+
135
+ Classification: `intensity < Lp` → 0 (NP), `[Lp, Mp)` → 1 (LP), `[Mp, Hp)` → 2 (MP),
136
+ `>= Hp` → 3 (HP). The bar-chart statistics always use the **non-blurred** classification.
137
+ </details>
138
+
139
+ <details>
140
+ <summary><strong>3. Virtual multiplexing from consecutive sections</strong></summary>
141
+
142
+ Feed registered channels from consecutive sections into the three colour channels of
143
+ `virtual_IHC` to obtain a single virtual-multiplex image (blue / red / brown).
144
+
145
+ ```python
146
+ from IMC_HistoTools import virtual_IHC
147
+
148
+ virtual_IHC(
149
+ Blue_image=dna, # section A — DNA (nuclear, blue)
150
+ Red_image=cd45, # section B — CD45 (red)
151
+ Brown_image=ecad, # section C — E-cadherin (membrane, brown)
152
+ blue_name="DNA", red_name="CD45", brown_name="E-cadherin",
153
+ blue_bar=True, red_bar=True, brown_bar=True,
154
+ save_dir="out/virtual_multiplex.png",
155
+ )
156
+ ```
157
+ </details>
158
+
159
+ # Citation
160
+
161
+ If you use **IMC-HistoTools** in your research, please cite the Se IMC paper (citation
162
+ to be added upon publication).
163
+
164
+ # License
165
+
166
+ This project is licensed under the **MIT License** — see the [LICENSE](LICENSE) file.
@@ -0,0 +1,9 @@
1
+ IMC_HistoTools/__init__.py,sha256=1fDaHiUHHD_yOpZQufQM2tQBiiTcI0F971yPO-GBmnY,821
2
+ IMC_HistoTools/annotation_region.py,sha256=nGnwsE6maM7fGCk0ekQFot4l8UmYC86U9UnIu5BRVjQ,6435
3
+ IMC_HistoTools/pix_Hscore.py,sha256=UposEy9g0ztw_vEUq3dgErjq_8j_qpBVom7spEJTrJg,17923
4
+ IMC_HistoTools/virtual_IHC.py,sha256=aXYYvzbJhGmLJCvSwyOkp7eZDwnGuImpo5uPG2xwnzg,17441
5
+ imc_histotools-0.1.0.dist-info/licenses/LICENSE,sha256=54qAOLjcTi4Dt9HUb2suxT1XFMeGtO5XbWC40Sl7oOA,1083
6
+ imc_histotools-0.1.0.dist-info/METADATA,sha256=IiS61o5w2f_Re47ZtWOWqeAVyqaQfPlxXL6V8anvbrs,7229
7
+ imc_histotools-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ imc_histotools-0.1.0.dist-info/top_level.txt,sha256=apPuZlTq6741pNrz50hbucde2nwH3cgUwa-4Vrc60T4,15
9
+ imc_histotools-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Guojun Han Laboratory @PKU
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ IMC_HistoTools