"""Minimal, dependency-free DICOM I/O (explicit VR little-endian). This is NOT a full DICOM implementation. It handles exactly the tags needed to reconstruct a 3D MR volume: geometry, ordering, and 16-bit pixel data. It exists so (a) the pipeline can be tested without pydicom/SimpleITK, and (b) it can serve as a last-resort fallback backend in prepare_data.py. In production you should prefer SimpleITK or pydicom (auto-detected there). """ import struct import numpy as np # tags we care about (group, element) -> name TAGS = { (0x0008, 0x0060): "Modality", (0x0020, 0x000D): "StudyInstanceUID", (0x0020, 0x000E): "SeriesInstanceUID", (0x0020, 0x0013): "InstanceNumber", (0x0020, 0x0032): "ImagePositionPatient", (0x0020, 0x0037): "ImageOrientationPatient", (0x0028, 0x0010): "Rows", (0x0028, 0x0011): "Columns", (0x0028, 0x0030): "PixelSpacing", (0x0018, 0x0050): "SliceThickness", (0x0028, 0x0100): "BitsAllocated", (0x0028, 0x0101): "BitsStored", (0x0028, 0x0103): "PixelRepresentation", (0x7FE0, 0x0010): "PixelData", } _EXPLICIT_LONG_VR = {"OB", "OW", "OF", "SQ", "UT", "UN"} def is_dicom(path): """True if the file has the DICM magic at byte 128 (works for extension-less files).""" try: with open(path, "rb") as f: f.seek(128) return f.read(4) == b"DICM" except Exception: return False def read_dicom(path): """Parse a single explicit-VR-LE DICOM file into a dict of the TAGS above.""" with open(path, "rb") as f: buf = f.read() if buf[128:132] != b"DICM": raise ValueError("not a preambled DICOM: " + path) i = 132 out = {} n = len(buf) while i + 8 <= n: group, elem = struct.unpack_from("= 2 else 0 else: out[name] = val.decode("ascii", "ignore").strip("\x00 ") return out def _ds_floats(s, k): try: return [float(x) for x in s.split("\\")][:k] except Exception: return [0.0] * k def volume_from_series(paths): """Stack a list of single-slice DICOM paths into an ordered 3D array + spacing. Ordering: by projection onto the slice normal (from ImageOrientationPatient + ImagePositionPatient) when available, else by InstanceNumber, else filename. Returns (volume[X,Y,Z] float32, spacing=(sx,sy,sz)). """ slices = [] for p in paths: try: d = read_dicom(p) if "PixelData" in d and "Rows" in d: slices.append((p, d)) except Exception: continue if not slices: raise ValueError("no readable slices") def sort_key(item): _, d = item ipp = _ds_floats(d.get("ImagePositionPatient", ""), 3) iop = _ds_floats(d.get("ImageOrientationPatient", ""), 6) if len(ipp) == 3 and len(iop) == 6: r = np.array(iop[:3]); c = np.array(iop[3:]); nrm = np.cross(r, c) return float(np.dot(nrm, ipp)) try: return float(d.get("InstanceNumber", 0)) except Exception: return 0.0 slices.sort(key=sort_key) rows = slices[0][1]["Rows"]; cols = slices[0][1]["Columns"] signed = slices[0][1].get("PixelRepresentation", 0) == 1 dtype = np.int16 if signed else np.uint16 planes = [] for _, d in slices: arr = np.frombuffer(d["PixelData"], dtype=dtype)[: rows * cols].reshape(rows, cols) planes.append(arr.astype(np.float32)) vol = np.stack(planes, axis=-1) # (rows, cols, nslices) = (Y, X, Z) vol = np.transpose(vol, (1, 0, 2)) # -> (X, Y, Z) ps = _ds_floats(slices[0][1].get("PixelSpacing", "1\\1"), 2) or [1.0, 1.0] # slice spacing from position delta if possible if len(slices) > 1: z0 = sort_key(slices[0]); z1 = sort_key(slices[-1]) sz = abs(z1 - z0) / max(1, len(slices) - 1) or _ds_floats(slices[0][1].get("SliceThickness", "1"), 1)[0] else: sz = _ds_floats(slices[0][1].get("SliceThickness", "1"), 1)[0] or 1.0 spacing = (float(ps[1]), float(ps[0]), float(sz)) return vol, spacing # -------------------------------------------------------------------------- # Minimal writer (used only by the test harness to synthesize DICOM series) # -------------------------------------------------------------------------- def _elem(group, elem, vr, val): if isinstance(val, str): val = val.encode("ascii") if len(val) % 2: val += b" " if vr not in ("UI",) else b"\x00" head = struct.pack("