Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python | |
| # -*- coding: utf-8 -*- | |
| # File: prepare_data.py | |
| # | |
| # Turn an arbitrary tree of DICOM data into the folder layout the RL-Medical / | |
| # PINN pipeline expects: | |
| # | |
| # data/ | |
| # <anything>/.../<dicom slices> <-- your raw data, ANY structure | |
| # images/ <-- generated: one <name>.nii.gz per DICOM series | |
| # filenames/ <-- generated: image_files.txt, landmark_files.txt | |
| # landmarks/ <-- generated: one <name>.txt per volume (>=15 points) | |
| # models/ <-- (left untouched) | |
| # | |
| # It walks everything under --data recursively, groups DICOM files into series | |
| # (3D volumes), converts each to .nii.gz, and writes the filenames + landmark | |
| # files so `dataReader.py` / `medical.py` run unchanged. | |
| # | |
| # Backends (auto-detected, best first): | |
| # 1. SimpleITK -- robust series grouping + ordering (recommended; the repo | |
| # already depends on it). | |
| # 2. pydicom (+ mini_nifti writer) | |
| # 3. mini_dicom (built-in, dependency-free fallback) | |
| # | |
| # LANDMARKS: you almost certainly do NOT have annotations yet. This script | |
| # writes VALID PLACEHOLDER landmark files (>=15 points, correct format) so the | |
| # pipeline is runnable immediately, and prints a loud warning. Replace them with | |
| # real annotations before training a detector, or pass --landmarks-csv to fill | |
| # in known points. (For the physics/PINN visualisations, the placeholder seed is | |
| # enough to compute a geodesic field.) | |
| # | |
| # Usage: | |
| # python prepare_data.py --data /path/to/data | |
| # python prepare_data.py --data ./data --num-landmarks 15 --landmarks centroid | |
| # python prepare_data.py --data ./data --landmarks-csv my_points.csv | |
| # | |
| # CSV format for --landmarks-csv (one row per point): | |
| # volume_name,index,x,y,z | |
| # 2_MR5OOO_20250514_1,13,89,88,84 # AC point for that volume | |
| # 2_MR5OOO_20250514_1,14,88,93,77 # PC point | |
| import os | |
| import sys | |
| import argparse | |
| import numpy as np | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| # ---- optional backends ------------------------------------------------------ | |
| try: | |
| import SimpleITK as sitk | |
| _HAVE_SITK = True | |
| except Exception: | |
| _HAVE_SITK = False | |
| try: | |
| import pydicom | |
| _HAVE_PYDICOM = True | |
| except Exception: | |
| _HAVE_PYDICOM = False | |
| import mini_dicom | |
| from mini_nifti import write_nii | |
| # ============================================================================= | |
| # Series discovery -- works on ANY folder structure | |
| # ============================================================================= | |
| def find_dicom_dirs(root, images_dirname): | |
| """Yield directories under `root` that contain at least one DICOM file, | |
| skipping the generated output folders.""" | |
| skip = {images_dirname, "filenames", "landmarks", "models"} | |
| for dirpath, dirnames, filenames in os.walk(root): | |
| rel = os.path.relpath(dirpath, root) | |
| parts = set(rel.split(os.sep)) | |
| if parts & skip: | |
| continue | |
| dcm = [os.path.join(dirpath, f) for f in filenames | |
| if _looks_dicom(os.path.join(dirpath, f))] | |
| if dcm: | |
| yield dirpath, dcm | |
| def _looks_dicom(path): | |
| if not os.path.isfile(path): | |
| return False | |
| low = path.lower() | |
| if low.endswith((".dcm", ".ima")): | |
| return True | |
| if low.endswith((".nii", ".nii.gz", ".txt", ".json", ".png", ".gif", ".pt")): | |
| return False | |
| return mini_dicom.is_dicom(path) # DICM magic at byte 128 (extension-less ok) | |
| def group_series_sitk(dirpath): | |
| """Return {series_uid: [ordered file paths]} using GDCM (robust).""" | |
| reader = sitk.ImageSeriesReader() | |
| ids = reader.GetGDCMSeriesIDs(dirpath) | |
| out = {} | |
| if ids: | |
| for sid in ids: | |
| files = reader.GetGDCMSeriesFileNames(dirpath, sid) | |
| if files: | |
| out[sid] = list(files) | |
| return out | |
| def group_series_fallback(dcm_files): | |
| """Group by SeriesInstanceUID via mini_dicom; if unreadable, one series/dir.""" | |
| groups = {} | |
| for p in dcm_files: | |
| try: | |
| d = mini_dicom.read_dicom(p) | |
| sid = d.get("SeriesInstanceUID") or "series" | |
| except Exception: | |
| sid = "series" | |
| groups.setdefault(sid, []).append(p) | |
| return groups | |
| # ============================================================================= | |
| # Reading a series into a volume + spacing | |
| # ============================================================================= | |
| def load_series(files, dirpath): | |
| if _HAVE_SITK: | |
| reader = sitk.ImageSeriesReader() | |
| reader.SetFileNames(files) | |
| img = reader.Execute() | |
| vol = sitk.GetArrayFromImage(img) # (z, y, x) | |
| vol = np.transpose(vol, (2, 1, 0)).astype(np.float32) # -> (x, y, z) | |
| return vol, tuple(img.GetSpacing()) # (sx, sy, sz) | |
| if _HAVE_PYDICOM: | |
| slices = [] | |
| for p in files: | |
| ds = pydicom.dcmread(p) | |
| slices.append(ds) | |
| slices.sort(key=lambda s: float(getattr(s, "InstanceNumber", 0))) | |
| vol = np.stack([s.pixel_array for s in slices], axis=-1) # (rows,cols,z)=(y,x,z) | |
| vol = np.transpose(vol, (1, 0, 2)).astype(np.float32) # -> (x,y,z) | |
| ps = [float(x) for x in getattr(slices[0], "PixelSpacing", [1, 1])] | |
| st = float(getattr(slices[0], "SliceThickness", 1) or 1) | |
| return vol, (ps[1], ps[0], st) | |
| return mini_dicom.volume_from_series(files) | |
| # ============================================================================= | |
| # Landmark file generation | |
| # ============================================================================= | |
| def make_landmarks(vol, mode, n, provided): | |
| """Return an (n,3) array of landmark voxel coords. | |
| provided: {index: (x,y,z)} of known points for THIS volume (from CSV). | |
| Unknown indices are filled with a placeholder (center or intensity centroid). | |
| """ | |
| if mode == "centroid": | |
| thr = np.percentile(vol, 60) | |
| mask = vol > thr | |
| if mask.any(): | |
| xs, ys, zs = np.where(mask) | |
| base = np.array([xs.mean(), ys.mean(), zs.mean()]) | |
| else: | |
| base = np.array(vol.shape) / 2.0 | |
| else: # center | |
| base = np.array(vol.shape) / 2.0 | |
| pts = np.tile(base, (n, 1)) | |
| # scatter placeholders slightly so they're not all identical | |
| offs = np.linspace(-6, 6, n) | |
| pts[:, 0] += offs | |
| for idx, xyz in (provided or {}).items(): | |
| if 0 <= idx < n: | |
| pts[idx] = xyz | |
| return np.clip(np.round(pts), 0, np.array(vol.shape) - 1).astype(int) | |
| def _series_cosines(files): | |
| """Read ImageOrientationPatient (row_dir, col_dir) from the first slice. | |
| Returns None if unavailable (auto_landmarks then assumes axial identity).""" | |
| for p in files: | |
| try: | |
| if _HAVE_PYDICOM: | |
| ds = pydicom.dcmread(p, stop_before_pixels=True) | |
| iop = [float(x) for x in ds.ImageOrientationPatient] | |
| else: | |
| d = mini_dicom.read_dicom(p) | |
| iop = [float(x) for x in d["ImageOrientationPatient"].split("\\")] | |
| return (iop[:3], iop[3:]) | |
| except Exception: | |
| continue | |
| return None | |
| def load_csv(path): | |
| """Parse --landmarks-csv into {volume_name: {index: (x,y,z)}}.""" | |
| table = {} | |
| if not path: | |
| return table | |
| with open(path) as f: | |
| for line in f: | |
| line = line.strip() | |
| if not line or line.lower().startswith("volume"): | |
| continue | |
| parts = [p.strip() for p in line.split(",")] | |
| if len(parts) < 5: | |
| continue | |
| name, idx, x, y, z = parts[0], int(parts[1]), float(parts[2]), float(parts[3]), float(parts[4]) | |
| table.setdefault(name, {})[idx] = (x, y, z) | |
| return table | |
| # ============================================================================= | |
| # Main | |
| # ============================================================================= | |
| def sanitize(root, dirpath, series_key, used): | |
| """Build a clean, unique volume name from the path (structure-agnostic).""" | |
| rel = os.path.relpath(dirpath, root) | |
| parts = [p for p in rel.split(os.sep) if p and p.upper() != "DICOM"] | |
| name = "_".join(parts) if parts else "vol" | |
| # disambiguate multiple series in the same dir | |
| if series_key and series_key != "series": | |
| name += "_s" + series_key.split(".")[-1][-4:] | |
| name = "".join(c if (c.isalnum() or c in "_-") else "_" for c in name) | |
| base = name | |
| k = 1 | |
| while name in used: | |
| name = f"{base}_{k}"; k += 1 | |
| used.add(name) | |
| return name | |
| def main(): | |
| ap = argparse.ArgumentParser(description="Prepare DICOM data for RL-Medical/PINN.") | |
| ap.add_argument("--data", required=True, help="path to the top-level data/ folder") | |
| ap.add_argument("--images-dir", default="images", help="output folder for .nii.gz") | |
| ap.add_argument("--num-landmarks", type=int, default=15, | |
| help=">=15 required (indices 13=AC, 14=PC are used by the code)") | |
| ap.add_argument("--landmarks", choices=["center", "centroid", "auto"], default="center", | |
| help="center/centroid = placeholder; auto = anatomical fiducials " | |
| "(oriented by the DICOM header; see auto_landmarks.py / AUTO_LANDMARKS.md)") | |
| ap.add_argument("--landmarks-csv", default=None, help="known points to fill in") | |
| ap.add_argument("--min-slices", type=int, default=3, | |
| help="ignore series with fewer slices than this") | |
| ap.add_argument("--relative", action="store_true", | |
| help="write relative paths in filename lists (default: absolute)") | |
| ap.add_argument("--overwrite", action="store_true", | |
| help="reconvert volumes even if the .nii.gz already exists") | |
| args = ap.parse_args() | |
| root = os.path.abspath(args.data) | |
| img_out = os.path.join(root, args.images_dir) | |
| fn_out = os.path.join(root, "filenames") | |
| lm_out = os.path.join(root, "landmarks") | |
| for d in (img_out, fn_out, lm_out): | |
| os.makedirs(d, exist_ok=True) | |
| backend = "SimpleITK" if _HAVE_SITK else ("pydicom" if _HAVE_PYDICOM else "mini_dicom (built-in)") | |
| print(f"[prepare_data] backend: {backend}") | |
| print(f"[prepare_data] scanning: {root}") | |
| csv_points = load_csv(args.landmarks_csv) | |
| if args.num_landmarks < 15: | |
| print("[warn] num-landmarks < 15; the code indexes landmark 13/14 (AC/PC). Forcing 15.") | |
| args.num_landmarks = 15 | |
| used_names = set() | |
| image_lines, landmark_lines = [], [] | |
| n_series = 0 | |
| for dirpath, dcm_files in find_dicom_dirs(root, args.images_dir): | |
| if _HAVE_SITK: | |
| groups = group_series_sitk(dirpath) or group_series_fallback(dcm_files) | |
| else: | |
| groups = group_series_fallback(dcm_files) | |
| for series_key, files in groups.items(): | |
| if len(files) < args.min_slices: | |
| continue | |
| name = sanitize(root, dirpath, series_key, used_names) | |
| nii_path = os.path.join(img_out, name + ".nii.gz") | |
| try: | |
| if args.overwrite or not os.path.exists(nii_path): | |
| vol, spacing = load_series(files, dirpath) | |
| if not _HAVE_SITK: | |
| write_nii(nii_path, vol, spacing) | |
| else: | |
| reader = sitk.ImageSeriesReader(); reader.SetFileNames(files) | |
| sitk.WriteImage(reader.Execute(), nii_path) | |
| vol, spacing = load_series(files, dirpath) # for landmark shape | |
| else: | |
| from read_nifti import read_nii | |
| vol, spacing = read_nii(nii_path) | |
| if vol.ndim == 4: | |
| vol = vol[..., 0] | |
| except Exception as e: | |
| print(f"[skip] {dirpath} [{series_key[:12]}]: {e}") | |
| continue | |
| # landmark file | |
| if args.landmarks == "auto": | |
| from auto_landmarks import anatomical_fiducials | |
| cos = _series_cosines(files) | |
| _, lm = anatomical_fiducials(vol, cosines=cos, spacing=spacing, | |
| n=args.num_landmarks) | |
| # let CSV points override specific indices if provided | |
| for idx, xyz in (csv_points.get(name) or {}).items(): | |
| if 0 <= idx < len(lm): | |
| lm[idx] = np.clip(np.round(xyz), 0, np.array(vol.shape) - 1).astype(int) | |
| else: | |
| lm = make_landmarks(vol, args.landmarks, args.num_landmarks, | |
| csv_points.get(name)) | |
| lm_path = os.path.join(lm_out, name + ".txt") | |
| with open(lm_path, "w") as f: | |
| f.write("\n".join(f"{int(x)},{int(y)},{int(z)}" for x, y, z in lm) + "\n") | |
| ip = nii_path if not args.relative else os.path.relpath(nii_path, root) | |
| lp = lm_path if not args.relative else os.path.relpath(lm_path, root) | |
| image_lines.append(ip) | |
| landmark_lines.append(lp) | |
| n_series += 1 | |
| print(f" + {name} shape={tuple(vol.shape)} slices={len(files)}") | |
| with open(os.path.join(fn_out, "image_files.txt"), "w") as f: | |
| f.write("\n".join(image_lines) + ("\n" if image_lines else "")) | |
| with open(os.path.join(fn_out, "landmark_files.txt"), "w") as f: | |
| f.write("\n".join(landmark_lines) + ("\n" if landmark_lines else "")) | |
| print(f"\n[prepare_data] done: {n_series} volume(s)") | |
| print(f" images -> {img_out}") | |
| print(f" filenames -> {os.path.join(fn_out, 'image_files.txt')} (+ landmark_files.txt)") | |
| print(f" landmarks -> {lm_out}") | |
| have_real = bool(csv_points) | |
| if not have_real: | |
| print("\n" + "!" * 72) | |
| print("! LANDMARKS ARE PLACEHOLDERS (image center/centroid), NOT real anatomy.") | |
| print("! They let the pipeline + physics run, but you MUST replace them with") | |
| print("! real annotations (or pass --landmarks-csv) before training a detector.") | |
| print("!" * 72) | |
| if __name__ == "__main__": | |
| main() | |