{"cells":[{"cell_type":"markdown","id":"e06d63f1","metadata":{},"source":"# MRI DICOM Protocol Fingerprints and Quality Control\n\nA reusable metadata-first tool for MRI collections. It profiles acquisition protocols,\nchecks geometric consistency, exports compact QC tables, and renders representative\nslice montages. No clinical labels or predictions are used."},{"cell_type":"code","execution_count":null,"id":"09b415c9","metadata":{},"outputs":[],"source":"import hashlib\nimport os\nfrom concurrent.futures import ThreadPoolExecutor\nfrom pathlib import Path\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nimport pydicom\nfrom IPython.display import display\n\nSEED = 20260806\nHEADER_SAMPLE_SIZE = 4000\nMONTAGE_SLICES = 9\nMAX_PROTOCOL_MONTAGES = 6\n\ndef find_competition_root():\n    candidates = [\n        Path('/kaggle/input/competitions/rsna-knee-abnormality-detection'),\n        Path('/kaggle/input/rsna-knee-abnormality-detection'),\n        Path('data/raw'),\n    ]\n    candidates += list(Path('/kaggle/input').glob('*knee*')) if Path('/kaggle/input').exists() else []\n    for root in candidates:\n        if (root / 'train.csv').exists() and (root / 'train_series.csv').exists():\n            return root\n    raise FileNotFoundError('Could not locate competition data')\n\nROOT = find_competition_root()\nWORK = Path('/kaggle/working') if Path('/kaggle').exists() else Path('outputs/dicom_protocol_qc')\nWORK.mkdir(parents=True, exist_ok=True)\nprint({'data_root': str(ROOT), 'output_root': str(WORK)})"},{"cell_type":"markdown","id":"1c82296a","metadata":{},"source":"## Inventory"},{"cell_type":"code","execution_count":null,"id":"c682ea6b","metadata":{},"outputs":[],"source":"studies = pd.read_csv(ROOT / 'train.csv', usecols=['StudyInstanceUID'])\nseries = pd.read_csv(ROOT / 'train_series.csv')\n\ninventory = pd.DataFrame({\n    'studies': [studies['StudyInstanceUID'].nunique()],\n    'series': [len(series)],\n    'series_per_study_median': [series.groupby('StudyInstanceUID').size().median()],\n    'series_per_study_p95': [series.groupby('StudyInstanceUID').size().quantile(.95)],\n})\ndisplay(inventory)\n\nfig, axes = plt.subplots(1, 2, figsize=(14, 4))\nseries['Anatomical_Plane'].value_counts().plot.bar(ax=axes[0], color='#35689a', title='Series by anatomical plane')\naxes[0].set_xlabel('')\naxes[0].set_ylabel('series')\nseries.groupby('StudyInstanceUID').size().plot.hist(ax=axes[1], bins=range(2, 17), color='#57a773', title='Series per study')\naxes[1].set_xlabel('number of series')\nplt.tight_layout()"},{"cell_type":"markdown","id":"b3eee23f","metadata":{},"source":"## Header sampling and protocol fingerprints\n\nA first DICOM header is read from each sampled series with `stop_before_pixels=True`.\nThe fingerprint combines geometric and acquisition fields, making protocol variation\nvisible without loading the image volume."},{"cell_type":"code","execution_count":null,"id":"a0186d98","metadata":{},"outputs":[],"source":"sample = series.sample(min(HEADER_SAMPLE_SIZE, len(series)), random_state=SEED).reset_index(drop=True)\n\ndef first_dicom_path(study_uid, series_uid):\n    folder = ROOT / 'train_series' / study_uid / series_uid\n    return next(folder.glob('*.dcm'), None)\n\ndef dcm_value(dataset, name, default=np.nan):\n    value = dataset.get(name, default)\n    if hasattr(value, 'value'):\n        value = value.value\n    if isinstance(value, (list, tuple)):\n        return '\\\\'.join(map(str, value))\n    return value\n\ndef normalize_number(value):\n    try:\n        return round(float(value), 4)\n    except (TypeError, ValueError):\n        return np.nan\n\ndef read_header(row):\n    record = row.to_dict()\n    path = first_dicom_path(row.StudyInstanceUID, row.SeriesInstanceUID)\n    record['path_found'] = path is not None\n    if path is None:\n        record['header_error'] = 'missing_dicom'\n        return record\n    try:\n        dataset = pydicom.dcmread(path, stop_before_pixels=True, force=True)\n        pixel_spacing = dcm_value(dataset, 'PixelSpacing', '')\n        orientation = dcm_value(dataset, 'ImageOrientationPatient', '')\n        record.update({\n            'header_error': '',\n            'Rows': normalize_number(dcm_value(dataset, 'Rows')),\n            'Columns': normalize_number(dcm_value(dataset, 'Columns')),\n            'PixelSpacing': str(pixel_spacing),\n            'SliceThickness': normalize_number(dcm_value(dataset, 'SliceThickness')),\n            'SpacingBetweenSlices': normalize_number(dcm_value(dataset, 'SpacingBetweenSlices')),\n            'EchoTime': normalize_number(dcm_value(dataset, 'EchoTime')),\n            'RepetitionTime': normalize_number(dcm_value(dataset, 'RepetitionTime')),\n            'FlipAngle': normalize_number(dcm_value(dataset, 'FlipAngle')),\n            'MagneticFieldStrength': normalize_number(dcm_value(dataset, 'MagneticFieldStrength')),\n            'Manufacturer': str(dcm_value(dataset, 'Manufacturer', '')),\n            'ManufacturerModelName': str(dcm_value(dataset, 'ManufacturerModelName', '')),\n            'ImageOrientationPatient': str(orientation),\n        })\n    except Exception as exc:\n        record['header_error'] = type(exc).__name__\n    return record\n\nwith ThreadPoolExecutor(max_workers=min(16, os.cpu_count() or 4)) as executor:\n    headers = list(executor.map(read_header, (row for _, row in sample.iterrows())))\n\nqc = pd.DataFrame(headers)\nfor column in ['Rows', 'Columns', 'SliceThickness', 'SpacingBetweenSlices', 'EchoTime', 'RepetitionTime', 'FlipAngle', 'MagneticFieldStrength']:\n    if column not in qc:\n        qc[column] = np.nan\n\nfingerprint_columns = [\n    'Anatomical_Plane', 'Fluid_Sensitive', 'Fat_Suppression', 'Rows', 'Columns',\n    'PixelSpacing', 'SliceThickness', 'SpacingBetweenSlices', 'EchoTime',\n    'RepetitionTime', 'MagneticFieldStrength', 'Manufacturer',\n]\nfingerprint_text = qc[fingerprint_columns].fillna('NA').astype(str).agg('|'.join, axis=1)\nqc['protocol_fingerprint'] = fingerprint_text.map(lambda text: hashlib.sha1(text.encode()).hexdigest()[:12])\nqc['geometry_complete'] = qc[['Rows', 'Columns', 'PixelSpacing']].notna().all(axis=1)\nqc['matrix_signature'] = qc['Rows'].fillna(-1).astype(int).astype(str) + 'x' + qc['Columns'].fillna(-1).astype(int).astype(str)\nmatrix_frequency = qc['matrix_signature'].value_counts()\nqc['rare_matrix'] = qc['matrix_signature'].map(matrix_frequency).lt(10)\nqc['qc_flag'] = np.select(\n    [qc['header_error'].ne(''), ~qc['geometry_complete'], qc['rare_matrix']],\n    ['header_read_error', 'missing_geometry', 'rare_matrix'],\n    default='ok',\n)\n\nprotocols = (\n    qc.groupby(['protocol_fingerprint', *fingerprint_columns], dropna=False)\n    .agg(series=('SeriesInstanceUID', 'size'), studies=('StudyInstanceUID', 'nunique'), qc_flags=('qc_flag', lambda x: int((x != 'ok').sum())))\n    .reset_index()\n    .sort_values('series', ascending=False)\n)\nqc.to_csv(WORK / 'dicom_series_qc.csv', index=False)\nprotocols.to_csv(WORK / 'protocol_fingerprints.csv', index=False)\ndisplay(protocols.head(12))"},{"cell_type":"markdown","id":"41d90767","metadata":{},"source":"## Protocol diversity and QC flags"},{"cell_type":"code","execution_count":null,"id":"c238a8c4","metadata":{"lines_to_next_cell":1},"outputs":[],"source":"fig, axes = plt.subplots(1, 2, figsize=(16, 5))\nprotocols.head(15).sort_values('series').plot.barh(x='protocol_fingerprint', y='series', legend=False, color='#5b8fb9', ax=axes[0])\naxes[0].set_title('Most frequent protocol fingerprints')\naxes[0].set_xlabel('sampled series')\n\nqc['qc_flag'].value_counts().plot.pie(autopct='%1.1f%%', ax=axes[1], colors=['#5bb98c', '#e4a64b', '#ce5d5d'], ylabel='')\naxes[1].set_title('Header and geometry checks')\nplt.tight_layout()"},{"cell_type":"markdown","id":"d8c941d8","metadata":{},"source":"## Representative MRI montages\n\nThree evenly distributed dimensions in a selected series are displayed as a 3×3 grid.\nThis makes orientation, field of view, and signal characteristics easy to compare across\ncommon protocol fingerprints."},{"cell_type":"code","execution_count":null,"id":"be38b8fa","metadata":{},"outputs":[],"source":"def normalized_slice(path):\n    image = pydicom.dcmread(path, force=True).pixel_array.astype(np.float32)\n    low, high = np.percentile(image, [1, 99])\n    if high <= low:\n        return np.zeros_like(image, dtype=np.float32)\n    return np.clip((image - low) / (high - low), 0, 1)\n\ndef show_montage(row):\n    folder = ROOT / 'train_series' / row.StudyInstanceUID / row.SeriesInstanceUID\n    files = sorted(folder.glob('*.dcm'))\n    if not files:\n        return\n    selection = np.linspace(0, len(files) - 1, MONTAGE_SLICES).round().astype(int)\n    fig, axes = plt.subplots(3, 3, figsize=(8, 8))\n    for axis, index in zip(axes.flat, selection):\n        try:\n            axis.imshow(normalized_slice(files[index]), cmap='gray')\n            axis.set_title(f'slice {index + 1}/{len(files)}', fontsize=8)\n        except Exception as exc:\n            axis.text(.5, .5, type(exc).__name__, ha='center', va='center', fontsize=8)\n        axis.axis('off')\n    fig.suptitle(\n        f\"{row.protocol_fingerprint} | {row.Anatomical_Plane} | fluid={row.Fluid_Sensitive} | fat-sat={row.Fat_Suppression}\",\n        fontsize=11,\n    )\n    plt.tight_layout()\n    plt.show()\n\nrepresentatives = (\n    qc[qc['qc_flag'].eq('ok')]\n    .sort_values('protocol_fingerprint')\n    .groupby('protocol_fingerprint', as_index=False)\n    .first()\n    .merge(protocols[['protocol_fingerprint', 'series']], on='protocol_fingerprint')\n    .sort_values('series', ascending=False)\n    .head(MAX_PROTOCOL_MONTAGES)\n)\nfor _, representative in representatives.iterrows():\n    show_montage(representative)"},{"cell_type":"markdown","id":"f01384c9","metadata":{},"source":"## Output files\n\n- `dicom_series_qc.csv`: one sampled series per row, including decoded header fields and QC flag.\n- `protocol_fingerprints.csv`: grouped protocol signatures with sampled series and study counts."},{"cell_type":"code","execution_count":null,"id":"55300ff2","metadata":{},"outputs":[],"source":"print({\n    'sampled_series': len(qc),\n    'unique_protocols': qc['protocol_fingerprint'].nunique(),\n    'qc_flagged': int((qc['qc_flag'] != 'ok').sum()),\n    'series_qc_csv': str(WORK / 'dicom_series_qc.csv'),\n    'protocol_csv': str(WORK / 'protocol_fingerprints.csv'),\n})"}],"metadata":{"jupytext":{"cell_metadata_filter":"-all","main_language":"python","notebook_metadata_filter":"-all"},"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"}},"nbformat":4,"nbformat_minor":5}