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.
- bruise_data/__init__.py +16 -0
- bruise_data/__main__.py +92 -0
- bruise_data/checks.py +94 -0
- bruise_data/core.py +422 -0
- bruise_data/transforms.py +107 -0
- bruise_data-1.2.0.dist-info/METADATA +607 -0
- bruise_data-1.2.0.dist-info/RECORD +10 -0
- bruise_data-1.2.0.dist-info/WHEEL +5 -0
- bruise_data-1.2.0.dist-info/entry_points.txt +2 -0
- bruise_data-1.2.0.dist-info/top_level.txt +1 -0
bruise_data/__init__.py
ADDED
|
@@ -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"
|
bruise_data/__main__.py
ADDED
|
@@ -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)
|