lfolle commited on
Commit
d67f2dd
·
verified ·
1 Parent(s): 94e4f13

Upload folder using huggingface_hub

Browse files
.gitignore CHANGED
@@ -2,3 +2,8 @@
2
  *.pyc
3
  gradio_cached_examples/*
4
  gradio_queue.db*
 
 
 
 
 
 
2
  *.pyc
3
  gradio_cached_examples/*
4
  gradio_queue.db*
5
+
6
+ # Model weights live in the private DeepNAPSI/DeepNAPSI-model HF Hub repo.
7
+ # They are downloaded at runtime via hf_hub_download (xet-backed).
8
+ # Never commit the ONNX file to this Space repo.
9
+ model/
.ruff_cache/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Automatically created by ruff.
2
+ *
.ruff_cache/0.13.2/16283386313770864173 ADDED
Binary file (183 Bytes). View file
 
.ruff_cache/CACHEDIR.TAG ADDED
@@ -0,0 +1 @@
 
 
1
+ Signature: 8a477f597d28d172789f06886806bc55
README.md CHANGED
@@ -4,8 +4,13 @@ emoji: 🤚
4
  colorFrom: blue
5
  colorTo: purple
6
  sdk: gradio
7
- sdk_version: 5.47.2
8
- python_version: 3.12
9
- app_file: entrypoint.py
10
  pinned: false
 
 
 
 
 
11
  ---
 
4
  colorFrom: blue
5
  colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 6.9.0
8
+ python_version: "3.12"
9
+ app_file: app.py
10
  pinned: false
11
+ secrets:
12
+ - name: DEEPNAPSI_HF_TOKEN
13
+ description: >
14
+ Read token for the private DeepNAPSI/DeepNAPSI-model repo.
15
+ The model is downloaded once on startup via huggingface_hub (xet-backed).
16
  ---
app.py CHANGED
@@ -1,77 +1,128 @@
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
2
 
3
- from backend import Infer
4
-
5
-
6
- DEBUG = False
7
-
8
- infer = Infer(DEBUG)
9
- example_image_path = [
10
- "assets/example_1.jpg",
11
- "assets/example_2.jpg",
12
- "assets/example_3.jpg",
13
- ]
14
-
15
- outputs = [
16
- gr.Image(label="Thumb"),
17
- gr.Number(label="DeepNAPSI Thumb", precision=0),
18
- gr.Image(label="Index"),
19
- gr.Number(label="DeepNAPSI Index", precision=0),
20
- gr.Image(label="Middle"),
21
- gr.Number(label="DeepNAPSI Middle", precision=0),
22
- gr.Image(label="Ring"),
23
- gr.Number(label="DeepNAPSI Ring", precision=0),
24
- gr.Image(label="Pinky"),
25
- gr.Number(label="DeepNAPSI Pinky", precision=0),
26
- gr.Number(label="DeepNAPSI Sum", precision=0),
27
- ]
28
-
29
- with gr.Blocks(analytics_enabled=False, title="DeepNAPSI") as demo:
30
- with gr.Column():
31
- gr.Markdown("## Welcome to the DeepNAPSI application!")
32
- gr.Markdown(
33
- "Upload an image of the one hand and click **Predict NAPSI** to see the output."
34
- )
35
- gr.Markdown(
36
- "*Note*: Make sure there are no identifying information present in the image. The prediction can take up to 4.5 minutes."
37
- )
38
- gr.Markdown(
39
- "*Note*: This is not a medical product and cannot be used for a patient diagnosis in any way."
40
- )
41
- with gr.Column():
42
- with gr.Row():
43
- with gr.Column():
44
- with gr.Row():
45
- image_input = gr.Image()
46
- example_images = gr.Examples(
47
- example_image_path,
48
- image_input,
49
- outputs,
50
- fn=infer.predict,
51
- cache_examples=True,
52
- )
53
- with gr.Row():
54
- image_button = gr.Button("Predict NAPSI")
55
- with gr.Row():
56
- with gr.Column():
57
- outputs[0].render()
58
- outputs[1].render()
59
- with gr.Column():
60
- outputs[2].render()
61
- outputs[3].render()
62
- with gr.Column():
63
- outputs[4].render()
64
- outputs[5].render()
65
- with gr.Column():
66
- outputs[6].render()
67
- outputs[7].render()
68
- with gr.Column():
69
- outputs[8].render()
70
- outputs[9].render()
71
- outputs[10].render()
72
- image_button.click(infer.predict, inputs=image_input, outputs=outputs)
73
-
74
- demo.launch(
75
- share=True if DEBUG else False,
76
- favicon_path="assets/favicon-32x32.png",
77
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DeepNAPSI – automated NAPSI scoring from a hand photo.
3
+
4
+ Gradio 6.x Blocks UI. Model: BEiT-base-384 quantised to INT8 ONNX.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
  import gradio as gr
11
 
12
+ from backend import Backend, FINGER_NAMES
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Load model once at startup
16
+ # ---------------------------------------------------------------------------
17
+
18
+ backend = Backend()
19
+
20
+ NAPSI_DESC = (
21
+ "**NAPSI (Nail Psoriasis Severity Index)** scores each nail 0–4 based "
22
+ "on nail-bed and nail-matrix changes. The total score across all 10 nails "
23
+ "ranges from 0 (no disease) to 40 (maximum disease)."
24
+ )
25
+
26
+ DISCLAIMER = (
27
+ "⚠️ **Not a medical product.** This tool is for research purposes only and "
28
+ "must not be used for patient diagnosis or treatment decisions."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  )
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Predict function
33
+ # ---------------------------------------------------------------------------
34
+
35
+ def predict(image: np.ndarray):
36
+ """Called by Gradio on button click or example selection."""
37
+ if image is None:
38
+ empty = np.zeros((64, 64, 3), dtype=np.uint8)
39
+ return (
40
+ empty, # annotated image
41
+ *([empty, "–"] * 5), # 5× (nail crop, score label)
42
+ "–", # total
43
+ "Please upload an image.",
44
+ )
45
+
46
+ result = backend.predict(image)
47
+
48
+ outputs = [result["annotated_image"]]
49
+ for nail_img, score in zip(result["nails"], result["napsi_scores"]):
50
+ outputs.append(nail_img)
51
+ outputs.append(str(score) if score >= 0 else "–")
52
+
53
+ total = result["napsi_sum"]
54
+ outputs.append(str(total) if total >= 0 else "–")
55
+ outputs.append(result["error"] or "")
56
+ return tuple(outputs)
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # UI
61
+ # ---------------------------------------------------------------------------
62
+
63
+ with gr.Blocks(
64
+ title="DeepNAPSI",
65
+ analytics_enabled=False,
66
+ ) as demo:
67
+ gr.Markdown("# 🤚 DeepNAPSI")
68
+ gr.Markdown(NAPSI_DESC)
69
+ gr.Markdown(DISCLAIMER)
70
+
71
+ # ── Top row: input + annotated hand at equal size ─────────────────────
72
+ with gr.Row():
73
+ with gr.Column(scale=1):
74
+ image_input = gr.Image(
75
+ label="Hand photo",
76
+ type="numpy",
77
+ sources=["upload", "clipboard", "webcam"],
78
+ )
79
+ predict_btn = gr.Button("Predict NAPSI", variant="primary")
80
+
81
+ gr.Examples(
82
+ examples=[
83
+ ["assets/example_1.jpg"],
84
+ ["assets/example_2.jpg"],
85
+ ["assets/example_3.jpg"],
86
+ ],
87
+ inputs=image_input,
88
+ label="Example images",
89
+ )
90
+
91
+ with gr.Column(scale=1):
92
+ annotated_out = gr.Image(
93
+ label="Detected hand",
94
+ type="numpy",
95
+ interactive=False,
96
+ )
97
+
98
+ # ── Bottom rows: nail crops + scores ──────────────────────────────────
99
+ with gr.Row():
100
+ nail_images = [gr.Image(label=f, type="numpy", interactive=False, height=160) for f in FINGER_NAMES]
101
+ with gr.Row():
102
+ nail_scores = [gr.Textbox(label=f"NAPSI {f}", interactive=False) for f in FINGER_NAMES]
103
+
104
+ with gr.Row():
105
+ total_score = gr.Textbox(label="DeepNAPSI Total (one hand, 0–20)", interactive=False)
106
+
107
+ error_box = gr.Textbox(label="Status", interactive=False, visible=True)
108
+
109
+ # Wire outputs into a flat list matching predict() return order
110
+ all_outputs = (
111
+ [annotated_out]
112
+ + [x for pair in zip(nail_images, nail_scores) for x in pair]
113
+ + [total_score, error_box]
114
+ )
115
+
116
+ predict_btn.click(fn=predict, inputs=image_input, outputs=all_outputs)
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Launch
120
+ # ---------------------------------------------------------------------------
121
+
122
+ if __name__ == "__main__":
123
+ demo.launch(
124
+ server_name="0.0.0.0",
125
+ favicon_path="assets/favicon-32x32.png",
126
+ theme=gr.themes.Soft(),
127
+ )
128
+
assets/example_1.jpg CHANGED

Git LFS Details

  • SHA256: 77ee8620048e6fd01d1fb21fbfb2020ab6cf7fe0d06ed385f21afbd86141b854
  • Pointer size: 131 Bytes
  • Size of remote file: 867 kB

Git LFS Details

  • SHA256: 70f8bc2543bd5a450f62c2b35aa1db280002ab8b2f9f89d6c76e3f9798e6b19c
  • Pointer size: 128 Bytes
  • Size of remote file: 131 Bytes
assets/example_2.jpg CHANGED

Git LFS Details

  • SHA256: 415a5f2123e309193d38bd70f2c90d1ca76a9273b3dd4b79add03ea749d8ed95
  • Pointer size: 132 Bytes
  • Size of remote file: 3.52 MB

Git LFS Details

  • SHA256: 3f8109a532e8369ce2b9378c2657f028262691893ced961201567ff316147b28
  • Pointer size: 128 Bytes
  • Size of remote file: 132 Bytes
assets/example_3.jpg CHANGED

Git LFS Details

  • SHA256: f225c4fc1232f5bb730dc509918800813a4588e21b27d5dcf0429916ec652b92
  • Pointer size: 132 Bytes
  • Size of remote file: 3.8 MB

Git LFS Details

  • SHA256: a08293baea772e784612224e488756baffb3a875f0e209c0f61ce9ee06de67d4
  • Pointer size: 128 Bytes
  • Size of remote file: 132 Bytes
backend.py CHANGED
@@ -1,31 +1,184 @@
1
- import torch
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import cv2
3
  import numpy as np
4
- from nail_detection.main import get_nails
5
-
6
- from Model import Model
7
-
8
-
9
- class Infer:
10
- def __init__(self, DEBUG):
11
- # self.model = load_dummy_model(DEBUG)
12
- self.model = Model(DEBUG)
13
-
14
- def predict(self, data):
15
- nails = get_nails(cv2.cvtColor(data, cv2.COLOR_RGB2BGR))
16
- predictions = []
17
- if nails is None:
18
- for _ in range(5):
19
- predictions.append(np.zeros((64, 64, 3)))
20
- predictions.append(-1)
21
- predictions.append("-1")
22
- else:
23
- model_prediction, _ = self.model(nails)
24
- model_prediction = model_prediction[0]
25
- napsi_predictions = torch.argmax(model_prediction, 1)
26
- napsi_sum = int(napsi_predictions.sum().detach().cpu())
27
- for napsi_prediction, nail in zip(napsi_predictions, nails):
28
- predictions.append(nail)
29
- predictions.append(int(napsi_prediction.detach().cpu()))
30
- predictions.append(napsi_sum)
31
- return predictions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ONNX Runtime inference backend for DeepNAPSI.
3
+
4
+ Uses the dynamically-quantised INT8 BEiT model for fast CPU inference.
5
+ The model is expected at model/model_int8.onnx (committed to the repo via
6
+ Git LFS). If that file is absent it is downloaded from the HF Hub.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+ from typing import List
14
+
15
  import cv2
16
  import numpy as np
17
+ import onnxruntime as ort
18
+ from PIL import Image
19
+
20
+ from nail_detection import get_nails_and_landmarks, draw_hand
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Constants
24
+ # ---------------------------------------------------------------------------
25
+
26
+ MODEL_LOCAL = Path(os.environ.get("DEEPNAPSI_MODEL_PATH", "")) or Path(__file__).parent / "model" / "model_int8.onnx"
27
+ HF_REPO_ID = os.environ.get("DEEPNAPSI_HF_REPO", "lfolle/DeepNAPSIModel")
28
+ HF_FILENAME = "model_int8.onnx"
29
+
30
+ # BEiT preprocessing parameters (from timm resolve_data_config)
31
+ INPUT_SIZE = 384
32
+ MEAN = np.array([0.5, 0.5, 0.5], dtype=np.float32)
33
+ STD = np.array([0.5, 0.5, 0.5], dtype=np.float32)
34
+
35
+ FINGER_NAMES = ["Thumb", "Index", "Middle", "Ring", "Pinky"]
36
+ NUM_CLASSES = 5
37
+ NUM_THREADS = int(os.environ.get("ORT_NUM_THREADS", "16"))
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Model loading
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def _get_model_path() -> Path:
45
+ # Env-var override (useful for local dev pointing at hf_space/model/)
46
+ env_path = os.environ.get("DEEPNAPSI_MODEL_PATH", "")
47
+ if env_path and Path(env_path).exists():
48
+ return Path(env_path)
49
+ # Default local path (committed to Space via Git LFS, or pre-downloaded)
50
+ if MODEL_LOCAL.exists():
51
+ return MODEL_LOCAL
52
+ # Fallback: download from private HF Hub model repo.
53
+ # Requires DEEPNAPSI_HF_TOKEN env var (set as a Space secret).
54
+ from huggingface_hub import hf_hub_download
55
+ token = os.environ.get("DEEPNAPSI_HF_TOKEN")
56
+ if not token:
57
+ raise FileNotFoundError(
58
+ f"Model not found at {MODEL_LOCAL} and DEEPNAPSI_HF_TOKEN is not set. "
59
+ "Either commit the model file to the Space or set the secret."
60
+ )
61
+ print(f"[backend] Downloading model from private repo {HF_REPO_ID} …")
62
+ path = hf_hub_download(HF_REPO_ID, HF_FILENAME, token=token)
63
+ return Path(path)
64
+
65
+
66
+ def _build_session(model_path: Path) -> ort.InferenceSession:
67
+ opts = ort.SessionOptions()
68
+ opts.intra_op_num_threads = NUM_THREADS
69
+ opts.inter_op_num_threads = NUM_THREADS
70
+ opts.execution_mode = ort.ExecutionMode.ORT_PARALLEL
71
+ opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
72
+ return ort.InferenceSession(
73
+ str(model_path),
74
+ sess_options=opts,
75
+ providers=["CPUExecutionProvider"],
76
+ )
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Preprocessing (replaces timm transforms, no heavy ML dependency at serve time)
81
+ # ---------------------------------------------------------------------------
82
+
83
+ def _preprocess(nail_rgb: np.ndarray) -> np.ndarray:
84
+ """
85
+ Resize → CenterCrop → ToTensor → Normalize, matching BEiT training config.
86
+ Returns float32 array [1, 3, 384, 384].
87
+ """
88
+ img = Image.fromarray(nail_rgb).convert("RGB")
89
+ # Resize shortest side to INPUT_SIZE with bicubic
90
+ w, h = img.size
91
+ scale = INPUT_SIZE / min(w, h)
92
+ new_w, new_h = max(INPUT_SIZE, round(w * scale)), max(INPUT_SIZE, round(h * scale))
93
+ img = img.resize((new_w, new_h), Image.BICUBIC)
94
+ # CenterCrop
95
+ left = (new_w - INPUT_SIZE) // 2
96
+ top = (new_h - INPUT_SIZE) // 2
97
+ img = img.crop((left, top, left + INPUT_SIZE, top + INPUT_SIZE))
98
+ # To float [0,1], normalise
99
+ arr = np.asarray(img, dtype=np.float32) / 255.0
100
+ arr = (arr - MEAN) / STD
101
+ return arr.transpose(2, 0, 1)[None] # [1, C, H, W]
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Inference with 3-view TTA
106
+ # ---------------------------------------------------------------------------
107
+
108
+ def _tta_logits(session: ort.InferenceSession, pixel_values: np.ndarray) -> np.ndarray:
109
+ """Average logits over original + hflip + vflip views."""
110
+ views = [
111
+ pixel_values,
112
+ pixel_values[:, :, :, ::-1].copy(), # horizontal flip
113
+ pixel_values[:, :, ::-1, :].copy(), # vertical flip
114
+ ]
115
+ logits = np.stack(
116
+ [session.run(None, {"pixel_values": v})[0] for v in views]
117
+ ).mean(axis=0)
118
+ return logits # [B, 5]
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Top-level backend class
123
+ # ---------------------------------------------------------------------------
124
+
125
+ class Backend:
126
+ def __init__(self) -> None:
127
+ model_path = _get_model_path()
128
+ self._session = _build_session(model_path)
129
+ print(f"[backend] Loaded model from {model_path} with {NUM_THREADS} ORT threads.")
130
+
131
+ def predict(self, image_rgb: np.ndarray) -> dict:
132
+ """
133
+ Run the full DeepNAPSI pipeline on a hand image.
134
+
135
+ Args:
136
+ image_rgb: HxWx3 uint8 RGB array (Gradio default).
137
+
138
+ Returns:
139
+ dict with keys:
140
+ annotated_image – RGB image with hand skeleton drawn
141
+ nails – list of 5 RGB nail crop arrays
142
+ napsi_scores – list of 5 int NAPSI predictions (0-4)
143
+ napsi_sum – int, sum of all 5 scores
144
+ error – str | None
145
+ """
146
+ image_bgr = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2BGR)
147
+ nails, landmarks = get_nails_and_landmarks(image_bgr)
148
+
149
+ if nails is None or landmarks is None:
150
+ return {
151
+ "annotated_image": image_rgb,
152
+ "nails": [np.zeros((64, 64, 3), dtype=np.uint8)] * 5,
153
+ "napsi_scores": [-1] * 5,
154
+ "napsi_sum": -1,
155
+ "error": "No hand detected. Please upload a clear photo of one hand.",
156
+ }
157
+
158
+ # Draw skeleton on a copy
159
+ annotated = image_bgr.copy()
160
+ draw_hand(annotated, landmarks)
161
+ annotated_rgb = cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB)
162
+
163
+ # Run classification on all 5 nails
164
+ napsi_scores: List[int] = []
165
+ nail_rgbs: List[np.ndarray] = []
166
+
167
+ for nail_bgr in nails:
168
+ # Nail crops come out as RGB from extract_nails (already BGR→RGB swapped inside)
169
+ nail_rgb = nail_bgr # already RGB after the ::-1 flip in extract_nails
170
+ nail_rgbs.append(nail_rgb)
171
+
172
+ pixel_values = _preprocess(nail_rgb)
173
+ logits = _tta_logits(self._session, pixel_values) # [1, 5]
174
+ pred = int(np.argmax(logits, axis=-1)[0])
175
+ napsi_scores.append(pred)
176
+
177
+ return {
178
+ "annotated_image": annotated_rgb,
179
+ "nails": nail_rgbs,
180
+ "napsi_scores": napsi_scores,
181
+ "napsi_sum": sum(napsi_scores),
182
+ "error": None,
183
+ }
184
+
entrypoint.py CHANGED
@@ -1,18 +1,3 @@
1
- import os
2
- import sys
3
- import subprocess
4
- import git
5
-
6
- repo_url = (
7
- f"https://oauth2:{os.getenv('HANDKIGIT5')}@git5.cs.fau.de/folle/hand-ki-model.git"
8
- )
9
- repo_path = "repos/hand-ki-model"
10
- if not os.path.exists(repo_path):
11
- git.Repo.clone_from(repo_url, repo_path)
12
-
13
- subprocess.check_call([sys.executable, "-m", "pip", "install", "repos/hand-ki-model/"])
14
- subprocess.check_call(
15
- [sys.executable, "-m", "pip", "install", "mediapipe==0.10.14", "numpy<2.0"]
16
- )
17
-
18
- import app # noqa: E402, F401
 
1
+ # entrypoint.py is kept for backward compatibility only.
2
+ # The Space now uses app_file: app.py directly (see README.md).
3
+ import app # noqa: F401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
nail_detection.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Self-contained nail detection pipeline.
3
+
4
+ Inlined from hand-ki-model so that the HF Space has no git-clone dependency.
5
+ Source: nail_detection/{main,hand_detection,extract_nails}.py + utils/{rotate,polygon,angle,valid_crop,draw_hand}.py
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from typing import List
12
+
13
+ import cv2
14
+ import mediapipe as mp
15
+ import numpy as np
16
+ from PIL import Image, ImageDraw
17
+ from scipy import ndimage
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Geometry helpers (from utils/)
22
+ # ---------------------------------------------------------------------------
23
+
24
+ def _rotate(origin, point, angle: float):
25
+ """Rotate *point* counter-clockwise by *angle* (radians) around *origin*."""
26
+ ox, oy = origin
27
+ px, py = point
28
+ qx = ox + math.cos(angle) * (px - ox) - math.sin(angle) * (py - oy)
29
+ qy = oy + math.sin(angle) * (px - ox) + math.cos(angle) * (py - oy)
30
+ return qx, qy
31
+
32
+
33
+ def _unit_vector(v):
34
+ return v / np.linalg.norm(v)
35
+
36
+
37
+ def _angle_between(v1, v2) -> float:
38
+ return float(np.arccos(np.clip(np.dot(_unit_vector(v1), _unit_vector(v2)), -1.0, 1.0)))
39
+
40
+
41
+ def _get_polygon_mask(width: int, height: int, polygon_idx) -> np.ndarray:
42
+ polygon_idx = [tuple(xy) for xy in polygon_idx]
43
+ img = Image.new("L", (width, height), 0)
44
+ ImageDraw.Draw(img).polygon(polygon_idx, outline=1, fill=1)
45
+ return np.array(img)
46
+
47
+
48
+ def _valid_crop(image: np.ndarray, mask: np.ndarray, offset: int = 10):
49
+ true_points = np.argwhere(mask)
50
+ top_left = true_points.min(axis=0)
51
+ bottom_right = true_points.max(axis=0)
52
+ x_low = max(top_left[0] - offset, 0)
53
+ x_high = min(bottom_right[0] + offset, image.shape[0])
54
+ y_low = max(top_left[1] - offset, 0)
55
+ y_high = min(bottom_right[1] + offset + 1, image.shape[1])
56
+ return image[x_low:x_high, y_low:y_high], mask[x_low:x_high, y_low:y_high]
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Hand detection (from nail_detection/hand_detection.py)
61
+ # ---------------------------------------------------------------------------
62
+
63
+ def detect_hand(image: np.ndarray):
64
+ """Return MediaPipe hand landmarks for the first detected hand, or None."""
65
+ mp_hands = mp.solutions.hands
66
+ with mp_hands.Hands(
67
+ static_image_mode=True, max_num_hands=1, min_detection_confidence=0.0
68
+ ) as hands:
69
+ results = hands.process(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
70
+ if results.multi_handedness is None:
71
+ return None
72
+ return results.multi_hand_landmarks[0]
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Nail extraction (from nail_detection/extract_nails.py)
77
+ # ---------------------------------------------------------------------------
78
+
79
+ def extract_nails(image: np.ndarray, hand_landmarks) -> List[np.ndarray]:
80
+ """Return list of 5 nail crop arrays (thumb → pinky), BGR."""
81
+ mp_hands = mp.solutions.hands
82
+ image_height, image_width, _ = image.shape
83
+ nails: List[np.ndarray] = []
84
+
85
+ for tip in [
86
+ mp_hands.HandLandmark.THUMB_TIP,
87
+ mp_hands.HandLandmark.INDEX_FINGER_TIP,
88
+ mp_hands.HandLandmark.MIDDLE_FINGER_TIP,
89
+ mp_hands.HandLandmark.RING_FINGER_TIP,
90
+ mp_hands.HandLandmark.PINKY_TIP,
91
+ ]:
92
+ tip_coords = np.array([
93
+ hand_landmarks.landmark[tip].x * image_width,
94
+ hand_landmarks.landmark[tip].y * image_height,
95
+ ])
96
+ dip_coords = np.array([
97
+ hand_landmarks.landmark[tip - 1].x * image_width,
98
+ hand_landmarks.landmark[tip - 1].y * image_height,
99
+ ])
100
+ dt = tip_coords - dip_coords
101
+ ext = np.array([tip_coords + 3 / 4 * dt, tip_coords - 3 / 4 * dt])
102
+
103
+ origin = 0.5 * (ext[0] - ext[1]) + ext[1]
104
+ orth_p1 = _rotate(origin, ext[0], np.deg2rad(90))
105
+ orth_p2 = _rotate(origin, ext[1], np.deg2rad(90))
106
+ orth = np.array([orth_p1, orth_p2])
107
+
108
+ half = 0.5 * (ext[0] - ext[1])
109
+ p1 = orth[0] + half
110
+ p2 = orth[0] - half
111
+ p3 = orth[1] + half
112
+ p4 = orth[1] - half
113
+
114
+ angle = 90 - np.rad2deg(_angle_between(ext[0] - ext[1], [1, 0]))
115
+
116
+ mask = _get_polygon_mask(image_width, image_height, [p2, p1, p3, p4])
117
+ mask3 = np.tile(mask[:, :, None], (1, 1, 3))
118
+ masked = mask3 * image[:, :, ::-1]
119
+
120
+ masked, mask3 = _valid_crop(masked, mask3)
121
+ masked = ndimage.rotate(masked, angle)
122
+ mask3 = ndimage.rotate(mask3, angle)
123
+ masked, mask3 = _valid_crop(masked, mask3, offset=0)
124
+ masked = masked[5:-5, 5:-5]
125
+ masked = np.ascontiguousarray(masked)
126
+ nails.append(masked)
127
+
128
+ return nails
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Draw hand skeleton (from utils/draw_hand.py)
133
+ # ---------------------------------------------------------------------------
134
+
135
+ def draw_hand(annotated_image: np.ndarray, hand_landmarks) -> None:
136
+ mp_drawing = mp.solutions.drawing_utils
137
+ mp_drawing_styles = mp.solutions.drawing_styles
138
+ mp_hands = mp.solutions.hands
139
+ mp_drawing.draw_landmarks(
140
+ annotated_image,
141
+ hand_landmarks,
142
+ mp_hands.HAND_CONNECTIONS,
143
+ mp_drawing_styles.get_default_hand_landmarks_style(),
144
+ mp_drawing_styles.get_default_hand_connections_style(),
145
+ )
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Top-level entry point
150
+ # ---------------------------------------------------------------------------
151
+
152
+ def get_nails_and_landmarks(image_bgr: np.ndarray):
153
+ """
154
+ Detect hand and extract all 5 nail crops.
155
+
156
+ Args:
157
+ image_bgr: BGR image as numpy array (as returned by cv2.imread or
158
+ cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)).
159
+
160
+ Returns:
161
+ (nails, hand_landmarks) or (None, None) if no hand detected.
162
+ """
163
+ if image_bgr.shape[0] < image_bgr.shape[1]: # landscape → rotate
164
+ image_bgr = cv2.rotate(image_bgr, cv2.ROTATE_90_CLOCKWISE)
165
+
166
+ landmarks = detect_hand(image_bgr)
167
+ if landmarks is None:
168
+ return None, None
169
+
170
+ # Flip upside-down images
171
+ mp_hands = mp.solutions.hands
172
+ if (
173
+ landmarks.landmark[mp_hands.HandLandmark.MIDDLE_FINGER_PIP].y
174
+ < landmarks.landmark[mp_hands.HandLandmark.MIDDLE_FINGER_TIP].y
175
+ ):
176
+ image_bgr = cv2.rotate(image_bgr, cv2.ROTATE_180)
177
+ landmarks = detect_hand(image_bgr)
178
+
179
+ nails = extract_nails(image_bgr, landmarks)
180
+ return nails, landmarks
requirements.txt CHANGED
@@ -1,9 +1,22 @@
1
- gitpython==3.1.45
2
- gradio
3
- httpx
4
- huggingface_hub
5
- pingouin
6
- pyheif
7
- seaborn
8
- setuptools<50
9
- timm==0.6.7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pinned for reproducibility – generated 2026-03-15
2
+ # Re-pin with: pip-compile or manually after testing
3
+
4
+ # Inference runtime (CPU)
5
+ onnxruntime==1.24.3
6
+
7
+ # Image processing & nail detection
8
+ mediapipe==0.10.14
9
+ opencv-python==4.11.0.86
10
+ Pillow==11.3.0
11
+ scipy==1.16.2
12
+ numpy==1.26.4
13
+
14
+ # HuggingFace ecosystem
15
+ # hf_xet is a dependency of huggingface-hub; listed explicitly so the Space
16
+ # runner picks it up and hf_hub_download can use xet chunked downloads from
17
+ # the private model repo automatically.
18
+ huggingface-hub==0.34.4
19
+ hf-xet==1.1.10
20
+
21
+ # UI
22
+ gradio==6.9.0