ctbodycomp 0.1.1__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.
ctbodycomp/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """CT body composition: select base features, then normalize morphometry."""
2
+ from .extractor import BodyCompositionExtractor
3
+ from .normalization import FeatureNormalizer
4
+ from .definitions import get_feature_catalog
5
+ from .normalized_composite import NormalizedCompositeCalculator, get_normalized_composite_catalog
6
+
7
+ __version__ = "0.1.1"
8
+ __all__ = ["BodyCompositionExtractor", "FeatureNormalizer", "get_feature_catalog",
9
+ "NormalizedCompositeCalculator", "get_normalized_composite_catalog"]
ctbodycomp/batch.py ADDED
@@ -0,0 +1,132 @@
1
+ """Separate CSV workflows for extraction and normalization."""
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+
9
+ def read_csv(path):
10
+ return pd.read_csv(path, dtype=str, keep_default_na=False, encoding="utf-8-sig")
11
+
12
+
13
+ def resolve_path(value, directory):
14
+ path = Path(value.strip()).expanduser()
15
+ return path if path.is_absolute() else directory / path
16
+
17
+
18
+ def protect_output(input_csv, output_csv, paths=()):
19
+ output = Path(output_csv).resolve()
20
+ if output == Path(input_csv).resolve() or any(output == Path(path).resolve() for path in paths):
21
+ raise ValueError("输出不能覆盖输入CSV、影像或mask")
22
+
23
+
24
+ def validate_new_columns(frame, columns):
25
+ overlap = set(frame.columns).intersection(columns)
26
+ if overlap:
27
+ raise ValueError(f"输入列与结果列重名,请更名后重试:{sorted(overlap)}")
28
+
29
+
30
+ def extract_csv(input_csv, output_csv, extractor):
31
+ frame = read_csv(input_csv)
32
+ if not {"Image", "Mask"}.issubset(frame.columns):
33
+ raise ValueError("提取CSV必须包含Image、Mask列,可选SpineMask和ID列")
34
+ directory = Path(input_csv).resolve().parent
35
+ image_paths = [resolve_path(value, directory) for column in ("Image", "Mask", "SpineMask")
36
+ if column in frame for value in frame[column] if value.strip()]
37
+ protect_output(input_csv, output_csv, image_paths)
38
+ names = extractor.get_feature_names()
39
+ extra = ["diagnostics_scope", "diagnostics_slices", "extraction_status", "extraction_error"]
40
+ validate_new_columns(frame, names + extra)
41
+ rows = []
42
+ for source in frame.to_dict(orient="records"):
43
+ row = {**source, **dict.fromkeys(names, np.nan), **dict.fromkeys(extra, "")}
44
+ try:
45
+ if not source["Image"].strip() or not source["Mask"].strip():
46
+ raise ValueError("Image和Mask不能为空")
47
+ spine = source.get("SpineMask", "").strip()
48
+ row.update(extractor.execute(resolve_path(source["Image"], directory),
49
+ resolve_path(source["Mask"], directory),
50
+ resolve_path(spine, directory) if spine else None))
51
+ row.update(diagnostics_scope=extractor.last_diagnostics["scope"],
52
+ diagnostics_slices=json.dumps(extractor.last_diagnostics["selected_slices"]),
53
+ extraction_status="success")
54
+ except Exception as error:
55
+ row.update(extraction_status="failed", extraction_error=str(error))
56
+ rows.append(row)
57
+ result = pd.DataFrame(rows, columns=list(frame.columns) + names + extra)
58
+ result.to_csv(output_csv, index=False, encoding="utf-8-sig")
59
+ return result
60
+
61
+
62
+ def normalize_csv(input_csv, output_csv, normalizer, height_cm=None, weight_kg=None, bsa_m2=None):
63
+ frame = read_csv(input_csv)
64
+ directory = Path(input_csv).resolve().parent
65
+ image_paths = [resolve_path(value, directory) for column in ("Image", "Mask", "SpineMask")
66
+ if column in frame for value in frame[column] if value.strip()]
67
+ protect_output(input_csv, output_csv, image_paths)
68
+ shape_columns = [column for column in frame if column.startswith("morphometry_")]
69
+ mean_columns = ([column for column in frame if column.startswith("firstorder_") and column.endswith("_Mean")]
70
+ if normalizer.include_composite else [])
71
+ names = normalizer.get_feature_names(dict.fromkeys(shape_columns, np.nan))
72
+ extra = ["normalization_status", "normalization_error", "normalization_bsa_source"]
73
+ validate_new_columns(frame, names + extra)
74
+ defaults = {"Height_cm": height_cm, "Weight_kg": weight_kg, "BSA_m2": bsa_m2}
75
+ rows = []
76
+ for source in frame.to_dict(orient="records"):
77
+ row = {**source, **dict.fromkeys(names, np.nan), **dict.fromkeys(extra, "")}
78
+ try:
79
+ if source.get("extraction_status") == "failed":
80
+ raise ValueError("该病例基础提取失败,不能校正")
81
+ needed_means = {f"firstorder_{t}_Mean" for t in normalizer._targets(dict.fromkeys(shape_columns))}
82
+ selected_means = [key for key in mean_columns if key in needed_means]
83
+ features = {key: float(source[key]) if source[key].strip() else np.nan
84
+ for key in shape_columns + selected_means}
85
+ subject = {key: source.get(key, "").strip() or value for key, value in defaults.items()}
86
+ row.update(normalizer.execute(features, height_cm=subject["Height_cm"],
87
+ weight_kg=subject["Weight_kg"], bsa_m2=subject["BSA_m2"]))
88
+ row.update(normalization_status="success",
89
+ normalization_bsa_source=normalizer.last_diagnostics["bsa_source"] or "")
90
+ except Exception as error:
91
+ row.update(normalization_status="failed", normalization_error=str(error))
92
+ rows.append(row)
93
+ result = pd.DataFrame(rows, columns=list(frame.columns) + names + extra)
94
+ result.to_csv(output_csv, index=False, encoding="utf-8-sig")
95
+ return result
96
+
97
+
98
+ def normalized_composite_csv(input_csv, output_csv, calculator):
99
+ """Read a normalized CSV with mean-HU columns; preserve all previous results."""
100
+ frame = read_csv(input_csv)
101
+ directory = Path(input_csv).resolve().parent
102
+ image_paths = [resolve_path(value, directory) for column in ("Image", "Mask", "SpineMask")
103
+ if column in frame for value in frame[column] if value.strip()]
104
+ protect_output(input_csv, output_csv, image_paths)
105
+ index_columns = [column for column in frame if column.startswith("normalization_") and
106
+ column not in ("normalization_status", "normalization_error", "normalization_bsa_source")]
107
+ mean_columns = [column for column in frame if column.startswith("firstorder_") and column.endswith("_Mean")]
108
+ names = calculator.get_feature_names(dict.fromkeys(index_columns, np.nan))
109
+ selected_names = set(names)
110
+ dependencies = [metadata for methods in calculator.catalog.values() for metadata in methods.values()
111
+ if metadata["key"] in selected_names]
112
+ index_columns = list(dict.fromkeys(metadata["index_key"] for metadata in dependencies))
113
+ needed_means = {metadata["mean_key"] for metadata in dependencies}
114
+ mean_columns = [column for column in mean_columns if column in needed_means]
115
+ extra = ["normalized_composite_status", "normalized_composite_error"]
116
+ validate_new_columns(frame, names + extra)
117
+ rows = []
118
+ for source in frame.to_dict(orient="records"):
119
+ row = {**source, **dict.fromkeys(names, np.nan), **dict.fromkeys(extra, "")}
120
+ try:
121
+ if source.get("extraction_status") == "failed" or source.get("normalization_status") == "failed":
122
+ raise ValueError("该病例基础提取或校正失败,不能计算校正复合特征")
123
+ indices = {key: float(source[key]) if source[key].strip() else np.nan for key in index_columns}
124
+ means = {key: float(source[key]) if source[key].strip() else np.nan for key in mean_columns}
125
+ row.update(calculator.execute(indices, means))
126
+ row["normalized_composite_status"] = "success"
127
+ except Exception as error:
128
+ row.update(normalized_composite_status="failed", normalized_composite_error=str(error))
129
+ rows.append(row)
130
+ result = pd.DataFrame(rows, columns=list(frame.columns) + names + extra)
131
+ result.to_csv(output_csv, index=False, encoding="utf-8-sig")
132
+ return result
ctbodycomp/cli.py ADDED
@@ -0,0 +1,72 @@
1
+ import argparse
2
+ import json
3
+ from pathlib import Path
4
+
5
+ from .batch import extract_csv, normalize_csv, normalized_composite_csv
6
+ from .definitions import get_feature_catalog
7
+ from .extractor import BodyCompositionExtractor
8
+ from .normalization import FeatureNormalizer, METHODS
9
+ from .normalized_composite import NormalizedCompositeCalculator, get_normalized_composite_catalog
10
+
11
+
12
+ def main(argv=None):
13
+ parser = argparse.ArgumentParser(description="CT体成分:选择基础特征提取,再独立校正")
14
+ commands = parser.add_subparsers(dest="command", required=True)
15
+ listing = commands.add_parser("features", help="列出四类可选基础特征及单位")
16
+ listing.add_argument("--mode", choices=["2D", "3D"], default="3D")
17
+ listing.add_argument("--normalized-composite", action="store_true", help="列出可选校正复合特征及其依赖")
18
+ extract = commands.add_parser("extract", help="提取所选基础特征")
19
+ extract.add_argument("input_csv")
20
+ extract.add_argument("output_csv")
21
+ extract.add_argument("--mode", choices=["2D", "3D"], default="3D")
22
+ extract.add_argument("--label", type=int, help="2D有SpineMask时需要单个标签")
23
+ extract.add_argument("--spine-range", type=int, nargs=2, default=[7, 18])
24
+ extract.add_argument("--config", help="JSON文件,内容为类别到特征列表的映射;[]表示该类全部")
25
+ normalize = commands.add_parser("normalize", help="对已提取的第一类形态指标进行校正")
26
+ normalize.add_argument("input_csv")
27
+ normalize.add_argument("output_csv")
28
+ normalize.add_argument("--mode", choices=["2D", "3D"], default="3D")
29
+ normalize.add_argument("--methods", nargs="+", choices=METHODS, required=True)
30
+ normalize.add_argument("--tissues", nargs="+")
31
+ normalize.add_argument("--height-cm", type=float)
32
+ normalize.add_argument("--weight-kg", type=float)
33
+ normalize.add_argument("--bsa-m2", type=float)
34
+ normalize.add_argument("--include-composite", action="store_true", help="同时输出校正指数×同组织Mean,与校正指数相邻排列")
35
+ composite = commands.add_parser("normalized-composite", help="校正指数乘同组织平均HU,不重新读取影像")
36
+ composite.add_argument("input_csv", help="包含校正指数和firstorder_*_Mean列的CSV")
37
+ composite.add_argument("output_csv")
38
+ composite.add_argument("--mode", choices=["2D", "3D"], default="3D")
39
+ composite.add_argument("--methods", nargs="+", choices=METHODS, required=True)
40
+ composite.add_argument("--tissues", nargs="+")
41
+ args = parser.parse_args(argv)
42
+ try:
43
+ if args.command == "features":
44
+ catalog = (get_normalized_composite_catalog(args.mode) if args.normalized_composite
45
+ else get_feature_catalog(args.mode))
46
+ print(json.dumps(catalog, ensure_ascii=False, indent=2))
47
+ return 0
48
+ if args.command == "extract":
49
+ config = json.loads(Path(args.config).read_text(encoding="utf-8")) if args.config else None
50
+ extractor = BodyCompositionExtractor(args.mode, features=config, spine_label=args.label,
51
+ spine_range=args.spine_range)
52
+ result = extract_csv(args.input_csv, args.output_csv, extractor)
53
+ status = "extraction_status"
54
+ elif args.command == "normalize":
55
+ normalizer = FeatureNormalizer(args.mode, methods=args.methods, tissues=args.tissues,
56
+ include_composite=args.include_composite)
57
+ result = normalize_csv(args.input_csv, args.output_csv, normalizer, args.height_cm,
58
+ args.weight_kg, args.bsa_m2)
59
+ status = "normalization_status"
60
+ else:
61
+ calculator = NormalizedCompositeCalculator(args.mode, methods=args.methods, tissues=args.tissues)
62
+ result = normalized_composite_csv(args.input_csv, args.output_csv, calculator)
63
+ status = "normalized_composite_status"
64
+ failures = int((result[status] == "failed").sum())
65
+ print(f"已输出{len(result)}个病例,失败{failures}个:{args.output_csv}")
66
+ return 1 if failures else 0
67
+ except (ValueError, TypeError, OSError) as error:
68
+ parser.error(str(error))
69
+
70
+
71
+ if __name__ == "__main__":
72
+ raise SystemExit(main())
@@ -0,0 +1,61 @@
1
+ """Feature definitions shared by 2D and 3D extraction."""
2
+
3
+ TISSUES = {
4
+ "SM": ("骨骼肌", (1,)), "SF": ("皮下脂肪", (4,)),
5
+ "IMF": ("肌间脂肪", (2,)), "VF": ("其余内脏脂肪", (3,)),
6
+ "PVAT": ("血管周围脂肪", (6,)), "EAT": ("心外膜脂肪", (7,)),
7
+ "AorticPVAT": ("主动脉周围脂肪", (8,)), "Bone": ("骨", (5,)),
8
+ "CVF": ("心血管相关脂肪", (6, 7, 8)),
9
+ "VF_total": ("总内脏脂肪", (3, 6, 7, 8)),
10
+ "TF": ("总脂肪", (2, 3, 4, 6, 7, 8)),
11
+ }
12
+ STATISTICS = ("Mean", "SD", "P10", "P50", "P90", "IQR")
13
+ COMPOSITION = {
14
+ "SF_to_SM": ("皮下脂肪与肌肉比", "SF", ("SM",)),
15
+ "VF_total_to_SM": ("总内脏脂肪与肌肉比", "VF_total", ("SM",)),
16
+ "TF_to_SM": ("总脂肪与肌肉比", "TF", ("SM",)),
17
+ "VF_total_to_SF": ("总内脏脂肪与皮下脂肪比", "VF_total", ("SF",)),
18
+ "MFF": ("肌间脂肪占比", "IMF", ("SM", "IMF")),
19
+ "SF_fraction": ("皮下脂肪占总脂肪比例", "SF", ("TF",)),
20
+ "IMF_fraction": ("肌间脂肪占总脂肪比例", "IMF", ("TF",)),
21
+ "VF_fraction": ("总内脏脂肪占总脂肪比例", "VF_total", ("TF",)),
22
+ "VF_fraction_of_VF_total": ("其余内脏脂肪占总内脏脂肪比例", "VF", ("VF_total",)),
23
+ "PVAT_fraction_of_VF_total": ("血管周围脂肪占总内脏脂肪比例", "PVAT", ("VF_total",)),
24
+ "EAT_fraction_of_VF_total": ("心外膜脂肪占总内脏脂肪比例", "EAT", ("VF_total",)),
25
+ "AorticPVAT_fraction_of_VF_total": ("主动脉周围脂肪占总内脏脂肪比例", "AorticPVAT", ("VF_total",)),
26
+ }
27
+
28
+
29
+ def validate_mode(mode):
30
+ if mode not in ("2D", "3D"):
31
+ raise ValueError("mode必须为'2D'或'3D'")
32
+ return mode
33
+
34
+
35
+ def size_name(mode):
36
+ return "Area" if validate_mode(mode) == "2D" else "Volume"
37
+
38
+
39
+ def size_unit(mode):
40
+ return "cm²" if validate_mode(mode) == "2D" else "cm³"
41
+
42
+
43
+ def morphology_key(tissue, mode):
44
+ return f"morphometry_{tissue}_{size_name(mode)}"
45
+
46
+
47
+ def get_feature_catalog(mode="3D"):
48
+ """返回四类可选特征的名称、输出键及单位;每次返回独立字典。"""
49
+ kind, unit = size_name(mode), size_unit(mode)
50
+ catalog = {name: {} for name in ("morphometry", "firstorder", "composition", "composite")}
51
+ for tissue, (name, _) in TISSUES.items():
52
+ catalog["morphometry"][f"{tissue}_{kind}"] = {"name": name, "unit": unit}
53
+ for statistic in STATISTICS:
54
+ catalog["firstorder"][f"{tissue}_{statistic}"] = {"name": f"{name} {statistic}", "unit": "HU"}
55
+ catalog["composite"][f"{tissue}_{kind}Mean"] = {"name": f"{name} {kind} × Mean", "unit": f"{unit}·HU"}
56
+ for feature, (name, _, _) in COMPOSITION.items():
57
+ catalog["composition"][feature] = {"name": name, "unit": "%"}
58
+ for group, features in catalog.items():
59
+ for feature, metadata in features.items():
60
+ metadata["key"] = f"{group}_{feature}"
61
+ return catalog
@@ -0,0 +1,84 @@
1
+ """PyRadiomics-style feature selection for body composition."""
2
+ from collections.abc import Mapping
3
+ import numpy as np
4
+
5
+ from .definitions import get_feature_catalog, validate_mode
6
+ from .features import calculate_selected
7
+ from .image import load_image, positive_label, read_mask, select_region, slice_axis
8
+
9
+
10
+ class BodyCompositionExtractor:
11
+ def __init__(self, mode="3D", features=None, spine_label=None, spine_range=(7, 18)):
12
+ self.mode = validate_mode(mode)
13
+ self.catalog = get_feature_catalog(mode)
14
+ self.spine_label = None if spine_label is None else positive_label(spine_label)
15
+ if len(spine_range) != 2:
16
+ raise ValueError("spine_range必须包含起始和结束标签")
17
+ self.spine_range = tuple(positive_label(value) for value in spine_range)
18
+ if self.spine_range[0] > self.spine_range[1]:
19
+ raise ValueError("spine_range起始标签不能大于结束标签")
20
+ self.enabledFeatures = {}
21
+ self.last_diagnostics = {}
22
+ if features is None:
23
+ self.enableAllFeatures()
24
+ else:
25
+ if not isinstance(features, Mapping):
26
+ raise TypeError("features必须为类别到特征名称列表的映射")
27
+ self.enableFeaturesByName(**features)
28
+
29
+ def disableAllFeatures(self):
30
+ self.enabledFeatures = {}
31
+ return self
32
+
33
+ def enableAllFeatures(self):
34
+ self.enabledFeatures = {group: list(features) for group, features in self.catalog.items()}
35
+ return self
36
+
37
+ def enableFeatureClassByName(self, featureClass, enabled=True):
38
+ if featureClass not in self.catalog:
39
+ raise ValueError(f"未知特征类别:{featureClass}")
40
+ if enabled:
41
+ self.enabledFeatures[featureClass] = list(self.catalog[featureClass])
42
+ else:
43
+ self.enabledFeatures.pop(featureClass, None)
44
+ return self
45
+
46
+ def enableFeaturesByName(self, **features):
47
+ updates = {}
48
+ for group, names in features.items():
49
+ if group not in self.catalog:
50
+ raise ValueError(f"未知特征类别:{group}")
51
+ if names is None:
52
+ names = []
53
+ if not isinstance(names, (list, tuple)):
54
+ raise TypeError(f"{group}需要特征名称列表;[]表示该类全部特征")
55
+ unknown = [name for name in names if name not in self.catalog[group]]
56
+ if unknown:
57
+ raise ValueError(f"{group}中存在未知特征:{unknown}")
58
+ updates[group] = list(dict.fromkeys(names)) if names else list(self.catalog[group])
59
+ self.enabledFeatures.update(updates)
60
+ return self
61
+
62
+ def get_feature_names(self):
63
+ return [f"{group}_{name}" for group, names in self.enabledFeatures.items() for name in names]
64
+
65
+ def execute(self, image, mask, spine_mask=None):
66
+ """返回所选原始特征;不进行身高、体重、BSA或骨参照校正。"""
67
+ self.last_diagnostics = {}
68
+ img = load_image(image)
69
+ slice_axis(img)
70
+ bc = read_mask(mask, img, "BC mask", maximum=8)
71
+ spine = read_mask(spine_mask, img, "Spine mask") if spine_mask is not None else None
72
+ axis, selected, element_size, metadata = select_region(
73
+ img, spine, self.mode, self.spine_label, self.spine_range)
74
+ ct = img.get_fdata()
75
+ if self.mode == "3D":
76
+ crop = [slice(None)] * 3
77
+ crop[axis] = slice(int(selected[0]), int(selected[-1]) + 1)
78
+ result = calculate_selected(ct[tuple(crop)], bc[tuple(crop)], element_size, self.mode, self.enabledFeatures)
79
+ else:
80
+ results = [calculate_selected(np.take(ct, i, axis=axis), np.take(bc, i, axis=axis),
81
+ element_size, self.mode, self.enabledFeatures) for i in selected]
82
+ result = {key: float(np.mean([row[key] for row in results])) for key in results[0]}
83
+ self.last_diagnostics = metadata
84
+ return result
ctbodycomp/features.py ADDED
@@ -0,0 +1,71 @@
1
+ """Only evaluate the requested features and their numerical dependencies."""
2
+ import numpy as np
3
+
4
+ from .definitions import COMPOSITION, TISSUES, size_name
5
+
6
+
7
+ def safe_ratio(numerator, denominator):
8
+ if not np.isfinite(denominator) or denominator <= 0:
9
+ return float("nan")
10
+ return float(numerator / denominator)
11
+
12
+
13
+ class FeatureContext:
14
+ def __init__(self, image, mask, element_size, mode):
15
+ self.image, self.mask = image, mask
16
+ self.scale = element_size / (100 if mode == "2D" else 1000)
17
+ self.counts, self.sizes, self.values, self.statistics = {}, {}, {}, {}
18
+
19
+ def size(self, tissue):
20
+ if tissue not in self.sizes:
21
+ labels = TISSUES[tissue][1]
22
+ for label in labels:
23
+ if label not in self.counts:
24
+ self.counts[label] = int(np.count_nonzero(self.mask == label))
25
+ self.sizes[tissue] = sum(self.counts[label] for label in labels) * self.scale
26
+ return self.sizes[tissue]
27
+
28
+ def hu(self, tissue):
29
+ if tissue not in self.values:
30
+ values = self.image[np.isin(self.mask, TISSUES[tissue][1])]
31
+ self.values[tissue] = values[np.isfinite(values)]
32
+ return self.values[tissue]
33
+
34
+ def statistic(self, tissue, statistic):
35
+ key = (tissue, statistic)
36
+ if key not in self.statistics:
37
+ hu = self.hu(tissue)
38
+ if not hu.size:
39
+ value = np.nan
40
+ elif statistic == "Mean":
41
+ value = np.mean(hu)
42
+ elif statistic == "SD":
43
+ value = np.std(hu, ddof=0)
44
+ elif statistic == "IQR":
45
+ q25, q75 = np.percentile(hu, [25, 75], method="linear")
46
+ value = q75 - q25
47
+ else:
48
+ value = np.percentile(hu, int(statistic[1:]), method="linear")
49
+ self.statistics[key] = float(value)
50
+ return self.statistics[key]
51
+
52
+
53
+ def calculate_selected(image, mask, element_size, mode, enabled):
54
+ ctx = FeatureContext(image, mask, element_size, mode)
55
+ kind = size_name(mode)
56
+ result = {}
57
+ for group, features in enabled.items():
58
+ for feature in features:
59
+ if group == "morphometry":
60
+ value = ctx.size(feature.removesuffix(f"_{kind}"))
61
+ elif group == "firstorder":
62
+ tissue, statistic = feature.rsplit("_", 1)
63
+ value = ctx.statistic(tissue, statistic)
64
+ elif group == "composition":
65
+ _, numerator, denominator = COMPOSITION[feature]
66
+ value = safe_ratio(ctx.size(numerator), sum(ctx.size(t) for t in denominator)) * 100
67
+ else:
68
+ tissue = feature.removesuffix(f"_{kind}Mean")
69
+ value = ctx.size(tissue) * ctx.statistic(tissue, "Mean")
70
+ result[f"{group}_{feature}"] = float(value)
71
+ return result
ctbodycomp/image.py ADDED
@@ -0,0 +1,74 @@
1
+ """Load matching NIfTI images and select a physical slice direction."""
2
+ import os
3
+ import nibabel as nib
4
+ import numpy as np
5
+
6
+
7
+ def load_image(value):
8
+ if isinstance(value, (str, os.PathLike)):
9
+ return nib.load(os.fspath(value))
10
+ if isinstance(value, nib.spatialimages.SpatialImage):
11
+ return value
12
+ raise TypeError("影像输入必须为文件路径或nibabel图像对象")
13
+
14
+
15
+ def slice_axis(image):
16
+ if len(image.shape) != 3 or any(size <= 0 for size in image.shape):
17
+ raise ValueError("CT必须为非空3D图像")
18
+ if not np.all(np.isfinite(image.affine)) or np.linalg.matrix_rank(image.affine[:3, :3]) < 3:
19
+ raise ValueError("CT的affine必须有限且可逆")
20
+ axes = np.flatnonzero(nib.orientations.io_orientation(image.affine)[:, 0] == 2)
21
+ if axes.size != 1:
22
+ raise ValueError("无法确定CT的上下方向")
23
+ return int(axes[0])
24
+
25
+
26
+ def read_mask(value, image, name, maximum=None):
27
+ mask = load_image(value)
28
+ if mask.shape != image.shape or not np.allclose(mask.affine, image.affine, rtol=0, atol=1e-4):
29
+ raise ValueError(f"{name}与CT空间不一致")
30
+ data = mask.get_fdata()
31
+ if not np.all(np.isfinite(data)) or np.any(data != np.rint(data)) or np.any(data < 0):
32
+ raise ValueError(f"{name}必须为有限非负整数标签")
33
+ if maximum is not None and np.any(data > maximum):
34
+ raise ValueError(f"{name}标签必须为0–{maximum}")
35
+ return data
36
+
37
+
38
+ def positive_label(value):
39
+ if not isinstance(value, (int, np.integer)) or isinstance(value, (bool, np.bool_)) or value <= 0:
40
+ raise ValueError("脊柱标签必须为正整数")
41
+ return int(value)
42
+
43
+
44
+ def select_region(image, spine, mode, spine_label, spine_range):
45
+ axis = slice_axis(image)
46
+ if spine is None:
47
+ candidates = np.arange(image.shape[axis])
48
+ scope = "full_image"
49
+ else:
50
+ if mode == "2D":
51
+ target = spine == positive_label(spine_label)
52
+ scope = f"spine_label_{spine_label}"
53
+ else:
54
+ lower, upper = spine_range
55
+ target = (spine >= lower) & (spine <= upper)
56
+ scope = f"spine_range_{lower}_{upper}"
57
+ occupied = np.any(target, axis=tuple(a for a in range(3) if a != axis))
58
+ candidates = np.flatnonzero(occupied)
59
+ if not candidates.size:
60
+ raise ValueError("Spine mask中未找到请求的脊柱标签")
61
+ if mode == "2D":
62
+ middle = candidates.size // 2
63
+ selected = candidates[middle:middle + 1] if candidates.size % 2 else candidates[middle - 1:middle + 1]
64
+ plane_axes = [a for a in range(3) if a != axis]
65
+ basis = image.affine[:3, plane_axes]
66
+ element_size = float(np.linalg.norm(np.cross(basis[:, 0], basis[:, 1])))
67
+ else:
68
+ selected = np.arange(candidates[0], candidates[-1] + 1)
69
+ element_size = float(abs(np.linalg.det(image.affine[:3, :3])))
70
+ if not np.isfinite(element_size) or element_size <= 0:
71
+ raise ValueError("像素面积或体素体积无效")
72
+ metadata = {"mode": mode, "scope": scope, "slice_axis": axis,
73
+ "candidate_slices": int(candidates.size), "selected_slices": selected.tolist()}
74
+ return axis, selected, element_size, metadata
@@ -0,0 +1,131 @@
1
+ """Normalize first-class morphology features without reading images."""
2
+ import math
3
+ from collections.abc import Mapping
4
+
5
+ from .definitions import TISSUES, morphology_key, size_name, size_unit, validate_mode
6
+
7
+ METHODS = ("height", "weight", "bsa", "bone")
8
+
9
+
10
+ def positive_number(value, name):
11
+ if value is None or isinstance(value, bool):
12
+ raise ValueError(f"{name}必须为有限正数")
13
+ try:
14
+ value = float(value)
15
+ except (ValueError, TypeError) as error:
16
+ raise ValueError(f"{name}必须为有限正数") from error
17
+ if not math.isfinite(value) or value <= 0:
18
+ raise ValueError(f"{name}必须为有限正数")
19
+ return value
20
+
21
+
22
+ class FeatureNormalizer:
23
+ """height: size / height(m)^2; weight: size/kg; bsa: size/m²; bone: size/bone size.
24
+
25
+ 骨校正可用于所有非骨组织;不输出恒为1的Bone/Bone。
26
+ 两层2D输入必须先完成形态指标的逐层平均,再调用本校正器。
27
+ """
28
+ def __init__(self, mode="3D", methods=METHODS, tissues=None, include_composite=False):
29
+ self.mode = validate_mode(mode)
30
+ if isinstance(methods, str) or not isinstance(methods, (tuple, list)):
31
+ raise TypeError("methods必须为校正方式列表")
32
+ unknown = [method for method in methods if method not in METHODS]
33
+ if unknown:
34
+ raise ValueError(f"未知校正方式:{unknown}")
35
+ self.methods = tuple(dict.fromkeys(methods))
36
+ if tissues is not None:
37
+ if not isinstance(tissues, (list, tuple)):
38
+ raise TypeError("tissues必须为组织代码列表")
39
+ unknown = [tissue for tissue in tissues if tissue not in TISSUES]
40
+ if unknown:
41
+ raise ValueError(f"未知组织:{unknown}")
42
+ tissues = tuple(dict.fromkeys(tissues))
43
+ self.tissues = tissues
44
+ if not isinstance(include_composite, bool):
45
+ raise TypeError("include_composite必须为布尔值")
46
+ self.include_composite = include_composite
47
+ self.last_diagnostics = {}
48
+
49
+ def _targets(self, features):
50
+ if not isinstance(features, Mapping):
51
+ raise TypeError("features必须为已提取特征的字典")
52
+ targets = self.tissues
53
+ if targets is None:
54
+ targets = tuple(t for t in TISSUES if morphology_key(t, self.mode) in features)
55
+ if self.methods and not targets:
56
+ raise ValueError("请先提取第一类形态指标;输入中没有匹配当前2D/3D模式的面积或体积")
57
+ for tissue in targets:
58
+ if morphology_key(tissue, self.mode) not in features:
59
+ raise ValueError(f"缺少第一类指标:{morphology_key(tissue, self.mode)}")
60
+ return targets
61
+
62
+ def get_feature_names(self, features):
63
+ targets = self._targets(features)
64
+ names = []
65
+ for method in self.methods:
66
+ for tissue in targets:
67
+ if method == "bone" and tissue == "Bone":
68
+ continue
69
+ names.append(f"normalization_{method}_{tissue}_{size_name(self.mode)}")
70
+ if self.include_composite:
71
+ names.append(f"normalized_composite_{method}_{tissue}_{size_name(self.mode)}Mean")
72
+ return names
73
+
74
+ def get_units(self, features):
75
+ units = {"height": f"{size_unit(self.mode)}/m²", "weight": f"{size_unit(self.mode)}/kg",
76
+ "bsa": f"{size_unit(self.mode)}/m²", "bone": "1"}
77
+ result = {}
78
+ for key in self.get_feature_names(features):
79
+ if key.startswith("normalized_composite_"):
80
+ method = key.split("_")[2]
81
+ result[key] = "HU" if method == "bone" else units[method].replace("/", "·HU/")
82
+ else:
83
+ result[key] = units[key.split("_")[1]]
84
+ return result
85
+
86
+ def execute(self, features, height_cm=None, weight_kg=None, bsa_m2=None):
87
+ """返回校正指数,可同时附带指数×Mean;不修改原始特征或读取影像。"""
88
+ self.last_diagnostics = {}
89
+ targets = self._targets(features)
90
+ denominator = {}
91
+ bsa_source = None
92
+ if "height" in self.methods:
93
+ denominator["height"] = (positive_number(height_cm, "height_cm") / 100) ** 2
94
+ if "weight" in self.methods:
95
+ denominator["weight"] = positive_number(weight_kg, "weight_kg")
96
+ if "bsa" in self.methods:
97
+ if bsa_m2 is None:
98
+ denominator["bsa"] = math.sqrt(positive_number(height_cm, "height_cm") *
99
+ positive_number(weight_kg, "weight_kg") / 3600)
100
+ bsa_source = "Mosteller"
101
+ else:
102
+ denominator["bsa"] = positive_number(bsa_m2, "bsa_m2")
103
+ bsa_source = "provided"
104
+ if "bone" in self.methods and any(t != "Bone" for t in targets):
105
+ bone_key = morphology_key("Bone", self.mode)
106
+ if bone_key not in features:
107
+ raise ValueError(f"骨校正需要先提取{bone_key};骨必须与目标组织来自相同范围")
108
+ denominator["bone"] = float(features[bone_key])
109
+ output = {}
110
+ for method in self.methods:
111
+ for tissue in targets:
112
+ if method == "bone" and tissue == "Bone":
113
+ continue
114
+ key = f"normalization_{method}_{tissue}_{size_name(self.mode)}"
115
+ numerator = float(features[morphology_key(tissue, self.mode)])
116
+ if math.isfinite(numerator) and numerator < 0:
117
+ raise ValueError("面积或体积不能为负数")
118
+ divisor = denominator[method]
119
+ output[key] = (numerator / divisor if math.isfinite(numerator) and
120
+ math.isfinite(divisor) and divisor > 0 else float("nan"))
121
+ if self.include_composite:
122
+ from .normalized_composite import NormalizedCompositeCalculator
123
+ calculator = NormalizedCompositeCalculator(self.mode, methods=self.methods, tissues=targets)
124
+ output.update(calculator.execute(output, features))
125
+ # 同一校正方式/组织的指数与复合项相邻,字典和CSV使用相同顺序。
126
+ output = {key: output[key] for key in self.get_feature_names(features)}
127
+ self.last_diagnostics = {"mode": self.mode, "methods": list(self.methods),
128
+ "height_power": 2, "bsa_source": bsa_source,
129
+ "include_composite": self.include_composite,
130
+ "denominators": denominator}
131
+ return output
@@ -0,0 +1,88 @@
1
+ """Optional corrected morphology indices multiplied by matching mean HU."""
2
+ import math
3
+ from collections.abc import Mapping
4
+
5
+ from .definitions import TISSUES, size_name, size_unit, validate_mode
6
+ from .normalization import FeatureNormalizer, METHODS
7
+
8
+
9
+ def get_normalized_composite_catalog(mode="3D"):
10
+ """List all 43 optional corrected composites and their input dependencies."""
11
+ kind, unit = size_name(mode), size_unit(mode)
12
+ units = {"height": f"{unit}·HU/m²", "weight": f"{unit}·HU/kg",
13
+ "bsa": f"{unit}·HU/m²", "bone": "HU"}
14
+ catalog = {}
15
+ for method in METHODS:
16
+ catalog[method] = {}
17
+ for tissue, (name, _) in TISSUES.items():
18
+ if method == "bone" and tissue == "Bone":
19
+ continue
20
+ feature = f"{tissue}_{kind}Mean"
21
+ catalog[method][feature] = {
22
+ "key": f"normalized_composite_{method}_{feature}",
23
+ "name": f"{name} {method}校正指数 × 平均HU",
24
+ "unit": units[method],
25
+ "index_key": f"normalization_{method}_{tissue}_{kind}",
26
+ "mean_key": f"firstorder_{tissue}_Mean",
27
+ }
28
+ return catalog
29
+
30
+
31
+ class NormalizedCompositeCalculator:
32
+ """Calculate only selected methods/tissues from previously computed results.
33
+
34
+ 输入必须来自同一病例和同一范围。2D两层模式先得到平均形态指标和
35
+ 平均HU,再使用已校正的平均形态指数乘平均HU,不平均逐层乘积。
36
+ """
37
+ def __init__(self, mode="3D", methods=METHODS, tissues=None):
38
+ self.mode = validate_mode(mode)
39
+ selection = FeatureNormalizer(mode, methods=methods, tissues=tissues)
40
+ self.methods, self.tissues = selection.methods, selection.tissues
41
+ self.catalog = get_normalized_composite_catalog(mode)
42
+ self.last_diagnostics = {}
43
+
44
+ def _selected(self, normalized_features):
45
+ if not isinstance(normalized_features, Mapping):
46
+ raise TypeError("normalized_features必须为已计算校正指数的字典")
47
+ selected = []
48
+ for method in self.methods:
49
+ matched = []
50
+ targets = self.tissues if self.tissues is not None else TISSUES
51
+ for tissue in targets:
52
+ if method == "bone" and tissue == "Bone":
53
+ continue
54
+ metadata = self.catalog[method][f"{tissue}_{size_name(self.mode)}Mean"]
55
+ if metadata["index_key"] in normalized_features:
56
+ matched.append(metadata)
57
+ elif self.tissues is not None:
58
+ raise ValueError(f"请先计算校正指数:{metadata['index_key']}")
59
+ if not matched and self.tissues is None:
60
+ raise ValueError(f"缺少{method}校正指数;请先提取形态特征并执行该校正,且匹配2D/3D模式")
61
+ selected.extend(matched)
62
+ return selected
63
+
64
+ def get_feature_names(self, normalized_features):
65
+ return [metadata["key"] for metadata in self._selected(normalized_features)]
66
+
67
+ def get_units(self, normalized_features):
68
+ return {metadata["key"]: metadata["unit"] for metadata in self._selected(normalized_features)}
69
+
70
+ def execute(self, normalized_features, original_features):
71
+ """Return selected corrected indices × mean HU; never read images or mutate inputs."""
72
+ self.last_diagnostics = {}
73
+ if not isinstance(original_features, Mapping):
74
+ raise TypeError("original_features必须为已提取基础特征的字典")
75
+ selected = self._selected(normalized_features)
76
+ output = {}
77
+ for metadata in selected:
78
+ if metadata["mean_key"] not in original_features:
79
+ raise ValueError(f"请先提取同组织同范围的平均HU:{metadata['mean_key']}")
80
+ index = float(normalized_features[metadata["index_key"]])
81
+ mean = float(original_features[metadata["mean_key"]])
82
+ if math.isfinite(index) and index < 0:
83
+ raise ValueError("校正面积或体积指数不能为负数")
84
+ value = index * mean
85
+ output[metadata["key"]] = value if math.isfinite(value) else float("nan")
86
+ self.last_diagnostics = {"mode": self.mode, "methods": list(self.methods),
87
+ "features": list(output), "aggregation": "corrected_index_times_mean_hu"}
88
+ return output
@@ -0,0 +1,216 @@
1
+ Metadata-Version: 2.4
2
+ Name: ctbodycomp
3
+ Version: 0.1.1
4
+ Summary: Selectable 2D and 3D CT body composition features with separate normalization
5
+ Keywords: CT,body-composition,medical-imaging,feature-extraction
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Operating System :: OS Independent
8
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: numpy>=1.22
12
+ Requires-Dist: nibabel>=5
13
+ Requires-Dist: pandas>=2
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest>=7; extra == "test"
16
+
17
+ # ctbodycomp
18
+
19
+ CT体成分2D/3D特征提取与独立校正。支持按类别或单个名称选择基础特征,以及独立的身高、体重、BSA和骨参照校正。
20
+
21
+ ## 安装
22
+
23
+ 在本项目目录运行`python -m pip install .`。开发时使用`python -m pip install -e .`。
24
+ 支持Python 3.10及以上,依赖numpy、nibabel、pandas;测试需要pytest。
25
+
26
+ ## 四类基础特征
27
+
28
+ | 类别 | 代码 | 数量 |
29
+ |---|---|---:|
30
+ | 形态:面积或体积 | morphometry | 11 |
31
+ | 一阶统计:Mean、SD、P10、P50、P90、IQR | firstorder | 66 |
32
+ | 组成与分布:组织比值或占比,百分比 | composition | 12 |
33
+ | 复合:面积或体积×平均HU | composite | 11 |
34
+ | 合计 | | 100 |
35
+
36
+ 旧脚本的8项骨参照比和8项骨参照比×HU不再混入基础输出。
37
+
38
+ 组织代码:SM骨骼肌(1)、SF皮下脂肪(4)、IMF肌间脂肪(2)、VF其余内脏脂肪(3)、PVAT血管周围脂肪(6)、EAT心外膜脂肪(7)、AorticPVAT主动脉周围脂肪(8)、Bone骨(5)、CVF(6/7/8)、VF_total(3/6/7/8)、TF(2/3/4/6/7/8)。基础标签互斥。
39
+
40
+ ## 先提取第一类,再校正
41
+
42
+ ```python
43
+ from ctbodycomp import BodyCompositionExtractor, FeatureNormalizer
44
+
45
+ extractor = BodyCompositionExtractor(
46
+ mode="3D",
47
+ features={"morphometry": ["SM_Volume", "SF_Volume", "Bone_Volume"]},
48
+ )
49
+ raw = extractor.execute(image="ct.nii.gz", mask="bc_mask.nii.gz")
50
+
51
+ # 只校正指定组织;原始结果与校正结果是两个独立字典。
52
+ normalizer = FeatureNormalizer(
53
+ mode="3D", methods=["height", "weight", "bsa", "bone"], tissues=["SM", "SF"]
54
+ )
55
+ corrected = normalizer.execute(raw, height_cm=170, weight_kg=65)
56
+ ```
57
+
58
+ 校正器根据第一类形态指标计算校正指数;开启`include_composite=True`时,同时读取一阶Mean并计算校正指数×Mean。不读取影像,不对HU本身或组成比值二次校正。可以把提取结果保存后,换一组校正方式再次计算。骨校正需提前提取同一范围的`Bone_Volume`或`Bone_Area`。
59
+
60
+ | 方法 | 公式 | 2D单位 | 3D单位 |
61
+ |---|---|---|---|
62
+ | height | 原始面积/体积 ÷ 身高(m)² | cm²/m² | cm³/m² |
63
+ | weight | 原始面积/体积 ÷ 体重(kg) | cm²/kg | cm³/kg |
64
+ | bsa | 原始面积/体积 ÷ BSA(m²) | cm²/m² | cm³/m² |
65
+ | bone | 原始面积/体积 ÷ 同范围骨面积/体积 | 无量纲 | 无量纲 |
66
+
67
+ 2D和3D都使用身高²。默认BSA使用Mosteller公式:`sqrt(height_cm * weight_kg / 3600)`,也可通过`bsa_m2`直接提供已知BSA;提供值优先,来源记录在`normalizer.last_diagnostics`。Mosteller公式参考:[原始论文](https://doi.org/10.1056/NEJM198710223171717)。
68
+
69
+ 身高、体重、BSA需为有限正数;只选择骨校正时不需要身高体重。缺少所选方法需要的参数会抛出明确错误。骨量为0时骨校正输出NaN。CSV校正失败时保留原始特征及失败原因。
70
+
71
+ 若提取全部11个形态特征且启用全部校正,height、weight、bsa各11项,bone对10个非骨组织输出,共43项。与100项基础特征分开存储。骨校正包括CVF和TF两个聚合组织,不输出恒为1的Bone/Bone。实际输出量由所选组织、特征和校正方式决定。
72
+
73
+ ## 可选:校正指数 × 平均HU
74
+
75
+ 从0.1.1开始,校正指数与校正后的复合特征统一由`FeatureNormalizer`管理。使用`include_composite=True`,同一次校正返回“指数”和“指数×同组织平均HU”,每一对相邻排列。两者均属于校正模块,与四类基础特征分开。默认不开启校正复合项。
76
+
77
+ ```python
78
+ from ctbodycomp import BodyCompositionExtractor, FeatureNormalizer
79
+
80
+ extractor = BodyCompositionExtractor(
81
+ mode="3D",
82
+ features={
83
+ "morphometry": ["SM_Volume", "Bone_Volume"],
84
+ "firstorder": ["SM_Mean"],
85
+ },
86
+ )
87
+ raw = extractor.execute("ct.nii.gz", "bc_mask.nii.gz")
88
+ normalizer = FeatureNormalizer(
89
+ mode="3D", methods=["height", "bone"], tissues=["SM"],
90
+ include_composite=True,
91
+ )
92
+ corrected = normalizer.execute(raw, height_cm=170)
93
+ # normalization_height_SM_Volume
94
+ # normalized_composite_height_SM_VolumeMean
95
+ # normalization_bone_SM_Volume
96
+ # normalized_composite_bone_SM_VolumeMean
97
+ units = normalizer.get_units(raw)
98
+ ```
99
+
100
+ 2D将模式改为`2D`,将第一类特征的`Volume`改为`Area`,输出为`normalized_composite_height_SM_AreaMean`等。
101
+
102
+ | 方法 | 公式,μ为同组织平均HU | 2D单位 | 3D单位 | 全部组织数量 |
103
+ |---|---|---|---|---:|
104
+ | height | (面积/体积 ÷ 身高(m)²) × μ | cm²·HU/m² | cm³·HU/m² | 11 |
105
+ | weight | (面积/体积 ÷ 体重(kg)) × μ | cm²·HU/kg | cm³·HU/kg | 11 |
106
+ | bsa | (面积/体积 ÷ BSA(m²)) × μ | cm²·HU/m² | cm³·HU/m² | 11 |
107
+ | bone | (面积/体积 ÷ 同范围骨面积/体积) × μ | HU | HU | 10 |
108
+
109
+ 共43项可选校正复合特征,不输出Bone/Bone×HU。每种模式最多有100项基础特征+43项校正指数+43项校正复合特征=186项。2D和3D是两种模式,不合并计数。负平均HU保留符号;指数或均值为NaN/无穷时结果为NaN,不解释为实际组织质量。
110
+
111
+ 通过`methods`选择校正方式,通过`tissues`选择组织;`tissues=None`从输入已有的形态指标中选择组织。开启复合项需要同时提取目标组织的Mean。可用`get_normalized_composite_catalog("3D")`查询完整复合名称、单位、指数键和均值键。
112
+
113
+ 对于已经保存的校正指数字典,仍支持`NormalizedCompositeCalculator(mode, methods, tissues).execute(indices, raw)`单独补算复合项;两个字典需来自同一病例、同一范围。
114
+
115
+ **2D两层的计算顺序**:先将两层面积与两层平均HU分别等权平均,面积平均值进行校正,再乘HU平均值。因此结果是“校正后的平均面积指数 × 平均HU”,与基础复合类的“逐层面积×HU后平均”定义不同。例如两层面积0.06、0.18 cm²,均值10、30 HU,身高1 m:校正复合为0.12×20=2.4 cm²·HU/m²,而基础复合为(0.06×10+0.18×30)/2=3 cm²·HU。
116
+
117
+ ## 选择类别或单项
118
+
119
+ ```python
120
+ from ctbodycomp import BodyCompositionExtractor, get_feature_catalog
121
+
122
+ extractor = BodyCompositionExtractor(mode="2D", spine_label=7)
123
+ extractor.disableAllFeatures()
124
+ extractor.enableFeaturesByName(
125
+ morphometry=["SM_Area", "Bone_Area"],
126
+ firstorder=["SM_Mean", "SM_P50"],
127
+ composition=["MFF"],
128
+ )
129
+ raw = extractor.execute("ct.nii.gz", "bc_mask.nii.gz", spine_mask="spine.nii.gz")
130
+
131
+ # 按整个类别启用:
132
+ extractor.enableFeatureClassByName("firstorder")
133
+ # 查询当前模式下全部可用名称、英文输出键和单位:
134
+ catalog = get_feature_catalog("2D")
135
+ ```
136
+
137
+ 构造参数`features=None`默认启用全部四类;`features={}`不启用任何特征。映射中未列出的类别不提取,类别中的`[]`或`None`表示该类全部。`enableFeaturesByName`替换指定类别的选择,其他已启用类别保持不变,因此要只选择几项时先调用`disableAllFeatures()`。支持全部启用`enableAllFeatures()`和单类别关闭`enableFeatureClassByName("firstorder", False)`。
138
+
139
+ 只输出选择的特征,内部依赖按需计算。例如只选Mean不计算分位数,只选形态不计算HU统计;只选一个组成比值时也不输出其内部依赖的组织面积/体积。
140
+
141
+ ## 输入与选层
142
+
143
+ - 必需:CT与BC mask。接受文件路径或nibabel图像对象,图像需3D且空间匹配,尺寸以mm解释;不重采样。
144
+ - 可选:Spine mask。不提供时3D计算整幅CT,2D在整幅CT全部层中选中间层。
145
+ - 2D有Spine mask时需`spine_label`,例如7或8。仅对该标签实际出现的切片排序,奇数取中间一层,偶数取中间两层。
146
+ - 3D有Spine mask时默认范围`spine_range=(7, 18)`,可配置,取首末层之间的完整范围。新库依据affine识别上下方向,支持数组轴置换。
147
+ - 每个选中切片的组织ROI来自完整BC mask平面,Spine mask仅定位。斜位影像保持原切片平面。
148
+ - 2D两层基础特征先逐层计算再等权平均。独立校正使用这个平均后的形态值;骨校正因此是“平均组织面积/平均骨面积”,不同于旧脚本的“两层骨比值平均”。
149
+ - SD使用ddof=0;分位数为线性插值;非有限CT值不进入HU统计;空组织的面积/体积为0,HU统计为NaN;任一层某基础指标NaN,则两层平均也为NaN。
150
+
151
+ 诊断信息在`extractor.last_diagnostics`,与纯数值特征分开。无Spine mask请传`None`;填写了错误文件路径不会自动退回全图计算。
152
+
153
+ ## CSV分两步运行
154
+
155
+ 新库的CSV使用有意义的列名(区分大小写),不同于旧脚本按列位置读取:
156
+
157
+ ```csv
158
+ ID,Image,Mask,SpineMask,Height_cm,Weight_kg
159
+ 001,ct.nii.gz,bc_mask.nii.gz,,170,65
160
+ ```
161
+
162
+ 必需`Image`、`Mask`,其他列可选;`SpineMask`可省略或留空。相对路径相对于输入CSV目录。`Height_cm`、`Weight_kg`、可选`BSA_m2`由校正步骤读取,逐病例值优先于命令行公共值。
163
+
164
+ ```bash
165
+ # 查询特征名
166
+ ctbodycomp features --mode 3D
167
+
168
+ # 仅提取肌肉、皮下脂肪和骨的体积
169
+ ctbodycomp extract cases.csv raw.csv --mode 3D --config examples/morphometry_3d.json
170
+
171
+ # 对提取结果进行校正,原始列也保留在输出中
172
+ ctbodycomp normalize raw.csv corrected.csv --mode 3D --methods height weight bsa bone --tissues SM SF
173
+
174
+ # 2D单标签、部分特征
175
+ ctbodycomp extract cases.csv raw_2d.csv --mode 2D --label 7 --config examples/selected_2d.json
176
+ ctbodycomp normalize raw_2d.csv corrected_2d.csv --mode 2D --methods bone --tissues SM
177
+ ```
178
+
179
+ 需要校正复合时,提取配置必须包含目标组织的`firstorder_*_Mean`。在同一校正步骤增加`--include-composite`即可:
180
+
181
+ ```bash
182
+ # 示例配置包含肌肉、骨体积和肌肉Mean
183
+ ctbodycomp extract cases.csv raw.csv --mode 3D --config examples/normalized_composite_3d.json
184
+ ctbodycomp normalize raw.csv corrected.csv --mode 3D --methods height weight bsa bone --tissues SM --include-composite
185
+
186
+ # 查询校正复合特征及所需输入
187
+ ctbodycomp features --mode 3D --normalized-composite
188
+ ```
189
+
190
+ 校正CSV保留基础特征,按“校正指数、对应复合项”的顺序追加列,使用统一的`normalization_status`、`normalization_error`记录结果。缺少所需Mean列时记录失败;Mean数值为空时对应复合项为NaN。影像不存在也不影响已成功提取结果的后续计算。
191
+
192
+ 如需对已有校正CSV补算复合项,也保留单独命令:`ctbodycomp normalized-composite corrected.csv products.csv --mode 3D --methods height bone --tissues SM`。
193
+
194
+ 配置JSON直接写“类别→特征名称列表”。命令返回0表示成功,1表示输出中存在失败病例,参数错误返回2。输出目录需存在,输出不能覆盖输入文件;重复输出路径会替换先前结果。
195
+
196
+ ## 完整使用示例
197
+
198
+ 源码包中的 `examples/reference_usage/README.md` 提供五种完整提取示例:2D T12、2D无脊柱、3D T12、3D T1–T12、3D无脊柱,以及独立校正示例。每种情况同时提供可运行的Python脚本和Markdown教程,不依赖额外本地辅助脚本。
199
+
200
+ 输入使用 `cases.example.csv` 和 `cases_without_spine.example.csv` 通用模板。模板不包含实际病例数据,使用前替换为自己的影像路径。示例计算结果写入示例目录下的 `results/`,不会纳入发布包。
201
+
202
+ ## 测试和构建
203
+
204
+ 在项目根目录运行:
205
+
206
+ ```bash
207
+ python -m pip install -e '.[test]'
208
+ python -m pytest -q -p no:cacheprovider
209
+ python -m pip install --upgrade build twine
210
+ python -m build
211
+ python -m twine check dist/ctbodycomp-0.1.1-py3-none-any.whl dist/ctbodycomp-0.1.1.tar.gz
212
+ ```
213
+
214
+ wheel只包含库模块和安装元数据;源码包另外包含测试与使用教程。虚拟环境、缓存、本地影像、实际病例表和计算结果不纳入发布文件。`PUBLISHING.md` 仅保留在开发目录供维护者参考,不随发布包分发。
215
+
216
+ 作者、邮箱和许可证字段当前未设置,可在后续版本补充。本项目目前没有声明MIT或Apache-2.0授权。
@@ -0,0 +1,14 @@
1
+ ctbodycomp/__init__.py,sha256=lR8Ntr4m_DHh4N6qsgy6mIU1N8lcHZuLRFHY5lx-tFE,499
2
+ ctbodycomp/batch.py,sha256=9WRBrA14a7I28ckkvYm4O0NYugr3sGnXuUlFpM05rpA,7523
3
+ ctbodycomp/cli.py,sha256=6JwOWCqwuMSyXVDWgvqx7ZxSVDkecPYkFf0JS2iPrvI,4342
4
+ ctbodycomp/definitions.py,sha256=43v-McsY68G0nU3dkomWHyztw03Cjx9VtUr3W86aDqQ,2891
5
+ ctbodycomp/extractor.py,sha256=2XPbhsji0Fz_sibL5PPniZLmF5UQEeKOUoMcyZNIWUE,3987
6
+ ctbodycomp/features.py,sha256=t21z2ANUO_InoiAX4km9_GpE6658wRywwWgTH_aYF2k,2849
7
+ ctbodycomp/image.py,sha256=BzaWu_sADuzqinJoq2rYnhb5glPT_-YOHt9psK8TU74,3226
8
+ ctbodycomp/normalization.py,sha256=LGZUs1tflL9rasvz6aNCY3-64Zz4cgqcA_YhNSsETzg,6620
9
+ ctbodycomp/normalized_composite.py,sha256=kYMRLPFgGezqvLbJwAWBNAkF1ibCKkCtsxUaT__eL3M,4411
10
+ ctbodycomp-0.1.1.dist-info/METADATA,sha256=-B0kCdNxQGcG8PC0kZDyQg-n1qnrdZem1XiZ8XRZSqQ,12547
11
+ ctbodycomp-0.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
+ ctbodycomp-0.1.1.dist-info/entry_points.txt,sha256=0uH_Rm57_IGGuWiGUeSKvvJH-SKfl94vq7WX0_CaM9o,51
13
+ ctbodycomp-0.1.1.dist-info/top_level.txt,sha256=7zA4lEw7s2faqmFcEVp4IexE1WdHmgQF5SMUguOerSQ,11
14
+ ctbodycomp-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ctbodycomp = ctbodycomp.cli:main
@@ -0,0 +1 @@
1
+ ctbodycomp