GetYourFit commited on
Commit
a1fdb98
Β·
verified Β·
1 Parent(s): deb6cb5

Upload folder using huggingface_hub

Browse files
app.py CHANGED
@@ -83,6 +83,74 @@ def embed_texts(model_id: str, texts: list[str]) -> dict:
83
  return {"embeddings": emb.tolist(), "dim": int(emb.shape[1])}
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  with gr.Blocks(title="GYF GPU lane") as demo:
87
  gr.Markdown(
88
  "# GYF GPU serving lane\n"
@@ -102,6 +170,18 @@ with gr.Blocks(title="GYF GPU lane") as demo:
102
  gr.Button("embed_texts").click(
103
  embed_texts, [model_in, txt_in], txt_out, api_name="embed_texts"
104
  )
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
 
107
  if __name__ == "__main__":
 
83
  return {"embeddings": emb.tolist(), "dim": int(emb.shape[1])}
84
 
85
 
86
+ # --- Skin-tone lane (M4): face-parse β†’ CIELAB β†’ MST, runs the vendored pipeline -
87
+ @lru_cache(maxsize=1)
88
+ def _skin_estimator():
89
+ """The real face-parsing skin-tone estimator (vendored under skintone/)."""
90
+ from skintone import FaceParsingSkinToneEstimator
91
+
92
+ return FaceParsingSkinToneEstimator()
93
+
94
+
95
+ @spaces.GPU
96
+ def estimate_skin_tone(image_b64: str) -> dict:
97
+ """One photo β†’ {'skin_tone': 'mstN', 'undertone': str, 'field_confidence': {...},
98
+ 'model_version': str}. Abstains ('unknown') honestly when no face/skin is found."""
99
+ from skintone import estimate_skin_tone as _run
100
+
101
+ est = _run(_decode_image(image_b64), _skin_estimator())
102
+ return {
103
+ "skin_tone": est.skin_tone,
104
+ "undertone": est.undertone,
105
+ "field_confidence": dict(est.field_confidence),
106
+ "model_version": est.model_version,
107
+ }
108
+
109
+
110
+ # --- Body-type lane (M3): SAM 3D Body β†’ MHR mesh, GPU-only -------------------
111
+ # Gated (request access to facebook/sam-3d-body-dinov3 + accept the SAM License)
112
+ # and heavy (PyTorch3D + hydra, installed from facebookresearch/sam-3d-body). The
113
+ # Space does only the GPU mesh fit; the caller's CPU runs the mesh→measurements→
114
+ # silhouette taxonomy (usermodel.body.estimate), so no SAM deps touch the API host.
115
+ _BODY_HF_REPO = "facebook/sam-3d-body-dinov3"
116
+
117
+
118
+ @lru_cache(maxsize=1)
119
+ def _load_body():
120
+ """Set up the SAM 3D Body estimator once (checkpoints downloaded on first call)."""
121
+ from notebook.utils import setup_sam_3d_body # provided by the sam-3d-body repo
122
+
123
+ return setup_sam_3d_body(hf_repo_id=_BODY_HF_REPO)
124
+
125
+
126
+ @spaces.GPU
127
+ def estimate_body(image_b64: str) -> dict:
128
+ """One photo β†’ MHR mesh of the largest detected person.
129
+
130
+ Returns ``{'vertices': [[x,y,z],...], 'region_quality': {}, 'model_confidence':
131
+ float, 'model_version': str}``. An empty/zero-confidence result is an honest
132
+ "no body found" the caller turns into an abstention (unknown body type).
133
+ """
134
+ image = _decode_image(image_b64)
135
+ rgb = np.ascontiguousarray(np.asarray(image))
136
+ people = _load_body().process_one_image(rgb)
137
+ if not people:
138
+ return {"vertices": [], "region_quality": {}, "model_confidence": 0.0,
139
+ "model_version": "sam3dbody-mhr-v1"}
140
+ person = max(people, key=lambda p: float(p.get("score", p.get("confidence", 1.0)) or 0.0))
141
+ verts = np.asarray(person.get("pred_vertices"), dtype=np.float32)
142
+ if verts.ndim != 2 or verts.shape[0] == 0:
143
+ return {"vertices": [], "region_quality": {}, "model_confidence": 0.0,
144
+ "model_version": "sam3dbody-mhr-v1"}
145
+ score = float(person.get("score", person.get("confidence", 1.0)) or 1.0)
146
+ return {
147
+ "vertices": verts.tolist(),
148
+ "region_quality": {},
149
+ "model_confidence": max(0.0, min(1.0, score)),
150
+ "model_version": "sam3dbody-mhr-v1",
151
+ }
152
+
153
+
154
  with gr.Blocks(title="GYF GPU lane") as demo:
155
  gr.Markdown(
156
  "# GYF GPU serving lane\n"
 
170
  gr.Button("embed_texts").click(
171
  embed_texts, [model_in, txt_in], txt_out, api_name="embed_texts"
172
  )
173
+ with gr.Tab("skintone"):
174
+ skin_in = gr.Textbox(label="image_b64 (base64 PNG)")
175
+ skin_out = gr.JSON(label="skin tone")
176
+ gr.Button("estimate_skin_tone").click(
177
+ estimate_skin_tone, skin_in, skin_out, api_name="estimate_skin_tone"
178
+ )
179
+ with gr.Tab("body"):
180
+ body_in = gr.Textbox(label="image_b64 (base64 PNG)")
181
+ body_out = gr.JSON(label="mesh")
182
+ gr.Button("estimate_body").click(
183
+ estimate_body, body_in, body_out, api_name="estimate_body"
184
+ )
185
 
186
 
187
  if __name__ == "__main__":
gyf_contracts/__init__.py ADDED
File without changes
gyf_contracts/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (193 Bytes). View file
 
gyf_contracts/__pycache__/usermodel.cpython-313.pyc ADDED
Binary file (626 Bytes). View file
 
gyf_contracts/usermodel.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vendored subset of gyf_contracts.usermodel for the GPU Space.
2
+
3
+ The Space runs the real skin-tone pipeline (usermodel.skintone, copied into this
4
+ repo) which only needs these two sentinels. Kept in lockstep with the canonical
5
+ packages/contracts/gyf_contracts/usermodel.py in the GYF monorepo.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ UNKNOWN_SKIN_TONE = "unknown"
11
+ UNKNOWN_UNDERTONE = "unknown"
requirements.txt CHANGED
@@ -9,3 +9,6 @@ numpy
9
  transformers>=4.40
10
  sentencepiece
11
  protobuf
 
 
 
 
9
  transformers>=4.40
10
  sentencepiece
11
  protobuf
12
+ # Skin-tone lane (M4): RetinaFace face detect + FaRL face-parsing (MIT) for the
13
+ # real CIELAB->MST pipeline vendored under skintone/. CPU-capable; torch already present.
14
+ pyfacer>=0.0.5
skintone/__init__.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Skin-tone user-model module (P1-B Cycle 3, ⚠ fairness-gated).
2
+
3
+ Photo β†’ true-skin pixels (face detect + parse) β†’ white-balance β†’ CIELAB β†’ Monk
4
+ Skin Tone bucket + undertone, with honest per-field confidence. The pure colour
5
+ science (``color``, ``whitebalance``, ``classify``) imports weightless; the real
6
+ segmentation model (``estimator``) lazy-loads its heavy deps only on first use,
7
+ so orchestration and tests run with an injected fake readout.
8
+
9
+ Surfaced in production only behind the fairness gate (``fairness_eval``); until it
10
+ passes, the module runs in shadow (computed, not shown) β€” see
11
+ docs/plans/p1b-cycle3-photo-skin-tone.md.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .classify import lab_to_mst, lab_to_undertone
17
+ from .estimate import SkinToneEstimate, estimate_skin_tone
18
+ from .estimator import (
19
+ DEFAULT_MODEL_VERSION,
20
+ FaceParsingSkinToneEstimator,
21
+ SkinReadout,
22
+ SkinToneEstimator,
23
+ )
24
+
25
+ __all__ = [
26
+ "DEFAULT_MODEL_VERSION",
27
+ "FaceParsingSkinToneEstimator",
28
+ "SkinReadout",
29
+ "SkinToneEstimate",
30
+ "SkinToneEstimator",
31
+ "estimate_skin_tone",
32
+ "lab_to_mst",
33
+ "lab_to_undertone",
34
+ ]
skintone/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (1.28 kB). View file
 
skintone/__pycache__/classify.cpython-313.pyc ADDED
Binary file (3.06 kB). View file
 
skintone/__pycache__/color.cpython-313.pyc ADDED
Binary file (2.89 kB). View file
 
skintone/__pycache__/estimate.cpython-313.pyc ADDED
Binary file (2.48 kB). View file
 
skintone/__pycache__/estimator.cpython-313.pyc ADDED
Binary file (8.66 kB). View file
 
skintone/__pycache__/whitebalance.cpython-313.pyc ADDED
Binary file (2.12 kB). View file
 
skintone/classify.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Map a CIELAB skin readout to a Monk Skin Tone bucket + undertone (pure).
2
+
3
+ Deliberately explainable (no black-box CNN): tone comes from ``L*`` against
4
+ per-bucket anchors, undertone from the ``a*``/``b*`` balance. Explainability is a
5
+ fairness feature β€” we can audit *why* a tone was assigned, and the anchors are a
6
+ single, reviewable table rather than opaque weights.
7
+
8
+ ⚠ The ``L*`` anchors below are **provisional**, pending calibration against a
9
+ balanced full-MST image set (``fairness_eval.py``). Until that gate passes, the
10
+ module runs in shadow (computed, not surfaced) β€” see
11
+ docs/plans/p1b-cycle3-photo-skin-tone.md.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from gyf_contracts.usermodel import UNKNOWN_SKIN_TONE, UNKNOWN_UNDERTONE
17
+
18
+ # Provisional L* anchor per MST bucket (lightest mst1 β†’ deepest mst10). Roughly
19
+ # even perceptual spacing across the lightness range observed for facial skin.
20
+ _MST_ANCHORS: list[tuple[str, float]] = [
21
+ ("mst1", 90.0),
22
+ ("mst2", 82.0),
23
+ ("mst3", 74.0),
24
+ ("mst4", 66.0),
25
+ ("mst5", 58.0),
26
+ ("mst6", 50.0),
27
+ ("mst7", 42.0),
28
+ ("mst8", 34.0),
29
+ ("mst9", 27.0),
30
+ ("mst10", 20.0),
31
+ ]
32
+
33
+ # Half the spacing between adjacent anchors (~4 L*): a reading this far from its
34
+ # nearest anchor sits exactly between two buckets β†’ confidence 0.5.
35
+ _ANCHOR_HALF_STEP = 4.0
36
+
37
+ # Below this, abstain to "unknown" rather than present a guessed tone (D6 honesty).
38
+ MIN_TONE_CONFIDENCE = 0.45
39
+
40
+
41
+ def lab_to_mst(L: float, a: float, b: float) -> tuple[str, float]:
42
+ """Nearest MST bucket by ``L*`` + a distance-based confidence in [0, 1]."""
43
+ bucket, anchor_L = min(_MST_ANCHORS, key=lambda kv: abs(kv[1] - L))
44
+ distance = abs(anchor_L - L)
45
+ confidence = max(0.0, 1.0 - distance / (2 * _ANCHOR_HALF_STEP))
46
+ if confidence < MIN_TONE_CONFIDENCE:
47
+ return UNKNOWN_SKIN_TONE, confidence
48
+ return bucket, confidence
49
+
50
+
51
+ def lab_to_undertone(a: float, b: float) -> tuple[str, float]:
52
+ """Warm / cool / neutral / olive from the ``a*``/``b*`` balance.
53
+
54
+ Warm skin skews yellow (high ``b*``), cool skews pink/blue (``b*`` low
55
+ relative to ``a*``), olive carries a green-yellow cast (``b*`` high, ``a*``
56
+ low). Confidence grows with the margin between the leading signal and neutral.
57
+ """
58
+ # Neutral band: small chroma in both axes.
59
+ if abs(b) < 6.0 and abs(a) < 6.0:
60
+ return "neutral", 0.6
61
+ if b >= 12.0 and a < 8.0:
62
+ return "olive", min(1.0, (b - 12.0) / 12.0 + 0.5)
63
+ if b > a:
64
+ return "warm", min(1.0, (b - a) / 20.0 + 0.5)
65
+ if a >= b:
66
+ return "cool", min(1.0, (a - b) / 20.0 + 0.5)
67
+ return UNKNOWN_UNDERTONE, 0.4
skintone/color.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """sRGB β†’ CIELAB conversion (pure numpy, D65).
2
+
3
+ Perceptually-uniform Lab is the space the skin-tone module reasons in: ``L*`` is
4
+ lightness (drives the Monk Skin Tone bucket) and ``a*``/``b*`` carry undertone
5
+ (warm/cool). Kept dependency-free (no skimage/opencv) so the pure logic imports
6
+ and unit-tests without any heavy runtime.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+ # D65 reference white (2Β° observer), the standard for sRGB content.
14
+ _D65 = np.array([95.047, 100.0, 108.883], dtype=np.float64)
15
+
16
+ # Linear-sRGB β†’ XYZ (sRGB primaries, D65).
17
+ _RGB_TO_XYZ = np.array(
18
+ [
19
+ [0.4124564, 0.3575761, 0.1804375],
20
+ [0.2126729, 0.7151522, 0.0721750],
21
+ [0.0193339, 0.1191920, 0.9503041],
22
+ ],
23
+ dtype=np.float64,
24
+ )
25
+
26
+
27
+ def _srgb_to_linear(rgb: np.ndarray) -> np.ndarray:
28
+ """Undo the sRGB gamma curve. ``rgb`` in [0, 1]."""
29
+ return np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
30
+
31
+
32
+ def _f(t: np.ndarray) -> np.ndarray:
33
+ delta = 6.0 / 29.0
34
+ return np.where(t > delta**3, np.cbrt(t), t / (3 * delta**2) + 4.0 / 29.0)
35
+
36
+
37
+ def srgb_to_lab(rgb: np.ndarray) -> np.ndarray:
38
+ """Convert sRGB pixels to CIELAB.
39
+
40
+ ``rgb``: array of shape (..., 3) with values in [0, 255] or [0, 1] (auto-
41
+ detected). Returns the same leading shape with the last axis = (L*, a*, b*).
42
+ """
43
+ arr = np.asarray(rgb, dtype=np.float64)
44
+ if arr.max() > 1.0:
45
+ arr = arr / 255.0
46
+ linear = _srgb_to_linear(arr)
47
+ xyz = linear @ _RGB_TO_XYZ.T
48
+ xyz_n = xyz / _D65 * 100.0
49
+ fx, fy, fz = (_f(xyz_n[..., i]) for i in range(3))
50
+ L = 116.0 * fy - 16.0
51
+ a = 500.0 * (fx - fy)
52
+ b = 200.0 * (fy - fz)
53
+ return np.stack([L, a, b], axis=-1)
skintone/estimate.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Skin-tone estimation orchestration.
2
+
3
+ Ties the real segmentation model (``SkinToneEstimator`` β†’ skin pixels) to the pure
4
+ colour-science classifier (white-balance β†’ CIELAB β†’ MST + undertone), producing a
5
+ :class:`SkinToneEstimate` with honest per-field confidence. The estimator is
6
+ injected, so this orchestration is unit-tested with a fake readout (no model).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ from .classify import lab_to_mst, lab_to_undertone
14
+ from .estimator import SkinReadout, SkinToneEstimator
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class SkinToneEstimate:
19
+ """The user-model output for the skin-tone field of a photo profile."""
20
+
21
+ skin_tone: str
22
+ undertone: str
23
+ field_confidence: dict[str, float] = field(default_factory=dict)
24
+ model_version: str = ""
25
+
26
+
27
+ def estimate_skin_tone(image: object, estimator: SkinToneEstimator) -> SkinToneEstimate:
28
+ """Estimate skin tone + undertone from a PIL image via ``estimator``.
29
+
30
+ Confidence is the product of the classifier's own confidence and the readout's
31
+ quality (face-detection confidence Γ— skin-pixel coverage) β€” so a low-quality
32
+ capture honestly lowers confidence rather than masquerading as certain.
33
+ """
34
+ readout: SkinReadout = estimator.estimate(image)
35
+ L, a, b = readout.lab
36
+
37
+ tone, tone_conf = lab_to_mst(L, a, b)
38
+ undertone, under_conf = lab_to_undertone(a, b)
39
+
40
+ quality = readout.face_confidence * readout.coverage
41
+ return SkinToneEstimate(
42
+ skin_tone=tone,
43
+ undertone=undertone,
44
+ field_confidence={
45
+ "skin_tone": round(tone_conf * quality, 4),
46
+ "undertone": round(under_conf * quality, 4),
47
+ },
48
+ model_version=readout.model_version,
49
+ )
skintone/estimator.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The face-parsing segmentation model behind the skin-tone module (production).
2
+
3
+ :class:`SkinToneEstimator` is the abstraction the orchestration depends on; it
4
+ turns a photo into a :class:`SkinReadout` (white-balanced mean CIELAB of true skin
5
+ pixels + quality signals).
6
+
7
+ :class:`FaceParsingSkinToneEstimator` is the **production** implementation:
8
+ robust in-the-wild **face detection (RetinaFace)** + **neural face-parsing**
9
+ (FaRL, a BiSeNet-class per-pixel segmenter) via ``pyfacer`` (MIT). Per-pixel
10
+ segmentation β€” not landmark patches β€” is what makes the tone read robust to pose,
11
+ occlusion, and faces that don't fill the frame, and lets us select *exactly* the
12
+ skin classes (face skin + nose, excluding eyes/brows/lips/hair/glasses) before the
13
+ colour read. Heavy deps (``torch``, ``pyfacer``) are imported lazily inside
14
+ ``_load`` so this module β€” and the whole skin-tone path under a fake β€” imports and
15
+ unit-tests with no weights. GPU-served on the ML platform; CPU works (slower).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from dataclasses import dataclass
22
+ from typing import TYPE_CHECKING, Protocol
23
+
24
+ import numpy as np
25
+
26
+ from .color import srgb_to_lab
27
+ from .whitebalance import shades_of_gray
28
+
29
+ if TYPE_CHECKING:
30
+ from PIL.Image import Image
31
+
32
+ DEFAULT_MODEL_VERSION = "retinaface-farl-celebm-cielab-mst-v1"
33
+
34
+ # Face-parsing label names that count as skin for the colour read. FaRL/CelebM and
35
+ # LaPa name classes; we match by name so the estimator is robust to label-index
36
+ # drift across parser checkpoints. Eyes/brows/lips/hair/glasses are excluded.
37
+ _SKIN_LABELS: frozenset[str] = frozenset({"face", "skin", "nose"})
38
+
39
+ # Drop the brightest/darkest decile of skin luminance (specular highlights, deep
40
+ # shadow) before the colour read so the median Lab reflects diffuse skin.
41
+ _TRIM_PCT = (10.0, 90.0)
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SkinReadout:
46
+ """White-balanced mean CIELAB of skin pixels + quality signals."""
47
+
48
+ lab: tuple[float, float, float]
49
+ coverage: float # fraction of the detected face box segmented as skin
50
+ face_confidence: float # detector score for the chosen face, 0.0 if none
51
+ skin_pixels: int
52
+ model_version: str = DEFAULT_MODEL_VERSION
53
+
54
+
55
+ class SkinToneEstimator(Protocol):
56
+ """Turns a PIL image into a :class:`SkinReadout`."""
57
+
58
+ def estimate(self, image: Image) -> SkinReadout: ...
59
+
60
+
61
+ def _select_device() -> str:
62
+ """Most capable accelerator, excluding Apple MPS (repo device convention)."""
63
+ import torch
64
+
65
+ if torch.cuda.is_available():
66
+ return "cuda"
67
+ if hasattr(torch, "xpu") and torch.xpu.is_available():
68
+ return "xpu"
69
+ return "cpu"
70
+
71
+
72
+ class FaceParsingSkinToneEstimator:
73
+ """Production estimator: RetinaFace detect β†’ FaRL parse β†’ white-balanced Lab."""
74
+
75
+ def __init__(self, device: str | None = None) -> None:
76
+ self._device = device or os.environ.get("GYF_SKINTONE_DEVICE") or None
77
+ self._detector: object | None = None
78
+ self._parser: object | None = None
79
+ self._torch: object | None = None
80
+ self._facer: object | None = None
81
+
82
+ def _load(self) -> None:
83
+ if self._parser is not None:
84
+ return
85
+ import facer # lazy: heavy, optional `skintone` extra
86
+ import torch
87
+
88
+ self._torch = torch
89
+ self._facer = facer
90
+ if self._device is None:
91
+ self._device = _select_device()
92
+ # RetinaFace handles in-the-wild / small / non-frontal faces; FaRL on the
93
+ # CelebM label set is a strong, commercial-clean per-pixel face parser.
94
+ self._detector = facer.face_detector("retinaface/mobilenet", device=self._device)
95
+ self._parser = facer.face_parser("farl/celebm/448", device=self._device)
96
+
97
+ def estimate(self, image: Image) -> SkinReadout:
98
+ self._load()
99
+ torch = self._torch
100
+ rgb = np.ascontiguousarray(np.asarray(image.convert("RGB")))
101
+
102
+ # facer expects an (1, 3, H, W) uint8 tensor (BCHW, RGB).
103
+ img_t = torch.from_numpy(rgb).permute(2, 0, 1).unsqueeze(0).to(self._device)
104
+
105
+ with torch.inference_mode():
106
+ faces = self._detector(img_t)
107
+ if faces is None or len(faces.get("scores", [])) == 0:
108
+ return SkinReadout((0.0, 0.0, 0.0), coverage=0.0, face_confidence=0.0, skin_pixels=0)
109
+ faces = self._parser(img_t, faces)
110
+
111
+ # Pick the highest-scoring detected face.
112
+ scores = faces["scores"].detach().cpu().numpy()
113
+ best = int(np.argmax(scores))
114
+ seg = faces["seg"]
115
+ label_names: list[str] = seg["label_names"]
116
+ # logits: (n_faces, n_classes, H, W) β†’ per-pixel argmax class for our face.
117
+ logits = seg["logits"][best].detach().cpu().numpy()
118
+ class_map = logits.argmax(axis=0)
119
+
120
+ skin_idx = [i for i, name in enumerate(label_names) if name.lower() in _SKIN_LABELS]
121
+ if not skin_idx:
122
+ skin_idx = [i for i, name in enumerate(label_names) if "skin" in name.lower()]
123
+ skin_mask = np.isin(class_map, skin_idx)
124
+
125
+ face_area = float((class_map != _bg_index(label_names)).sum()) or 1.0
126
+ skin_px = rgb[skin_mask]
127
+ if skin_px.shape[0] < 50: # too little skin to read honestly β†’ abstain
128
+ return SkinReadout((0.0, 0.0, 0.0), coverage=0.0, face_confidence=float(scores[best]), skin_pixels=int(skin_px.shape[0]))
129
+
130
+ # White-balance using the skin pixels as the illuminant estimate, then trim
131
+ # specular highlights / deep shadow before the colour read.
132
+ balanced = shades_of_gray(skin_px.reshape(1, -1, 3).astype(np.float64)).reshape(-1, 3)
133
+ lum = balanced.mean(axis=1)
134
+ lo, hi = np.percentile(lum, _TRIM_PCT)
135
+ keep = balanced[(lum >= lo) & (lum <= hi)]
136
+ if keep.size == 0:
137
+ keep = balanced
138
+
139
+ median_lab = np.median(srgb_to_lab(keep), axis=0)
140
+ return SkinReadout(
141
+ lab=(float(median_lab[0]), float(median_lab[1]), float(median_lab[2])),
142
+ coverage=float(skin_mask.sum()) / face_area,
143
+ face_confidence=float(scores[best]),
144
+ skin_pixels=int(keep.shape[0]),
145
+ )
146
+
147
+
148
+ def _bg_index(label_names: list[str]) -> int:
149
+ for i, name in enumerate(label_names):
150
+ if name.lower() in {"background", "bg"}:
151
+ return i
152
+ return -1
skintone/whitebalance.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Illumination normalization (pure numpy).
2
+
3
+ The single biggest fairness lever in skin-tone estimation: an uncorrected colour
4
+ cast (warm indoor light, cool shade) shifts the measured tone and does so
5
+ *unevenly* across the spectrum, which is exactly how naive tone estimators end up
6
+ biased. We white-balance before reading colour so the Lab value reflects skin,
7
+ not the lamp. Shades-of-Gray (Finlayson & Trezzi 2004) generalizes Gray-World and
8
+ is robust and cheap β€” no model, no GPU.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+
15
+
16
+ def shades_of_gray(rgb: np.ndarray, power: float = 6.0, mask: np.ndarray | None = None) -> np.ndarray:
17
+ """Return ``rgb`` white-balanced via the Shades-of-Gray assumption.
18
+
19
+ ``rgb``: (H, W, 3) in [0, 255]. ``power`` = the Minkowski norm order (1 ==
20
+ Gray-World, ∞ == max-RGB; 6 is a robust default). ``mask``: optional bool
21
+ (H, W) selecting the pixels used to estimate the illuminant (e.g. skin only),
22
+ while the gains are applied to the whole image.
23
+ """
24
+ arr = np.asarray(rgb, dtype=np.float64)
25
+ pixels = arr[mask] if mask is not None else arr.reshape(-1, 3)
26
+ if pixels.size == 0:
27
+ return arr
28
+ # Per-channel Minkowski mean = illuminant estimate.
29
+ illum = np.power(np.mean(np.power(pixels, power), axis=0), 1.0 / power)
30
+ illum = np.where(illum <= 1e-6, 1.0, illum)
31
+ gray = float(np.mean(illum))
32
+ gains = gray / illum
33
+ return np.clip(arr * gains, 0, 255)