Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .gitignore +5 -0
- .ruff_cache/.gitignore +2 -0
- .ruff_cache/0.13.2/16283386313770864173 +0 -0
- .ruff_cache/CACHEDIR.TAG +1 -0
- README.md +8 -3
- app.py +125 -74
- assets/example_1.jpg +2 -2
- assets/example_2.jpg +2 -2
- assets/example_3.jpg +2 -2
- backend.py +182 -29
- entrypoint.py +3 -18
- nail_detection.py +180 -0
- requirements.txt +22 -9
.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:
|
| 8 |
-
python_version: 3.12
|
| 9 |
-
app_file:
|
| 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
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
"
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 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
|
|
Git LFS Details
|
assets/example_2.jpg
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
assets/example_3.jpg
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
backend.py
CHANGED
|
@@ -1,31 +1,184 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import cv2
|
| 3 |
import numpy as np
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
import
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|