Spaces:
Sleeping
Sleeping
Evan Li commited on
Commit Β·
e3292bb
1
Parent(s): 64a48d2
attributes and skin features
Browse files- analyzers/attribute_analyzer.py +104 -114
- analyzers/demographic_analyzer.py +16 -7
- analyzers/parsing_analyzer.py +94 -161
- app.py +2 -2
analyzers/attribute_analyzer.py
CHANGED
|
@@ -1,15 +1,23 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from typing import Any
|
| 10 |
|
|
|
|
| 11 |
from PIL import Image
|
| 12 |
-
from transformers import
|
| 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.
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def _load_classifier():
|
| 61 |
try:
|
| 62 |
-
|
|
|
|
| 63 |
except Exception as exc:
|
| 64 |
-
print(f"[AttributeAnalyzer] Failed to load CLIP
|
| 65 |
-
return None
|
| 66 |
|
|
|
|
| 67 |
def analyze(self, img_rgb) -> dict[str, Any]:
|
| 68 |
-
|
| 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 |
-
|
| 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 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
}
|
| 106 |
-
result["hair_texture_celeba"] = hair_texture.split()[0]
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
result["receding_hairline"] = pair_score("receding_hairline")[0]
|
| 111 |
|
| 112 |
-
|
| 113 |
-
result["has_beard"] = has_beard
|
| 114 |
result["facial_hair"] = {
|
| 115 |
-
"5_o_clock_shadow":
|
| 116 |
-
"goatee":
|
| 117 |
-
"mustache":
|
| 118 |
-
"sideburns":
|
| 119 |
-
"full_beard":
|
| 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 |
-
|
| 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 |
-
|
| 5 |
-
- Age:
|
| 6 |
-
- Gender:
|
| 7 |
-
-
|
| 8 |
|
| 9 |
-
The
|
| 10 |
-
|
| 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 = "
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
#
|
| 30 |
PARSING_LABELS = {
|
| 31 |
0: "background",
|
| 32 |
-
1: "
|
| 33 |
-
2: "
|
| 34 |
-
3: "
|
| 35 |
-
4: "
|
| 36 |
-
5: "
|
| 37 |
-
6: "
|
| 38 |
-
7: "
|
| 39 |
-
8: "
|
| 40 |
-
9: "
|
| 41 |
-
10: "
|
| 42 |
-
11: "
|
| 43 |
-
12: "
|
| 44 |
-
13: "
|
| 45 |
-
14: "
|
| 46 |
-
15: "
|
| 47 |
-
16: "
|
| 48 |
-
17: "
|
| 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.
|
| 125 |
-
|
| 126 |
-
def _load_model(self) -> nn.Module:
|
| 127 |
-
model = BiSeNetMini(n_classes=19)
|
| 128 |
try:
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 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 |
-
|
| 152 |
|
| 153 |
with torch.no_grad():
|
| 154 |
-
|
| 155 |
-
if isinstance(out, tuple):
|
| 156 |
-
out = out[0]
|
| 157 |
|
| 158 |
-
|
| 159 |
-
|
| 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 |
-
|
| 166 |
-
|
| 167 |
-
|
| 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 |
-
|
| 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("
|
| 208 |
result["hat_detected"] = region_coverage.get("hat", 0) > 0.01
|
| 209 |
-
result["earring_detected"] =
|
| 210 |
-
result["necklace_detected"] =
|
| 211 |
|
| 212 |
-
# ββ Skin analysis on
|
| 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.
|
| 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(
|
| 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(
|
| 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)
|