Evan Li commited on
Commit
e3292bb
Β·
1 Parent(s): 64a48d2

attributes and skin features

Browse files
analyzers/attribute_analyzer.py CHANGED
@@ -1,15 +1,23 @@
1
  """
2
- Pretrained CLIP-based face attribute classification.
3
-
4
- The old implementation tried to download unavailable CelebA weights and then
5
- fell back to random predictions. This version uses a public zero-shot CLIP
6
- model so the output is deterministic and grounded in pretrained weights.
 
 
 
 
 
 
 
7
  """
8
 
9
  from typing import Any
10
 
 
11
  from PIL import Image
12
- from transformers import pipeline
13
 
14
 
15
  CLIP_MODEL_ID = "openai/clip-vit-base-patch32"
@@ -51,114 +59,117 @@ PAIRS = {
51
  HAIR_COLOR_LABELS = ["black hair", "blond hair", "brown hair", "gray hair"]
52
  HAIR_TEXTURE_LABELS = ["straight hair", "wavy hair", "curly hair"]
53
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  class AttributeAnalyzer:
56
  def __init__(self):
57
- self.classifier = self._load_classifier()
58
-
59
- @staticmethod
60
- def _load_classifier():
61
  try:
62
- return pipeline("zero-shot-image-classification", model=CLIP_MODEL_ID)
 
63
  except Exception as exc:
64
- print(f"[AttributeAnalyzer] Failed to load CLIP classifier: {exc}")
65
- return None
66
 
 
67
  def analyze(self, img_rgb) -> dict[str, Any]:
68
- pil = Image.fromarray(img_rgb)
69
-
70
- candidate_labels = []
71
- for positive, negative in PAIRS.values():
72
- candidate_labels.extend([positive, negative])
73
- candidate_labels.extend(HAIR_COLOR_LABELS)
74
- candidate_labels.extend(HAIR_TEXTURE_LABELS)
75
-
76
- if self.classifier is None:
77
  return self._empty_result()
78
 
79
- try:
80
- predictions = self.classifier(
81
- pil,
82
- candidate_labels=candidate_labels,
83
- hypothesis_template="a photo of {}",
84
- )
85
- except Exception as exc:
86
- print(f"[AttributeAnalyzer] Prediction failed: {exc}")
87
- return self._empty_result()
88
- score_map = {item["label"]: float(item["score"]) for item in predictions}
89
-
90
- result: dict[str, Any] = {"_celeba_raw": {k: round(v, 3) for k, v in score_map.items()}}
91
-
92
- def pair_score(key: str) -> tuple[bool, float]:
93
- positive, negative = PAIRS[key]
94
- positive_score = score_map.get(positive, 0.0)
95
- negative_score = score_map.get(negative, 0.0)
96
- return positive_score >= negative_score, round(positive_score, 3)
97
-
98
- hair_color = max(HAIR_COLOR_LABELS, key=lambda label: score_map.get(label, 0.0))
99
- hair_texture = max(HAIR_TEXTURE_LABELS, key=lambda label: score_map.get(label, 0.0))
100
 
101
- result["hair_color_celeba"] = hair_color.split()[0]
102
- result["hair_color_scores"] = {
103
- label.split()[0]: round(score_map.get(label, 0.0), 3)
104
- for label in HAIR_COLOR_LABELS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  }
106
- result["hair_texture_celeba"] = hair_texture.split()[0]
107
 
108
- result["has_bangs"] = pair_score("has_bangs")[0]
109
- result["is_bald"] = pair_score("is_bald")[0]
110
- result["receding_hairline"] = pair_score("receding_hairline")[0]
111
 
112
- has_beard, beard_score = pair_score("has_beard")
113
- result["has_beard"] = has_beard
114
  result["facial_hair"] = {
115
- "5_o_clock_shadow": score_map.get("has a beard", 0.0) > 0.45,
116
- "goatee": pair_score("goatee")[0],
117
- "mustache": pair_score("mustache")[0],
118
- "sideburns": pair_score("sideburns")[0],
119
- "full_beard": has_beard and beard_score > 0.55,
120
  }
121
 
122
- result["wearing_glasses"] = pair_score("wearing_glasses")[0]
123
- result["wearing_earrings"] = pair_score("wearing_earrings")[0]
124
- result["wearing_hat"] = pair_score("wearing_hat")[0]
125
- result["wearing_necklace"] = pair_score("wearing_necklace")[0]
126
- result["wearing_necktie"] = pair_score("wearing_necktie")[0]
127
-
128
- result["heavy_makeup"] = pair_score("heavy_makeup")[0]
129
- result["wearing_lipstick"] = pair_score("wearing_lipstick")[0]
130
-
131
- result["big_nose"] = pair_score("big_nose")[0]
132
- result["pointy_nose"] = pair_score("pointy_nose")[0]
133
- result["big_lips"] = pair_score("big_lips")[0]
134
- result["high_cheekbones"] = pair_score("high_cheekbones")[0]
135
- result["oval_face_celeba"] = pair_score("oval_face_celeba")[0]
136
- result["double_chin"] = pair_score("double_chin")[0]
137
- result["chubby"] = pair_score("chubby")[0]
138
- result["rosy_cheeks"] = pair_score("rosy_cheeks")[0]
139
- result["bags_under_eyes"] = pair_score("bags_under_eyes")[0]
140
- result["narrow_eyes"] = pair_score("narrow_eyes")[0]
141
- result["arched_eyebrows"] = pair_score("arched_eyebrows")[0]
142
- result["bushy_eyebrows"] = pair_score("bushy_eyebrows")[0]
143
- result["pale_skin"] = pair_score("pale_skin")[0]
144
- result["attractive"] = pair_score("attractive")[0]
145
- result["young"] = pair_score("young")[0]
146
- result["smiling_celeba"] = pair_score("smiling_celeba")[0]
147
- result["mouth_open"] = pair_score("mouth_open")[0]
148
-
149
  return result
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  @staticmethod
152
  def _empty_result() -> dict[str, Any]:
153
- return {
154
  "_celeba_raw": {},
155
  "hair_color_celeba": "unknown",
156
  "hair_color_scores": {"black": 0.0, "blond": 0.0, "brown": 0.0, "gray": 0.0},
157
  "hair_texture_celeba": "unknown",
158
- "has_bangs": False,
159
- "is_bald": False,
160
- "receding_hairline": False,
161
- "has_beard": False,
162
  "facial_hair": {
163
  "5_o_clock_shadow": False,
164
  "goatee": False,
@@ -166,28 +177,7 @@ class AttributeAnalyzer:
166
  "sideburns": False,
167
  "full_beard": False,
168
  },
169
- "wearing_glasses": False,
170
- "wearing_earrings": False,
171
- "wearing_hat": False,
172
- "wearing_necklace": False,
173
- "wearing_necktie": False,
174
- "heavy_makeup": False,
175
- "wearing_lipstick": False,
176
- "big_nose": False,
177
- "pointy_nose": False,
178
- "big_lips": False,
179
- "high_cheekbones": False,
180
- "oval_face_celeba": False,
181
- "double_chin": False,
182
- "chubby": False,
183
- "rosy_cheeks": False,
184
- "bags_under_eyes": False,
185
- "narrow_eyes": False,
186
- "arched_eyebrows": False,
187
- "bushy_eyebrows": False,
188
- "pale_skin": False,
189
- "attractive": False,
190
- "young": False,
191
- "smiling_celeba": False,
192
- "mouth_open": False,
193
  }
 
 
 
 
1
  """
2
+ CLIP zero-shot attribute classification.
3
+
4
+ Previous version put all ~70 candidate labels into a single zero-shot pipeline
5
+ call, which applied one softmax across every label at once. That meant each
6
+ binary pair ("wearing earrings" vs "not wearing earrings") received ~1/70 of
7
+ the probability mass and the comparison between positive and negative was
8
+ essentially noise β€” hence the hallucinated accessories.
9
+
10
+ This version encodes the image once with CLIPModel.get_image_features, then
11
+ runs a fresh 2-way softmax per binary pair. Group labels (hair color,
12
+ hair texture) get their own N-way softmax. All scores are now independent
13
+ of how many other labels we happen to be asking about.
14
  """
15
 
16
  from typing import Any
17
 
18
+ import torch
19
  from PIL import Image
20
+ from transformers import CLIPModel, CLIPProcessor
21
 
22
 
23
  CLIP_MODEL_ID = "openai/clip-vit-base-patch32"
 
59
  HAIR_COLOR_LABELS = ["black hair", "blond hair", "brown hair", "gray hair"]
60
  HAIR_TEXTURE_LABELS = ["straight hair", "wavy hair", "curly hair"]
61
 
62
+ # Some pairs default to False unless CLIP is confidently past this threshold.
63
+ # Stops borderline cases from being flipped to True on a 51/49 split.
64
+ ACCESSORY_THRESHOLD = 0.65
65
+ ACCESSORY_KEYS = {
66
+ "wearing_earrings", "wearing_necklace", "wearing_necktie", "wearing_hat",
67
+ "heavy_makeup", "wearing_lipstick",
68
+ }
69
+
70
+
71
+ def _prompt(text: str) -> str:
72
+ return f"a photo of {text}"
73
+
74
 
75
  class AttributeAnalyzer:
76
  def __init__(self):
77
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
78
+ self.model = None
79
+ self.processor = None
 
80
  try:
81
+ self.model = CLIPModel.from_pretrained(CLIP_MODEL_ID).to(self.device).eval()
82
+ self.processor = CLIPProcessor.from_pretrained(CLIP_MODEL_ID)
83
  except Exception as exc:
84
+ print(f"[AttributeAnalyzer] Failed to load CLIP: {exc}")
 
85
 
86
+ @torch.no_grad()
87
  def analyze(self, img_rgb) -> dict[str, Any]:
88
+ if self.model is None or self.processor is None:
 
 
 
 
 
 
 
 
89
  return self._empty_result()
90
 
91
+ pil = Image.fromarray(img_rgb)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
+ # Encode image once.
94
+ image_inputs = self.processor(images=pil, return_tensors="pt").to(self.device)
95
+ image_features = self.model.get_image_features(**image_inputs)
96
+ image_features = image_features / image_features.norm(dim=-1, keepdim=True)
97
+
98
+ # Per-pair scoring: each pair gets its own independent 2-way softmax.
99
+ pair_scores: dict[str, float] = {}
100
+ for key, (positive, negative) in PAIRS.items():
101
+ prompts = [_prompt(positive), _prompt(negative)]
102
+ pair_scores[key] = self._softmax_positive(image_features, prompts)
103
+
104
+ # Group scoring (N-way softmax within each group).
105
+ color_scores = self._group_softmax(
106
+ image_features, [_prompt(x) for x in HAIR_COLOR_LABELS]
107
+ )
108
+ texture_scores = self._group_softmax(
109
+ image_features, [_prompt(x) for x in HAIR_TEXTURE_LABELS]
110
+ )
111
+
112
+ hair_color_name = HAIR_COLOR_LABELS[int(torch.argmax(torch.tensor(color_scores)))].split()[0]
113
+ hair_texture_name = HAIR_TEXTURE_LABELS[int(torch.argmax(torch.tensor(texture_scores)))].split()[0]
114
+
115
+ def flag(key: str) -> bool:
116
+ score = pair_scores.get(key, 0.0)
117
+ threshold = ACCESSORY_THRESHOLD if key in ACCESSORY_KEYS else 0.5
118
+ return score >= threshold
119
+
120
+ result: dict[str, Any] = {
121
+ "_celeba_raw": {k: round(v, 3) for k, v in pair_scores.items()},
122
+ "hair_color_celeba": hair_color_name,
123
+ "hair_color_scores": {
124
+ label.split()[0]: round(float(score), 3)
125
+ for label, score in zip(HAIR_COLOR_LABELS, color_scores)
126
+ },
127
+ "hair_texture_celeba": hair_texture_name,
128
  }
 
129
 
130
+ for key in PAIRS:
131
+ result[key] = flag(key)
 
132
 
133
+ beard_score = pair_scores.get("has_beard", 0.0)
 
134
  result["facial_hair"] = {
135
+ "5_o_clock_shadow": 0.45 < beard_score < 0.7,
136
+ "goatee": flag("goatee"),
137
+ "mustache": flag("mustache"),
138
+ "sideburns": flag("sideburns"),
139
+ "full_beard": beard_score > 0.7,
140
  }
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  return result
143
 
144
+ @torch.no_grad()
145
+ def _softmax_positive(self, image_features: torch.Tensor, prompts: list[str]) -> float:
146
+ text_inputs = self.processor(
147
+ text=prompts, return_tensors="pt", padding=True
148
+ ).to(self.device)
149
+ text_features = self.model.get_text_features(**text_inputs)
150
+ text_features = text_features / text_features.norm(dim=-1, keepdim=True)
151
+ logits = (image_features @ text_features.T) * self.model.logit_scale.exp()
152
+ probs = torch.softmax(logits, dim=-1)[0]
153
+ return float(probs[0])
154
+
155
+ @torch.no_grad()
156
+ def _group_softmax(self, image_features: torch.Tensor, prompts: list[str]) -> list[float]:
157
+ text_inputs = self.processor(
158
+ text=prompts, return_tensors="pt", padding=True
159
+ ).to(self.device)
160
+ text_features = self.model.get_text_features(**text_inputs)
161
+ text_features = text_features / text_features.norm(dim=-1, keepdim=True)
162
+ logits = (image_features @ text_features.T) * self.model.logit_scale.exp()
163
+ probs = torch.softmax(logits, dim=-1)[0]
164
+ return [float(p) for p in probs]
165
+
166
  @staticmethod
167
  def _empty_result() -> dict[str, Any]:
168
+ base: dict[str, Any] = {
169
  "_celeba_raw": {},
170
  "hair_color_celeba": "unknown",
171
  "hair_color_scores": {"black": 0.0, "blond": 0.0, "brown": 0.0, "gray": 0.0},
172
  "hair_texture_celeba": "unknown",
 
 
 
 
173
  "facial_hair": {
174
  "5_o_clock_shadow": False,
175
  "goatee": False,
 
177
  "sideburns": False,
178
  "full_beard": False,
179
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  }
181
+ for key in PAIRS:
182
+ base[key] = False
183
+ return base
analyzers/demographic_analyzer.py CHANGED
@@ -1,13 +1,13 @@
1
  """
2
  Public pretrained demographic classifiers.
3
 
4
- This replaces the broken private FairFace weight download with public HF models:
5
- - Age: dima806/fairface_age_image_detection
6
- - Gender: dima806/fairface_gender_image_detection
7
- - Race: NikhilJaddu/fairface-race-vit
8
 
9
- The output remains compatible with the rest of the app, but predictions come
10
- from real pretrained weights instead of random fallback values.
11
  """
12
 
13
  from typing import Any
@@ -18,10 +18,13 @@ from transformers import pipeline
18
 
19
  AGE_MODEL_ID = "dima806/fairface_age_image_detection"
20
  GENDER_MODEL_ID = "dima806/fairface_gender_image_detection"
21
- RACE_MODEL_ID = "NikhilJaddu/fairface-race-vit"
22
 
23
  AGE_LABELS = ["0-2", "3-9", "10-19", "20-29", "30-39", "40-49", "50-59", "60-69", "70+"]
24
  GENDER_LABELS = ["Male", "Female"]
 
 
 
25
  RACE_LABELS = ["White", "Black", "Latino_Hispanic", "East Asian", "Southeast Asian", "Indian", "Middle Eastern"]
26
 
27
 
@@ -97,6 +100,7 @@ class DemographicAnalyzer:
97
  def _normalize_race_label(label: str) -> str:
98
  normalized = label.strip().lower().replace("-", "_")
99
  race_aliases = {
 
100
  "white": "White",
101
  "black": "Black",
102
  "latino_hispanic": "Latino_Hispanic",
@@ -105,6 +109,11 @@ class DemographicAnalyzer:
105
  "southeast asian": "Southeast Asian",
106
  "indian": "Indian",
107
  "middle eastern": "Middle Eastern",
 
 
 
 
 
108
  }
109
  return race_aliases.get(normalized, label)
110
 
 
1
  """
2
  Public pretrained demographic classifiers.
3
 
4
+ Models used (all public, with published accuracy):
5
+ - Age: dima806/fairface_age_image_detection (~59% top-1 on FairFace age buckets)
6
+ - Gender: dima806/fairface_gender_image_detection (~93.4% on FairFace)
7
+ - Ethnicity: cledoux42/Ethnicity_Test_v003 (ViT, 79.6% accuracy, macro-F1 0.797)
8
 
9
+ The ethnicity model replaces the former NikhilJaddu/fairface-race-vit checkpoint,
10
+ which had no published performance metrics on the HF model card.
11
  """
12
 
13
  from typing import Any
 
18
 
19
  AGE_MODEL_ID = "dima806/fairface_age_image_detection"
20
  GENDER_MODEL_ID = "dima806/fairface_gender_image_detection"
21
+ RACE_MODEL_ID = "cledoux42/Ethnicity_Test_v003"
22
 
23
  AGE_LABELS = ["0-2", "3-9", "10-19", "20-29", "30-39", "40-49", "50-59", "60-69", "70+"]
24
  GENDER_LABELS = ["Male", "Female"]
25
+ # cledoux42/Ethnicity_Test_v003 outputs 5 classes: african, asian, caucasian, hispanic, indian.
26
+ # We keep the legacy 7-bucket schema internally so the rest of the app still works;
27
+ # unseen buckets simply stay at 0.0 in the distribution.
28
  RACE_LABELS = ["White", "Black", "Latino_Hispanic", "East Asian", "Southeast Asian", "Indian", "Middle Eastern"]
29
 
30
 
 
100
  def _normalize_race_label(label: str) -> str:
101
  normalized = label.strip().lower().replace("-", "_")
102
  race_aliases = {
103
+ # Original FairFace 7-class labels
104
  "white": "White",
105
  "black": "Black",
106
  "latino_hispanic": "Latino_Hispanic",
 
109
  "southeast asian": "Southeast Asian",
110
  "indian": "Indian",
111
  "middle eastern": "Middle Eastern",
112
+ # cledoux42/Ethnicity_Test_v003 5-class labels β†’ map into our schema
113
+ "african": "Black",
114
+ "asian": "East Asian",
115
+ "caucasian": "White",
116
+ "hispanic": "Latino_Hispanic",
117
  }
118
  return race_aliases.get(normalized, label)
119
 
analyzers/parsing_analyzer.py CHANGED
@@ -1,171 +1,93 @@
1
  """
2
- BiSeNet β€” 19-class face parsing / segmentation.
3
-
4
- Segments the face into regions: skin, left/right eyebrow, left/right eye,
5
- nose, upper/lower lip, hair, hat, earring, necklace, neck, cloth, etc.
6
-
7
- From the segmentation masks we derive:
8
- - Hair presence and approximate length
9
- - Wearing glasses, hat, earrings (from mask presence)
10
- - Skin smoothness (wrinkle detection via edge density on skin mask)
11
- - Freckle/mole estimation (dark spots on skin mask)
12
- - Makeup presence indicators
13
-
14
- The masks (_skin_mask, _hair_mask) are also shared with color_analyzer
15
- for accurate pixel sampling.
 
 
 
 
 
16
  """
17
 
18
- import os
19
  from typing import Any
20
 
21
  import cv2
22
  import numpy as np
23
  import torch
24
- import torch.nn as nn
25
- from huggingface_hub import hf_hub_download
26
  from PIL import Image
27
- from torchvision import transforms
 
 
 
 
 
28
 
29
- # BiSeNet class labels (19 classes)
30
  PARSING_LABELS = {
31
  0: "background",
32
- 1: "skin",
33
- 2: "left_eyebrow",
34
- 3: "right_eyebrow",
35
- 4: "left_eye",
36
- 5: "right_eye",
37
- 6: "eyeglasses",
38
- 7: "left_ear",
39
- 8: "right_ear",
40
- 9: "earring",
41
- 10: "nose",
42
- 11: "mouth_interior",
43
- 12: "upper_lip",
44
- 13: "lower_lip",
45
- 14: "neck",
46
- 15: "necklace",
47
- 16: "cloth",
48
- 17: "hair",
49
- 18: "hat",
50
  }
51
 
52
- BISENET_REPO = "jonathandinu/face-parsing"
53
- BISENET_FILE = "model.safetensors"
54
-
55
- TRANSFORM = transforms.Compose([
56
- transforms.Resize((512, 512)),
57
- transforms.ToTensor(),
58
- transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
59
- ])
60
-
61
-
62
- # ── Lightweight BiSeNet Architecture ──────────────────────────────────
63
- # If the HuggingFace weights aren't available, we define a minimal
64
- # architecture. The actual weights determine accuracy.
65
-
66
- class ConvBNReLU(nn.Module):
67
- def __init__(self, in_c, out_c, ks=3, stride=1, padding=1):
68
- super().__init__()
69
- self.conv = nn.Conv2d(in_c, out_c, ks, stride, padding, bias=False)
70
- self.bn = nn.BatchNorm2d(out_c)
71
- self.relu = nn.ReLU(inplace=True)
72
-
73
- def forward(self, x):
74
- return self.relu(self.bn(self.conv(x)))
75
-
76
-
77
- class BiSeNetMini(nn.Module):
78
- """Minimal BiSeNet-like architecture for face parsing."""
79
-
80
- def __init__(self, n_classes=19):
81
- super().__init__()
82
- # Simplified encoder
83
- self.layer1 = nn.Sequential(
84
- ConvBNReLU(3, 64, 7, 2, 3),
85
- ConvBNReLU(64, 64),
86
- )
87
- self.layer2 = nn.Sequential(
88
- ConvBNReLU(64, 128, 3, 2, 1),
89
- ConvBNReLU(128, 128),
90
- )
91
- self.layer3 = nn.Sequential(
92
- ConvBNReLU(128, 256, 3, 2, 1),
93
- ConvBNReLU(256, 256),
94
- )
95
- self.layer4 = nn.Sequential(
96
- ConvBNReLU(256, 512, 3, 2, 1),
97
- ConvBNReLU(512, 512),
98
- )
99
- # Decoder
100
- self.up4 = nn.ConvTranspose2d(512, 256, 2, stride=2)
101
- self.dec4 = ConvBNReLU(512, 256)
102
- self.up3 = nn.ConvTranspose2d(256, 128, 2, stride=2)
103
- self.dec3 = ConvBNReLU(256, 128)
104
- self.up2 = nn.ConvTranspose2d(128, 64, 2, stride=2)
105
- self.dec2 = ConvBNReLU(128, 64)
106
- self.up1 = nn.ConvTranspose2d(64, 64, 2, stride=2)
107
- self.conv_out = nn.Conv2d(64, n_classes, 1)
108
-
109
- def forward(self, x):
110
- e1 = self.layer1(x)
111
- e2 = self.layer2(e1)
112
- e3 = self.layer3(e2)
113
- e4 = self.layer4(e3)
114
- d4 = self.dec4(torch.cat([self.up4(e4), e3], 1))
115
- d3 = self.dec3(torch.cat([self.up3(d4), e2], 1))
116
- d2 = self.dec2(torch.cat([self.up2(d3), e1], 1))
117
- d1 = self.up1(d2)
118
- return self.conv_out(d1)
119
-
120
 
121
  class ParsingAnalyzer:
122
  def __init__(self):
123
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
124
- self.model = self._load_model()
125
-
126
- def _load_model(self) -> nn.Module:
127
- model = BiSeNetMini(n_classes=19)
128
  try:
129
- weight_path = hf_hub_download(
130
- repo_id=BISENET_REPO,
131
- filename=BISENET_FILE,
132
- )
133
- # Try loading safetensors first, fall back to torch
134
- try:
135
- from safetensors.torch import load_file
136
- state_dict = load_file(weight_path)
137
- except ImportError:
138
- state_dict = torch.load(weight_path, map_location=self.device, weights_only=True)
139
-
140
- model.load_state_dict(state_dict, strict=False)
141
- except Exception as e:
142
- print(f"[ParsingAnalyzer] Could not load BiSeNet weights: {e}")
143
- print("[ParsingAnalyzer] Falling back to random weights")
144
-
145
- model.to(self.device).eval()
146
- return model
147
 
148
  def analyze(self, img_rgb: np.ndarray) -> dict[str, Any]:
149
  h, w = img_rgb.shape[:2]
 
 
 
 
150
  pil = Image.fromarray(img_rgb)
151
- tensor = TRANSFORM(pil).unsqueeze(0).to(self.device)
152
 
153
  with torch.no_grad():
154
- out = self.model(tensor)
155
- if isinstance(out, tuple):
156
- out = out[0]
157
 
158
- # Resize parsing map to original image size
159
- parsing = out.squeeze(0).cpu().numpy()
160
- parsing = np.argmax(parsing, axis=0) # (512, 512)
161
- parsing = cv2.resize(
162
- parsing.astype(np.uint8), (w, h), interpolation=cv2.INTER_NEAREST
163
  )
 
164
 
165
- # Build individual masks
166
- masks: dict[str, np.ndarray] = {}
167
- for label_id, label_name in PARSING_LABELS.items():
168
- masks[label_name] = (parsing == label_id)
169
 
170
  total_pixels = h * w
171
  region_coverage = {
@@ -174,22 +96,20 @@ class ParsingAnalyzer:
174
  if name != "background"
175
  }
176
 
177
- result: dict[str, Any] = {
178
- "region_coverage": region_coverage,
179
- }
180
 
181
- # Pass masks for color analyzer
182
- skin_mask = masks.get("skin", np.zeros((h, w), dtype=bool))
183
  hair_mask = masks.get("hair", np.zeros((h, w), dtype=bool))
 
 
 
184
  result["_skin_mask"] = skin_mask
185
  result["_hair_mask"] = hair_mask
186
- lip_mask = masks.get("upper_lip", np.zeros((h, w), dtype=bool)) | \
187
- masks.get("lower_lip", np.zeros((h, w), dtype=bool))
188
  result["_lip_mask"] = lip_mask
189
 
190
  # ── Hair length estimation ───────────────────────────────────
191
- hair_pixels = hair_mask.sum()
192
- face_pixels = skin_mask.sum() + hair_pixels
193
  hair_ratio = hair_pixels / face_pixels if face_pixels else 0
194
 
195
  if hair_ratio < 0.05:
@@ -204,18 +124,13 @@ class ParsingAnalyzer:
204
  result["hair_present"] = hair_ratio > 0.03
205
 
206
  # ── Accessories from segmentation ────────────────────────────
207
- result["glasses_detected"] = region_coverage.get("eyeglasses", 0) > 0.005
208
  result["hat_detected"] = region_coverage.get("hat", 0) > 0.01
209
- result["earring_detected"] = region_coverage.get("earring", 0) > 0.002
210
- result["necklace_detected"] = region_coverage.get("necklace", 0) > 0.002
211
 
212
- # ── Skin analysis on skin mask ───────────────────────────────
213
  if skin_mask.sum() > 100:
214
- skin_region = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
215
- skin_region = skin_region.astype(float)
216
- skin_region[~skin_mask] = np.nan
217
-
218
- # Wrinkle estimation via Laplacian edge density
219
  skin_gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
220
  laplacian = cv2.Laplacian(skin_gray, cv2.CV_64F)
221
  skin_edges = np.abs(laplacian)
@@ -233,7 +148,6 @@ class ParsingAnalyzer:
233
 
234
  result["skin_texture_score"] = round(float(edge_density), 2)
235
 
236
- # Freckle/mole estimation (dark spots)
237
  skin_lab = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2LAB)
238
  l_channel = skin_lab[:, :, 0].astype(float)
239
  l_channel[~skin_mask] = np.nan
@@ -247,9 +161,8 @@ class ParsingAnalyzer:
247
  else "none"
248
  )
249
 
250
- # Skin uniformity
251
  skin_l_values = l_channel[skin_mask]
252
- result["skin_uniformity"] = round(float(np.std(skin_l_values)), 2)
253
  else:
254
  result["wrinkle_level"] = "unknown"
255
  result["skin_texture_score"] = 0
@@ -257,3 +170,23 @@ class ParsingAnalyzer:
257
  result["skin_uniformity"] = 0
258
 
259
  return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ SegFormer-B5 human parsing β€” replaces the old jonathandinu/face-parsing loader.
3
+
4
+ Model: matei-dorian/segformer-b5-finetuned-human-parsing
5
+ - Architecture: SegFormer-B5 (nvidia/mit-b5 backbone)
6
+ - Published metrics on its eval set:
7
+ β€’ Mean IoU: 0.6258
8
+ β€’ Mean accuracy: 0.7547
9
+ β€’ Overall acc.: 0.8256
10
+ β€’ Face: acc 0.9094 / IoU 0.8294
11
+ β€’ Hair: acc 0.8974 / IoU 0.8171
12
+ - Outputs 18 classes (background, hat, hair, sunglasses, upper-clothes, skirt,
13
+ pants, dress, belt, left-shoe, right-shoe, face, left-leg, right-leg,
14
+ left-arm, right-arm, bag, scarf).
15
+
16
+ We keep the same downstream contract as before: skin/hair/lip masks plus
17
+ hair-length, accessory flags, wrinkle estimation, freckle/mole detection.
18
+ The lip mask is approximated from the face region (no lip-specific class)
19
+ and is mainly used as a fallback β€” MediaPipe lip landmarks are still the
20
+ primary source for lip geometry/color in color_analyzer.
21
  """
22
 
 
23
  from typing import Any
24
 
25
  import cv2
26
  import numpy as np
27
  import torch
 
 
28
  from PIL import Image
29
+ from transformers import (
30
+ SegformerForSemanticSegmentation,
31
+ SegformerImageProcessor,
32
+ )
33
+
34
+ MODEL_ID = "matei-dorian/segformer-b5-finetuned-human-parsing"
35
 
36
+ # Official label map from the model card.
37
  PARSING_LABELS = {
38
  0: "background",
39
+ 1: "hat",
40
+ 2: "hair",
41
+ 3: "sunglasses",
42
+ 4: "upper_clothes",
43
+ 5: "skirt",
44
+ 6: "pants",
45
+ 7: "dress",
46
+ 8: "belt",
47
+ 9: "left_shoe",
48
+ 10: "right_shoe",
49
+ 11: "face",
50
+ 12: "left_leg",
51
+ 13: "right_leg",
52
+ 14: "left_arm",
53
+ 15: "right_arm",
54
+ 16: "bag",
55
+ 17: "scarf",
 
56
  }
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  class ParsingAnalyzer:
60
  def __init__(self):
61
  self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
62
+ self.processor = None
63
+ self.model = None
 
 
64
  try:
65
+ self.processor = SegformerImageProcessor.from_pretrained(MODEL_ID)
66
+ self.model = SegformerForSemanticSegmentation.from_pretrained(MODEL_ID)
67
+ self.model.to(self.device).eval()
68
+ except Exception as exc:
69
+ print(f"[ParsingAnalyzer] Failed to load {MODEL_ID}: {exc}")
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
  def analyze(self, img_rgb: np.ndarray) -> dict[str, Any]:
72
  h, w = img_rgb.shape[:2]
73
+
74
+ if self.model is None or self.processor is None:
75
+ return self._empty_result(h, w)
76
+
77
  pil = Image.fromarray(img_rgb)
78
+ inputs = self.processor(images=pil, return_tensors="pt").to(self.device)
79
 
80
  with torch.no_grad():
81
+ logits = self.model(**inputs).logits # (1, C, H/4, W/4)
 
 
82
 
83
+ upsampled = torch.nn.functional.interpolate(
84
+ logits, size=(h, w), mode="bilinear", align_corners=False
 
 
 
85
  )
86
+ parsing = upsampled.argmax(dim=1)[0].cpu().numpy().astype(np.uint8)
87
 
88
+ masks: dict[str, np.ndarray] = {
89
+ name: (parsing == label_id) for label_id, name in PARSING_LABELS.items()
90
+ }
 
91
 
92
  total_pixels = h * w
93
  region_coverage = {
 
96
  if name != "background"
97
  }
98
 
99
+ result: dict[str, Any] = {"region_coverage": region_coverage}
 
 
100
 
101
+ skin_mask = masks.get("face", np.zeros((h, w), dtype=bool))
 
102
  hair_mask = masks.get("hair", np.zeros((h, w), dtype=bool))
103
+ # No dedicated lip class; color_analyzer falls back to landmarks for lips.
104
+ lip_mask = np.zeros((h, w), dtype=bool)
105
+
106
  result["_skin_mask"] = skin_mask
107
  result["_hair_mask"] = hair_mask
 
 
108
  result["_lip_mask"] = lip_mask
109
 
110
  # ── Hair length estimation ───────────────────────────────────
111
+ hair_pixels = int(hair_mask.sum())
112
+ face_pixels = int(skin_mask.sum()) + hair_pixels
113
  hair_ratio = hair_pixels / face_pixels if face_pixels else 0
114
 
115
  if hair_ratio < 0.05:
 
124
  result["hair_present"] = hair_ratio > 0.03
125
 
126
  # ── Accessories from segmentation ────────────────────────────
127
+ result["glasses_detected"] = region_coverage.get("sunglasses", 0) > 0.005
128
  result["hat_detected"] = region_coverage.get("hat", 0) > 0.01
129
+ result["earring_detected"] = False # no earring class in this model
130
+ result["necklace_detected"] = False # no necklace class in this model
131
 
132
+ # ── Skin analysis on face mask ───────────────────────────────
133
  if skin_mask.sum() > 100:
 
 
 
 
 
134
  skin_gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
135
  laplacian = cv2.Laplacian(skin_gray, cv2.CV_64F)
136
  skin_edges = np.abs(laplacian)
 
148
 
149
  result["skin_texture_score"] = round(float(edge_density), 2)
150
 
 
151
  skin_lab = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2LAB)
152
  l_channel = skin_lab[:, :, 0].astype(float)
153
  l_channel[~skin_mask] = np.nan
 
161
  else "none"
162
  )
163
 
 
164
  skin_l_values = l_channel[skin_mask]
165
+ result["skin_uniformity"] = round(float(np.nanstd(skin_l_values)), 2)
166
  else:
167
  result["wrinkle_level"] = "unknown"
168
  result["skin_texture_score"] = 0
 
170
  result["skin_uniformity"] = 0
171
 
172
  return result
173
+
174
+ @staticmethod
175
+ def _empty_result(h: int, w: int) -> dict[str, Any]:
176
+ empty = np.zeros((h, w), dtype=bool)
177
+ return {
178
+ "region_coverage": {},
179
+ "_skin_mask": empty,
180
+ "_hair_mask": empty,
181
+ "_lip_mask": empty,
182
+ "hair_length": "unknown",
183
+ "hair_present": False,
184
+ "glasses_detected": False,
185
+ "hat_detected": False,
186
+ "earring_detected": False,
187
+ "necklace_detected": False,
188
+ "wrinkle_level": "unknown",
189
+ "skin_texture_score": 0,
190
+ "freckles_or_moles": "unknown",
191
+ "skin_uniformity": 0,
192
+ }
app.py CHANGED
@@ -164,7 +164,7 @@ async def analyze_face(file: UploadFile = File(...)):
164
 
165
  # Step 4: BiSeNet β†’ pixel segmentation β†’ hair length, wrinkles, spots
166
  logger.info("Running face parsing...")
167
- parse_results = parsing.analyze(img_bgr)
168
  results.update(parse_results)
169
 
170
  # Step 5: HSEmotion β†’ emotion classification
@@ -234,7 +234,7 @@ async def analyze_face_base64(body: dict):
234
  attr_results = attributes.analyze(img_array)
235
  results.update(attr_results)
236
 
237
- parse_results = parsing.analyze(img_bgr)
238
  results.update(parse_results)
239
 
240
  emo_results = emotions.analyze(img_array)
 
164
 
165
  # Step 4: BiSeNet β†’ pixel segmentation β†’ hair length, wrinkles, spots
166
  logger.info("Running face parsing...")
167
+ parse_results = parsing.analyze(img_array)
168
  results.update(parse_results)
169
 
170
  # Step 5: HSEmotion β†’ emotion classification
 
234
  attr_results = attributes.analyze(img_array)
235
  results.update(attr_results)
236
 
237
+ parse_results = parsing.analyze(img_array)
238
  results.update(parse_results)
239
 
240
  emo_results = emotions.analyze(img_array)