Spaces:
Running
Running
Hugging Face specific Dockerfile and .jpg excluded
Browse files- .devcontainer/devcontainer.json +7 -4
- Dockerfile +22 -20
- README.md +36 -10
- tests/test_e2e_local.py +29 -9
- tests/test_inference_trigger.py +12 -2
- tests/test_ui.py +10 -0
.devcontainer/devcontainer.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
| 1 |
{
|
| 2 |
"name": "Dermatolog AI Scanner Dev",
|
| 3 |
-
"
|
| 4 |
-
|
| 5 |
-
"
|
|
|
|
|
|
|
|
|
|
| 6 |
},
|
| 7 |
"customizations": {
|
| 8 |
"vscode": {
|
|
@@ -15,7 +18,7 @@
|
|
| 15 |
}
|
| 16 |
},
|
| 17 |
"forwardPorts": [
|
| 18 |
-
|
| 19 |
],
|
| 20 |
"postCreateCommand": "pip install -r requirements-dev.txt && playwright install --with-deps chromium && npm install",
|
| 21 |
"runArgs": [
|
|
|
|
| 1 |
{
|
| 2 |
"name": "Dermatolog AI Scanner Dev",
|
| 3 |
+
"image": "medgemma_ic-medgemma-app:latest",
|
| 4 |
+
"features": {
|
| 5 |
+
"ghcr.io/devcontainers/features/common-utils:2": {
|
| 6 |
+
"installZsh": "true",
|
| 7 |
+
"upgradePackages": "true"
|
| 8 |
+
}
|
| 9 |
},
|
| 10 |
"customizations": {
|
| 11 |
"vscode": {
|
|
|
|
| 18 |
}
|
| 19 |
},
|
| 20 |
"forwardPorts": [
|
| 21 |
+
8080
|
| 22 |
],
|
| 23 |
"postCreateCommand": "pip install -r requirements-dev.txt && playwright install --with-deps chromium && npm install",
|
| 24 |
"runArgs": [
|
Dockerfile
CHANGED
|
@@ -1,7 +1,5 @@
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
-
WORKDIR /app
|
| 4 |
-
|
| 5 |
# Install system dependencies
|
| 6 |
RUN apt-get update && apt-get install -y \
|
| 7 |
curl \
|
|
@@ -13,31 +11,35 @@ RUN apt-get update && apt-get install -y \
|
|
| 13 |
&& apt-get install -y nodejs \
|
| 14 |
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
|
| 16 |
-
#
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
| 24 |
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 26 |
import os; \
|
| 27 |
-
token =
|
| 28 |
-
print(f'Downloading MedSigLIP model with token present: {bool(token)}...'); \
|
| 29 |
AutoProcessor.from_pretrained('google/medsiglip-448', token=token); \
|
| 30 |
AutoModel.from_pretrained('google/medsiglip-448', token=token)"
|
| 31 |
|
| 32 |
-
# Pre-download YOLO
|
| 33 |
RUN python -c "from ultralytics import YOLO; YOLO('yolov8n.pt')"
|
| 34 |
|
| 35 |
-
# Copy application code
|
| 36 |
-
COPY
|
| 37 |
|
| 38 |
-
#
|
| 39 |
-
ENV PORT=
|
| 40 |
-
EXPOSE
|
| 41 |
|
| 42 |
-
|
| 43 |
-
CMD uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8080}
|
|
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
|
|
|
|
|
|
| 3 |
# Install system dependencies
|
| 4 |
RUN apt-get update && apt-get install -y \
|
| 5 |
curl \
|
|
|
|
| 11 |
&& apt-get install -y nodejs \
|
| 12 |
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
|
| 14 |
+
# Set up a new user named "user" with user ID 1000 (Required by HF Spaces)
|
| 15 |
+
RUN useradd -m -u 1000 user
|
| 16 |
+
USER user
|
| 17 |
+
ENV HOME=/home/user \
|
| 18 |
+
PATH=/home/user/.local/bin:$PATH
|
| 19 |
|
| 20 |
+
WORKDIR $HOME/app
|
| 21 |
+
|
| 22 |
+
# Copy requirements and install
|
| 23 |
+
COPY --chown=user requirements.txt .
|
| 24 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 25 |
|
| 26 |
+
# Securely mount the HF_TOKEN secret during build to pre-download the model
|
| 27 |
+
# You configure HF_TOKEN in the "Secrets" tab of your Hugging Face Space settings
|
| 28 |
+
RUN --mount=type=secret,id=HF_TOKEN,mode=0444,required=true \
|
| 29 |
+
python -c "from transformers import AutoProcessor, AutoModel; \
|
| 30 |
import os; \
|
| 31 |
+
token = open('/run/secrets/HF_TOKEN').read().strip(); \
|
|
|
|
| 32 |
AutoProcessor.from_pretrained('google/medsiglip-448', token=token); \
|
| 33 |
AutoModel.from_pretrained('google/medsiglip-448', token=token)"
|
| 34 |
|
| 35 |
+
# Pre-download YOLO
|
| 36 |
RUN python -c "from ultralytics import YOLO; YOLO('yolov8n.pt')"
|
| 37 |
|
| 38 |
+
# Copy the rest of the application code
|
| 39 |
+
COPY --chown=user . $HOME/app
|
| 40 |
|
| 41 |
+
# Hugging Face defaults to port 7860
|
| 42 |
+
ENV PORT=7860
|
| 43 |
+
EXPOSE 7860
|
| 44 |
|
| 45 |
+
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT}"]
|
|
|
README.md
CHANGED
|
@@ -14,7 +14,7 @@ A privacy-first, free, and easy-to-use dermatology scan app powered by latest AI
|
|
| 14 |
- **Privacy**: All data is scoped to your browser session.
|
| 15 |
- **Zero-Shot Dermatology Analysis**:
|
| 16 |
- Uses **Google Health's MedSigLIP** (`google/medsiglip-448`) model for localized analysis.
|
| 17 |
-
- Classifies images against a
|
| 18 |
- **Rationale**: The label set focuses on high-mortality cancers (Melanoma), high-prevalence conditions (Eczema, Acne), and common differential diagnoses to aid in effective triage.
|
| 19 |
|
| 20 |
### Confidence & Interpretation Logic
|
|
@@ -38,11 +38,11 @@ The system is tuned to detect the following conditions based on EU referral guid
|
|
| 38 |
|
| 39 |
| Category | Conditions | Rationale |
|
| 40 |
| :--- | :--- | :--- |
|
| 41 |
-
| **Malignant / Pre-malignant** | Melanoma, Basal Cell Carcinoma (BCC)
|
| 42 |
-
| **Inflammatory** | Psoriasis, Atopic Dermatitis (Eczema), Acne Vulgaris, Rosacea
|
| 43 |
-
| **Infectious** |
|
| 44 |
-
| **Benign / Differential** | Melanocytic Nevus, Seborrheic Keratosis
|
| 45 |
-
| **
|
| 46 |
|
| 47 |
## Privacy & Security
|
| 48 |
|
|
@@ -110,10 +110,19 @@ The project is designed to be developed inside a **Dev Container**. This ensures
|
|
| 110 |
5. **Start Dev Container**:
|
| 111 |
- Open the folder in VS Code.
|
| 112 |
- When prompted, click **"Reopen in Container"** (or run standard command `Dev Containers: Reopen in Container`).
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
Inside the integrated terminal of VS Code (running in the container):
|
| 116 |
```bash
|
|
|
|
| 117 |
npm install # If not run automatically
|
| 118 |
uvicorn app.main:app --reload --host 0.0.0.0 --port 8080
|
| 119 |
```
|
|
@@ -141,16 +150,33 @@ docker run -p 8080:8080 -e HF_TOKEN=$HF_TOKEN medgemma-app
|
|
| 141 |
|
| 142 |
### 🧪 Running Tests
|
| 143 |
|
| 144 |
-
We use `pytest` for
|
| 145 |
|
| 146 |
- **Unit Tests**:
|
|
|
|
| 147 |
```bash
|
| 148 |
pytest tests/unit
|
| 149 |
```
|
| 150 |
|
| 151 |
-
- **
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
```bash
|
| 153 |
-
|
|
|
|
| 154 |
```
|
| 155 |
|
| 156 |
- **JavaScript Unit Tests**:
|
|
|
|
| 14 |
- **Privacy**: All data is scoped to your browser session.
|
| 15 |
- **Zero-Shot Dermatology Analysis**:
|
| 16 |
- Uses **Google Health's MedSigLIP** (`google/medsiglip-448`) model for localized analysis.
|
| 17 |
+
- Classifies images against a focused set of **12 dermatological conditions** relevant to EU medical practices.
|
| 18 |
- **Rationale**: The label set focuses on high-mortality cancers (Melanoma), high-prevalence conditions (Eczema, Acne), and common differential diagnoses to aid in effective triage.
|
| 19 |
|
| 20 |
### Confidence & Interpretation Logic
|
|
|
|
| 38 |
|
| 39 |
| Category | Conditions | Rationale |
|
| 40 |
| :--- | :--- | :--- |
|
| 41 |
+
| **Malignant / Pre-malignant** | Melanoma, Basal Cell Carcinoma (BCC) | Priority for early detection due to mortality risk (Melanoma) or high prevalence impacting healthcare resources (BCC). |
|
| 42 |
+
| **Inflammatory** | Psoriasis, Atopic Dermatitis (Eczema), Acne Vulgaris, Rosacea | Represents the highest burden of disease on quality of life in the EU population. |
|
| 43 |
+
| **Infectious** | Herpes Zoster (Shingles), Warts, Molluscum Contagiosum | Contagious nature requires accurate identification, often with distinct morphologies. |
|
| 44 |
+
| **Benign / Differential** | Melanocytic Nevus, Seborrheic Keratosis | Crucial for distinguishing from malignant lesions to reduce unnecessary anxiety and referrals. |
|
| 45 |
+
| **Baseline** | Normal Skin | Provides a control basis for healthy skin. |
|
| 46 |
|
| 47 |
## Privacy & Security
|
| 48 |
|
|
|
|
| 110 |
5. **Start Dev Container**:
|
| 111 |
- Open the folder in VS Code.
|
| 112 |
- When prompted, click **"Reopen in Container"** (or run standard command `Dev Containers: Reopen in Container`).
|
| 113 |
+
- VS Code will build the container and install all dependencies defined in `requirements-dev.txt` and `package.json`.
|
| 114 |
+
|
| 115 |
+
**CLI Alternative:**
|
| 116 |
+
If you cannot find the "Rebuild" option in the UI, you can force a rebuild of the environment from your local terminal:
|
| 117 |
+
```bash
|
| 118 |
+
# Ensure HF_TOKEN is exported for the build
|
| 119 |
+
export HF_TOKEN=$(cat .env | grep HF_TOKEN | cut -d'=' -f2)
|
| 120 |
+
docker compose up -d --build
|
| 121 |
+
```
|
| 122 |
|
| 123 |
Inside the integrated terminal of VS Code (running in the container):
|
| 124 |
```bash
|
| 125 |
+
source venv/bin/activate # If using a local virtual environment
|
| 126 |
npm install # If not run automatically
|
| 127 |
uvicorn app.main:app --reload --host 0.0.0.0 --port 8080
|
| 128 |
```
|
|
|
|
| 150 |
|
| 151 |
### 🧪 Running Tests
|
| 152 |
|
| 153 |
+
We use `pytest` for Python tests and `Jest` for JavaScript unit tests.
|
| 154 |
|
| 155 |
- **Unit Tests**:
|
| 156 |
+
Tests the core logic (image preprocessing, result interpretation, API endpoints) with external dependencies like AI models mocked for speed.
|
| 157 |
```bash
|
| 158 |
pytest tests/unit
|
| 159 |
```
|
| 160 |
|
| 161 |
+
- **YOLO Tests**:
|
| 162 |
+
Specifically tests the lesion detection and cropping logic.
|
| 163 |
+
```bash
|
| 164 |
+
pytest tests/yolo_tests
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
- **Running All Backend Tests**:
|
| 168 |
+
You can run the core logic and YOLO tests together:
|
| 169 |
+
```bash
|
| 170 |
+
pytest tests/unit tests/yolo_tests
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
> **Note on YOLO State**: Currently, both unit and YOLO tests use **mocking** for the actual YOLOv8 inference. This allows the test suite to run in seconds without requiring model weights or high-performance hardware.
|
| 174 |
+
|
| 175 |
+
- **Integration & E2E Tests**:
|
| 176 |
+
These tests interact with a live server and require `playwright` for browser automation.
|
| 177 |
```bash
|
| 178 |
+
# Run UI and E2E tests
|
| 179 |
+
pytest tests/test_ui.py tests/test_e2e_local.py
|
| 180 |
```
|
| 181 |
|
| 182 |
- **JavaScript Unit Tests**:
|
tests/test_e2e_local.py
CHANGED
|
@@ -26,16 +26,26 @@ def test_local_upload_and_analysis_flow(page, dummy_image, test_server):
|
|
| 26 |
page.on("console", lambda msg: print(f"Browser Console: [{msg.type}] {msg.text}"))
|
| 27 |
page.goto(test_server)
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
# 1. Clear any existing state
|
| 30 |
-
clear_btn = page.locator("sl-button", has_text="Clear
|
| 31 |
if clear_btn.count() > 0:
|
| 32 |
try:
|
| 33 |
clear_btn.wait_for(state="visible", timeout=2000)
|
| 34 |
page.once("dialog", lambda dialog: dialog.accept())
|
| 35 |
clear_btn.click()
|
| 36 |
-
page.locator("text=
|
| 37 |
except:
|
| 38 |
-
print("Clear
|
| 39 |
|
| 40 |
# 2. Trigger "Upload" (Local load)
|
| 41 |
# We expect TO NOT see an /upload network call
|
|
@@ -63,16 +73,16 @@ def test_local_upload_and_analysis_flow(page, dummy_image, test_server):
|
|
| 63 |
print(f"Analysis successful: {data['predictions'][0]['label']}")
|
| 64 |
|
| 65 |
# 4. Verify results display in UI
|
| 66 |
-
# The '
|
| 67 |
-
print("Waiting for
|
| 68 |
-
page.locator("text=
|
| 69 |
|
| 70 |
# Verify the label is visible
|
| 71 |
label_text = data["predictions"][0]["label"]
|
| 72 |
page.locator(f"text={label_text}").first.wait_for(state="visible", timeout=30000)
|
| 73 |
|
| 74 |
# Verify Saliency Map toggle is present
|
| 75 |
-
saliency_toggle = page.locator("sl-details", has_text="View
|
| 76 |
saliency_toggle.wait_for(state="visible", timeout=10000)
|
| 77 |
|
| 78 |
# 5. Trigger Lazy Saliency Load
|
|
@@ -99,12 +109,22 @@ def test_local_duplicate_handling(page, dummy_image, test_server):
|
|
| 99 |
"""Verifies that selecting the same file twice doesn't create duplicate timeline items."""
|
| 100 |
page.goto(test_server)
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
# 1. Clear state
|
| 103 |
-
clear_btn = page.locator("sl-button", has_text="Clear
|
| 104 |
if clear_btn.is_visible():
|
| 105 |
page.once("dialog", lambda dialog: dialog.accept())
|
| 106 |
clear_btn.click()
|
| 107 |
-
page.locator("text=
|
| 108 |
|
| 109 |
# 2. Load first time
|
| 110 |
page.set_input_files("input[type='file']", dummy_image)
|
|
|
|
| 26 |
page.on("console", lambda msg: print(f"Browser Console: [{msg.type}] {msg.text}"))
|
| 27 |
page.goto(test_server)
|
| 28 |
|
| 29 |
+
# Handle Medical Disclaimer
|
| 30 |
+
print("Checking for Medical Disclaimer...")
|
| 31 |
+
try:
|
| 32 |
+
disclaimer_btn = page.locator("sl-button", has_text="I Understand & Agree").first
|
| 33 |
+
disclaimer_btn.wait_for(state="visible", timeout=5000)
|
| 34 |
+
disclaimer_btn.click()
|
| 35 |
+
print("Disclaimer accepted.")
|
| 36 |
+
except:
|
| 37 |
+
print("Disclaimer not visible or already accepted, continuing...")
|
| 38 |
+
|
| 39 |
# 1. Clear any existing state
|
| 40 |
+
clear_btn = page.locator("sl-button", has_text="Clear History").first
|
| 41 |
if clear_btn.count() > 0:
|
| 42 |
try:
|
| 43 |
clear_btn.wait_for(state="visible", timeout=2000)
|
| 44 |
page.once("dialog", lambda dialog: dialog.accept())
|
| 45 |
clear_btn.click()
|
| 46 |
+
page.locator("text=History Empty").wait_for(state="visible", timeout=5000)
|
| 47 |
except:
|
| 48 |
+
print("Clear History button not visible or timed out, continuing...")
|
| 49 |
|
| 50 |
# 2. Trigger "Upload" (Local load)
|
| 51 |
# We expect TO NOT see an /upload network call
|
|
|
|
| 73 |
print(f"Analysis successful: {data['predictions'][0]['label']}")
|
| 74 |
|
| 75 |
# 4. Verify results display in UI
|
| 76 |
+
# The 'AI Scan result' text should appear
|
| 77 |
+
print("Waiting for AI Scan result UI element...")
|
| 78 |
+
page.locator("text=AI Scan result").first.wait_for(state="visible", timeout=300000)
|
| 79 |
|
| 80 |
# Verify the label is visible
|
| 81 |
label_text = data["predictions"][0]["label"]
|
| 82 |
page.locator(f"text={label_text}").first.wait_for(state="visible", timeout=30000)
|
| 83 |
|
| 84 |
# Verify Saliency Map toggle is present
|
| 85 |
+
saliency_toggle = page.locator("sl-details", has_text="View Saliency Map").first
|
| 86 |
saliency_toggle.wait_for(state="visible", timeout=10000)
|
| 87 |
|
| 88 |
# 5. Trigger Lazy Saliency Load
|
|
|
|
| 109 |
"""Verifies that selecting the same file twice doesn't create duplicate timeline items."""
|
| 110 |
page.goto(test_server)
|
| 111 |
|
| 112 |
+
# Handle Medical Disclaimer
|
| 113 |
+
print("Checking for Medical Disclaimer...")
|
| 114 |
+
try:
|
| 115 |
+
disclaimer_btn = page.locator("sl-button", has_text="I Understand & Agree").first
|
| 116 |
+
disclaimer_btn.wait_for(state="visible", timeout=5000)
|
| 117 |
+
disclaimer_btn.click()
|
| 118 |
+
print("Disclaimer accepted.")
|
| 119 |
+
except:
|
| 120 |
+
print("Disclaimer not visible or already accepted, continuing...")
|
| 121 |
+
|
| 122 |
# 1. Clear state
|
| 123 |
+
clear_btn = page.locator("sl-button", has_text="Clear History").first
|
| 124 |
if clear_btn.is_visible():
|
| 125 |
page.once("dialog", lambda dialog: dialog.accept())
|
| 126 |
clear_btn.click()
|
| 127 |
+
page.locator("text=History Empty").wait_for(state="visible", timeout=5000)
|
| 128 |
|
| 129 |
# 2. Load first time
|
| 130 |
page.set_input_files("input[type='file']", dummy_image)
|
tests/test_inference_trigger.py
CHANGED
|
@@ -22,12 +22,22 @@ def test_inference_starts_on_upload(page, dummy_image, test_server):
|
|
| 22 |
"""
|
| 23 |
page.goto(test_server)
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
# Clear session to ensure we are fresh
|
| 26 |
-
clear_btn = page.locator("sl-button", has_text="Clear
|
| 27 |
if clear_btn.is_visible():
|
| 28 |
page.once("dialog", lambda dialog: dialog.accept())
|
| 29 |
clear_btn.click()
|
| 30 |
-
page.locator("text=
|
| 31 |
|
| 32 |
# We expect a POST to /api/photos/*/analyze
|
| 33 |
# The app.js calls it after a 300ms timeout
|
|
|
|
| 22 |
"""
|
| 23 |
page.goto(test_server)
|
| 24 |
|
| 25 |
+
# Handle Medical Disclaimer
|
| 26 |
+
print("Checking for Medical Disclaimer...")
|
| 27 |
+
try:
|
| 28 |
+
disclaimer_btn = page.locator("sl-button", has_text="I Understand & Agree").first
|
| 29 |
+
disclaimer_btn.wait_for(state="visible", timeout=5000)
|
| 30 |
+
disclaimer_btn.click()
|
| 31 |
+
print("Disclaimer accepted.")
|
| 32 |
+
except:
|
| 33 |
+
print("Disclaimer not visible or already accepted, continuing...")
|
| 34 |
+
|
| 35 |
# Clear session to ensure we are fresh
|
| 36 |
+
clear_btn = page.locator("sl-button", has_text="Clear History").first
|
| 37 |
if clear_btn.is_visible():
|
| 38 |
page.once("dialog", lambda dialog: dialog.accept())
|
| 39 |
clear_btn.click()
|
| 40 |
+
page.locator("text=History Empty").wait_for(state="visible", timeout=5000)
|
| 41 |
|
| 42 |
# We expect a POST to /api/photos/*/analyze
|
| 43 |
# The app.js calls it after a 300ms timeout
|
tests/test_ui.py
CHANGED
|
@@ -26,6 +26,16 @@ def test_duplicate_upload_shows_warning_context(page, dummy_image, test_server):
|
|
| 26 |
page.on("console", lambda msg: print(f"Browser Console: {msg.text}"))
|
| 27 |
page.goto(test_server)
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
# Check if we need to clear previous state
|
| 30 |
# We use a locator for the button
|
| 31 |
clear_btn = page.locator("sl-button", has_text="Clear All").first
|
|
|
|
| 26 |
page.on("console", lambda msg: print(f"Browser Console: {msg.text}"))
|
| 27 |
page.goto(test_server)
|
| 28 |
|
| 29 |
+
# Handle Medical Disclaimer
|
| 30 |
+
print("Checking for Medical Disclaimer...")
|
| 31 |
+
try:
|
| 32 |
+
disclaimer_btn = page.locator("sl-button", has_text="I Understand & Agree").first
|
| 33 |
+
disclaimer_btn.wait_for(state="visible", timeout=5000)
|
| 34 |
+
disclaimer_btn.click()
|
| 35 |
+
print("Disclaimer accepted.")
|
| 36 |
+
except:
|
| 37 |
+
print("Disclaimer not visible or already accepted, continuing...")
|
| 38 |
+
|
| 39 |
# Check if we need to clear previous state
|
| 40 |
# We use a locator for the button
|
| 41 |
clear_btn = page.locator("sl-button", has_text="Clear All").first
|