From a937d2a7fc83d309cb2ff7eb2e038957a6a8381d Mon Sep 17 00:00:00 2001 From: Amlan Mishra Date: Wed, 26 Aug 2026 16:48:34 +0530 Subject: [PATCH 01/11] Add CheXlocalize_Dataset for CheXpert official val/test + segmentation masks CheX_Dataset can't load CheXlocalize's data: it infers the train/valid split by string-matching the CSV path and raises NotImplementedError otherwise, and it assumes Sex/Age/Frontal-Lateral/AP-PA columns exist, which the blinded official test_labels.csv omits to prevent re-identification. CheXlocalize_Dataset handles both, and adds pathology_masks support for the 10 pathologies with radiologist ground-truth segmentations (COCO RLE via pycocotools), following the pattern in SIIM_Pneumothorax_Dataset. --- README.md | 5 + tests/test_dataloaders.py | 94 ++++++++++++++++ torchxrayvision/datasets.py | 206 +++++++++++++++++++++++++++++++++++- 3 files changed, 302 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8933e04..d0f4852 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,10 @@ d_vin = xrv.datasets.VinBrain_Dataset(imgpath=".../train", # National Library of Medicine Tuberculosis Datasets. https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4256233/ d_nlmtb = xrv.datasets.NLMTB_Dataset(imgpath="path to MontgomerySet or ChinaSet_AllFiles") + +# CheXlocalize: official CheXpert val/test images, blinded test labels, and radiologist segmentations. https://doi.org/10.1038/s42256-022-00536-x +d_chexlocalize = xrv.datasets.CheXlocalize_Dataset(imgpath="path to CheXpert val or test image folder", + csvpath="path to val_labels.csv or test_labels.csv") ``` ## Dataset fields @@ -263,6 +267,7 @@ Masks are available in the following datasets: xrv.datasets.RSNA_Pneumonia_Dataset() # for Lung Opacity xrv.datasets.SIIM_Pneumothorax_Dataset() # for Pneumothorax xrv.datasets.NIH_Dataset() # for Cardiomegaly, Mass, Effusion, ... +xrv.datasets.CheXlocalize_Dataset() # for 10 pathologies, from radiologist ground-truth segmentations ``` Example usage: diff --git a/tests/test_dataloaders.py b/tests/test_dataloaders.py index adfb381..0dd3f6f 100644 --- a/tests/test_dataloaders.py +++ b/tests/test_dataloaders.py @@ -1,3 +1,4 @@ +import json import os import shutil import sys @@ -323,3 +324,96 @@ def test_nih_dataset_patient_sex_column(tmp_path): assert d.csv["sex_male"].iloc[0] assert not d.csv["sex_female"].iloc[0] + + +def _make_chexlocalize_test_csv(tmp_path, split="test"): + """A blinded-official-test-style CSV: no Sex/Age/Frontal-Lateral/AP-PA + columns, and a 'valid/' path prefix on val rows to test normalization.""" + import pandas as pd + + prefix = "valid/" if split == "val" else f"{split}/" + csv = pd.DataFrame({ + "Path": [f"CheXpert-v1.0/{prefix}patient64622/study1/view1_frontal.jpg"], + "Atelectasis": [1], + "Cardiomegaly": [0], + "Consolidation": [np.nan], + "Edema": [-1], + "Pleural Effusion": [1], + }) + csv_path = tmp_path / f"{split}_labels.csv" + csv.to_csv(csv_path, index=False) + + img_dir = tmp_path / ("val" if split == "val" else split) / "patient64622" / "study1" + img_dir.mkdir(parents=True) + shutil.copyfile(test_jpg_img_file, img_dir / "view1_frontal.jpg") + + return csv_path + + +def test_chexlocalize_dataset_blinded_test_csv(tmp_path): + """CheX_Dataset raises NotImplementedError on a CSV path without 'train' + or 'valid' in it (as with CheXlocalize's test_labels.csv), and assumes + Sex/Age/Frontal-Lateral/AP-PA columns exist. CheXlocalize_Dataset must + load this blinded test CSV without either problem.""" + csv_path = _make_chexlocalize_test_csv(tmp_path, split="test") + + d = xrv.datasets.CheXlocalize_Dataset(imgpath=str(tmp_path), csvpath=str(csv_path)) + + assert len(d) == 1 + sample = d[0] + assert "img" in sample + assert "lab" in sample + assert d.csv["patientid"].iloc[0] == "64622" + + atelectasis_idx = d.pathologies.index("Atelectasis") + edema_idx = d.pathologies.index("Edema") + assert sample["lab"][atelectasis_idx] == 1 + assert np.isnan(sample["lab"][edema_idx]) # uncertain (-1) becomes NaN + + +def test_chexlocalize_dataset_valid_prefix_normalized_to_val(tmp_path): + """CheXlocalize's val CSV uses a 'valid/' path prefix, but images ship + under a 'val/' directory on disk — CheXlocalize_Dataset must normalize + this rather than fail to find the image.""" + csv_path = _make_chexlocalize_test_csv(tmp_path, split="val") + + d = xrv.datasets.CheXlocalize_Dataset(imgpath=str(tmp_path), csvpath=str(csv_path)) + + assert len(d) == 1 + sample = d[0] + assert "img" in sample + + +def test_chexlocalize_dataset_segmentation_masks(tmp_path): + pycocotools = pytest.importorskip("pycocotools") + from pycocotools import mask as coco_mask + + csv_path = _make_chexlocalize_test_csv(tmp_path, split="test") + + raw_mask = np.zeros((256, 256), dtype=np.uint8, order="F") + raw_mask[64:128, 64:128] = 1 + rle = coco_mask.encode(raw_mask) + rle["counts"] = rle["counts"].decode("ascii") + + segmentations = { + "patient64622_study1_view1_frontal": { + "Atelectasis": rle, + } + } + seg_path = tmp_path / "gt_segmentations_test.json" + with open(seg_path, "w") as f: + json.dump(segmentations, f) + + d = xrv.datasets.CheXlocalize_Dataset( + imgpath=str(tmp_path), csvpath=str(csv_path), + pathology_masks=True, segmentation_jsonpath=str(seg_path)) + + sample = d[0] + assert "pathology_masks" in sample + + atelectasis_idx = d.pathologies.index("Atelectasis") + cardiomegaly_idx = d.pathologies.index("Cardiomegaly") + assert atelectasis_idx in sample["pathology_masks"] + assert sample["pathology_masks"][atelectasis_idx].sum() > 0 + # Pathologies absent from the segmentation JSON must yield an all-zero mask + assert sample["pathology_masks"][cardiomegaly_idx].sum() == 0 diff --git a/torchxrayvision/datasets.py b/torchxrayvision/datasets.py index 2d96641..764592c 100644 --- a/torchxrayvision/datasets.py +++ b/torchxrayvision/datasets.py @@ -1162,6 +1162,206 @@ def __getitem__(self, idx): return sample +class CheXlocalize_Dataset(Dataset): + """CheXlocalize dataset (Stanford) + + CheXlocalize supplies the official CheXpert validation and test image + sets together with two things not available anywhere else: the blinded + official test-set labels (majority vote of 5 board-certified + radiologists) and radiologist-drawn segmentation masks localizing 10 of + the 13 CheXpert pathologies. Use this class instead of ``CheX_Dataset`` + for the CheXpert val/test splits: ``CheX_Dataset`` infers the split by + string-matching ``'train'``/``'valid'`` in the CSV path and raises + ``NotImplementedError`` on CheXlocalize's ``test_labels.csv``, and the + blinded test CSV omits the ``Sex``/``Age``/``Frontal-Lateral``/``AP-PA`` + columns ``CheX_Dataset`` assumes exist (dropped to prevent + re-identification of the blinded test set). + + **Pathologies (13):** same as ``CheX_Dataset`` — Atelectasis, + Cardiomegaly, Consolidation, Edema, Effusion, Enlarged + Cardiomediastinum, Fracture, Lung Lesion, Lung Opacity, Pleural Other, + Pneumonia, Pneumothorax, Support Devices. + + **Segmentation masks** are available for 10 of these pathologies (all + but Fracture, Pleural Other, Pneumonia) via ``pathology_masks=True`` and + ``segmentation_jsonpath`` pointing at ``gt_segmentations_val.json`` or + ``gt_segmentations_test.json``. Masks are stored as COCO RLE + (``pycocotools``) keyed by CXR id (``patientX_studyY_viewZ_frontal``). + Requires ``pycocotools`` to decode. + + Citation: + Saporta A, Gui X, Agrawal A, et al. + Benchmarking saliency methods for chest X-ray interpretation. + *Nature Machine Intelligence*, 2022. + https://doi.org/10.1038/s42256-022-00536-x + + Dataset website: + https://stanfordaimi.azurewebsites.net/datasets/23c56a0d-15de-405b-87c8-99c30138950c + """ + + def __init__(self, + imgpath, + csvpath, + views=["PA", "AP"], + transform=None, + data_aug=None, + seed=0, + unique_patients=True, + pathology_masks=False, + segmentation_jsonpath=None + ): + + super(CheXlocalize_Dataset, self).__init__() + np.random.seed(seed) # Reset the seed so all runs are the same. + + self.pathologies = ["Enlarged Cardiomediastinum", + "Cardiomegaly", + "Lung Opacity", + "Lung Lesion", + "Edema", + "Consolidation", + "Pneumonia", + "Atelectasis", + "Pneumothorax", + "Pleural Effusion", + "Pleural Other", + "Fracture", + "Support Devices"] + + self.pathologies = sorted(self.pathologies) + + self.imgpath = imgpath + self.transform = transform + self.data_aug = data_aug + self.pathology_masks = pathology_masks + self.segmentation_jsonpath = segmentation_jsonpath + self.csvpath = csvpath + self.csv = pd.read_csv(self.csvpath) + self.views = views + + # clean up path in csv so the user can specify the path, and so + # patient/view parsing below works regardless of the val/test split + self.csv["Path"] = self.csv["Path"].str.replace("CheXpert-v1.0-small/", "", regex=False) + self.csv["Path"] = self.csv["Path"].str.replace("CheXpert-v1.0/", "", regex=False) + self.csv["Path"] = self.csv["Path"].str.replace(r"^valid/", "val/", regex=True) + + # The blinded official test CSV omits demographic/view columns to + # prevent re-identification. Synthesize safe defaults instead of + # assuming they exist like CheX_Dataset does. + if "Frontal/Lateral" not in self.csv.columns: + self.csv["Frontal/Lateral"] = np.where( + self.csv["Path"].str.contains("_lateral", case=False), "Lateral", "Frontal") + if "AP/PA" not in self.csv.columns: + self.csv["AP/PA"] = "AP" + if "Sex" not in self.csv.columns: + self.csv["Sex"] = "Unknown" + if "Age" not in self.csv.columns: + self.csv["Age"] = np.nan + + self.csv["view"] = self.csv["Frontal/Lateral"] # Assign view column + self.csv.loc[(self.csv["view"] == "Frontal"), "view"] = self.csv["AP/PA"] # If Frontal change with the corresponding value in the AP/PA column otherwise remains Lateral + self.csv["view"] = self.csv["view"].replace({'Lateral': "L"}) # Rename Lateral with L + + self.limit_to_selected_views(views) + + if unique_patients: + self.csv["PatientID"] = self.csv["Path"].str.extract(pat=r'(patient\d+)') + self.csv = self.csv.groupby("PatientID").first().reset_index() + + # Get our classes. + healthy = self.csv["No Finding"] == 1 if "No Finding" in self.csv.columns else pd.Series(False, index=self.csv.index) + labels = [] + for pathology in self.pathologies: + if pathology in self.csv.columns: + if pathology != "Support Devices": + self.csv.loc[healthy, pathology] = 0 + mask = self.csv[pathology] + else: + mask = pd.Series(np.nan, index=self.csv.index) + + labels.append(mask.values) + self.labels = np.asarray(labels).T + self.labels = self.labels.astype(np.float32) + + # Make all the -1 values into nans to keep things simple + self.labels[self.labels == -1] = np.nan + + # Rename pathologies + self.pathologies = list(np.char.replace(self.pathologies, "Pleural Effusion", "Effusion")) + + # patientid (split-agnostic, unlike CheX_Dataset) + patientid = self.csv["Path"].str.extract(pat=r'patient(\d+)')[0] + self.csv["patientid"] = patientid + + # age + self.csv['age_years'] = self.csv['Age'] * 1.0 + self.csv.loc[self.csv['Age'] == 0, 'Age'] = None + + # sex + self.csv['sex_male'] = self.csv['Sex'] == 'Male' + self.csv['sex_female'] = self.csv['Sex'] == 'Female' + + if self.pathology_masks and self.segmentation_jsonpath: + import json + with open(self.segmentation_jsonpath) as f: + self.segmentations = json.load(f) + else: + self.segmentations = {} + + def string(self): + return self.__class__.__name__ + " num_samples={} views={} data_aug={}".format(len(self), self.views, self.data_aug) + + def __len__(self): + return len(self.labels) + + def __getitem__(self, idx): + sample = {} + sample["idx"] = idx + sample["lab"] = self.labels[idx] + + imgid = self.csv['Path'].iloc[idx] + img_path = os.path.join(self.imgpath, imgid) + img = imread(img_path) + + sample["img"] = normalize(img, maxval=255, reshape=True) + + if self.pathology_masks: + sample["pathology_masks"] = self.get_pathology_mask_dict(imgid, sample["img"].shape[2]) + + sample = apply_transforms(sample, self.transform) + sample = apply_transforms(sample, self.data_aug) + + return sample + + def get_pathology_mask_dict(self, imgid, this_size): + try: + from pycocotools import mask as coco_mask + except ImportError: + raise Exception("Please install pycocotools to work with CheXlocalize segmentation masks") + + # e.g. "val/patient64622/study1/view1_frontal.jpg" -> "patient64622_study1_view1_frontal" + cxr_id = os.path.splitext(imgid)[0] + cxr_id = "_".join(cxr_id.split("/")[1:]) + + path_mask = {} + entry = self.segmentations.get(cxr_id, {}) + for patho in self.pathologies: + # "Effusion" in this class's pathologies vs. "Pleural Effusion" in the JSON + json_key = "Pleural Effusion" if patho == "Effusion" else patho + mask = np.zeros([this_size, this_size]) + + if json_key in entry: + rle = entry[json_key] + decoded = coco_mask.decode(rle).astype(np.float32) + decoded = skimage.transform.resize(decoded, (this_size, this_size), mode='constant', order=0) + mask = decoded.round() # make 0,1 + + mask = mask[None, :, :] + path_mask[self.pathologies.index(patho)] = mask + + return path_mask + + class MIMIC_Dataset(Dataset): """MIMIC-CXR dataset (MIT / Beth Israel Deaconess Medical Center) @@ -1771,7 +1971,7 @@ def __init__(self, with open(os.path.join(self.imgpath, "annotations", "json", split_to_json[split])) as f: data = json.load(f) - + self.csv = pd.DataFrame(data["images"]) ann_dict = defaultdict(list) for ann in data["annotations"]: @@ -1803,7 +2003,7 @@ def string(self): def __len__(self): return len(self.labels) - + def __getitem__(self, idx): sample = {} sample["idx"] = idx @@ -1816,7 +2016,7 @@ def __getitem__(self, idx): sample = apply_transforms(sample, self.transform) sample = apply_transforms(sample, self.data_aug) return sample - + class SIIM_Pneumothorax_Dataset(Dataset): """SIIM-ACR Pneumothorax Segmentation dataset From 879db0a3ce0666d1be4ae49379e9dab63615c43a Mon Sep 17 00:00:00 2001 From: Amlan Mishra Date: Thu, 27 Aug 2026 11:46:04 +0530 Subject: [PATCH 02/11] Align CheXlocalize mask dict with other loaders, document annotation license --- tests/test_dataloaders.py | 6 ++++-- torchxrayvision/datasets.py | 32 ++++++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/tests/test_dataloaders.py b/tests/test_dataloaders.py index 0dd3f6f..c8c0f97 100644 --- a/tests/test_dataloaders.py +++ b/tests/test_dataloaders.py @@ -415,5 +415,7 @@ def test_chexlocalize_dataset_segmentation_masks(tmp_path): cardiomegaly_idx = d.pathologies.index("Cardiomegaly") assert atelectasis_idx in sample["pathology_masks"] assert sample["pathology_masks"][atelectasis_idx].sum() > 0 - # Pathologies absent from the segmentation JSON must yield an all-zero mask - assert sample["pathology_masks"][cardiomegaly_idx].sum() == 0 + # Pathologies absent from the segmentation JSON get no entry (sparse dict, + # matching NIH_Dataset/VinBrain_Dataset/ObjectCXR_Dataset) + assert cardiomegaly_idx not in sample["pathology_masks"] + assert bool(d.csv["has_masks"].iloc[0]) diff --git a/torchxrayvision/datasets.py b/torchxrayvision/datasets.py index 764592c..7e6003e 100644 --- a/torchxrayvision/datasets.py +++ b/torchxrayvision/datasets.py @@ -1189,6 +1189,15 @@ class CheXlocalize_Dataset(Dataset): (``pycocotools``) keyed by CXR id (``patientX_studyY_viewZ_frontal``). Requires ``pycocotools`` to decode. + License: + The images and segmentation annotations are released under the + Stanford University School of Medicine CheXlocalize Dataset + Research Use Agreement (shown at registration on the Stanford AIMI + download portal, see dataset website below): personal, + non-commercial research use only, no redistribution, no derivative + works. The benchmarking code in the ``cheXlocalize`` GitHub repo is + separately MIT-licensed (https://github.com/rajpurkarlab/cheXlocalize). + Citation: Saporta A, Gui X, Agrawal A, et al. Benchmarking saliency methods for chest X-ray interpretation. @@ -1308,6 +1317,10 @@ def __init__(self, else: self.segmentations = {} + # e.g. "val/patient64622/study1/view1_frontal.jpg" -> "patient64622_study1_view1_frontal" + cxr_id = self.csv["Path"].apply(lambda p: "_".join(os.path.splitext(p)[0].split("/")[1:])) + self.csv["has_masks"] = cxr_id.isin(self.segmentations.keys()) + def string(self): return self.__class__.__name__ + " num_samples={} views={} data_aug={}".format(len(self), self.views, self.data_aug) @@ -1326,14 +1339,14 @@ def __getitem__(self, idx): sample["img"] = normalize(img, maxval=255, reshape=True) if self.pathology_masks: - sample["pathology_masks"] = self.get_pathology_mask_dict(imgid, sample["img"].shape[2]) + sample["pathology_masks"] = self.get_mask_dict(imgid, sample["img"].shape[2]) sample = apply_transforms(sample, self.transform) sample = apply_transforms(sample, self.data_aug) return sample - def get_pathology_mask_dict(self, imgid, this_size): + def get_mask_dict(self, imgid, this_size): try: from pycocotools import mask as coco_mask except ImportError: @@ -1348,15 +1361,18 @@ def get_pathology_mask_dict(self, imgid, this_size): for patho in self.pathologies: # "Effusion" in this class's pathologies vs. "Pleural Effusion" in the JSON json_key = "Pleural Effusion" if patho == "Effusion" else patho - mask = np.zeros([this_size, this_size]) - if json_key in entry: - rle = entry[json_key] - decoded = coco_mask.decode(rle).astype(np.float32) - decoded = skimage.transform.resize(decoded, (this_size, this_size), mode='constant', order=0) - mask = decoded.round() # make 0,1 + # Don't add masks for labels we don't have (matches NIH_Dataset, + # VinBrain_Dataset, ObjectCXR_Dataset: sparse dict, no zero masks) + if json_key not in entry: + continue + rle = entry[json_key] + decoded = coco_mask.decode(rle).astype(np.float32) + decoded = skimage.transform.resize(decoded, (this_size, this_size), mode='constant', order=0) + mask = decoded.round() # make 0,1 mask = mask[None, :, :] + path_mask[self.pathologies.index(patho)] = mask return path_mask From 29e8dc3194d556f576a656d987989dc94ca38a8a Mon Sep 17 00:00:00 2001 From: Amlan Mishra Date: Thu, 27 Aug 2026 11:48:07 +0530 Subject: [PATCH 03/11] Stop fabricating AP/PA=AP for blinded CheXlocalize test set, use UNKNOWN --- tests/test_dataloaders.py | 4 ++++ torchxrayvision/datasets.py | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_dataloaders.py b/tests/test_dataloaders.py index c8c0f97..8457f23 100644 --- a/tests/test_dataloaders.py +++ b/tests/test_dataloaders.py @@ -359,6 +359,10 @@ def test_chexlocalize_dataset_blinded_test_csv(tmp_path): d = xrv.datasets.CheXlocalize_Dataset(imgpath=str(tmp_path), csvpath=str(csv_path)) + # AP/PA is genuinely unknown for the blinded test CSV — must not be + # guessed as "AP", and the frontal image must still survive the default + # views filter rather than being silently dropped as an unknown view. + assert d.csv["AP/PA"].iloc[0] == "UNKNOWN" assert len(d) == 1 sample = d[0] assert "img" in sample diff --git a/torchxrayvision/datasets.py b/torchxrayvision/datasets.py index 7e6003e..6386682 100644 --- a/torchxrayvision/datasets.py +++ b/torchxrayvision/datasets.py @@ -1211,7 +1211,7 @@ class CheXlocalize_Dataset(Dataset): def __init__(self, imgpath, csvpath, - views=["PA", "AP"], + views=["PA", "AP", "UNKNOWN"], transform=None, data_aug=None, seed=0, @@ -1261,7 +1261,11 @@ def __init__(self, self.csv["Frontal/Lateral"] = np.where( self.csv["Path"].str.contains("_lateral", case=False), "Lateral", "Frontal") if "AP/PA" not in self.csv.columns: - self.csv["AP/PA"] = "AP" + # Neither CheXlocalize nor CheXpert documentation states whether + # the blinded test set is AP, PA, or a mix — leave it unknown + # rather than guess. `views` defaults to include "UNKNOWN" so + # these frontal images aren't silently dropped. + self.csv["AP/PA"] = "UNKNOWN" if "Sex" not in self.csv.columns: self.csv["Sex"] = "Unknown" if "Age" not in self.csv.columns: From 79f2ca483f63de60cda11e71eba07f2e1583b711 Mon Sep 17 00:00:00 2001 From: Amlan Mishra Date: Thu, 27 Aug 2026 11:56:10 +0530 Subject: [PATCH 04/11] Add usage example to CheXlocalize_Dataset docstring --- torchxrayvision/datasets.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/torchxrayvision/datasets.py b/torchxrayvision/datasets.py index 6386682..3a2ee6f 100644 --- a/torchxrayvision/datasets.py +++ b/torchxrayvision/datasets.py @@ -1206,6 +1206,13 @@ class CheXlocalize_Dataset(Dataset): Dataset website: https://stanfordaimi.azurewebsites.net/datasets/23c56a0d-15de-405b-87c8-99c30138950c + + Example: + >>> d = xrv.datasets.CheXlocalize_Dataset( + ... imgpath="CheXpert-v1.0/test", + ... csvpath="test_labels.csv", + ... pathology_masks=True, + ... segmentation_jsonpath="gt_segmentations_test.json") """ def __init__(self, From fdd9580e7a4beb096126b48ac67c057ede90b86f Mon Sep 17 00:00:00 2001 From: Amlan Mishra Date: Thu, 27 Aug 2026 12:00:11 +0530 Subject: [PATCH 05/11] Add demo notebook for CheXlocalize_Dataset pathology masks --- scripts/xray_chexlocalize.ipynb | 117 ++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 scripts/xray_chexlocalize.ipynb diff --git a/scripts/xray_chexlocalize.ipynb b/scripts/xray_chexlocalize.ipynb new file mode 100644 index 0000000..31e400d --- /dev/null +++ b/scripts/xray_chexlocalize.ipynb @@ -0,0 +1,117 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os,sys\n", + "sys.path.insert(0,\"..\")\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import torchxrayvision as xrv" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`CheXlocalize_Dataset` follows the same `pathology_masks` interface as `NIH_Dataset`, `VinBrain_Dataset`, and `SIIM_Pneumothorax_Dataset`: `sample[\"pathology_masks\"]` is a sparse dict keyed by pathology index, each value a `(1, H, W)` array." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "dataset_path = \"/home/groups/akshaysc/joecohen/CheXpert\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def plot_sample_with_masks(sample, df):\n", + " width = len(sample[\"pathology_masks\"])\n", + " fig, axs = plt.subplots(1, max(2,1+width), sharey=True, figsize=(3+3*width,3))\n", + " axs[0].imshow(sample[\"img\"][0], cmap=\"Greys_r\");\n", + " axs[0].set_title(\"idx:\" + str(sample[\"idx\"]))\n", + " for i, patho in enumerate(sample[\"pathology_masks\"].keys()):\n", + " axs[i+1].imshow(sample[\"img\"][0], cmap=\"Greys_r\");\n", + " axs[i+1].imshow(sample[\"pathology_masks\"][patho][0]+1, alpha=0.5);\n", + " axs[i+1].set_title(df.pathologies[patho])\n", + " plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d_chexlocalize = xrv.datasets.CheXlocalize_Dataset(\n", + " imgpath=os.path.join(dataset_path, \"CheXpert-v1.0/test\"),\n", + " csvpath=os.path.join(dataset_path, \"test_labels.csv\"),\n", + " pathology_masks=True,\n", + " segmentation_jsonpath=os.path.join(dataset_path, \"gt_segmentations_test.json\"))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d_chexlocalize.csv.has_masks.value_counts()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for idx in np.where(d_chexlocalize.csv.has_masks)[0][:5]:\n", + " sample = d_chexlocalize[idx]\n", + " if len(sample[\"pathology_masks\"]) > 0:\n", + " plot_sample_with_masks(sample, d_chexlocalize)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From a9caf8b9ecb697b42f0974086186d429d6d4b06d Mon Sep 17 00:00:00 2001 From: Amlan Mishra Date: Thu, 27 Aug 2026 12:29:55 +0530 Subject: [PATCH 06/11] Decode CheXlocalize COCO RLE masks without pycocotools. Add a pure-Python COCO RLE decoder so CheXlocalize segmentation masks no longer require pycocotools; SIIM's flat start/length RLE format is incompatible. Co-authored-by: Cursor --- tests/test_dataloaders.py | 34 +++++++++++++--- torchxrayvision/datasets.py | 78 ++++++++++++++++++++++++++++++++----- 2 files changed, 98 insertions(+), 14 deletions(-) diff --git a/tests/test_dataloaders.py b/tests/test_dataloaders.py index 8457f23..28e7068 100644 --- a/tests/test_dataloaders.py +++ b/tests/test_dataloaders.py @@ -388,16 +388,40 @@ def test_chexlocalize_dataset_valid_prefix_normalized_to_val(tmp_path): assert "img" in sample -def test_chexlocalize_dataset_segmentation_masks(tmp_path): - pycocotools = pytest.importorskip("pycocotools") - from pycocotools import mask as coco_mask +def _mask_to_uncompressed_coco_rle(mask): + """Build an uncompressed COCO RLE dict from a binary mask (Fortran order).""" + data = mask.reshape(-1, order="F") + counts = [] + value = 0 + run = 0 + for pixel in data: + if pixel == value: + run += 1 + else: + counts.append(run) + run = 1 + value = 1 - value + counts.append(run) + return {"size": list(mask.shape), "counts": counts} + + +def test_decode_coco_rle_compressed_string(): + """CheXlocalize ships compressed COCO RLE strings, not SIIM-style flat pairs.""" + rle = { + "size": [256, 256], + "counts": "PR`0P2P600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Pno0", + } + mask = xrv.datasets._decode_coco_rle(rle) + assert mask.shape == (256, 256) + assert int(mask[64:128, 64:128].sum()) == 64 * 64 + +def test_chexlocalize_dataset_segmentation_masks(tmp_path): csv_path = _make_chexlocalize_test_csv(tmp_path, split="test") raw_mask = np.zeros((256, 256), dtype=np.uint8, order="F") raw_mask[64:128, 64:128] = 1 - rle = coco_mask.encode(raw_mask) - rle["counts"] = rle["counts"].decode("ascii") + rle = _mask_to_uncompressed_coco_rle(raw_mask) segmentations = { "patient64622_study1_view1_frontal": { diff --git a/torchxrayvision/datasets.py b/torchxrayvision/datasets.py index 3a2ee6f..ea7a162 100644 --- a/torchxrayvision/datasets.py +++ b/torchxrayvision/datasets.py @@ -95,6 +95,72 @@ def apply_transforms(sample, transform, seed=None) -> Dict: return sample +def _rle_fr_string(encoded_string): + """Convert a COCO-compressed RLE string to run-length counts. + + Pure-Python translation of rleFrString() from COCO maskApi.c. + """ + encoded_bytes = encoded_string.encode("latin-1") + encoded_length = len(encoded_bytes) + counts_array = [0] * encoded_length + num_counts = 0 + string_position = 0 + + while string_position < encoded_length: + decoded_value = 0 + bit_position = 0 + has_more_bits = True + + while has_more_bits and string_position < encoded_length: + char_value = encoded_bytes[string_position] - 48 + decoded_value |= (char_value & 0x1F) << (5 * bit_position) + has_more_bits = (char_value & 0x20) != 0 + string_position += 1 + bit_position += 1 + + if not has_more_bits and (char_value & 0x10): + decoded_value |= -1 << (5 * bit_position) + + if num_counts > 2: + decoded_value += counts_array[num_counts - 2] + + counts_array[num_counts] = decoded_value + num_counts += 1 + + return counts_array[:num_counts] + + +def _decode_coco_rle(rle): + """Decode a COCO RLE dict to a (H, W) uint8 mask without pycocotools.""" + height, width = rle["size"] + counts = rle["counts"] + + if isinstance(counts, (str, bytes)): + if isinstance(counts, bytes): + counts = counts.decode("latin-1") + counts = _rle_fr_string(counts) + elif not isinstance(counts, list): + raise ValueError("Invalid COCO RLE counts") + + total_pixels = height * width + mask = np.zeros(total_pixels, dtype=np.uint8) + current_position = 0 + current_value = 0 + + for count in counts: + if count <= 0: + continue + end_position = current_position + count + if end_position > total_pixels: + raise ValueError("Invalid COCO RLE data") + if current_value == 1: + mask[current_position:end_position] = 1 + current_position = end_position + current_value = 1 - current_value + + return mask.reshape((height, width), order="F") + + def relabel_dataset(pathologies, dataset, silent=False): """This function will add, remove, and reorder the `.labels` field to have the same order as the pathologies argument passed to it. If a pathology is specified but doesn’t @@ -1185,9 +1251,8 @@ class CheXlocalize_Dataset(Dataset): **Segmentation masks** are available for 10 of these pathologies (all but Fracture, Pleural Other, Pneumonia) via ``pathology_masks=True`` and ``segmentation_jsonpath`` pointing at ``gt_segmentations_val.json`` or - ``gt_segmentations_test.json``. Masks are stored as COCO RLE - (``pycocotools``) keyed by CXR id (``patientX_studyY_viewZ_frontal``). - Requires ``pycocotools`` to decode. + ``gt_segmentations_test.json``. Masks are stored as COCO RLE keyed by CXR + id (``patientX_studyY_viewZ_frontal``). License: The images and segmentation annotations are released under the @@ -1358,11 +1423,6 @@ def __getitem__(self, idx): return sample def get_mask_dict(self, imgid, this_size): - try: - from pycocotools import mask as coco_mask - except ImportError: - raise Exception("Please install pycocotools to work with CheXlocalize segmentation masks") - # e.g. "val/patient64622/study1/view1_frontal.jpg" -> "patient64622_study1_view1_frontal" cxr_id = os.path.splitext(imgid)[0] cxr_id = "_".join(cxr_id.split("/")[1:]) @@ -1379,7 +1439,7 @@ def get_mask_dict(self, imgid, this_size): continue rle = entry[json_key] - decoded = coco_mask.decode(rle).astype(np.float32) + decoded = _decode_coco_rle(rle).astype(np.float32) decoded = skimage.transform.resize(decoded, (this_size, this_size), mode='constant', order=0) mask = decoded.round() # make 0,1 mask = mask[None, :, :] From 554496651c68d6f00ac49e621b272aa713bee181 Mon Sep 17 00:00:00 2001 From: Amlan Mishra Date: Thu, 27 Aug 2026 12:34:43 +0530 Subject: [PATCH 07/11] Revert "Decode CheXlocalize COCO RLE masks without pycocotools." This reverts commit a9caf8b9ecb697b42f0974086186d429d6d4b06d. --- tests/test_dataloaders.py | 34 +++------------- torchxrayvision/datasets.py | 78 +++++-------------------------------- 2 files changed, 14 insertions(+), 98 deletions(-) diff --git a/tests/test_dataloaders.py b/tests/test_dataloaders.py index 28e7068..8457f23 100644 --- a/tests/test_dataloaders.py +++ b/tests/test_dataloaders.py @@ -388,40 +388,16 @@ def test_chexlocalize_dataset_valid_prefix_normalized_to_val(tmp_path): assert "img" in sample -def _mask_to_uncompressed_coco_rle(mask): - """Build an uncompressed COCO RLE dict from a binary mask (Fortran order).""" - data = mask.reshape(-1, order="F") - counts = [] - value = 0 - run = 0 - for pixel in data: - if pixel == value: - run += 1 - else: - counts.append(run) - run = 1 - value = 1 - value - counts.append(run) - return {"size": list(mask.shape), "counts": counts} - - -def test_decode_coco_rle_compressed_string(): - """CheXlocalize ships compressed COCO RLE strings, not SIIM-style flat pairs.""" - rle = { - "size": [256, 256], - "counts": "PR`0P2P600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Pno0", - } - mask = xrv.datasets._decode_coco_rle(rle) - assert mask.shape == (256, 256) - assert int(mask[64:128, 64:128].sum()) == 64 * 64 - - def test_chexlocalize_dataset_segmentation_masks(tmp_path): + pycocotools = pytest.importorskip("pycocotools") + from pycocotools import mask as coco_mask + csv_path = _make_chexlocalize_test_csv(tmp_path, split="test") raw_mask = np.zeros((256, 256), dtype=np.uint8, order="F") raw_mask[64:128, 64:128] = 1 - rle = _mask_to_uncompressed_coco_rle(raw_mask) + rle = coco_mask.encode(raw_mask) + rle["counts"] = rle["counts"].decode("ascii") segmentations = { "patient64622_study1_view1_frontal": { diff --git a/torchxrayvision/datasets.py b/torchxrayvision/datasets.py index ea7a162..3a2ee6f 100644 --- a/torchxrayvision/datasets.py +++ b/torchxrayvision/datasets.py @@ -95,72 +95,6 @@ def apply_transforms(sample, transform, seed=None) -> Dict: return sample -def _rle_fr_string(encoded_string): - """Convert a COCO-compressed RLE string to run-length counts. - - Pure-Python translation of rleFrString() from COCO maskApi.c. - """ - encoded_bytes = encoded_string.encode("latin-1") - encoded_length = len(encoded_bytes) - counts_array = [0] * encoded_length - num_counts = 0 - string_position = 0 - - while string_position < encoded_length: - decoded_value = 0 - bit_position = 0 - has_more_bits = True - - while has_more_bits and string_position < encoded_length: - char_value = encoded_bytes[string_position] - 48 - decoded_value |= (char_value & 0x1F) << (5 * bit_position) - has_more_bits = (char_value & 0x20) != 0 - string_position += 1 - bit_position += 1 - - if not has_more_bits and (char_value & 0x10): - decoded_value |= -1 << (5 * bit_position) - - if num_counts > 2: - decoded_value += counts_array[num_counts - 2] - - counts_array[num_counts] = decoded_value - num_counts += 1 - - return counts_array[:num_counts] - - -def _decode_coco_rle(rle): - """Decode a COCO RLE dict to a (H, W) uint8 mask without pycocotools.""" - height, width = rle["size"] - counts = rle["counts"] - - if isinstance(counts, (str, bytes)): - if isinstance(counts, bytes): - counts = counts.decode("latin-1") - counts = _rle_fr_string(counts) - elif not isinstance(counts, list): - raise ValueError("Invalid COCO RLE counts") - - total_pixels = height * width - mask = np.zeros(total_pixels, dtype=np.uint8) - current_position = 0 - current_value = 0 - - for count in counts: - if count <= 0: - continue - end_position = current_position + count - if end_position > total_pixels: - raise ValueError("Invalid COCO RLE data") - if current_value == 1: - mask[current_position:end_position] = 1 - current_position = end_position - current_value = 1 - current_value - - return mask.reshape((height, width), order="F") - - def relabel_dataset(pathologies, dataset, silent=False): """This function will add, remove, and reorder the `.labels` field to have the same order as the pathologies argument passed to it. If a pathology is specified but doesn’t @@ -1251,8 +1185,9 @@ class CheXlocalize_Dataset(Dataset): **Segmentation masks** are available for 10 of these pathologies (all but Fracture, Pleural Other, Pneumonia) via ``pathology_masks=True`` and ``segmentation_jsonpath`` pointing at ``gt_segmentations_val.json`` or - ``gt_segmentations_test.json``. Masks are stored as COCO RLE keyed by CXR - id (``patientX_studyY_viewZ_frontal``). + ``gt_segmentations_test.json``. Masks are stored as COCO RLE + (``pycocotools``) keyed by CXR id (``patientX_studyY_viewZ_frontal``). + Requires ``pycocotools`` to decode. License: The images and segmentation annotations are released under the @@ -1423,6 +1358,11 @@ def __getitem__(self, idx): return sample def get_mask_dict(self, imgid, this_size): + try: + from pycocotools import mask as coco_mask + except ImportError: + raise Exception("Please install pycocotools to work with CheXlocalize segmentation masks") + # e.g. "val/patient64622/study1/view1_frontal.jpg" -> "patient64622_study1_view1_frontal" cxr_id = os.path.splitext(imgid)[0] cxr_id = "_".join(cxr_id.split("/")[1:]) @@ -1439,7 +1379,7 @@ def get_mask_dict(self, imgid, this_size): continue rle = entry[json_key] - decoded = _decode_coco_rle(rle).astype(np.float32) + decoded = coco_mask.decode(rle).astype(np.float32) decoded = skimage.transform.resize(decoded, (this_size, this_size), mode='constant', order=0) mask = decoded.round() # make 0,1 mask = mask[None, :, :] From 325191426cbe7eb865481cc7994b016a8ad80778 Mon Sep 17 00:00:00 2001 From: Amlan Mishra Date: Fri, 28 Aug 2026 11:32:48 +0530 Subject: [PATCH 08/11] Match AP/PA unknown sentinel casing to other fields ("Unknown") --- torchxrayvision/datasets.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/torchxrayvision/datasets.py b/torchxrayvision/datasets.py index 3a2ee6f..148d4d5 100644 --- a/torchxrayvision/datasets.py +++ b/torchxrayvision/datasets.py @@ -1218,7 +1218,7 @@ class CheXlocalize_Dataset(Dataset): def __init__(self, imgpath, csvpath, - views=["PA", "AP", "UNKNOWN"], + views=["PA", "AP", "Unknown"], transform=None, data_aug=None, seed=0, @@ -1270,9 +1270,9 @@ def __init__(self, if "AP/PA" not in self.csv.columns: # Neither CheXlocalize nor CheXpert documentation states whether # the blinded test set is AP, PA, or a mix — leave it unknown - # rather than guess. `views` defaults to include "UNKNOWN" so + # rather than guess. `views` defaults to include "Unknown" so # these frontal images aren't silently dropped. - self.csv["AP/PA"] = "UNKNOWN" + self.csv["AP/PA"] = "Unknown" if "Sex" not in self.csv.columns: self.csv["Sex"] = "Unknown" if "Age" not in self.csv.columns: From 7d67900cda7cc2759f8299e3dc359301544aa5d9 Mon Sep 17 00:00:00 2001 From: Amlan Mishra Date: Fri, 28 Aug 2026 11:34:45 +0530 Subject: [PATCH 09/11] Default pathology_masks=True for CheXlocalize_Dataset --- torchxrayvision/datasets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/torchxrayvision/datasets.py b/torchxrayvision/datasets.py index 148d4d5..de718c2 100644 --- a/torchxrayvision/datasets.py +++ b/torchxrayvision/datasets.py @@ -1223,7 +1223,7 @@ def __init__(self, data_aug=None, seed=0, unique_patients=True, - pathology_masks=False, + pathology_masks=True, segmentation_jsonpath=None ): From 6b04d00b6de10de281330bc64997bbc684661431 Mon Sep 17 00:00:00 2001 From: Amlan Mishra Date: Fri, 28 Aug 2026 13:32:26 +0530 Subject: [PATCH 10/11] Update chexlocalize tests for Unknown casing and pathology_masks default --- tests/test_dataloaders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_dataloaders.py b/tests/test_dataloaders.py index 8457f23..6753b7b 100644 --- a/tests/test_dataloaders.py +++ b/tests/test_dataloaders.py @@ -362,7 +362,7 @@ def test_chexlocalize_dataset_blinded_test_csv(tmp_path): # AP/PA is genuinely unknown for the blinded test CSV — must not be # guessed as "AP", and the frontal image must still survive the default # views filter rather than being silently dropped as an unknown view. - assert d.csv["AP/PA"].iloc[0] == "UNKNOWN" + assert d.csv["AP/PA"].iloc[0] == "Unknown" assert len(d) == 1 sample = d[0] assert "img" in sample @@ -381,7 +381,7 @@ def test_chexlocalize_dataset_valid_prefix_normalized_to_val(tmp_path): this rather than fail to find the image.""" csv_path = _make_chexlocalize_test_csv(tmp_path, split="val") - d = xrv.datasets.CheXlocalize_Dataset(imgpath=str(tmp_path), csvpath=str(csv_path)) + d = xrv.datasets.CheXlocalize_Dataset(imgpath=str(tmp_path), csvpath=str(csv_path), pathology_masks=False) assert len(d) == 1 sample = d[0] From d28408796e1f6043481390fcd40c9fd79f053e1c Mon Sep 17 00:00:00 2001 From: Amlan Mishra Date: Fri, 28 Aug 2026 13:40:26 +0530 Subject: [PATCH 11/11] Disable pathology_masks in blinded_test_csv test to match CI deps --- tests/test_dataloaders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_dataloaders.py b/tests/test_dataloaders.py index 6753b7b..89e2b72 100644 --- a/tests/test_dataloaders.py +++ b/tests/test_dataloaders.py @@ -357,7 +357,7 @@ def test_chexlocalize_dataset_blinded_test_csv(tmp_path): load this blinded test CSV without either problem.""" csv_path = _make_chexlocalize_test_csv(tmp_path, split="test") - d = xrv.datasets.CheXlocalize_Dataset(imgpath=str(tmp_path), csvpath=str(csv_path)) + d = xrv.datasets.CheXlocalize_Dataset(imgpath=str(tmp_path), csvpath=str(csv_path), pathology_masks=False) # AP/PA is genuinely unknown for the blinded test CSV — must not be # guessed as "AP", and the frontal image must still survive the default