{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.12.13"},"kaggle":{"accelerator":"none"}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"1dd680ae-c1f9-46da-81dc-bbbd9326185b","cell_type":"markdown","source":"# RSNA Knee — DICOM Slice Thickness\n\nWalks every series under `train_series/` (or `test_series/`), reads the **mid-slice header** only (pixels skipped), and reports **mean / median / min / max** for:\n\n- **Slice Thickness** `(0018,0050)`\n- **Spacing Between Slices** `(0018,0088)` (same header, for comparison)\n\nPlane (Sagittal / Coronal / Axial) comes from `ImageOrientationPatient`.\n\n**Kaggle:** add the competition dataset `rsna-knee-abnormality-detection`. CPU is enough. Run all cells.\n\nSet `MAX_SERIES = 200` for a dry run. Full `train_series` is on the order of a few minutes.\n","metadata":{}},{"id":"ba4ca04f-d646-4579-b001-a102dcea34be","cell_type":"markdown","source":"## Setup\n","metadata":{}},{"id":"6327787a-f76e-4458-ae80-2bd7ee34d948","cell_type":"code","source":"from __future__ import annotations\n\nimport os\nimport subprocess\nimport sys\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom pathlib import Path\n\nfor mod, pip_name in ((\"pydicom\", \"pydicom\"), (\"pandas\", \"pandas\"), (\"matplotlib\", \"matplotlib\")):\n    try:\n        __import__(mod)\n    except Exception:\n        subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", pip_name])\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nimport pydicom\nfrom tqdm.auto import tqdm\n\nSPLIT = \"train_series\"       # or \"test_series\"\nMAX_SERIES = None            # e.g. 200 for a dry run; None = all\nN_WORKERS = min(32, max(4, (os.cpu_count() or 4)))\nSAVE_CSV = True\nOUT_DIR = Path(\"/kaggle/working\") if Path(\"/kaggle\").exists() else Path.cwd()\n\nprint(f\"CPUs={os.cpu_count() or 1}  workers={N_WORKERS}  split={SPLIT}  max={MAX_SERIES}\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"8f895146-97e0-4382-b32a-7d5bfe05c5c4","cell_type":"markdown","source":"## Find the DICOM tree\n","metadata":{}},{"id":"e23ea9c3-9ee1-462e-8ff8-9bb16f399ebd","cell_type":"code","source":"def find_dicom_root(split: str = \"train\") -> Path:\n    is_test = str(split).startswith(\"test\")\n    csv_name = \"test.csv\" if is_test else \"train.csv\"\n    series_name = \"test_series\" if is_test else \"train_series\"\n    cands: list[Path] = []\n    env = os.environ.get(\"RSNA_DATA_ROOT\")\n    if env:\n        cands.append(Path(env))\n    cands += [\n        Path(\"/kaggle/input/competitions/rsna-knee-abnormality-detection\"),\n        Path(\"/kaggle/input/rsna-knee-abnormality-detection\"),\n        Path(\"data\"),\n        Path(\".\"),\n    ]\n    for c in cands:\n        if (c / csv_name).is_file() and ((c / series_name).is_dir() or (c / f\"{series_name}.csv\").is_file()):\n            return c\n    base = Path(\"/kaggle/input\")\n    if base.is_dir():\n        for depth1 in sorted(p for p in base.iterdir() if p.is_dir()):\n            for cand in [depth1, *sorted(p for p in depth1.iterdir() if p.is_dir())]:\n                if (cand / csv_name).is_file():\n                    return cand\n    raise FileNotFoundError(\n        f\"No {csv_name} + {series_name}/ found. Add the competition dataset \"\n        \"rsna-knee-abnormality-detection, or set RSNA_DATA_ROOT.\"\n    )\n\n\nSPLIT_KEY = \"test\" if str(SPLIT).startswith(\"test\") else \"train\"\nSERIES_DIR = \"test_series\" if SPLIT_KEY == \"test\" else \"train_series\"\nROOT = find_dicom_root(SPLIT_KEY)\nprint(f\"root = {ROOT}\")\nprint(f\"series dir exists: {(ROOT / SERIES_DIR).is_dir()}\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"a4ea8351-e068-41b1-ac3b-4028340f7f14","cell_type":"markdown","source":"## Header probe (pixels skipped)\n","metadata":{}},{"id":"b9326dab-7774-4959-91af-42f6ba8e3bce","cell_type":"code","source":"TAGS = [(0x0018, 0x0050), (0x0018, 0x0088), (0x0020, 0x0037)]  # thickness, spacing, IOP\n\n\ndef _float_tag(ds, name: str) -> float:\n    v = getattr(ds, name, None)\n    if v is None:\n        return float(\"nan\")\n    try:\n        if hasattr(v, \"__iter__\") and not isinstance(v, (str, bytes)):\n            v = next(iter(v))\n        return float(v)\n    except (TypeError, ValueError, StopIteration):\n        return float(\"nan\")\n\n\ndef plane_from_iop(iop) -> str | None:\n    if iop is None:\n        return None\n    try:\n        vec = np.asarray([float(x) for x in iop], dtype=np.float64).reshape(-1)\n    except (TypeError, ValueError):\n        return None\n    if vec.size < 6 or not np.isfinite(vec).all():\n        return None\n    normal = np.cross(vec[:3], vec[3:6])\n    ax = int(np.argmax(np.abs(normal)))\n    return {0: \"Sagittal\", 1: \"Coronal\", 2: \"Axial\"}.get(ax)\n\n\ndef list_series(root: Path, series_dir: str) -> list[tuple[str, str, str]]:\n    base = root / series_dir\n    items = []\n    if not base.is_dir():\n        return items\n    for study in os.scandir(base):\n        if not study.is_dir():\n            continue\n        for series in os.scandir(study.path):\n            if series.is_dir():\n                items.append((study.name, series.name, series.path))\n    return items\n\n\ndef probe_thickness(item: tuple[str, str, str]) -> dict:\n    study, series, path = item\n    row = {\n        \"StudyInstanceUID\": study,\n        \"SeriesInstanceUID\": series,\n        \"SliceThickness\": float(\"nan\"),\n        \"SpacingBetweenSlices\": float(\"nan\"),\n        \"plane\": None,\n        \"n_slices\": 0,\n    }\n    try:\n        files = [e.name for e in os.scandir(path) if e.name.endswith(\".dcm\")]\n        row[\"n_slices\"] = len(files)\n        if not files:\n            return row\n        files.sort()\n        ds = pydicom.dcmread(\n            os.path.join(path, files[len(files) // 2]),\n            stop_before_pixels=True,\n            force=True,\n            specific_tags=TAGS,\n        )\n        row[\"SliceThickness\"] = _float_tag(ds, \"SliceThickness\")\n        row[\"SpacingBetweenSlices\"] = _float_tag(ds, \"SpacingBetweenSlices\")\n        row[\"plane\"] = plane_from_iop(getattr(ds, \"ImageOrientationPatient\", None))\n    except Exception as exc:\n        row[\"err\"] = str(exc)[:120]\n    return row\n\n\nprint(\"helpers ready\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"ca7e521e-0bb7-4da9-98a8-378afedf0c00","cell_type":"markdown","source":"## Walk series\n","metadata":{}},{"id":"49d1f5e7-40b7-4f2d-a33e-641da69f80e9","cell_type":"code","source":"t0 = time.time()\nitems = list_series(ROOT, SERIES_DIR)\nif MAX_SERIES:\n    items = items[: int(MAX_SERIES)]\nprint(f\"{SERIES_DIR}: {len(items)} series  ({time.time() - t0:.1f}s to list)\")\n\nrows = []\nwith ThreadPoolExecutor(max_workers=N_WORKERS) as pool:\n    futs = [pool.submit(probe_thickness, it) for it in items]\n    for fut in tqdm(as_completed(futs), total=len(futs), desc=\"headers\"):\n        rows.append(fut.result())\n\ndf = pd.DataFrame(rows)\nprint(f\"done in {time.time() - t0:.1f}s  n={len(df)}  planes={df['plane'].value_counts(dropna=False).to_dict()}\")\ndf.head()\n","metadata":{},"outputs":[],"execution_count":null},{"id":"d3576fd0-0029-4f23-8b81-f2610c402aeb","cell_type":"markdown","source":"## Mean / median / min / max\n","metadata":{}},{"id":"207ba331-ff53-48b6-b56b-0d0828b0d531","cell_type":"code","source":"def summarize(values) -> pd.Series:\n    v = pd.to_numeric(pd.Series(values), errors=\"coerce\").to_numpy(np.float64)\n    ok = np.isfinite(v) & (v > 0)\n    n_miss = int((~ok).sum())\n    v = v[ok]\n    if v.size == 0:\n        return pd.Series({\"n\": 0, \"missing\": n_miss, \"mean\": np.nan, \"median\": np.nan, \"min\": np.nan, \"max\": np.nan})\n    return pd.Series(\n        {\n            \"n\": int(v.size),\n            \"missing\": n_miss,\n            \"mean\": float(np.mean(v)),\n            \"median\": float(np.median(v)),\n            \"min\": float(np.min(v)),\n            \"max\": float(np.max(v)),\n        }\n    )\n\n\ndef stats_table(col: str) -> pd.DataFrame:\n    blocks = {\"all\": summarize(df[col])}\n    for plane in (\"Sagittal\", \"Coronal\", \"Axial\"):\n        blocks[plane] = summarize(df.loc[df[\"plane\"] == plane, col])\n    other = df[\"plane\"].isna() | ~df[\"plane\"].isin((\"Sagittal\", \"Coronal\", \"Axial\"))\n    if int(other.sum()):\n        blocks[\"unknown\"] = summarize(df.loc[other, col])\n    out = pd.DataFrame(blocks).T\n    for c in (\"n\", \"missing\"):\n        out[c] = out[c].astype(int)\n    return out\n\n\nthick = stats_table(\"SliceThickness\")\nspace = stats_table(\"SpacingBetweenSlices\")\nprint(\"Slice Thickness (0018,0050)  [mm]\")\ndisplay(thick.round(4))\nprint(\"\\nSpacing Between Slices (0018,0088)  [mm]\")\ndisplay(space.round(4))\n","metadata":{},"outputs":[],"execution_count":null},{"id":"84d59a3d-4b51-4f2d-b6e9-8d8e69d78892","cell_type":"markdown","source":"## Histograms\n","metadata":{}},{"id":"23482d71-ef4e-4a2d-a840-c7ee709dc5de","cell_type":"code","source":"def _finite_pos(s: pd.Series) -> np.ndarray:\n    v = pd.to_numeric(s, errors=\"coerce\").to_numpy(np.float64)\n    return v[np.isfinite(v) & (v > 0)]\n\n\nfig, axes = plt.subplots(1, 2, figsize=(11, 4), sharey=False)\nfor ax, col, title in (\n    (axes[0], \"SliceThickness\", \"Slice Thickness (mm)\"),\n    (axes[1], \"SpacingBetweenSlices\", \"Spacing Between Slices (mm)\"),\n):\n    for plane, color in ((\"Sagittal\", \"#1f77b4\"), (\"Coronal\", \"#ff7f0e\"), (\"Axial\", \"#2ca02c\")):\n        vals = _finite_pos(df.loc[df[\"plane\"] == plane, col])\n        if vals.size:\n            ax.hist(vals, bins=40, alpha=0.45, label=f\"{plane} n={vals.size}\", color=color)\n    ax.set_title(title)\n    ax.set_xlabel(\"mm\")\n    ax.legend(fontsize=8)\naxes[0].set_ylabel(\"series\")\nfig.tight_layout()\nplt.show()\n","metadata":{},"outputs":[],"execution_count":null},{"id":"c7753a35-ec95-468c-b983-6f4d82ee8b64","cell_type":"markdown","source":"## Save CSV\n","metadata":{}},{"id":"f7ebdf74-a3da-4107-8160-f41207d70182","cell_type":"code","source":"if SAVE_CSV:\n    OUT_DIR.mkdir(parents=True, exist_ok=True)\n    csv_path = OUT_DIR / f\"slice_thickness_{SERIES_DIR}.csv\"\n    df.to_csv(csv_path, index=False)\n    print(f\"wrote {csv_path}  ({csv_path.stat().st_size / 1024:.1f} KB)\")\nelse:\n    print(\"SAVE_CSV is False — skip\")\n","metadata":{},"outputs":[],"execution_count":null}]}