bruise-data 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ """bruise_data - one loader for NIJ, Fenwick, bruise mimics and extra bruise images.
2
+
3
+ from bruise_data import get_dataloaders
4
+ train_dl, val_dl, test_dl = get_dataloaders(["nij", "fenwick", "mimics"])
5
+
6
+ See README.md next to this folder for the full guide.
7
+ """
8
+ from .checks import check_splits
9
+ from .core import (DEFAULT_ROOT, REQUIRED_COLUMNS, BruiseDataset, dataset_info, get_dataloaders, get_datasets,
10
+ get_test_loaders, list_datasets, load_index, read_manifest, summary)
11
+ from .transforms import BruiseTransform, denormalize
12
+
13
+ __all__ = ["get_dataloaders", "get_datasets", "get_test_loaders", "load_index", "summary", "read_manifest",
14
+ "check_splits", "BruiseDataset", "BruiseTransform", "denormalize", "list_datasets", "dataset_info",
15
+ "REQUIRED_COLUMNS", "DEFAULT_ROOT"]
16
+ __version__ = "1.2.0"
@@ -0,0 +1,92 @@
1
+ """Command line:
2
+
3
+ python -m bruise_data datasets # every dataset found (any folder with a manifest.csv)
4
+ python -m bruise_data summary --datasets nij fenwick mimics
5
+ python -m bruise_data check [--deep 50]
6
+ python -m bruise_data preview --datasets nij mimics --out preview.jpg
7
+ python -m bruise_data classes
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ from PIL import Image, ImageDraw
16
+
17
+ from . import check_splits, dataset_info, get_datasets, list_datasets, load_index, read_manifest, summary
18
+ from .core import DEFAULT_MERGE
19
+ from .transforms import denormalize
20
+
21
+
22
+ def main():
23
+ ap = argparse.ArgumentParser(prog="python -m bruise_data", description=__doc__,
24
+ formatter_class=argparse.RawDescriptionHelpFormatter)
25
+ ap.add_argument("command", choices=["datasets", "summary", "check", "preview", "classes"])
26
+ ap.add_argument("--root", default=None, help="BRUISE_DATA folder (default: the folder holding bruise_data/)")
27
+ ap.add_argument("--datasets", nargs="+", default=None,
28
+ help="dataset folder names (default: nij fenwick mimics; check: every dataset found)")
29
+ ap.add_argument("--task", default="segmentation", choices=["segmentation", "classification"])
30
+ ap.add_argument("--mimic-classes", nargs="+", default=["all"])
31
+ ap.add_argument("--annotator", default="primary")
32
+ ap.add_argument("--test-annotator", default="majority")
33
+ ap.add_argument("--val-fold", type=int, default=0)
34
+ ap.add_argument("--deep", type=int, default=0, help="check: open N random images per dataset to compare mask size")
35
+ ap.add_argument("--split", default="train", choices=["train", "val", "test"], help="preview: which split")
36
+ ap.add_argument("--n", type=int, default=16, help="preview: number of images")
37
+ ap.add_argument("--out", default="preview.jpg")
38
+ a = ap.parse_args()
39
+ mc = "all" if a.mimic_classes == ["all"] else a.mimic_classes
40
+ kw = dict(root=a.root, task=a.task, mimic_classes=mc, annotator=a.annotator,
41
+ test_annotator=a.test_annotator, val_fold=a.val_fold)
42
+ pd.set_option("display.width", 200)
43
+
44
+ chosen = a.datasets or ["nij", "fenwick", "mimics"]
45
+
46
+ if a.command == "datasets":
47
+ rows = []
48
+ for d in list_datasets(a.root):
49
+ m = read_manifest(d, a.root)
50
+ i = dataset_info(d, manifest=m)
51
+ kind = "annotated bruise" if i["annotators"] else "bruise, no annotators" if i["has_bruise"] else "mimic / negative"
52
+ rows.append(dict(dataset=d, kind=kind, images=m.image_id.nunique(), masks=i["has_masks"],
53
+ primary=i["primary"] or "-", annotators=" ".join(i["annotators"]) or "-",
54
+ majority_test=i["has_majority"], folds=i["has_folds"]))
55
+ print(pd.DataFrame(rows).to_string(index=False))
56
+ elif a.command == "summary":
57
+ df = load_index(chosen, **kw)
58
+ print(summary(df).to_string())
59
+ print("\nclasses (label index order):", df.attrs["class_names"])
60
+ elif a.command == "check":
61
+ raise SystemExit(1 if check_splits(a.root, a.datasets, deep=a.deep) else 0)
62
+ elif a.command == "classes":
63
+ m = read_manifest("mimics", a.root)
64
+ m["class_name"] = m.class_name.replace(DEFAULT_MERGE)
65
+ print(pd.crosstab([m.class_name, m.source_dataset], m.split).to_string())
66
+ elif a.command == "preview":
67
+ tr, va, te = get_datasets(chosen, image_size=256, augment=(a.split == "train"), **kw)
68
+ ds = {"train": tr, "val": va, "test": te}[a.split]
69
+ idx = np.random.default_rng(0).choice(len(ds), size=min(a.n, len(ds)), replace=False)
70
+ tiles = []
71
+ for i in idx:
72
+ it = ds[int(i)]
73
+ img = Image.fromarray((denormalize(it["image"]).permute(1, 2, 0).numpy() * 255).astype(np.uint8))
74
+ if "mask" in it and it["mask"].sum() > 0:
75
+ m = Image.fromarray((it["mask"][0].numpy() * 255).astype(np.uint8))
76
+ red = Image.new("RGB", img.size, (255, 0, 0))
77
+ img = Image.composite(Image.blend(img, red, 0.45), img, m)
78
+ d = ImageDraw.Draw(img)
79
+ d.rectangle([0, 0, 256, 14], fill=(0, 0, 0))
80
+ d.text((3, 1), f"{it['dataset']}/{it['class_name']}", fill=(255, 255, 255))
81
+ tiles.append(img)
82
+ cols = 4
83
+ rows = (len(tiles) + cols - 1) // cols
84
+ grid = Image.new("RGB", (cols * 256, rows * 256))
85
+ for k, t in enumerate(tiles):
86
+ grid.paste(t, ((k % cols) * 256, (k // cols) * 256))
87
+ grid.save(a.out, quality=90)
88
+ print(f"saved {a.out} ({len(tiles)} {a.split} images, red = bruise mask)")
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
bruise_data/checks.py ADDED
@@ -0,0 +1,94 @@
1
+ """Sanity checks: missing files, subject leakage between splits, mask/image size."""
2
+ from __future__ import annotations
3
+
4
+ import random
5
+ from pathlib import Path
6
+
7
+ import pandas as pd
8
+ from PIL import Image
9
+
10
+ from .core import DEFAULT_ROOT, dataset_info, list_datasets, read_manifest
11
+
12
+
13
+ def check_splits(root: str | Path | None = None, datasets=None, deep: int = 0, verbose: bool = True) -> list[str]:
14
+ """Run all checks; returns a list of problems (empty list = everything passed).
15
+
16
+ datasets: default = every dataset found under root (any folder with a manifest.csv).
17
+ deep: also open this many random images per dataset and confirm the mask has the image's size.
18
+ """
19
+ root = Path(root or DEFAULT_ROOT)
20
+ datasets = list_datasets(root) if datasets is None else ([datasets] if isinstance(datasets, str) else list(datasets))
21
+ problems: list[str] = []
22
+ shared: dict[str, list] = {} # source_dataset -> [(dataset, manifest)] of annotated sets, e.g. nij + nij_als
23
+
24
+ def report(name, bad):
25
+ if verbose:
26
+ print(f" [{'PASS' if not bad else 'FAIL'}] {name}" + (f" ({len(bad)} problems, e.g. {bad[:3]})" if bad else ""))
27
+ problems.extend(f"{name}: {b}" for b in bad)
28
+
29
+ for d in datasets:
30
+ p = root / d / "manifest.csv"
31
+ if not p.exists():
32
+ if verbose:
33
+ print(f"{d}: manifest missing - skipped")
34
+ continue
35
+ try:
36
+ m = read_manifest(d, root)
37
+ info = dataset_info(d, manifest=m)
38
+ except ValueError as e:
39
+ report(f"{d} manifest readable", [str(e)])
40
+ continue
41
+ if verbose:
42
+ print(f"{d}: {m.image_id.nunique()} images, {len(m)} manifest rows"
43
+ + (f", annotators {list(info['annotators'])} (primary {info['primary']})" if info["annotators"] else ""))
44
+ report(f"{d} split values are train/val/test", sorted(set(m.split.astype(str)) - {"train", "val", "test"}))
45
+
46
+ files = list(m.image_path) + [x for x in m.mask_path if isinstance(x, str) and x]
47
+ report(f"{d} files exist", [f for f in dict.fromkeys(files) if not (root / f).exists()])
48
+
49
+ if info["annotators"]:
50
+ no_subj = m.subject_id.isna() | (m.subject_id.astype(str).str.strip() == "")
51
+ report(f"{d} every row has a subject_id (needed for subject-level splits)", list(m[no_subj].image_id.unique()))
52
+ test_subj = set(m[(m.split == "test") & ~no_subj].subject_id)
53
+ tv_subj = set(m[(m.split != "test") & ~no_subj].subject_id)
54
+ report(f"{d} test subjects never in train/val (any annotator)", sorted(test_subj & tv_subj))
55
+ bad = []
56
+ for a, g in m[m.split != "test"].groupby("annotator"):
57
+ multi = g.groupby("subject_id").fold.nunique()
58
+ bad += [f"{a}:{s}" for s in multi[multi > 1].index]
59
+ report(f"{d} each subject sits in exactly one fold (so val never shares subjects with train)", bad)
60
+ dup = m[m.split != "test"].duplicated(["image_id", "annotator"])
61
+ report(f"{d} no duplicate train/val rows", list(m[m.split != "test"][dup].image_id))
62
+ shared.setdefault(str(m.source_dataset.mode().iat[0]), []).append((d, m[~no_subj]))
63
+ else:
64
+ report(f"{d} image_id unique", list(m[m.image_id.duplicated()].image_id))
65
+ for col in ("subject_id", "near_dup_group"):
66
+ g = m[m[col].notna() & (m[col] != "")].groupby(col).split.nunique()
67
+ report(f"{d} each {col} in one split only", list(g[g > 1].index))
68
+
69
+ if deep:
70
+ rows = m[m.mask_path.notna() & (m.mask_path != "")]
71
+ idx = random.Random(0).sample(range(len(rows)), min(deep, len(rows))) if len(rows) else []
72
+ bad = []
73
+ for i in idx:
74
+ r = rows.iloc[i]
75
+ with Image.open(root / r.image_path) as a, Image.open(root / r.mask_path) as b:
76
+ if a.size != b.size:
77
+ bad.append(f"{r.image_id} image {a.size} mask {b.size}")
78
+ report(f"{d} mask size == image size ({len(idx)} sampled)", bad)
79
+
80
+ # datasets with the same subjects (same source_dataset, e.g. nij + nij_als) are often loaded together
81
+ for parts in shared.values():
82
+ if len(parts) < 2:
83
+ continue
84
+ names = " + ".join(d for d, _ in parts)
85
+ m = pd.concat([x for _, x in parts], ignore_index=True)
86
+ tv = m[m.split != "test"]
87
+ report(f"{names}: test subjects never in train/val of any of them",
88
+ sorted(set(m[m.split == "test"].subject_id) & set(tv.subject_id)))
89
+ multi = tv[tv.is_primary_annotator].groupby("subject_id").fold.nunique()
90
+ report(f"{names}: a subject has the same fold in all of them (primary annotators)", sorted(multi[multi > 1].index))
91
+
92
+ if verbose:
93
+ print("ALL CHECKS PASSED" if not problems else f"{len(problems)} PROBLEMS FOUND")
94
+ return problems
bruise_data/core.py ADDED
@@ -0,0 +1,422 @@
1
+ """Index loading, the PyTorch Dataset and the one-call ``get_dataloaders``."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import warnings
6
+ from pathlib import Path
7
+ from typing import Callable, Iterable
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ import torch
12
+ from PIL import Image
13
+ from torch.utils.data import DataLoader, Dataset, WeightedRandomSampler
14
+
15
+ from .transforms import BruiseTransform
16
+
17
+ DEFAULT_ROOT = Path(os.environ.get("BRUISE_DATA_ROOT", Path(__file__).resolve().parents[1]))
18
+ SHIPPED = ("nij", "nij_als", "fenwick", "fenwick_als", "mimics", "extra_bruise") # listed first, in this order
19
+ DEFAULT_MERGE = {"petechiae": "purpura"}
20
+ SPLITS = ("train", "val", "test")
21
+ REQUIRED_COLUMNS = ("image_id", "split", "class_name", "is_bruise", "image_path")
22
+ _NOT_A_PERSON = {"", "nan", "none", "majority"}
23
+ _STR_COLS = {"subject_id": str, "lesion_id": str, "near_dup_group": str, "image_id": str,
24
+ "mask_path": str, "source_class": str, "ita_category": str, "body_site": str}
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Index
29
+ # ---------------------------------------------------------------------------
30
+ def list_datasets(root: str | Path | None = None) -> list[str]:
31
+ """Every dataset under ``root``: any sub-folder that holds a ``manifest.csv``."""
32
+ root = Path(root or DEFAULT_ROOT)
33
+ found = sorted(p.parent.name for p in root.glob("*/manifest.csv"))
34
+ return [d for d in SHIPPED if d in found] + [d for d in found if d not in SHIPPED]
35
+
36
+
37
+ def read_manifest(dataset: str, root: str | Path | None = None) -> pd.DataFrame:
38
+ """Raw ``<root>/<dataset>/manifest.csv`` as a DataFrame (no filtering).
39
+
40
+ Only REQUIRED_COLUMNS must exist; any other column the loader uses is filled with a default.
41
+ """
42
+ root = Path(root or DEFAULT_ROOT)
43
+ p = root / dataset / "manifest.csv"
44
+ if not p.exists():
45
+ raise FileNotFoundError(f"{p} not found. Is root={root} the BRUISE_DATA folder? "
46
+ f"Datasets found there: {list_datasets(root)}")
47
+ m = pd.read_csv(p, dtype=_STR_COLS, keep_default_na=True, low_memory=False)
48
+ missing = [c for c in REQUIRED_COLUMNS if c not in m.columns]
49
+ if missing:
50
+ raise ValueError(f"{p} is missing required column(s) {missing}")
51
+ defaults = {"dataset": dataset, "fold": -1, "annotator": "none", "is_primary_annotator": True, "mask_path": "",
52
+ "source_dataset": dataset, "subject_id": "", "near_dup_group": "", "commercial_use": "unknown"}
53
+ defaults.update({k: np.nan for k in BruiseDataset.META_FLOATS})
54
+ for c, v in defaults.items():
55
+ if c not in m.columns:
56
+ m[c] = v
57
+ m["fold"] = pd.to_numeric(m.fold, errors="coerce").fillna(-1).astype(int)
58
+ m["is_bruise"] = _as_bool(m.is_bruise)
59
+ m["is_primary_annotator"] = _as_bool(m.is_primary_annotator)
60
+ return m
61
+
62
+
63
+ def _as_bool(s: pd.Series) -> pd.Series:
64
+ return s.astype(str).str.strip().str.lower().isin(("true", "1", "yes"))
65
+
66
+
67
+ def dataset_info(dataset: str, root: str | Path | None = None, manifest: pd.DataFrame | None = None) -> dict:
68
+ """What a dataset holds, read from its manifest (nothing about a dataset is hard-coded).
69
+
70
+ annotators people with their own train/val masks, primary first (empty tuple if none)
71
+ primary the annotator flagged ``is_primary_annotator`` (None if there are no annotators)
72
+ has_masks at least one row has a mask_path
73
+ has_bruise at least one bruise image (False = a mimic / negative dataset)
74
+ has_majority test rows with annotator == "majority" exist
75
+ has_folds train/val rows carry a fold number (0..4), so ``val_fold`` can re-split them
76
+ """
77
+ m = read_manifest(dataset, root) if manifest is None else manifest
78
+ tv = m[m.split != "test"]
79
+ ann = tv.annotator.astype(str).str.strip()
80
+ names = [a for a in dict.fromkeys(ann) if a.lower() not in _NOT_A_PERSON]
81
+ primary = [a for a in dict.fromkeys(ann[tv.is_primary_annotator]) if a in names]
82
+ if names and not primary:
83
+ if len(names) > 1:
84
+ raise ValueError(f"{dataset}: annotators {names} but none has is_primary_annotator=True")
85
+ primary = names
86
+ if len(primary) > 1:
87
+ raise ValueError(f"{dataset}: more than one primary annotator {primary}")
88
+ return dict(name=dataset,
89
+ annotators=tuple(primary + [a for a in names if a not in primary]),
90
+ primary=primary[0] if primary else None,
91
+ has_masks=bool((m.mask_path.fillna("").astype(str).str.len() > 0).any()),
92
+ has_bruise=bool(m.is_bruise.any()),
93
+ has_majority=bool((m[m.split == "test"].annotator.astype(str) == "majority").any()),
94
+ has_folds=bool((tv.fold >= 0).any()))
95
+
96
+
97
+ def _pick(value, info: dict, kind: str, known: set) -> str:
98
+ """Resolve an annotator setting (str / dict / 'primary' / 'majority') for one dataset."""
99
+ d = info["name"]
100
+ v = value.get(d, "primary") if isinstance(value, dict) else value
101
+ if v == "primary":
102
+ return info["primary"]
103
+ if v == "majority" and kind == "test":
104
+ if info["has_majority"]:
105
+ return "majority"
106
+ warnings.warn(f"{d} has no majority-vote test masks; scoring against its primary annotator {info['primary']}")
107
+ return info["primary"]
108
+ if v not in info["annotators"]:
109
+ if isinstance(value, dict) or v not in known:
110
+ raise ValueError(f"{kind} annotator '{v}' does not exist for {d}; choose from {info['annotators']}")
111
+ return info["primary"] # a single name only applies to the dataset that has it
112
+ return v
113
+
114
+
115
+ def load_index(
116
+ datasets: Iterable[str] = ("nij", "fenwick"),
117
+ root: str | Path | None = None,
118
+ task: str = "segmentation",
119
+ mimic_classes: str | Iterable[str] = "all",
120
+ mimic_sources: Iterable[str] | None = None,
121
+ exclude_mimic_sources: Iterable[str] | None = None,
122
+ merge: dict | None = None,
123
+ min_class_images: int = 50,
124
+ annotator: str | dict = "primary",
125
+ test_annotator: str | dict = "majority",
126
+ val_fold: int = 0,
127
+ commercial_only: bool = False,
128
+ binary: bool = False,
129
+ ) -> pd.DataFrame:
130
+ """Build the filtered image table that the loaders use.
131
+
132
+ Any folder under ``root`` with a ``manifest.csv`` is a dataset. How it is handled depends only on
133
+ what its manifest holds (see ``dataset_info``):
134
+ - with annotators (nij, fenwick, ...): one annotator's train/val masks, val re-split by fold,
135
+ test masks from ``test_annotator``;
136
+ - bruise images without annotators (extra_bruise, ...): used as the manifest lists them;
137
+ - no bruise images at all (mimics, ...): the mimic_* / merge filters apply to all of them together.
138
+
139
+ Returns one row per image with a final ``split`` (train/val/test), ``class_name`` and
140
+ integer ``label``. The class list is in ``df.attrs["class_names"]``.
141
+ See README.md for what every argument does.
142
+ """
143
+ root = Path(root or DEFAULT_ROOT)
144
+ datasets = [datasets] if isinstance(datasets, str) else list(dict.fromkeys(datasets))
145
+ available = list_datasets(root)
146
+ bad = [d for d in datasets if d not in available]
147
+ if bad:
148
+ raise ValueError(f"unknown dataset(s) {bad}; datasets in {root}: {available}")
149
+ if task not in ("segmentation", "classification"):
150
+ raise ValueError("task must be 'segmentation' or 'classification'")
151
+ if not 0 <= int(val_fold) <= 4:
152
+ raise ValueError("val_fold must be 0..4")
153
+ merge = DEFAULT_MERGE if merge is None else merge
154
+
155
+ manifests = {d: read_manifest(d, root) for d in datasets}
156
+ infos = {d: dataset_info(d, manifest=m) for d, m in manifests.items()}
157
+ for d, info in infos.items():
158
+ if task == "segmentation" and info["has_bruise"] and not info["has_masks"]:
159
+ raise ValueError(f"'{d}' has no masks, so it can only be used with task='classification'")
160
+ known = {a for info in infos.values() for a in info["annotators"]}
161
+
162
+ parts, negatives = [], []
163
+ for d in datasets:
164
+ m, info = manifests[d], infos[d]
165
+ if info["annotators"]:
166
+ ann = _pick(annotator, info, "train", known)
167
+ tst = _pick(test_annotator, info, "test", known)
168
+ tv = m[(m.split != "test") & (m.annotator == ann)].copy()
169
+ if info["has_folds"]:
170
+ tv["split"] = np.where(tv.fold == int(val_fold), "val", "train")
171
+ te = m[(m.split == "test") & (m.annotator == tst)].copy()
172
+ parts.append(pd.concat([tv, te], ignore_index=True))
173
+ elif info["has_bruise"]:
174
+ parts.append(m)
175
+ else:
176
+ negatives.append(m)
177
+
178
+ if negatives:
179
+ m = pd.concat(negatives, ignore_index=True)
180
+ m["class_name"] = m.class_name.replace(merge)
181
+ if mimic_sources is not None:
182
+ m = m[m.source_dataset.isin(list(mimic_sources))]
183
+ if exclude_mimic_sources is not None:
184
+ m = m[~m.source_dataset.isin(list(exclude_mimic_sources))]
185
+ available = sorted(m.class_name.unique())
186
+ if mimic_classes == "all":
187
+ counts = m.class_name.value_counts()
188
+ small = sorted(counts[counts < min_class_images].index)
189
+ if small:
190
+ warnings.warn(f"mimic classes with < {min_class_images} images left out: {small} "
191
+ f"(ask for them by name, or set min_class_images=0)")
192
+ m = m[~m.class_name.isin(small)]
193
+ else:
194
+ want = [mimic_classes] if isinstance(mimic_classes, str) else list(mimic_classes)
195
+ want = [merge.get(c, c) for c in want]
196
+ missing = [c for c in want if c not in available]
197
+ if missing:
198
+ raise ValueError(f"unknown mimic class(es) {missing}; available: {available}")
199
+ m = m[m.class_name.isin(want)]
200
+ parts.append(m)
201
+
202
+ df = pd.concat(parts, ignore_index=True)
203
+ if commercial_only:
204
+ df = df[df.commercial_use.astype(str).str.lower() == "yes"]
205
+
206
+ if binary: # every non-bruise class becomes one "non_bruise" class; bruise is the positive class (label 1)
207
+ df["class_name"] = np.where(df.is_bruise, "bruise", "non_bruise")
208
+ class_names = ["non_bruise", "bruise"]
209
+ else:
210
+ mimic_names = sorted(c for c in df.class_name.unique() if c != "bruise")
211
+ class_names = (["bruise"] if df.is_bruise.any() or task == "segmentation" else []) + mimic_names
212
+ df["label"] = df.class_name.map({c: i for i, c in enumerate(class_names)}).astype(int)
213
+ df = df.reset_index(drop=True)
214
+ df.attrs["class_names"] = class_names
215
+ df.attrs["root"] = str(root)
216
+ df.attrs["task"] = task
217
+ return df
218
+
219
+
220
+ def summary(df: pd.DataFrame | None = None, **kwargs) -> pd.DataFrame:
221
+ """Image counts per dataset / class / split. Pass a ``load_index`` result or its arguments."""
222
+ df = load_index(**kwargs) if df is None else df
223
+ df = df.reset_index(drop=True)
224
+ t = pd.crosstab([df.dataset, df.class_name], df.split).reindex(columns=list(SPLITS), fill_value=0)
225
+ t["total"] = t.sum(axis=1)
226
+ t.loc[("ALL", ""), :] = t.sum()
227
+ return t.astype(int)
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Dataset
232
+ # ---------------------------------------------------------------------------
233
+ def _f(v):
234
+ try:
235
+ v = float(v)
236
+ return v
237
+ except (TypeError, ValueError):
238
+ return float("nan")
239
+
240
+
241
+ class BruiseDataset(Dataset):
242
+ """One split of the index as a PyTorch Dataset.
243
+
244
+ Each item is a dict:
245
+ image float tensor (3, H, W)
246
+ mask float tensor (1, H, W), 1 = bruise (segmentation only; all-zero for mimics)
247
+ label int, index into ``dataset.class_names`` (0 = bruise; with binary=True, 1 = bruise)
248
+ class_name, dataset, source_dataset, subject_id, image_id (strings)
249
+ is_bruise bool
250
+ skin_L, skin_a, skin_b, skin_ita, lesion_L, lesion_a, lesion_b, fitzpatrick, monk_tone
251
+ floats (NaN when unknown)
252
+ """
253
+
254
+ META_FLOATS = ("skin_L", "skin_a", "skin_b", "skin_ita", "lesion_L", "lesion_a", "lesion_b", "fitzpatrick", "monk_tone")
255
+
256
+ def __init__(self, df: pd.DataFrame, root: str | Path | None = None, task: str | None = None,
257
+ transform: Callable | None = None, fast_decode: int | None = None):
258
+ self.df = df.reset_index(drop=True)
259
+ self.root = Path(root or df.attrs.get("root") or DEFAULT_ROOT)
260
+ self.task = task or df.attrs.get("task", "segmentation")
261
+ self.class_names = list(df.attrs.get("class_names", sorted(df.class_name.unique())))
262
+ self.transform = transform
263
+ self.fast_decode = fast_decode
264
+
265
+ def __len__(self):
266
+ return len(self.df)
267
+
268
+ def __repr__(self):
269
+ return f"BruiseDataset({len(self)} images, task={self.task}, classes={self.class_names})"
270
+
271
+ def load_image(self, i: int) -> Image.Image:
272
+ im = Image.open(self.root / self.df.at[i, "image_path"])
273
+ if self.fast_decode and im.format == "JPEG":
274
+ im.draft("RGB", (self.fast_decode, self.fast_decode)) # decode big JPEGs at reduced scale
275
+ return im.convert("RGB")
276
+
277
+ def load_mask(self, i: int, size: tuple[int, int]) -> np.ndarray:
278
+ p = self.df.at[i, "mask_path"]
279
+ if not isinstance(p, str) or not p:
280
+ return np.zeros((size[1], size[0]), np.uint8)
281
+ m = Image.open(self.root / p).convert("L")
282
+ if m.size != size:
283
+ m = m.resize(size, Image.NEAREST)
284
+ return (np.asarray(m) > 0).astype(np.uint8) * 255
285
+
286
+ def __getitem__(self, i: int) -> dict:
287
+ r = self.df.iloc[i]
288
+ img = self.load_image(i)
289
+ mask = self.load_mask(i, img.size) if self.task == "segmentation" else None
290
+ image = np.asarray(img)
291
+ if self.transform is not None:
292
+ out = self.transform(image=image, mask=mask) if mask is not None else self.transform(image=image)
293
+ image, mask = out["image"], out.get("mask", mask)
294
+ if isinstance(image, np.ndarray): # custom transform returned numpy
295
+ image = torch.from_numpy(np.ascontiguousarray(image)).permute(2, 0, 1).float() / 255.0
296
+ item = {"image": image}
297
+ if self.task == "segmentation":
298
+ if isinstance(mask, np.ndarray):
299
+ mask = torch.from_numpy((mask > 0).astype(np.float32))
300
+ mask = mask.float()
301
+ if mask.ndim == 2:
302
+ mask = mask[None]
303
+ item["mask"] = (mask > 0).float()
304
+ item.update(label=int(r.label), class_name=r.class_name, dataset=r.dataset,
305
+ source_dataset=str(r.source_dataset), subject_id=str(r.subject_id) if pd.notna(r.subject_id) else "",
306
+ image_id=r.image_id, is_bruise=bool(r.is_bruise))
307
+ for k in self.META_FLOATS:
308
+ item[k] = _f(r[k])
309
+ return item
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # Loaders
314
+ # ---------------------------------------------------------------------------
315
+ def _sampler(df: pd.DataFrame, balance: str | None, seed: int):
316
+ if balance in (None, "none"):
317
+ return None
318
+ key = {"dataset": df.dataset, "class": df.class_name, "source": df.dataset + "/" + df.source_dataset.astype(str)}.get(balance)
319
+ if key is None:
320
+ raise ValueError("balance must be None, 'dataset', 'class' or 'source'")
321
+ w = 1.0 / key.map(key.value_counts()).to_numpy(dtype=np.float64)
322
+ g = torch.Generator().manual_seed(seed)
323
+ return WeightedRandomSampler(torch.as_tensor(w, dtype=torch.double), num_samples=len(df), replacement=True, generator=g)
324
+
325
+
326
+ def get_datasets(datasets=("nij", "fenwick"), image_size: int = 512, resize: str = "pad", augment: bool = True,
327
+ train_transform: Callable | None = None, eval_transform: Callable | None = None,
328
+ fast_decode: bool = True, **index_kwargs) -> tuple[BruiseDataset, BruiseDataset, BruiseDataset]:
329
+ """Same arguments as ``get_dataloaders`` minus the DataLoader ones; returns (train, val, test) Datasets."""
330
+ df = load_index(datasets, **index_kwargs)
331
+ ttr = train_transform or BruiseTransform(image_size, train=augment, resize=resize)
332
+ tev = eval_transform or BruiseTransform(image_size, train=False, resize=resize)
333
+ fd = 2 * image_size if (fast_decode and train_transform is None) else None
334
+ fde = 2 * image_size if (fast_decode and eval_transform is None) else None
335
+ out = []
336
+ for s, t, f in (("train", ttr, fd), ("val", tev, fde), ("test", tev, fde)):
337
+ part = df[df.split == s].copy()
338
+ part.attrs = dict(df.attrs)
339
+ out.append(BruiseDataset(part, transform=t, fast_decode=f))
340
+ return tuple(out)
341
+
342
+
343
+ def get_dataloaders(
344
+ datasets: Iterable[str] = ("nij", "fenwick"),
345
+ *,
346
+ task: str = "segmentation",
347
+ mimic_classes: str | Iterable[str] = "all",
348
+ mimic_sources: Iterable[str] | None = None,
349
+ exclude_mimic_sources: Iterable[str] | None = None,
350
+ merge: dict | None = None,
351
+ min_class_images: int = 50,
352
+ annotator: str | dict = "primary",
353
+ test_annotator: str | dict = "majority",
354
+ val_fold: int = 0,
355
+ commercial_only: bool = False,
356
+ binary: bool = False,
357
+ image_size: int = 512,
358
+ resize: str = "pad",
359
+ augment: bool = True,
360
+ train_transform: Callable | None = None,
361
+ eval_transform: Callable | None = None,
362
+ batch_size: int = 8,
363
+ eval_batch_size: int | None = None,
364
+ num_workers: int = 4,
365
+ balance: str | None = "dataset",
366
+ seed: int = 42,
367
+ pin_memory: bool | None = None,
368
+ root: str | Path | None = None,
369
+ verbose: bool = True,
370
+ ) -> tuple[DataLoader, DataLoader, DataLoader]:
371
+ """Return ``(train_loader, val_loader, test_loader)`` for any mix of datasets.
372
+
373
+ Example:
374
+ train_dl, val_dl, test_dl = get_dataloaders(["nij", "fenwick", "mimics"])
375
+ class_names = train_dl.dataset.class_names
376
+
377
+ See README.md for every option.
378
+ """
379
+ torch.manual_seed(seed)
380
+ tr, va, te = get_datasets(datasets, image_size=image_size, resize=resize, augment=augment,
381
+ train_transform=train_transform, eval_transform=eval_transform, root=root,
382
+ task=task, mimic_classes=mimic_classes, mimic_sources=mimic_sources,
383
+ exclude_mimic_sources=exclude_mimic_sources, merge=merge,
384
+ min_class_images=min_class_images, annotator=annotator,
385
+ test_annotator=test_annotator, val_fold=val_fold, commercial_only=commercial_only,
386
+ binary=binary)
387
+ if verbose:
388
+ print(summary(pd.concat([tr.df, va.df, te.df], ignore_index=True)).to_string())
389
+ print("classes:", tr.class_names)
390
+ if len(tr) == 0:
391
+ raise ValueError("the training split is empty - check your dataset / class selection")
392
+ pin = torch.cuda.is_available() if pin_memory is None else pin_memory
393
+ sampler = _sampler(tr.df, balance, seed)
394
+ g = torch.Generator().manual_seed(seed)
395
+ common = dict(num_workers=num_workers, pin_memory=pin, persistent_workers=num_workers > 0)
396
+ train_dl = DataLoader(tr, batch_size=batch_size, sampler=sampler, shuffle=sampler is None, generator=g,
397
+ drop_last=len(tr) > batch_size, worker_init_fn=_seed_worker, **common)
398
+ eb = eval_batch_size or batch_size
399
+ val_dl = DataLoader(va, batch_size=eb, shuffle=False, **common)
400
+ test_dl = DataLoader(te, batch_size=eb, shuffle=False, **common)
401
+ return train_dl, val_dl, test_dl
402
+
403
+
404
+ def get_test_loaders(datasets=("nij", "fenwick"), batch_size: int = 8, num_workers: int = 4,
405
+ **kwargs) -> dict[str, DataLoader]:
406
+ """One test DataLoader per dataset (e.g. {"nij": ..., "fenwick": ..., "mimics": ...})."""
407
+ kwargs.pop("augment", None)
408
+ _, _, te = get_datasets(datasets, augment=False, **kwargs)
409
+ out = {}
410
+ for d in te.df.dataset.unique():
411
+ part = te.df[te.df.dataset == d].copy()
412
+ part.attrs = dict(te.df.attrs)
413
+ ds = BruiseDataset(part, transform=te.transform, fast_decode=te.fast_decode)
414
+ out[d] = DataLoader(ds, batch_size=batch_size, shuffle=False, num_workers=num_workers)
415
+ return out
416
+
417
+
418
+ def _seed_worker(worker_id: int):
419
+ import random
420
+ s = torch.initial_seed() % 2**32
421
+ np.random.seed(s)
422
+ random.seed(s)