spine-segment 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,16 @@
1
+ from spine_segment.api import segment_file, segment_files
2
+ from spine_segment.backend import (
3
+ SegmentationResult,
4
+ SpineSegmentBackend,
5
+ SpineSegmentBackendError,
6
+ load_backend,
7
+ )
8
+
9
+ __all__ = [
10
+ "SegmentationResult",
11
+ "SpineSegmentBackend",
12
+ "SpineSegmentBackendError",
13
+ "load_backend",
14
+ "segment_file",
15
+ "segment_files",
16
+ ]
spine_segment/api.py ADDED
@@ -0,0 +1,214 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ import SimpleITK as sitk
10
+
11
+ from spine_segment.backend import SpineSegmentBackend, SpineSegmentBackendError
12
+ from spine_segment.compartments import CortTrabConfig, derive_cort_trab_labels
13
+ from spine_segment.device import resolve_device
14
+ from spine_segment.io import read_image, write_image
15
+ from spine_segment.outputs import OutputPaths, build_output_paths
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class SegmentRunResult:
20
+ input_path: Path
21
+ output_paths: OutputPaths
22
+ device: str
23
+ metadata: dict[str, Any]
24
+
25
+
26
+ def segment_file(
27
+ input_path: str | Path,
28
+ output_dir: str | Path,
29
+ *,
30
+ backend: SpineSegmentBackend,
31
+ device: str = "auto",
32
+ overwrite: bool = False,
33
+ cort_trab_config: CortTrabConfig | None = None,
34
+ level_only: bool = False,
35
+ localization_only: bool = False,
36
+ ) -> SegmentRunResult:
37
+ source_path = Path(input_path)
38
+ image = read_image(source_path, pixel_type=sitk.sitkFloat32)
39
+ selected_device = resolve_device(device)
40
+ output_paths = build_output_paths(source_path, output_dir)
41
+ if localization_only:
42
+ localization = backend.localize(
43
+ image=image,
44
+ source_path=source_path,
45
+ device=selected_device,
46
+ )
47
+ centroids = localization.centroids
48
+ metadata = dict(localization.metadata or {})
49
+ metadata["device"] = selected_device
50
+ metadata["localization_only"] = True
51
+ write_json(
52
+ {
53
+ "input": str(source_path),
54
+ "coordinate_system": "voxel_xyz",
55
+ "centroids": centroids,
56
+ "metadata": metadata,
57
+ },
58
+ output_paths.centroids,
59
+ overwrite=overwrite,
60
+ )
61
+ return SegmentRunResult(
62
+ input_path=source_path,
63
+ output_paths=output_paths,
64
+ device=selected_device,
65
+ metadata=metadata,
66
+ )
67
+
68
+ result = backend.segment(
69
+ image=image,
70
+ source_path=source_path,
71
+ device=selected_device,
72
+ level_only=level_only,
73
+ )
74
+
75
+ write_image(result.vertebral_level, output_paths.vertebral_level, overwrite=overwrite)
76
+ centroids = centroids_from_labelmap(result.vertebral_level)
77
+ if level_only:
78
+ metadata = dict(result.metadata or {})
79
+ metadata["device"] = selected_device
80
+ metadata["level_only"] = True
81
+ write_centroids_json(
82
+ input_path=source_path,
83
+ centroids=centroids,
84
+ metadata=metadata,
85
+ path=output_paths.centroids,
86
+ overwrite=overwrite,
87
+ )
88
+ return SegmentRunResult(
89
+ input_path=source_path,
90
+ output_paths=output_paths,
91
+ device=selected_device,
92
+ metadata=metadata,
93
+ )
94
+
95
+ if result.process_body is None:
96
+ raise SpineSegmentBackendError(
97
+ "Standalone spine backend did not return a process/body labelmap."
98
+ )
99
+
100
+ cort_trab = result.cort_trab or derive_cort_trab_labels(
101
+ image=image,
102
+ process_body=result.process_body,
103
+ vertebral_level=result.vertebral_level,
104
+ config=cort_trab_config,
105
+ )
106
+
107
+ write_image(result.process_body, output_paths.process_body, overwrite=overwrite)
108
+ write_image(cort_trab, output_paths.cort_trab, overwrite=overwrite)
109
+
110
+ metadata = dict(result.metadata or {})
111
+ metadata["device"] = selected_device
112
+ write_centroids_json(
113
+ input_path=source_path,
114
+ centroids=centroids,
115
+ metadata=metadata,
116
+ path=output_paths.centroids,
117
+ overwrite=overwrite,
118
+ )
119
+ return SegmentRunResult(
120
+ input_path=source_path,
121
+ output_paths=output_paths,
122
+ device=selected_device,
123
+ metadata=metadata,
124
+ )
125
+
126
+
127
+ def segment_files(
128
+ input_paths: list[str | Path],
129
+ output_dir: str | Path,
130
+ *,
131
+ backend: SpineSegmentBackend,
132
+ device: str = "auto",
133
+ overwrite: bool = False,
134
+ cort_trab_config: CortTrabConfig | None = None,
135
+ level_only: bool = False,
136
+ localization_only: bool = False,
137
+ ) -> list[SegmentRunResult]:
138
+ return [
139
+ segment_file(
140
+ path,
141
+ output_dir,
142
+ backend=backend,
143
+ device=device,
144
+ overwrite=overwrite,
145
+ cort_trab_config=cort_trab_config,
146
+ level_only=level_only,
147
+ localization_only=localization_only,
148
+ )
149
+ for path in input_paths
150
+ ]
151
+
152
+
153
+ def write_json(payload: dict[str, Any], path: str | Path, *, overwrite: bool = False) -> Path:
154
+ output_path = Path(path)
155
+ output_path.parent.mkdir(parents=True, exist_ok=True)
156
+ if output_path.exists() and not overwrite:
157
+ raise FileExistsError(
158
+ f"Refusing to overwrite existing output without --overwrite: {output_path}"
159
+ )
160
+ output_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
161
+ return output_path
162
+
163
+
164
+ def write_centroids_json(
165
+ *,
166
+ input_path: Path,
167
+ centroids: dict[str, dict[str, Any]],
168
+ metadata: dict[str, Any],
169
+ path: str | Path,
170
+ overwrite: bool,
171
+ ) -> Path:
172
+ return write_json(
173
+ {
174
+ "input": str(input_path),
175
+ "coordinate_system": "voxel_xyz",
176
+ "centroids": centroids,
177
+ "metadata": metadata,
178
+ },
179
+ path,
180
+ overwrite=overwrite,
181
+ )
182
+
183
+
184
+ def centroids_from_labelmap(labelmap: sitk.Image) -> dict[str, dict[str, Any]]:
185
+ labels = sitk.GetArrayFromImage(labelmap)
186
+ centroids: dict[str, dict[str, Any]] = {}
187
+ for raw_label in sorted(int(value) for value in np.unique(labels) if int(value) != 0):
188
+ coords_zyx = np.argwhere(labels == raw_label)
189
+ if coords_zyx.size == 0:
190
+ continue
191
+ centroid_zyx = coords_zyx.mean(axis=0)
192
+ voxel_xyz = (
193
+ float(centroid_zyx[2]),
194
+ float(centroid_zyx[1]),
195
+ float(centroid_zyx[0]),
196
+ )
197
+ physical_xyz = labelmap.TransformContinuousIndexToPhysicalPoint(voxel_xyz)
198
+ centroids[str(raw_label)] = {
199
+ "label": raw_label,
200
+ "index": _verse_index_from_label(raw_label),
201
+ "voxel_xyz": [float(value) for value in voxel_xyz],
202
+ "physical_xyz": [float(value) for value in physical_xyz],
203
+ "voxel_count": int(coords_zyx.shape[0]),
204
+ "source": "segmentation",
205
+ }
206
+ return centroids
207
+
208
+
209
+ def _verse_index_from_label(label: int) -> int | None:
210
+ if 1 <= int(label) <= 25:
211
+ return int(label) - 1
212
+ if int(label) == 28:
213
+ return 25
214
+ return None
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from importlib import import_module
5
+ from pathlib import Path
6
+ from typing import Any, Protocol, runtime_checkable
7
+
8
+ import SimpleITK as sitk
9
+
10
+
11
+ class SpineSegmentBackendError(RuntimeError):
12
+ """Raised when the standalone spine backend cannot run."""
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class SegmentationResult:
17
+ vertebral_level: sitk.Image
18
+ process_body: sitk.Image | None = None
19
+ cort_trab: sitk.Image | None = None
20
+ metadata: dict[str, Any] | None = None
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class LocalizationResult:
25
+ centroids: dict[str, dict[str, Any]]
26
+ metadata: dict[str, Any] | None = None
27
+
28
+
29
+ @runtime_checkable
30
+ class SpineSegmentBackend(Protocol):
31
+ def segment(
32
+ self,
33
+ *,
34
+ image: sitk.Image,
35
+ source_path: Path,
36
+ device: str,
37
+ level_only: bool = False,
38
+ ) -> SegmentationResult: ...
39
+
40
+ def localize(
41
+ self,
42
+ *,
43
+ image: sitk.Image,
44
+ source_path: Path,
45
+ device: str,
46
+ ) -> LocalizationResult: ...
47
+
48
+
49
+ class UnavailableBackend:
50
+ def segment(
51
+ self,
52
+ *,
53
+ image: sitk.Image,
54
+ source_path: Path,
55
+ device: str,
56
+ level_only: bool = False,
57
+ ) -> SegmentationResult:
58
+ raise SpineSegmentBackendError(
59
+ "No standalone spine backend is wired yet. "
60
+ "Provide --backend module_path:factory or set SPINE_SEGMENT_BACKEND "
61
+ "to the PyTorch runtime entrypoint for the vertebral-level and "
62
+ "process/body models."
63
+ )
64
+
65
+ def localize(
66
+ self,
67
+ *,
68
+ image: sitk.Image,
69
+ source_path: Path,
70
+ device: str,
71
+ ) -> LocalizationResult:
72
+ raise SpineSegmentBackendError(
73
+ "No standalone spine backend is wired yet. "
74
+ "Provide --backend module_path:factory or set SPINE_SEGMENT_BACKEND."
75
+ )
76
+
77
+
78
+ def load_backend(spec: str | None) -> SpineSegmentBackend:
79
+ if spec is None or not str(spec).strip():
80
+ return UnavailableBackend()
81
+
82
+ module_name, separator, attr_name = str(spec).partition(":")
83
+ module = import_module(module_name)
84
+ target_name = attr_name if separator else "create_backend"
85
+ target = getattr(module, target_name)
86
+ instance = target() if callable(target) else target
87
+
88
+ if not isinstance(instance, SpineSegmentBackend) and not hasattr(instance, "segment"):
89
+ raise SpineSegmentBackendError(
90
+ f"Backend '{spec}' does not expose a segment(...) method."
91
+ )
92
+ return instance
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import SimpleITK as sitk
6
+
7
+ from spine_segment.backend import SegmentationResult, SpineSegmentBackend
8
+
9
+
10
+ class ExampleBackend(SpineSegmentBackend):
11
+ """
12
+ Replace this class with the real PyTorch runtime.
13
+
14
+ The backend must return:
15
+ - vertebral_level: integer vertebral labelmap in scan space
16
+ - process_body: integer process/body labelmap in scan space
17
+ - cort_trab: optional integer cort/trab labelmap in scan space
18
+ """
19
+
20
+ def segment(
21
+ self,
22
+ *,
23
+ image: sitk.Image,
24
+ source_path: Path,
25
+ device: str,
26
+ ) -> SegmentationResult:
27
+ raise NotImplementedError(
28
+ "Replace spine_segment.backend_template.ExampleBackend with the "
29
+ "PyTorch vertebral segmentation runtime."
30
+ )
31
+
32
+
33
+ def create_backend() -> ExampleBackend:
34
+ return ExampleBackend()
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import re
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class MappingRule:
9
+ pattern: re.Pattern[str]
10
+ replacement: str
11
+ tensor_kind: str
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ConversionCoverage:
16
+ converted_count: int
17
+ ignored_source_count: int
18
+ missing_target_keys: tuple[str, ...]
19
+ unmapped_source_names: tuple[str, ...]
20
+ shape_mismatches: tuple[dict[str, object], ...]
21
+
22
+
23
+ _VARIABLE_VALUE_SUFFIX = "/.ATTRIBUTES/VARIABLE_VALUE"
24
+ _IGNORED_EXACT_NAMES = {
25
+ "_CHECKPOINTABLE_OBJECT_GRAPH",
26
+ "save_counter",
27
+ "sigmas",
28
+ }
29
+ _IGNORED_PREFIXES = (
30
+ "optimizer/",
31
+ "save_counter/",
32
+ )
33
+ _IGNORED_SUBSTRINGS = (
34
+ "/.OPTIMIZER_SLOT/",
35
+ )
36
+
37
+ _UNET_BLOCK_RULE = re.compile(
38
+ r"^model/(?P<prefix>unet|scnet_local|scnet_spatial)/"
39
+ r"(?P<block>contracting_layers|expanding_layers)/level(?P<level>\d+)/"
40
+ r"layers/(?P<layer_index>\d+)/(?P<var>kernel|bias)$"
41
+ )
42
+ _PREDICTION_RULE = re.compile(
43
+ r"^model/prediction/layers/0/(?P<var>kernel|bias)$"
44
+ )
45
+ _LOCAL_HEATMAP_RULE = re.compile(
46
+ r"^model/local_heatmaps/layers/0/(?P<var>kernel|bias)$"
47
+ )
48
+ _SPATIAL_HEATMAP_RULE = re.compile(
49
+ r"^model/spatial_heatmaps/(?P<var>kernel|bias)$"
50
+ )
51
+
52
+
53
+ def normalize_tf_variable_name(variable_name: str) -> str:
54
+ normalized = str(variable_name).strip()
55
+ if normalized.endswith(_VARIABLE_VALUE_SUFFIX):
56
+ normalized = normalized[: -len(_VARIABLE_VALUE_SUFFIX)]
57
+ return normalized
58
+
59
+
60
+ def should_ignore_tf_variable(variable_name: str) -> bool:
61
+ normalized = normalize_tf_variable_name(variable_name)
62
+ if normalized in _IGNORED_EXACT_NAMES:
63
+ return True
64
+ if any(token in normalized for token in _IGNORED_SUBSTRINGS):
65
+ return True
66
+ return normalized.startswith(_IGNORED_PREFIXES)
67
+
68
+
69
+ def map_tf_variable_to_torch_key(variable_name: str) -> str | None:
70
+ if should_ignore_tf_variable(variable_name):
71
+ return None
72
+
73
+ normalized = normalize_tf_variable_name(variable_name)
74
+ match = _UNET_BLOCK_RULE.match(normalized)
75
+ if match:
76
+ prefix = match.group("prefix")
77
+ block = match.group("block")
78
+ level = int(match.group("level"))
79
+ layer_index = int(match.group("layer_index"))
80
+ variable = "weight" if match.group("var") == "kernel" else "bias"
81
+ conv_index = layer_index // 2
82
+ pytorch_conv_index = conv_index * 2
83
+
84
+ if prefix == "unet":
85
+ base = "unet"
86
+ elif prefix == "scnet_local":
87
+ base = "scnet_local"
88
+ elif prefix == "scnet_spatial":
89
+ base = "scnet_spatial"
90
+ else:
91
+ return None
92
+
93
+ block_name = "contracting" if block == "contracting_layers" else "expanding"
94
+ return f"{base}.{block_name}.{level}.{pytorch_conv_index}.{variable}"
95
+
96
+ match = _PREDICTION_RULE.match(normalized)
97
+ if match:
98
+ variable = "weight" if match.group("var") == "kernel" else "bias"
99
+ return f"prediction.{variable}"
100
+
101
+ match = _LOCAL_HEATMAP_RULE.match(normalized)
102
+ if match:
103
+ variable = "weight" if match.group("var") == "kernel" else "bias"
104
+ return f"local_heatmaps.0.{variable}"
105
+
106
+ match = _SPATIAL_HEATMAP_RULE.match(normalized)
107
+ if match:
108
+ variable = "weight" if match.group("var") == "kernel" else "bias"
109
+ return f"spatial_heatmaps.{variable}"
110
+
111
+ return None
112
+
113
+
114
+ def needs_conv_kernel_transpose(variable_name: str) -> bool:
115
+ return normalize_tf_variable_name(variable_name).endswith("/kernel")
116
+
117
+
118
+ def check_conversion_coverage(
119
+ *,
120
+ converted_keys: set[str],
121
+ target_keys: set[str],
122
+ ignored_source_names: list[str],
123
+ unmapped_source_names: list[str],
124
+ shape_mismatches: list[dict[str, object]],
125
+ ) -> ConversionCoverage:
126
+ return ConversionCoverage(
127
+ converted_count=len(converted_keys),
128
+ ignored_source_count=len(ignored_source_names),
129
+ missing_target_keys=tuple(sorted(target_keys - converted_keys)),
130
+ unmapped_source_names=tuple(sorted(unmapped_source_names)),
131
+ shape_mismatches=tuple(shape_mismatches),
132
+ )
spine_segment/cli.py ADDED
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from spine_segment.api import segment_files
8
+ from spine_segment.backend import SpineSegmentBackendError, load_backend
9
+ from spine_segment.compartments import CortTrabConfig
10
+ from spine_segment.io import expand_input_paths
11
+ from spine_segment.native_torch_backend import create_backend as create_native_backend
12
+
13
+
14
+ def build_parser() -> argparse.ArgumentParser:
15
+ parser = argparse.ArgumentParser(
16
+ prog="spine-segment",
17
+ description="Standalone vertebral segmentation CLI.",
18
+ )
19
+ parser.add_argument("inputs", nargs="+", help="Input CT volumes (.nii or .nii.gz).")
20
+ parser.add_argument(
21
+ "--output",
22
+ required=True,
23
+ help="Directory that will receive outputs.",
24
+ )
25
+ parser.add_argument(
26
+ "--device",
27
+ default="auto",
28
+ choices=("auto", "cuda", "mps", "cpu"),
29
+ help="Runtime device preference.",
30
+ )
31
+ parser.add_argument(
32
+ "--backend",
33
+ default=os.environ.get("SPINE_SEGMENT_BACKEND", ""),
34
+ help="Backend factory in the form module_path:factory. "
35
+ "Defaults to the native PyTorch backend.",
36
+ )
37
+ parser.add_argument(
38
+ "--model-bundle",
39
+ default=os.environ.get("SPINE_SEGMENT_MODEL_BUNDLE", ""),
40
+ help="Path to a spine-segment model bundle. Defaults to "
41
+ "SPINE_SEGMENT_MODEL_BUNDLE, ./model-bundle, ./build/model-bundle-pytorch, "
42
+ "./build/model-bundle, or an automatically downloaded cached bundle.",
43
+ )
44
+ parser.add_argument(
45
+ "--no-model-download",
46
+ action="store_true",
47
+ default=os.environ.get("SPINE_SEGMENT_NO_DOWNLOAD", "").strip().lower()
48
+ not in ("", "0", "false", "no", "off"),
49
+ help="Disable automatic first-run model bundle download.",
50
+ )
51
+ parser.add_argument(
52
+ "--overwrite",
53
+ action="store_true",
54
+ help="Overwrite existing outputs.",
55
+ )
56
+ parser.add_argument(
57
+ "--level-only",
58
+ action="store_true",
59
+ help="Write *_vertebral-level.nii.gz and *_centroids.json; skip process/body plus cort/trab stages.",
60
+ )
61
+ parser.add_argument(
62
+ "--localization-only",
63
+ action="store_true",
64
+ help="Write only *_centroids.json with detected vertebral centroids in original scan voxel coordinates.",
65
+ )
66
+ parser.add_argument(
67
+ "--vertebra-batch-size",
68
+ type=int,
69
+ default=1,
70
+ help="Number of MDAT vertebra segmentation patches to run per PyTorch batch.",
71
+ )
72
+ parser.add_argument(
73
+ "--process-body-batch-size",
74
+ type=int,
75
+ default=4,
76
+ help="Number of process/body relabel crops to run per PyTorch batch.",
77
+ )
78
+ parser.add_argument(
79
+ "--cortical-threshold-hu",
80
+ type=float,
81
+ default=500.0,
82
+ help="HU threshold used for connected cortical assignment within the outer shell.",
83
+ )
84
+ parser.add_argument(
85
+ "--cort-trab-isotropic-spacing-mm",
86
+ type=float,
87
+ default=1.0,
88
+ help="Isotropic spacing used internally for cort/trab shell generation.",
89
+ )
90
+ parser.add_argument(
91
+ "--cortical-max-thickness-mm",
92
+ type=float,
93
+ default=6.0,
94
+ help="Maximum distance from the vertebral surface considered for threshold-based cortical assignment.",
95
+ )
96
+ return parser
97
+
98
+
99
+ def main(argv: list[str] | None = None) -> int:
100
+ parser = build_parser()
101
+ args = parser.parse_args(argv)
102
+ inputs = expand_input_paths(list(args.inputs))
103
+ if not inputs:
104
+ parser.error("No input images were resolved.")
105
+
106
+ missing = [path for path in inputs if not path.exists()]
107
+ if missing:
108
+ parser.error(f"Input does not exist: {missing[0]}")
109
+
110
+ if args.backend:
111
+ backend = load_backend(args.backend)
112
+ else:
113
+ backend = create_native_backend(
114
+ args.model_bundle or "./build/model-bundle",
115
+ allow_download=not bool(args.no_model_download),
116
+ )
117
+ if hasattr(backend, "vertebra_segmentation_batch_size"):
118
+ backend.vertebra_segmentation_batch_size = max(1, int(args.vertebra_batch_size))
119
+ if hasattr(backend, "process_body_batch_size"):
120
+ backend.process_body_batch_size = max(1, int(args.process_body_batch_size))
121
+ cort_trab_config = CortTrabConfig(
122
+ cortical_threshold_hu=float(args.cortical_threshold_hu),
123
+ isotropic_spacing_mm=float(args.cort_trab_isotropic_spacing_mm),
124
+ cortical_max_thickness_mm=float(args.cortical_max_thickness_mm),
125
+ )
126
+ if args.localization_only and args.level_only:
127
+ parser.error("--localization-only and --level-only are mutually exclusive.")
128
+
129
+ try:
130
+ results = segment_files(
131
+ input_paths=inputs,
132
+ output_dir=Path(args.output),
133
+ backend=backend,
134
+ device=args.device,
135
+ overwrite=bool(args.overwrite),
136
+ cort_trab_config=cort_trab_config,
137
+ level_only=bool(args.level_only),
138
+ localization_only=bool(args.localization_only),
139
+ )
140
+ except SpineSegmentBackendError as exc:
141
+ print(f"[spine-segment] backend error: {exc}")
142
+ return 2
143
+ except FileExistsError as exc:
144
+ print(f"[spine-segment] output exists: {exc}")
145
+ return 2
146
+
147
+ for result in results:
148
+ fields = [
149
+ f"input={result.input_path}",
150
+ f"device={result.device}",
151
+ ]
152
+ if args.localization_only:
153
+ fields.append(f"centroids={result.output_paths.centroids}")
154
+ else:
155
+ fields.append(f"vertebral_level={result.output_paths.vertebral_level}")
156
+ fields.append(f"centroids={result.output_paths.centroids}")
157
+ if not args.level_only:
158
+ fields.append(f"process_body={result.output_paths.process_body}")
159
+ fields.append(f"cort_trab={result.output_paths.cort_trab}")
160
+ print("[spine-segment] " + " ".join(fields))
161
+ return 0
162
+
163
+
164
+ if __name__ == "__main__":
165
+ raise SystemExit(main())