Spaces:
Sleeping
Sleeping
File size: 4,895 Bytes
260fe62 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 | """
Data loading for the Nature Sci Data 2024 fingernail+Hb dataset.
Conventions:
- Hb is always exposed in g/dL (the raw CSV is g/L; we divide by 10 on load).
- Bboxes are (x1, y1, x2, y2) in image pixel coords.
- "Crops" mean per-nail RGB arrays in shape (H, W, 3) dtype uint8.
- Splits are subject-disjoint: a PATIENT_ID lives in exactly one of train/val/test.
"""
from __future__ import annotations
import ast
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator
import numpy as np
import pandas as pd
from PIL import Image
DEFAULT_ROOT = Path("data/extracted")
@dataclass
class Crop:
patient_id: int
region: str # "nail" or "skin"
crop_idx: int # 0, 1, or 2 — which of the 3 bboxes per image
hb_g_per_dL: float
image: np.ndarray # (H, W, 3) uint8
def load_metadata(root: Path | str = DEFAULT_ROOT) -> pd.DataFrame:
"""Load and parse metadata.csv. Returns a DataFrame with parsed bboxes and g/dL Hb."""
root = Path(root)
df = pd.read_csv(root / "metadata.csv")
df["hb_g_per_dL"] = df["HB_LEVEL_GperL"] / 10.0
df["nail_bboxes"] = df["NAIL_BOUNDING_BOXES"].apply(ast.literal_eval)
df["skin_bboxes"] = df["SKIN_BOUNDING_BOXES"].apply(ast.literal_eval)
df["image_path"] = df["PATIENT_ID"].apply(lambda pid: root / "photo" / f"{pid}.jpg")
return df
def subject_disjoint_split(
df: pd.DataFrame,
ratios: tuple[float, float, float] = (0.70, 0.15, 0.15),
seed: int = 42,
) -> dict[str, list[int]]:
"""Split PATIENT_IDs into train/val/test. Returns dict of lists of patient IDs."""
assert abs(sum(ratios) - 1.0) < 1e-6, "ratios must sum to 1"
rng = np.random.default_rng(seed)
pids = df["PATIENT_ID"].unique().tolist()
rng.shuffle(pids)
n = len(pids)
n_train = int(round(n * ratios[0]))
n_val = int(round(n * ratios[1]))
return {
"train": sorted(pids[:n_train]),
"val": sorted(pids[n_train : n_train + n_val]),
"test": sorted(pids[n_train + n_val :]),
}
def iter_crops(
df: pd.DataFrame,
patient_ids: list[int] | None = None,
region: str = "nail",
) -> Iterator[Crop]:
"""Yield Crop objects. If patient_ids given, restricts to those subjects."""
bbox_col = {"nail": "nail_bboxes", "skin": "skin_bboxes"}[region]
if patient_ids is not None:
df = df[df["PATIENT_ID"].isin(patient_ids)]
for _, row in df.iterrows():
img = np.asarray(Image.open(row["image_path"]).convert("RGB"))
H, W = img.shape[:2]
for i, (x1, y1, x2, y2) in enumerate(row[bbox_col]):
# some dataset labels have y1 > y2 (or x1 > x2); normalise.
x1, x2 = sorted((int(x1), int(x2)))
y1, y2 = sorted((int(y1), int(y2)))
# NOTE: the public Nature 2024 release contains 600x800 images, but
# many skin bboxes were labelled in a taller (~700+ tall) source frame
# and now reach below the image bottom edge. Clip to image bounds and
# use whatever pixels survive — most skin bboxes only overshoot by a
# few rows, so the in-bounds remainder is still a usable skin patch.
x1c = max(0, min(x1, W))
x2c = max(0, min(x2, W))
y1c = max(0, min(y1, H))
y2c = max(0, min(y2, H))
crop = img[y1c:y2c, x1c:x2c].copy()
if crop.size == 0:
continue
yield Crop(
patient_id=int(row["PATIENT_ID"]),
region=region,
crop_idx=i,
hb_g_per_dL=float(row["hb_g_per_dL"]),
image=crop,
)
def mean_rgb_features(crop: np.ndarray) -> np.ndarray:
"""Per-crop colour feature vector. Returns shape (6,):
[R_mean, G_mean, B_mean, R_norm, G_norm, B_norm] where norms are channel/sum.
Normalised channels are robust to overall brightness; absolute channels carry pallor signal.
"""
flat = crop.reshape(-1, 3).astype(np.float64)
means = flat.mean(axis=0) # (3,)
total = means.sum() + 1e-8
norms = means / total
return np.concatenate([means / 255.0, norms])
def build_feature_table(
df: pd.DataFrame,
patient_ids: list[int],
region: str = "nail",
) -> pd.DataFrame:
"""For each crop, return one row of features + label.
Columns:
patient_id, crop_idx, hb_g_per_dL, R, G, B, rN, gN, bN
"""
rows = []
for c in iter_crops(df, patient_ids=patient_ids, region=region):
feats = mean_rgb_features(c.image)
rows.append({
"patient_id": c.patient_id,
"crop_idx": c.crop_idx,
"hb_g_per_dL": c.hb_g_per_dL,
"R": feats[0],
"G": feats[1],
"B": feats[2],
"rN": feats[3],
"gN": feats[4],
"bN": feats[5],
})
return pd.DataFrame(rows)
|