{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[],"dockerImageVersionId":28755,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# -*- coding: utf-8 -*-\n\"\"\"\nRSNA Knee — 预处理 v4（压脂部分）| Kaggle CPU notebook 单独跑\n=================================================================\n本脚本只处理「3 个液敏序列」（矢状/冠状/轴位各 1 个液敏），输出 <20GB，\n与 preprocess_v4_t1.py（T1 部分）分开跑，规避 /kaggle/working 20GB 上限。\n\n相对 v3 的改动：\n1. 左右归一化：series_side = IPP[0]（LPS 的 X 坐标），右膝(X<0)对 Cor/Ax 水平镜像\n   （Sagittal 不 flip —— 矢状位不直接显示左右，与最强方案一致）\n2. 丢头尾比例 trim_ratio 0.1 → 0.2（对齐最强方案 SLICE_BAND 0.2~0.8）\n3. 选序列：每平面取 1 个液敏(fluid=1)；缺液敏则 T1 顶替(标记 [0,1])；完全缺则零填充(标记 [0,0])\n\n输出：\n  /kaggle/working/processed_v4_fluid/{StudyInstanceUID}_fluid.npy   shape=(3, 24, 238, 238) uint8\n  /kaggle/working/processed_v4_fluid/fluid_meta.csv\n\n存储估算：3 × 24 × 238² × 4407 ≈ 17.2 GB  < 20GB  ✅\n\"\"\"\nimport os, csv\nimport numpy as np\nimport pydicom\nimport cv2\nimport pandas as pd\nfrom multiprocessing import Pool\nfrom tqdm import tqdm\n\nBASE = '/kaggle/input/competitions/rsna-knee-abnormality-detection'\nOUT_DIR = '/kaggle/working/processed_v4_fluid'\nos.makedirs(OUT_DIR, exist_ok=True)\n\nN_SLICES = 24              # 存 24 张（训练时切片抖动抽 16）\nSIZE = 238                 # 238=17×14(DINOv2 patch14 整除)\nTARGET_SPACING = 0.7      # mm，间距重采样目标\nPLANES = ['Sagittal', 'Coronal', 'Axial']\n\n\ndef sort_by_position(paths):\n    \"\"\"几何排序：k = IPP · (IOP_x × IOP_y)\"\"\"\n    keyed = []\n    for p in paths:\n        try:\n            ds = pydicom.dcmread(p, stop_before_pixels=True)\n            iop = np.asarray(ds.ImageOrientationPatient, dtype=float)\n            ipp = np.asarray(ds.ImagePositionPatient, dtype=float)\n            k = float(np.dot(ipp, np.cross(iop[:3], iop[3:])))\n            keyed.append((k, p))\n        except Exception:\n            continue\n    keyed.sort(key=lambda x: x[0])\n    return [p for _, p in keyed]\n\n\ndef series_side(path):\n    \"\"\"左右判定：读 IPP[0]（LPS 的 X 坐标）。X<0 = 右膝\"\"\"\n    try:\n        return float(pydicom.dcmread(path, stop_before_pixels=True).ImagePositionPatient[0])\n    except Exception:\n        return 0.0\n\n\ndef normalize(vol):\n    \"\"\"堆栈级 1%~99% 百分位归一化到 uint8\"\"\"\n    vol = vol.astype(np.float32)\n    lo, hi = np.percentile(vol, 1), np.percentile(vol, 99)\n    if hi - lo < 1e-6:\n        return np.zeros(vol.shape, dtype=np.uint8)\n    return (np.clip((vol - lo) / (hi - lo), 0, 1) * 255).astype(np.uint8)\n\n\ndef middle_indices(N, n, trim_ratio=0.2):\n    \"\"\"丢头尾无关节切片，中间间隔抽 n 张的索引\"\"\"\n    n = min(n, N)\n    trim = min(int(N * trim_ratio), max(0, (N - n) // 2))\n    lo, hi = trim, N - trim\n    if hi - lo >= n:\n        return np.linspace(lo, hi - 1, n).round().astype(int)\n    idx = np.arange(lo, hi)\n    return np.concatenate([idx, np.repeat(idx[-1], n - len(idx))]) if len(idx) else np.zeros(n, dtype=int)\n\n\ndef sample_middle(vol, n=N_SLICES, trim_ratio=0.2):\n    \"\"\"丢头尾 + 中间间隔抽 n 张，不足末尾重复补齐\"\"\"\n    N = vol.shape[0]\n    trim = min(int(N * trim_ratio), max(0, (N - n) // 2))\n    if trim > 0:\n        vol = vol[trim:N - trim]\n    M = vol.shape[0]\n    if M >= n:\n        return vol[np.linspace(0, M - 1, n).round().astype(int)]\n    return np.concatenate([vol, np.repeat(vol[[-1]], n - M, axis=0)], axis=0)\n\n\ndef center_crop_pad(img, size):\n    \"\"\"中心裁剪/补零，高/宽分别独立处理\"\"\"\n    h, w = img.shape\n    if h > size:\n        top = (h - size) // 2\n        img = img[top:top + size, :]\n    if w > size:\n        left = (w - size) // 2\n        img = img[:, left:left + size]\n    h, w = img.shape\n    out = np.zeros((size, size), dtype=img.dtype)\n    top = (size - h) // 2\n    left = (size - w) // 2\n    out[top:top + h, left:left + w] = img\n    return out\n\n\ndef resample(img, ps):\n    \"\"\"间距重采样到 TARGET_SPACING，再 center crop/pad 到 SIZE\"\"\"\n    if ps is None or ps <= 0:\n        ps = TARGET_SPACING\n    scale = ps / TARGET_SPACING\n    new_w = int(round(img.shape[1] * scale))\n    new_h = int(round(img.shape[0] * scale))\n    interp = cv2.INTER_AREA if scale < 1 else cv2.INTER_LINEAR\n    img = cv2.resize(img, (new_w, new_h), interpolation=interp)\n    return center_crop_pad(img, SIZE)\n\n\ndef load_series(study_uid, series_uid, plane):\n    \"\"\"读序列 → 排序 → 归一化 → 抽 24 → 间距重采样 → 左右 flip，返回 (24,238,238) uint8\"\"\"\n    d = f'{BASE}/train_series/{study_uid}/{series_uid}'\n    paths = [os.path.join(d, f) for f in sorted(os.listdir(d))]\n    if not paths:\n        return None\n    paths = sort_by_position(paths)\n    if not paths:\n        return None\n    # 左右判定：Cor/Ax 的右膝水平镜像；Sagittal 不 flip\n    flip = (plane != 'Sagittal') and (series_side(paths[0]) < 0)\n    try:\n        ps = float(pydicom.dcmread(paths[0], stop_before_pixels=True).PixelSpacing[0])\n    except Exception:\n        ps = None\n    N = len(paths)\n    picked = [paths[i] for i in middle_indices(N, N_SLICES)]\n    imgs = []\n    for p in picked:\n        try:\n            imgs.append(pydicom.dcmread(p).pixel_array.astype(np.float32))\n        except Exception:\n            continue\n    if len(imgs) < 8:\n        return None\n    vol = normalize(np.stack(imgs))\n    vol = sample_middle(vol)\n    out = np.stack([resample(v, ps) for v in vol])\n    if flip:\n        out = out[:, :, ::-1].copy()      # 水平镜像（W 维翻转）\n    return out\n\n\ndef process_study(item):\n    \"\"\"处理单个 study 的 3 液敏序列\"\"\"\n    study_uid, series_list = item\n    vol3, type_oh = [], []\n    for plane in PLANES:\n        cand_fluid = [s for s in series_list if s[2] == plane and s[1] == 1]   # 液敏\n        cand_all = [s for s in series_list if s[2] == plane]                    # 该平面所有\n        if cand_fluid:\n            chosen, fluid_flag = cand_fluid[0], 1\n        elif cand_all:\n            chosen, fluid_flag = cand_all[0], 0                                 # T1 顶替\n        else:\n            chosen, fluid_flag = None, 0                                        # 完全缺\n        v = None\n        if chosen is not None:\n            v = load_series(study_uid, chosen[0], plane)\n        if v is None:\n            v = np.zeros((N_SLICES, SIZE, SIZE), dtype=np.uint8)\n            fluid_flag = 0\n        vol3.append(v)\n        type_oh += [fluid_flag, 1 - fluid_flag]\n    arr = np.stack(vol3)   # (3, 24, 238, 238)\n    np.save(os.path.join(OUT_DIR, f'{study_uid}_fluid.npy'), arr)\n    return [study_uid] + type_oh\n\n\nif __name__ == '__main__':\n    ts = pd.read_csv(f'{BASE}/train_series.csv')\n    groups = {}\n    for _, r in ts.iterrows():\n        groups.setdefault(r.StudyInstanceUID, []).append(\n            (r.SeriesInstanceUID, int(r.Fluid_Sensitive), r.Anatomical_Plane))\n    items = list(groups.items())\n\n    n_workers = os.cpu_count() or 4\n    print(f'并行进程数: {n_workers}, 总 study: {len(items)}')\n    with Pool(n_workers) as pool:\n        results = list(tqdm(pool.imap(process_study, items), total=len(items)))\n\n    meta_rows = [r for r in results if r is not None]\n    with open(f'{OUT_DIR}/fluid_meta.csv', 'w', newline='', encoding='utf-8') as f:\n        w = csv.writer(f)\n        w.writerow(['StudyInstanceUID',\n                    'sagF_fluid', 'sagF_t1', 'corF_fluid', 'corF_t1', 'axF_fluid', 'axF_t1'])\n        w.writerows(meta_rows)\n\n    print(f'处理完成 {len(meta_rows)}/{len(items)} study，输出到 {OUT_DIR}')\n","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-08-15T08:43:09.101868Z","iopub.execute_input":"2026-08-15T08:43:09.10257Z","iopub.status.idle":"2026-08-15T08:44:39.994808Z","shell.execute_reply.started":"2026-08-15T08:43:09.102534Z","shell.execute_reply":"2026-08-15T08:44:39.993546Z"}},"outputs":[],"execution_count":null}]}