Primeintern10 commited on
Commit
1f2fc4e
Β·
1 Parent(s): 7933257

refactor: replace VLM with rule-based skin type classifier, fix timing to seconds, Sri Lanka timezone

Browse files
app/routes/analysis.py CHANGED
@@ -1,13 +1,13 @@
1
  """
2
  POST /analyze β€” full skin analysis pipeline.
3
 
4
- Pipeline (all steps after skin crop run in parallel threads):
5
  1. FaceLandmarker β†’ skin mask crop
6
  2a. OpenCV feature extraction ┐ ThreadPoolExecutor
7
- 2b. EfficientNet + skintel β”‚ (4 threads, wall time β‰ˆ slowest)
8
- 2c. Face detection β”‚
9
- 2d. Qwen3-VL-2B-Instruct β”˜ β†’ skin type + vlm concern scores
10
- 3. Scoring engine β†’ 10 concern scores (existing pipeline)
11
  """
12
 
13
  import base64
@@ -15,19 +15,20 @@ import io
15
  import logging
16
  import time
17
  from concurrent.futures import ThreadPoolExecutor
18
- from datetime import datetime
19
 
20
  import cv2
21
  import numpy as np
22
  from fastapi import APIRouter, File, Form, HTTPException, UploadFile
23
  from PIL import Image
24
 
25
- from app.schemas import AnalysisResponse, ConcernResult, OpenCVFeatures, PipelineTiming, VLMConcernResult
26
  from app.services.face_detection import detect_faces
27
  from app.services.feature_extraction import extract_opencv_features
28
- from app.services.model_inference import _models_available, run_parallel_inference, run_vlm_analysis
29
  from app.services.scoring import calculate_concerns
30
  from app.services.skin_crop import extract_skin_crop
 
31
 
32
  router = APIRouter(prefix="/analyze", tags=["analysis"])
33
  log = logging.getLogger("skinscope.pipeline")
@@ -38,6 +39,9 @@ logging.basicConfig(
38
  datefmt="%H:%M:%S",
39
  )
40
 
 
 
 
41
 
42
  def _encode(image_rgb: np.ndarray) -> str:
43
  bgr = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2BGR)
@@ -50,8 +54,8 @@ async def analyze(
50
  file: UploadFile = File(...),
51
  confidence: float = Form(0.5),
52
  ):
53
- t_start = time.perf_counter()
54
- dt_start = datetime.now()
55
 
56
  # ── 1. Decode image ───────────────────────────────────────────────────────
57
  contents = await file.read()
@@ -61,7 +65,7 @@ async def analyze(
61
 
62
  log.info("═" * 65)
63
  log.info(" SKINSCOPE ANALYSIS PIPELINE")
64
- log.info(f" START {dt_start.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}")
65
  log.info("═" * 65)
66
  log.info(f" INPUT filename={file.filename} size={w}Γ—{h}px "
67
  f"conf_threshold={confidence}")
@@ -70,36 +74,32 @@ async def analyze(
70
  # ── 2. Skin crop ──────────────────────────────────────────────────────────
71
  t0 = time.perf_counter()
72
  skin_crop = extract_skin_crop(image_rgb)
73
- skin_time = time.perf_counter() - t0
74
 
75
  if skin_crop is None:
76
  log.warning(" SKIN CROP no face detected β€” aborting")
77
  raise HTTPException(status_code=422, detail="No face detected in image.")
78
 
79
  skin_px = int(np.any(skin_crop > 0, axis=2).sum())
80
- log.info(f" SKIN CROP {skin_px:,} skin pixels extracted ({skin_time*1000:.0f}ms)")
81
 
82
- # ── 3. Parallel feature extraction (4 threads) ───────────────────────────
83
- log.info(" PARALLEL THREADS starting OpenCV + EfficientNet + Detection + Qwen3-VL …")
84
  t0 = time.perf_counter()
85
 
86
- with ThreadPoolExecutor(max_workers=4) as pool:
87
  f_opencv = pool.submit(extract_opencv_features, skin_crop)
88
  f_ml = pool.submit(run_parallel_inference, image_rgb, skin_crop)
89
  f_detect = pool.submit(detect_faces, image_rgb, confidence)
90
- f_vlm = pool.submit(run_vlm_analysis, skin_crop)
91
 
92
  opencv_features = f_opencv.result()
93
  ml_features = f_ml.result()
94
  detection_result = f_detect.result()
95
- vlm_result = f_vlm.result()
96
-
97
- parallel_time = time.perf_counter() - t0
98
 
99
- # ── 4. Log raw features ───────────────────────────────────────────────────
100
  all_features = {**opencv_features, **ml_features}
101
 
102
- log.info(f" PARALLEL THREADS done in {parallel_time*1000:.0f}ms")
103
  log.info(" β”Œβ”€ RAW FEATURES ───────────────────────────────────────")
104
  log.info(f" β”‚ OpenCV")
105
  for k, v in opencv_features.items():
@@ -111,19 +111,16 @@ async def analyze(
111
  log.info(f" β”‚ {k:<28} = {v:.4f} {bar}")
112
  log.info(f" β”‚ Face Detection")
113
  log.info(f" β”‚ faces_detected = {detection_result.face_count}")
114
- log.info(f" β”‚ Qwen3-VL-2B")
115
- log.info(f" β”‚ skin_type = {vlm_result['skin_type']}")
116
- log.info(f" β”‚ vlm_inference_time = {vlm_result['vlm_inference_ms']:.0f}ms")
117
- for c in vlm_result["vlm_concerns"]:
118
- bar = "β–“" * int((c["score"] - 10) / 85 * 20)
119
- log.info(f" β”‚ {c['name']:<28} = {c['score']:.1f}/95 [{c['severity']:<8}] {bar}")
120
  log.info(" └──────────────────────────────────────────────────────")
121
 
 
 
 
122
  # ── 5. Score 10 concerns ──────────────────────────────────────────────────
123
  t0 = time.perf_counter()
124
  concerns = calculate_concerns(all_features)
125
- score_time = time.perf_counter() - t0
126
- log.info(f" SCORING done in {score_time*1000:.0f}ms")
127
 
128
  # ── 6. Build response ─────────────────────────────────────────────────────
129
  models_used = ["OpenCV"]
@@ -131,25 +128,22 @@ async def analyze(
131
  models_used.append("EfficientNet-B0")
132
  if _models_available.get("skintel"):
133
  models_used.append("skintelligent-acne")
134
- if _models_available.get("qwen_vlm"):
135
- models_used.append("Qwen3-VL-2B-Instruct")
136
 
137
  annotated_bytes = base64.b64decode(detection_result.annotated_image)
138
  annotated_array = np.array(Image.open(io.BytesIO(annotated_bytes)).convert("RGB"))
139
 
140
- total_ms = (time.perf_counter() - t_start) * 1000
141
- dt_end = datetime.now()
142
 
143
  log.info(f" OUTPUT top concern = {concerns[0].name} ({concerns[0].score}/95)")
144
- log.info(f" OUTPUT skin type = {vlm_result['skin_type']}")
145
  log.info(f" β”Œβ”€ TIMING BREAKDOWN ───────────────────────────────────")
146
- log.info(f" β”‚ Skin Crop : {skin_time*1000:>7.1f} ms")
147
- log.info(f" β”‚ Parallel Threads : {parallel_time*1000:>7.1f} ms (wall time, 4 threads)")
148
- log.info(f" β”‚ └─ Qwen3-VL only : {vlm_result['vlm_inference_ms']:>7.1f} ms")
149
- log.info(f" β”‚ Scoring : {score_time*1000:>7.1f} ms")
150
- log.info(f" β”‚ TOTAL : {total_ms:>7.1f} ms")
151
  log.info(f" └──────────────────────────────────────────────────────")
152
- log.info(f" END {dt_end.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}")
153
  log.info("═" * 65)
154
 
155
  return AnalysisResponse(
@@ -169,20 +163,11 @@ async def analyze(
169
  skin_crop_image=_encode(skin_crop),
170
  annotated_image=_encode(annotated_array),
171
  models_used=models_used,
172
- skin_type=vlm_result["skin_type"],
173
- vlm_concerns=[
174
- VLMConcernResult(
175
- name=c["name"],
176
- score=c["score"],
177
- severity=c["severity"],
178
- )
179
- for c in vlm_result["vlm_concerns"]
180
- ],
181
  timing=PipelineTiming(
182
- skin_crop_ms=round(skin_time * 1000, 1),
183
- parallel_pipeline_ms=round(parallel_time * 1000, 1),
184
- vlm_inference_ms=round(vlm_result["vlm_inference_ms"], 1),
185
- scoring_ms=round(score_time * 1000, 1),
186
- total_ms=round(total_ms, 1),
187
  ),
188
  )
 
1
  """
2
  POST /analyze β€” full skin analysis pipeline.
3
 
4
+ Pipeline:
5
  1. FaceLandmarker β†’ skin mask crop
6
  2a. OpenCV feature extraction ┐ ThreadPoolExecutor
7
+ 2b. EfficientNet + skintel β”‚ (3 threads, wall time β‰ˆ slowest)
8
+ 2c. Face detection β”˜
9
+ 3. Rule-based skin type classification (from OpenCV features, ~0ms)
10
+ 4. Scoring engine β†’ 10 concern scores
11
  """
12
 
13
  import base64
 
15
  import logging
16
  import time
17
  from concurrent.futures import ThreadPoolExecutor
18
+ from datetime import datetime, timezone, timedelta
19
 
20
  import cv2
21
  import numpy as np
22
  from fastapi import APIRouter, File, Form, HTTPException, UploadFile
23
  from PIL import Image
24
 
25
+ from app.schemas import AnalysisResponse, ConcernResult, OpenCVFeatures, PipelineTiming
26
  from app.services.face_detection import detect_faces
27
  from app.services.feature_extraction import extract_opencv_features
28
+ from app.services.model_inference import _models_available, run_parallel_inference
29
  from app.services.scoring import calculate_concerns
30
  from app.services.skin_crop import extract_skin_crop
31
+ from app.services.skin_type import classify_skin_type
32
 
33
  router = APIRouter(prefix="/analyze", tags=["analysis"])
34
  log = logging.getLogger("skinscope.pipeline")
 
39
  datefmt="%H:%M:%S",
40
  )
41
 
42
+ # Sri Lanka Standard Time = UTC+5:30
43
+ _SL_TZ = timezone(timedelta(hours=5, minutes=30))
44
+
45
 
46
  def _encode(image_rgb: np.ndarray) -> str:
47
  bgr = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2BGR)
 
54
  file: UploadFile = File(...),
55
  confidence: float = Form(0.5),
56
  ):
57
+ t_start = time.perf_counter()
58
+ dt_start = datetime.now(_SL_TZ)
59
 
60
  # ── 1. Decode image ───────────────────────────────────────────────────────
61
  contents = await file.read()
 
65
 
66
  log.info("═" * 65)
67
  log.info(" SKINSCOPE ANALYSIS PIPELINE")
68
+ log.info(f" START {dt_start.strftime('%Y-%m-%d %H:%M:%S')} (Sri Lanka Time)")
69
  log.info("═" * 65)
70
  log.info(f" INPUT filename={file.filename} size={w}Γ—{h}px "
71
  f"conf_threshold={confidence}")
 
74
  # ── 2. Skin crop ──────────────────────────────────────────────────────────
75
  t0 = time.perf_counter()
76
  skin_crop = extract_skin_crop(image_rgb)
77
+ skin_s = time.perf_counter() - t0
78
 
79
  if skin_crop is None:
80
  log.warning(" SKIN CROP no face detected β€” aborting")
81
  raise HTTPException(status_code=422, detail="No face detected in image.")
82
 
83
  skin_px = int(np.any(skin_crop > 0, axis=2).sum())
84
+ log.info(f" SKIN CROP {skin_px:,} skin pixels extracted ({skin_s:.3f}s)")
85
 
86
+ # ── 3. Parallel feature extraction (3 threads) ───────────────────────────
87
+ log.info(" PARALLEL THREADS starting OpenCV + EfficientNet + Detection …")
88
  t0 = time.perf_counter()
89
 
90
+ with ThreadPoolExecutor(max_workers=3) as pool:
91
  f_opencv = pool.submit(extract_opencv_features, skin_crop)
92
  f_ml = pool.submit(run_parallel_inference, image_rgb, skin_crop)
93
  f_detect = pool.submit(detect_faces, image_rgb, confidence)
 
94
 
95
  opencv_features = f_opencv.result()
96
  ml_features = f_ml.result()
97
  detection_result = f_detect.result()
 
 
 
98
 
99
+ parallel_s = time.perf_counter() - t0
100
  all_features = {**opencv_features, **ml_features}
101
 
102
+ log.info(f" PARALLEL THREADS done in {parallel_s:.3f}s")
103
  log.info(" β”Œβ”€ RAW FEATURES ───────────────────────────────────────")
104
  log.info(f" β”‚ OpenCV")
105
  for k, v in opencv_features.items():
 
111
  log.info(f" β”‚ {k:<28} = {v:.4f} {bar}")
112
  log.info(f" β”‚ Face Detection")
113
  log.info(f" β”‚ faces_detected = {detection_result.face_count}")
 
 
 
 
 
 
114
  log.info(" └──────────────────────────────────────────────────────")
115
 
116
+ # ── 4. Rule-based skin type classification ────────────────────────────────
117
+ skin_type = classify_skin_type(opencv_features)
118
+
119
  # ── 5. Score 10 concerns ──────────────────────────────────────────────────
120
  t0 = time.perf_counter()
121
  concerns = calculate_concerns(all_features)
122
+ scoring_s = time.perf_counter() - t0
123
+ log.info(f" SCORING done in {scoring_s:.3f}s")
124
 
125
  # ── 6. Build response ─────────────────────────────────────────────────────
126
  models_used = ["OpenCV"]
 
128
  models_used.append("EfficientNet-B0")
129
  if _models_available.get("skintel"):
130
  models_used.append("skintelligent-acne")
 
 
131
 
132
  annotated_bytes = base64.b64decode(detection_result.annotated_image)
133
  annotated_array = np.array(Image.open(io.BytesIO(annotated_bytes)).convert("RGB"))
134
 
135
+ total_s = time.perf_counter() - t_start
136
+ dt_end = datetime.now(_SL_TZ)
137
 
138
  log.info(f" OUTPUT top concern = {concerns[0].name} ({concerns[0].score}/95)")
139
+ log.info(f" OUTPUT skin type = {skin_type}")
140
  log.info(f" β”Œβ”€ TIMING BREAKDOWN ───────────────────────────────────")
141
+ log.info(f" β”‚ Skin Crop : {skin_s:.3f}s")
142
+ log.info(f" β”‚ Parallel Threads : {parallel_s:.3f}s (wall time, 3 threads)")
143
+ log.info(f" β”‚ Scoring : {scoring_s:.3f}s")
144
+ log.info(f" β”‚ TOTAL : {total_s:.3f}s")
 
145
  log.info(f" └──────────────────────────────────────────────────────")
146
+ log.info(f" END {dt_end.strftime('%Y-%m-%d %H:%M:%S')} (Sri Lanka Time)")
147
  log.info("═" * 65)
148
 
149
  return AnalysisResponse(
 
163
  skin_crop_image=_encode(skin_crop),
164
  annotated_image=_encode(annotated_array),
165
  models_used=models_used,
166
+ skin_type=skin_type,
 
 
 
 
 
 
 
 
167
  timing=PipelineTiming(
168
+ skin_crop_s=round(skin_s, 3),
169
+ parallel_pipeline_s=round(parallel_s, 3),
170
+ scoring_s=round(scoring_s, 3),
171
+ total_s=round(total_s, 3),
 
172
  ),
173
  )
app/schemas.py CHANGED
@@ -1,5 +1,5 @@
1
  from pydantic import BaseModel
2
- from typing import List, Optional
3
 
4
 
5
  # ---------------------------------------------------------------------------
@@ -30,18 +30,11 @@ class ConcernResult(BaseModel):
30
  description: str
31
 
32
 
33
- class VLMConcernResult(BaseModel):
34
- name: str
35
- score: float # 10–95
36
- severity: str # Low | Moderate | High
37
-
38
-
39
  class PipelineTiming(BaseModel):
40
- skin_crop_ms: float
41
- parallel_pipeline_ms: float # OpenCV + ML + face detection threads
42
- vlm_inference_ms: float # Qwen3-VL thread
43
- scoring_ms: float
44
- total_ms: float
45
 
46
 
47
  class OpenCVFeatures(BaseModel):
@@ -64,7 +57,5 @@ class AnalysisResponse(BaseModel):
64
  skin_crop_image: str # base64-encoded PNG of masked skin region
65
  annotated_image: str # base64-encoded PNG with face box
66
  models_used: List[str] # which ML models contributed
67
- # VLM additions
68
- skin_type: str # normal | oily | dry | combination | sensitive | unknown
69
- vlm_concerns: List[VLMConcernResult] # same 10 concerns scored by Qwen3-VL
70
  timing: PipelineTiming
 
1
  from pydantic import BaseModel
2
+ from typing import List
3
 
4
 
5
  # ---------------------------------------------------------------------------
 
30
  description: str
31
 
32
 
 
 
 
 
 
 
33
  class PipelineTiming(BaseModel):
34
+ skin_crop_s: float
35
+ parallel_pipeline_s: float
36
+ scoring_s: float
37
+ total_s: float
 
38
 
39
 
40
  class OpenCVFeatures(BaseModel):
 
57
  skin_crop_image: str # base64-encoded PNG of masked skin region
58
  annotated_image: str # base64-encoded PNG with face box
59
  models_used: List[str] # which ML models contributed
60
+ skin_type: str # normal | oily | dry | combination | sensitive
 
 
61
  timing: PipelineTiming
app/services/model_inference.py CHANGED
@@ -1,23 +1,18 @@
1
  """
2
- ML model inference β€” EfficientNet-B0 (texture) + skintelligent-acne ViT (acne)
3
- + Qwen3-VL-2B-Instruct (skin type + concern scoring).
4
 
5
  Models are loaded once at startup as module-level singletons.
6
- run_parallel_inference() runs all models concurrently via ThreadPoolExecutor.
7
  """
8
 
9
- import json
10
  import os
11
- import re
12
  import threading
13
- import time
14
  from concurrent.futures import ThreadPoolExecutor
15
  from pathlib import Path
16
  from typing import Any
17
 
18
  os.environ.setdefault("HF_HUB_DISABLE_IMPLICIT_TOKEN", "1")
19
 
20
- import cv2
21
  import numpy as np
22
 
23
  # ---------------------------------------------------------------------------
@@ -28,9 +23,7 @@ _effnet: Any = None
28
  _effnet_transforms: Any = None
29
  _skintel: Any = None
30
  _skintel_processor: Any = None
31
- _qwen: Any = None
32
- _qwen_processor: Any = None
33
- _models_available = {"effnet": False, "skintel": False, "qwen_vlm": False}
34
 
35
  MODELS_DIR = Path(__file__).parent.parent.parent / "models"
36
 
@@ -79,41 +72,13 @@ def _load_skintelligent() -> None:
79
  print(f"[WARN] skintelligent-acne unavailable: {e}.")
80
 
81
 
82
- def _load_qwen_vlm() -> None:
83
- global _qwen, _qwen_processor
84
- try:
85
- print("Loading Qwen3-VL-2B-Instruct VLM …")
86
- # Try Qwen2.5-VL class first (used by Qwen3-VL), fall back to Qwen2-VL
87
- try:
88
- from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor
89
- _qwen = Qwen2_5_VLForConditionalGeneration.from_pretrained(
90
- "Qwen/Qwen3-VL-2B-Instruct",
91
- torch_dtype="auto",
92
- device_map="auto",
93
- )
94
- except (ImportError, AttributeError):
95
- from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
96
- _qwen = Qwen2VLForConditionalGeneration.from_pretrained(
97
- "Qwen/Qwen3-VL-2B-Instruct",
98
- torch_dtype="auto",
99
- device_map="auto",
100
- )
101
- _qwen_processor = AutoProcessor.from_pretrained("Qwen/Qwen3-VL-2B-Instruct")
102
- _qwen.eval()
103
- _models_available["qwen_vlm"] = True
104
- print("Qwen3-VL-2B-Instruct ready.")
105
- except Exception as e:
106
- print(f"[WARN] Qwen3-VL unavailable: {e}. VLM skin type/concerns will be skipped.")
107
-
108
-
109
  def load_all_models() -> None:
110
  """Call once at application startup to pre-warm all models."""
111
  with _lock:
112
  t1 = threading.Thread(target=_load_efficientnet, daemon=True)
113
  t2 = threading.Thread(target=_load_skintelligent, daemon=True)
114
- t3 = threading.Thread(target=_load_qwen_vlm, daemon=True)
115
- t1.start(); t2.start(); t3.start()
116
- t1.join(); t2.join(); t3.join()
117
 
118
 
119
  # ---------------------------------------------------------------------------
@@ -185,181 +150,6 @@ def _run_efficientnet(skin_crop_rgb: np.ndarray) -> dict[str, float]:
185
  }
186
 
187
 
188
- # ---------------------------------------------------------------------------
189
- # VLM β€” Qwen3-VL-2B-Instruct
190
- # ---------------------------------------------------------------------------
191
-
192
- _VLM_CONCERN_NAMES = [
193
- "Acne / Breakouts",
194
- "Redness / Inflammation",
195
- "Dark Spots / Hyperpigmentation",
196
- "Enlarged Pores",
197
- "Wrinkles / Fine Lines",
198
- "Excess Oiliness",
199
- "Dryness / Dehydration",
200
- "Uneven Skin Tone",
201
- "Dullness",
202
- "Rough Texture",
203
- ]
204
-
205
- _VLM_PROMPT = """You are a professional dermatologist analyzing a facial skin image.
206
-
207
- Task 1 β€” Skin Type: Classify the skin type as EXACTLY one of:
208
- normal, oily, dry, combination, sensitive
209
-
210
- Task 2 β€” Skin Concerns: Score each concern on a scale of 10 to 95
211
- (10 = completely clear, 95 = very severe).
212
-
213
- Respond with ONLY a valid JSON object. No explanation, no markdown, no extra text.
214
- Format:
215
- {
216
- "skin_type": "<one of: normal | oily | dry | combination | sensitive>",
217
- "concerns": {
218
- "Acne / Breakouts": <int 10-95>,
219
- "Redness / Inflammation": <int 10-95>,
220
- "Dark Spots / Hyperpigmentation": <int 10-95>,
221
- "Enlarged Pores": <int 10-95>,
222
- "Wrinkles / Fine Lines": <int 10-95>,
223
- "Excess Oiliness": <int 10-95>,
224
- "Dryness / Dehydration": <int 10-95>,
225
- "Uneven Skin Tone": <int 10-95>,
226
- "Dullness": <int 10-95>,
227
- "Rough Texture": <int 10-95>
228
- }
229
- }"""
230
-
231
-
232
- def _parse_vlm_output(text: str) -> dict:
233
- """Extract JSON from VLM output robustly."""
234
- # Strip markdown code fences if present
235
- text = re.sub(r"```(?:json)?", "", text).strip()
236
- # Find first { ... } block
237
- match = re.search(r"\{.*\}", text, re.DOTALL)
238
- if match:
239
- try:
240
- return json.loads(match.group())
241
- except json.JSONDecodeError:
242
- pass
243
- return {}
244
-
245
-
246
- def _vlm_severity(score: float) -> str:
247
- if score < 35:
248
- return "Low"
249
- if score < 65:
250
- return "Moderate"
251
- return "High"
252
-
253
-
254
- def run_vlm_analysis(skin_crop_rgb: np.ndarray) -> dict:
255
- """
256
- Run Qwen3-VL-2B-Instruct on the skin crop.
257
- Returns:
258
- skin_type: str
259
- vlm_concerns: list of dicts {name, score, severity}
260
- vlm_inference_ms: float
261
- """
262
- default = {
263
- "skin_type": "unknown",
264
- "vlm_concerns": [],
265
- "vlm_inference_ms": 0.0,
266
- }
267
-
268
- if not _models_available["qwen_vlm"]:
269
- return default
270
-
271
- try:
272
- import torch
273
- from PIL import Image as PILImage
274
-
275
- t0 = time.perf_counter()
276
-
277
- pil_img = PILImage.fromarray(skin_crop_rgb)
278
-
279
- messages = [
280
- {
281
- "role": "user",
282
- "content": [
283
- {"type": "image", "image": pil_img},
284
- {"type": "text", "text": _VLM_PROMPT},
285
- ],
286
- }
287
- ]
288
-
289
- # Build inputs using the processor's chat template
290
- text_input = _qwen_processor.apply_chat_template(
291
- messages, tokenize=False, add_generation_prompt=True
292
- )
293
-
294
- # process_vision_info is part of qwen_vl_utils; fall back to manual if missing
295
- try:
296
- from qwen_vl_utils import process_vision_info
297
- image_inputs, video_inputs = process_vision_info(messages)
298
- inputs = _qwen_processor(
299
- text=[text_input],
300
- images=image_inputs,
301
- videos=video_inputs,
302
- padding=True,
303
- return_tensors="pt",
304
- )
305
- except ImportError:
306
- inputs = _qwen_processor(
307
- text=[text_input],
308
- images=[pil_img],
309
- padding=True,
310
- return_tensors="pt",
311
- )
312
-
313
- inputs = inputs.to(_qwen.device)
314
-
315
- with torch.no_grad():
316
- output_ids = _qwen.generate(
317
- **inputs,
318
- max_new_tokens=300,
319
- do_sample=False,
320
- )
321
-
322
- # Decode only the newly generated tokens
323
- generated = output_ids[:, inputs["input_ids"].shape[1]:]
324
- raw_text = _qwen_processor.batch_decode(
325
- generated, skip_special_tokens=True, clean_up_tokenization_spaces=False
326
- )[0]
327
-
328
- vlm_inference_ms = (time.perf_counter() - t0) * 1000
329
-
330
- import logging
331
- logging.getLogger("skinscope.vlm").info(
332
- f" Qwen3-VL raw output: {raw_text[:200]}"
333
- )
334
-
335
- parsed = _parse_vlm_output(raw_text)
336
- skin_type = parsed.get("skin_type", "unknown").strip().lower()
337
- if skin_type not in {"normal", "oily", "dry", "combination", "sensitive"}:
338
- skin_type = "unknown"
339
-
340
- raw_concerns = parsed.get("concerns", {})
341
- vlm_concerns = []
342
- for name in _VLM_CONCERN_NAMES:
343
- score = float(raw_concerns.get(name, 10))
344
- score = max(10.0, min(95.0, score))
345
- vlm_concerns.append({
346
- "name": name,
347
- "score": score,
348
- "severity": _vlm_severity(score),
349
- })
350
-
351
- return {
352
- "skin_type": skin_type,
353
- "vlm_concerns": vlm_concerns,
354
- "vlm_inference_ms": vlm_inference_ms,
355
- }
356
-
357
- except Exception as e:
358
- import logging
359
- logging.getLogger("skinscope.vlm").warning(f" Qwen3-VL inference failed: {e}")
360
- return default
361
-
362
-
363
  # ---------------------------------------------------------------------------
364
  # Public API β€” parallel execution
365
  # ---------------------------------------------------------------------------
 
1
  """
2
+ ML model inference β€” EfficientNet-B0 (texture) + skintelligent-acne ViT (acne).
 
3
 
4
  Models are loaded once at startup as module-level singletons.
5
+ run_parallel_inference() runs both models concurrently via ThreadPoolExecutor.
6
  """
7
 
 
8
  import os
 
9
  import threading
 
10
  from concurrent.futures import ThreadPoolExecutor
11
  from pathlib import Path
12
  from typing import Any
13
 
14
  os.environ.setdefault("HF_HUB_DISABLE_IMPLICIT_TOKEN", "1")
15
 
 
16
  import numpy as np
17
 
18
  # ---------------------------------------------------------------------------
 
23
  _effnet_transforms: Any = None
24
  _skintel: Any = None
25
  _skintel_processor: Any = None
26
+ _models_available = {"effnet": False, "skintel": False}
 
 
27
 
28
  MODELS_DIR = Path(__file__).parent.parent.parent / "models"
29
 
 
72
  print(f"[WARN] skintelligent-acne unavailable: {e}.")
73
 
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  def load_all_models() -> None:
76
  """Call once at application startup to pre-warm all models."""
77
  with _lock:
78
  t1 = threading.Thread(target=_load_efficientnet, daemon=True)
79
  t2 = threading.Thread(target=_load_skintelligent, daemon=True)
80
+ t1.start(); t2.start()
81
+ t1.join(); t2.join()
 
82
 
83
 
84
  # ---------------------------------------------------------------------------
 
150
  }
151
 
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  # ---------------------------------------------------------------------------
154
  # Public API β€” parallel execution
155
  # ---------------------------------------------------------------------------
app/services/skin_type.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rule-based skin type classifier.
3
+
4
+ Derives skin type from OpenCV features already extracted by the pipeline.
5
+ No extra model or inference time required.
6
+
7
+ Rules (thresholds tuned to normalised 0–1 feature range):
8
+ oily : oiliness high, redness low
9
+ dry : flakiness high, oiliness low
10
+ sensitive : redness high (inflammation markers)
11
+ combination : moderate oiliness with uneven texture/color variance
12
+ normal : all features in balanced range
13
+ """
14
+
15
+ import logging
16
+
17
+ log = logging.getLogger("skinscope.skintype")
18
+
19
+ # Thresholds
20
+ _OILY_THRESH = 0.30 # oiliness >= this β†’ leaning oily
21
+ _DRY_OILY_MAX = 0.15 # oiliness <= this for dry classification
22
+ _FLAKY_THRESH = 0.40 # flakiness >= this β†’ dry indicator
23
+ _REDNESS_SENSITIVE = 0.45 # redness >= this β†’ sensitive
24
+ _COMBO_OILY = 0.20 # moderate oiliness for combination
25
+ _COLOR_VAR_COMBO = 0.45 # color variance for combination (uneven zones)
26
+
27
+
28
+ def classify_skin_type(features: dict[str, float]) -> str:
29
+ """
30
+ Classify skin type from extracted OpenCV features.
31
+
32
+ Args:
33
+ features: dict containing at minimum:
34
+ oiliness, redness, flakiness, brightness,
35
+ texture_variance, color_variance, saturation_inv
36
+
37
+ Returns:
38
+ One of: "oily" | "dry" | "sensitive" | "combination" | "normal"
39
+ """
40
+ oiliness = features.get("oiliness", 0.0)
41
+ redness = features.get("redness", 0.0)
42
+ flakiness = features.get("flakiness", 0.0)
43
+ color_var = features.get("color_variance", 0.0)
44
+ saturation_inv = features.get("saturation_inv", 0.0)
45
+
46
+ # ── Sensitive: high redness is the dominant signal ────────────────────────
47
+ if redness >= _REDNESS_SENSITIVE:
48
+ skin_type = "sensitive"
49
+
50
+ # ── Oily: high oiliness, redness not dominant ────────────────────────────
51
+ elif oiliness >= _OILY_THRESH and redness < _REDNESS_SENSITIVE:
52
+ skin_type = "oily"
53
+
54
+ # ── Dry: low oiliness + high flakiness or high saturation drop ───────────
55
+ elif oiliness <= _DRY_OILY_MAX and (flakiness >= _FLAKY_THRESH or saturation_inv >= 0.35):
56
+ skin_type = "dry"
57
+
58
+ # ── Combination: moderate oiliness + uneven color zones ──────────────────
59
+ elif oiliness >= _COMBO_OILY and color_var >= _COLOR_VAR_COMBO:
60
+ skin_type = "combination"
61
+
62
+ # ── Normal: nothing stands out ────────────────────────────────────────────
63
+ else:
64
+ skin_type = "normal"
65
+
66
+ log.info(
67
+ f" SKIN TYPE {skin_type:<12} "
68
+ f"oiliness={oiliness:.3f} redness={redness:.3f} "
69
+ f"flakiness={flakiness:.3f} color_var={color_var:.3f} "
70
+ f"saturation_inv={saturation_inv:.3f}"
71
+ )
72
+ return skin_type
requirements.txt CHANGED
@@ -16,8 +16,6 @@ torch
16
  torchvision
17
  timm
18
  transformers
19
- accelerate
20
- qwen-vl-utils
21
 
22
  # HTTP client (frontend β†’ backend)
23
  requests
 
16
  torchvision
17
  timm
18
  transformers
 
 
19
 
20
  # HTTP client (frontend β†’ backend)
21
  requests