deploy BE ke huggingface
Browse files- .python-version +1 -0
- __pycache__/features.cpython-313.pyc +0 -0
- __pycache__/inference.cpython-313.pyc +0 -0
- __pycache__/main.cpython-313.pyc +0 -0
- build.sh +5 -0
- dockerfile +14 -0
- features.py +159 -0
- inference.py +99 -0
- main.py +157 -0
- model/final_model_Random_Forest.onnx +3 -0
- pyproject.toml +16 -0
- requirements.txt +8 -0
- uv.lock +0 -0
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.12
|
__pycache__/features.cpython-313.pyc
ADDED
|
Binary file (12 kB). View file
|
|
|
__pycache__/inference.cpython-313.pyc
ADDED
|
Binary file (4.38 kB). View file
|
|
|
__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (6.58 kB). View file
|
|
|
build.sh
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -o errexit
|
| 3 |
+
set -o pipefail
|
| 4 |
+
|
| 5 |
+
uv sync --frozen
|
dockerfile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
RUN useradd -m -u 1000 user
|
| 4 |
+
USER user
|
| 5 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
COPY --chown=user ./requirements.txt requirements.txt
|
| 10 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 11 |
+
|
| 12 |
+
COPY --chown=user . .
|
| 13 |
+
|
| 14 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
features.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
features.py β 120-dimensional handcrafted CV feature extraction.
|
| 3 |
+
|
| 4 |
+
Pipeline (matches training notebook exactly):
|
| 5 |
+
preprocess(img) β get_mask(img) β extract_features(img, mask)
|
| 6 |
+
|
| 7 |
+
Feature vector layout:
|
| 8 |
+
[0:24] GLCM texture (6 props Γ 4 angles)
|
| 9 |
+
[24:50] LBP texture (26-bin uniform histogram, P=24 R=3)
|
| 10 |
+
[50:74] Gabor texture (4 freqs Γ 3 orientations, mean+std)
|
| 11 |
+
[74:106] Colour histogram (16H + 8S + 8V, normalised, within mask)
|
| 12 |
+
[106:115] Colour moments (mean, std, skewness per HSV channel)
|
| 13 |
+
[115:120] ABCD morphology (asymmetry, compactness, cvar, diam, elong)
|
| 14 |
+
Total: 120 dimensions
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
import cv2
|
| 19 |
+
from skimage.feature import graycomatrix, graycoprops, local_binary_pattern
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ββ Preprocessing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 23 |
+
|
| 24 |
+
def preprocess(img_bgr: np.ndarray, size: tuple = (256, 256)) -> np.ndarray:
|
| 25 |
+
"""Hair removal (black-hat) β Gaussian blur β CLAHE β resize."""
|
| 26 |
+
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
|
| 27 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 17))
|
| 28 |
+
bhat = cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, kernel)
|
| 29 |
+
_, hmask = cv2.threshold(bhat, 10, 255, cv2.THRESH_BINARY)
|
| 30 |
+
img_bgr = cv2.inpaint(img_bgr, hmask, 3, cv2.INPAINT_TELEA)
|
| 31 |
+
img_bgr = cv2.GaussianBlur(img_bgr, (5, 5), 0)
|
| 32 |
+
lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB)
|
| 33 |
+
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
| 34 |
+
lab[:, :, 0] = clahe.apply(lab[:, :, 0])
|
| 35 |
+
img_bgr = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)
|
| 36 |
+
return cv2.resize(img_bgr, size, interpolation=cv2.INTER_AREA)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def get_mask(img_bgr: np.ndarray) -> np.ndarray:
|
| 40 |
+
"""Otsu thresholding + morphological refinement to isolate lesion."""
|
| 41 |
+
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
|
| 42 |
+
blur = cv2.GaussianBlur(gray, (7, 7), 0)
|
| 43 |
+
_, mask = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
| 44 |
+
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
|
| 45 |
+
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k)
|
| 46 |
+
mask = cv2.morphologyEx(
|
| 47 |
+
mask, cv2.MORPH_OPEN,
|
| 48 |
+
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
| 49 |
+
)
|
| 50 |
+
return mask
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ββ Feature groups βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 54 |
+
|
| 55 |
+
def feat_glcm(gray: np.ndarray) -> np.ndarray:
|
| 56 |
+
"""24-dim GLCM at 4 angles Γ 6 properties."""
|
| 57 |
+
glcm = graycomatrix(
|
| 58 |
+
gray, distances=[1],
|
| 59 |
+
angles=[0, np.pi / 4, np.pi / 2, 3 * np.pi / 4],
|
| 60 |
+
levels=256, symmetric=True, normed=True
|
| 61 |
+
)
|
| 62 |
+
props = ["contrast", "correlation", "energy", "homogeneity", "dissimilarity", "ASM"]
|
| 63 |
+
return np.concatenate([graycoprops(glcm, p).flatten() for p in props])
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def feat_lbp(gray: np.ndarray) -> np.ndarray:
|
| 67 |
+
"""26-dim LBP histogram β rotation-invariant uniform, P=24 R=3."""
|
| 68 |
+
lbp = local_binary_pattern(gray, P=24, R=3, method="uniform")
|
| 69 |
+
hist, _ = np.histogram(lbp.ravel(), bins=26, range=(0, 26), density=True)
|
| 70 |
+
return hist.astype(np.float32)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def feat_gabor(gray: np.ndarray) -> np.ndarray:
|
| 74 |
+
"""24-dim Gabor responses at 4 scales Γ 3 orientations (mean + std each)."""
|
| 75 |
+
feats = []
|
| 76 |
+
for freq in [0.1, 0.2, 0.3, 0.4]:
|
| 77 |
+
for theta in [0, np.pi / 3, 2 * np.pi / 3]:
|
| 78 |
+
kernel = cv2.getGaborKernel(
|
| 79 |
+
(21, 21), sigma=4.0, theta=theta,
|
| 80 |
+
lambd=1.0 / freq, gamma=0.5, psi=0
|
| 81 |
+
)
|
| 82 |
+
resp = cv2.filter2D(gray.astype(np.float32), cv2.CV_32F, kernel)
|
| 83 |
+
feats.extend([resp.mean(), resp.std()])
|
| 84 |
+
return np.array(feats, dtype=np.float32)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def feat_colour(img_bgr: np.ndarray, mask: np.ndarray) -> np.ndarray:
|
| 88 |
+
"""32-dim normalised HSV histogram within lesion mask (16H + 8S + 8V)."""
|
| 89 |
+
hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
|
| 90 |
+
m = (mask > 0).astype(np.uint8) * 255
|
| 91 |
+
h = cv2.calcHist([hsv], [0], m, [16], [0, 180]).flatten()
|
| 92 |
+
s = cv2.calcHist([hsv], [1], m, [8], [0, 256]).flatten()
|
| 93 |
+
v = cv2.calcHist([hsv], [2], m, [8], [0, 256]).flatten()
|
| 94 |
+
norm = lambda x: x / (x.sum() + 1e-8)
|
| 95 |
+
return np.concatenate([norm(h), norm(s), norm(v)]).astype(np.float32)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def feat_colour_moments(img_bgr: np.ndarray, mask: np.ndarray) -> np.ndarray:
|
| 99 |
+
"""9-dim colour moments (mean, std, skewness) per HSV channel."""
|
| 100 |
+
hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
|
| 101 |
+
feats = []
|
| 102 |
+
for ch in range(3):
|
| 103 |
+
pixels = hsv[:, :, ch][mask > 0].astype(float)
|
| 104 |
+
if pixels.size == 0:
|
| 105 |
+
feats.extend([0.0, 0.0, 0.0])
|
| 106 |
+
continue
|
| 107 |
+
mu = pixels.mean()
|
| 108 |
+
sigma = pixels.std() + 1e-8
|
| 109 |
+
skew = float(np.mean(((pixels - mu) / sigma) ** 3))
|
| 110 |
+
feats.extend([mu, sigma, skew])
|
| 111 |
+
return np.array(feats, dtype=np.float32)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def feat_abcd(mask: np.ndarray, hsv: np.ndarray) -> np.ndarray:
|
| 115 |
+
"""5-dim ABCD dermoscopy features (asymmetry, compactness, cvar, diam, elong)."""
|
| 116 |
+
cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 117 |
+
if not cnts:
|
| 118 |
+
return np.zeros(5, dtype=np.float32)
|
| 119 |
+
cnt = max(cnts, key=cv2.contourArea)
|
| 120 |
+
area = cv2.contourArea(cnt) + 1e-6
|
| 121 |
+
perim = cv2.arcLength(cnt, True) + 1e-6
|
| 122 |
+
if len(cnt) >= 5:
|
| 123 |
+
axes_sorted = sorted(cv2.fitEllipse(cnt)[1])
|
| 124 |
+
asym = axes_sorted[0] / (axes_sorted[1] + 1e-6)
|
| 125 |
+
else:
|
| 126 |
+
asym = 1.0
|
| 127 |
+
comp = (4 * np.pi * area) / (perim ** 2)
|
| 128 |
+
hue_pixels = hsv[:, :, 0][mask > 0]
|
| 129 |
+
cvar = float(hue_pixels.std()) if hue_pixels.size else 0.0
|
| 130 |
+
diam = np.sqrt(4 * area / np.pi)
|
| 131 |
+
elong = perim / (2 * np.sqrt(np.pi * area) + 1e-6)
|
| 132 |
+
return np.array([asym, comp, cvar, diam, elong], dtype=np.float32)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ββ Full 120D extraction βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 136 |
+
|
| 137 |
+
def extract_features(img_bgr: np.ndarray) -> np.ndarray:
|
| 138 |
+
"""
|
| 139 |
+
Full pipeline: preprocess β mask β 120D feature vector.
|
| 140 |
+
|
| 141 |
+
Input : BGR image (any size)
|
| 142 |
+
Output: float32 ndarray of shape (120,)
|
| 143 |
+
"""
|
| 144 |
+
proc = preprocess(img_bgr) # 256Γ256 BGR
|
| 145 |
+
mask = get_mask(proc) # binary mask
|
| 146 |
+
gray = cv2.cvtColor(proc, cv2.COLOR_BGR2GRAY)
|
| 147 |
+
hsv = cv2.cvtColor(proc, cv2.COLOR_BGR2HSV)
|
| 148 |
+
|
| 149 |
+
vec = np.concatenate([
|
| 150 |
+
feat_glcm(gray), # 24
|
| 151 |
+
feat_lbp(gray), # 26
|
| 152 |
+
feat_gabor(gray), # 24
|
| 153 |
+
feat_colour(proc, mask), # 32
|
| 154 |
+
feat_colour_moments(proc, mask), # 9
|
| 155 |
+
feat_abcd(mask, hsv), # 5
|
| 156 |
+
]).astype(np.float32)
|
| 157 |
+
|
| 158 |
+
assert vec.shape == (120,), f"Expected 120 dims, got {vec.shape}"
|
| 159 |
+
return vec
|
inference.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
inference.py β ONNX Runtime wrapper for the Random Forest model.
|
| 3 |
+
|
| 4 |
+
Expected model: final_model_Random_Forest.onnx
|
| 5 |
+
Input : float_input [None, 120] float32 (raw features β no scaling needed)
|
| 6 |
+
Outputs: label [None] int64
|
| 7 |
+
probabilities [None, 3] float32
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from threading import Lock
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import onnxruntime as rt
|
| 15 |
+
|
| 16 |
+
MODEL_PATH = Path(__file__).parent / "model" / "final_model_Random_Forest.onnx"
|
| 17 |
+
|
| 18 |
+
CLASS_NAMES = {
|
| 19 |
+
0: "Common / Benign Nevi",
|
| 20 |
+
1: "Atypical / Other Benign",
|
| 21 |
+
2: "Melanoma (Suspected)",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
CLASS_RISK = {
|
| 25 |
+
0: "healthy",
|
| 26 |
+
1: "watch",
|
| 27 |
+
2: "danger",
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
CLASS_WHAT = {
|
| 31 |
+
0: "A common benign mole. Melanocytic nevi are very common β most adults have 10β40. Almost always completely harmless.",
|
| 32 |
+
1: "This category includes atypical or other benign lesions such as seborrhoeic keratosis, actinic keratosis, dermatofibroma, or vascular lesions. While many are harmless, some may need treatment.",
|
| 33 |
+
2: "Melanoma is the most serious type of skin cancer. It develops from pigment-producing cells. Early detection is critical β when caught early, treatment is highly effective.",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
CLASS_ACTION = {
|
| 37 |
+
0: "No action needed. Monitor for changes in shape, colour, size, or bleeding.",
|
| 38 |
+
1: "Recommended: book a consultation with a dermatologist for professional evaluation.",
|
| 39 |
+
2: "Please see a dermatologist or doctor as soon as possible. Do not delay.",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
_session = None
|
| 43 |
+
_session_lock = Lock()
|
| 44 |
+
|
| 45 |
+
def load_model() -> rt.InferenceSession:
|
| 46 |
+
"""Load ONNX model (cached after first call)."""
|
| 47 |
+
global _session
|
| 48 |
+
if _session is None:
|
| 49 |
+
with _session_lock:
|
| 50 |
+
if _session is None:
|
| 51 |
+
if not MODEL_PATH.exists():
|
| 52 |
+
raise FileNotFoundError(
|
| 53 |
+
f"ONNX model not found at {MODEL_PATH}. "
|
| 54 |
+
"Please copy final_model_Random_Forest.onnx into backend/models/"
|
| 55 |
+
)
|
| 56 |
+
opts = rt.SessionOptions()
|
| 57 |
+
opts.intra_op_num_threads = 4
|
| 58 |
+
_session = rt.InferenceSession(str(MODEL_PATH), sess_options=opts)
|
| 59 |
+
return _session
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def predict(features: np.ndarray) -> dict:
|
| 63 |
+
"""
|
| 64 |
+
Run ONNX inference on a (120,) or (1, 120) feature vector.
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
{
|
| 68 |
+
"label": int, # 0, 1, or 2
|
| 69 |
+
"class_name": str,
|
| 70 |
+
"risk": str, # healthy / watch / danger
|
| 71 |
+
"probabilities": [p0, p1, p2], # float list, sums to 1
|
| 72 |
+
"confidence": float, # max probability
|
| 73 |
+
"what": str, # plain-language explanation
|
| 74 |
+
"action": str, # recommended next step
|
| 75 |
+
}
|
| 76 |
+
"""
|
| 77 |
+
sess = load_model()
|
| 78 |
+
if features.ndim == 1:
|
| 79 |
+
features = features.reshape(1, -1)
|
| 80 |
+
features = features.astype(np.float32)
|
| 81 |
+
|
| 82 |
+
label_arr, prob_arr = sess.run(
|
| 83 |
+
["label", "probabilities"],
|
| 84 |
+
{"float_input": features}
|
| 85 |
+
)
|
| 86 |
+
label = int(label_arr[0])
|
| 87 |
+
probs = prob_arr[0].tolist()
|
| 88 |
+
if label not in CLASS_NAMES:
|
| 89 |
+
raise ValueError(f"Model returned unexpected label {label}; probabilities={probs}")
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
"label": label,
|
| 93 |
+
"class_name": CLASS_NAMES[label],
|
| 94 |
+
"risk": CLASS_RISK[label],
|
| 95 |
+
"probabilities": probs,
|
| 96 |
+
"confidence": float(max(probs)),
|
| 97 |
+
"what": CLASS_WHAT[label],
|
| 98 |
+
"action": CLASS_ACTION[label],
|
| 99 |
+
}
|
main.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
main.py β DermaAI FastAPI backend.
|
| 3 |
+
|
| 4 |
+
Endpoints:
|
| 5 |
+
GET /api/health β health check + model status
|
| 6 |
+
POST /api/analyze β upload image β 120D features β ONNX β JSON result
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import io
|
| 10 |
+
import logging
|
| 11 |
+
import time
|
| 12 |
+
from contextlib import asynccontextmanager
|
| 13 |
+
|
| 14 |
+
import cv2
|
| 15 |
+
import numpy as np
|
| 16 |
+
from fastapi import FastAPI, File, HTTPException, UploadFile
|
| 17 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 18 |
+
from PIL import Image, UnidentifiedImageError
|
| 19 |
+
|
| 20 |
+
from features import extract_features
|
| 21 |
+
from inference import load_model, predict
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ββ Lifespan: pre-load model on startup βββββββββββββββββββββββββββββββββββββββ
|
| 27 |
+
|
| 28 |
+
@asynccontextmanager
|
| 29 |
+
async def lifespan(app: FastAPI):
|
| 30 |
+
try:
|
| 31 |
+
load_model()
|
| 32 |
+
print("β
Random Forest ONNX model loaded successfully.")
|
| 33 |
+
except FileNotFoundError as e:
|
| 34 |
+
print(f"β οΈ {e}")
|
| 35 |
+
yield
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ββ App βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
|
| 40 |
+
app = FastAPI(
|
| 41 |
+
title="DermaAI API",
|
| 42 |
+
description="Skin disease detection using Random Forest ONNX + 120D CV features",
|
| 43 |
+
version="1.0.0",
|
| 44 |
+
lifespan=lifespan,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
app.add_middleware(
|
| 48 |
+
CORSMiddleware,
|
| 49 |
+
allow_origins=["https://comvisproject.vercel.app/"],
|
| 50 |
+
allow_credentials=True,
|
| 51 |
+
allow_methods=["*"],
|
| 52 |
+
allow_headers=["*"],
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
+
|
| 58 |
+
ALLOWED_IMAGE_FORMATS = {"JPEG", "PNG", "BMP", "WEBP"}
|
| 59 |
+
MAX_SIZE_MB = 10
|
| 60 |
+
MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024
|
| 61 |
+
UPLOAD_CHUNK_SIZE = 1024 * 1024
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class InvalidImageError(ValueError):
|
| 65 |
+
"""Raised when uploaded bytes are not a supported image."""
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def decode_image(data: bytes) -> np.ndarray:
|
| 69 |
+
"""Decode uploaded bytes β BGR ndarray."""
|
| 70 |
+
try:
|
| 71 |
+
with Image.open(io.BytesIO(data)) as pil:
|
| 72 |
+
if pil.format not in ALLOWED_IMAGE_FORMATS:
|
| 73 |
+
raise InvalidImageError("Unsupported image format. Use JPEG, PNG, BMP, or WEBP.")
|
| 74 |
+
rgb = pil.convert("RGB")
|
| 75 |
+
except InvalidImageError:
|
| 76 |
+
raise
|
| 77 |
+
except (Image.DecompressionBombError, OSError, UnidentifiedImageError) as exc:
|
| 78 |
+
raise InvalidImageError("Invalid image data. Upload a valid image file.") from exc
|
| 79 |
+
|
| 80 |
+
bgr = cv2.cvtColor(np.array(rgb), cv2.COLOR_RGB2BGR)
|
| 81 |
+
return bgr
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def read_limited_upload(file: UploadFile) -> bytes:
|
| 85 |
+
"""Read an upload while enforcing the configured byte limit."""
|
| 86 |
+
data = bytearray()
|
| 87 |
+
while chunk := await file.read(UPLOAD_CHUNK_SIZE):
|
| 88 |
+
if len(data) + len(chunk) > MAX_SIZE_BYTES:
|
| 89 |
+
raise HTTPException(status_code=400, detail=f"File too large (max {MAX_SIZE_MB} MB).")
|
| 90 |
+
data.extend(chunk)
|
| 91 |
+
return bytes(data)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 95 |
+
|
| 96 |
+
@app.get("/api/health")
|
| 97 |
+
def health():
|
| 98 |
+
"""Health check β confirms API and model status."""
|
| 99 |
+
try:
|
| 100 |
+
load_model()
|
| 101 |
+
model_ok = True
|
| 102 |
+
except Exception:
|
| 103 |
+
model_ok = False
|
| 104 |
+
return {
|
| 105 |
+
"status": "ok",
|
| 106 |
+
"model_loaded": model_ok,
|
| 107 |
+
"model": "final_model_Random_Forest.onnx",
|
| 108 |
+
"features": "120D (GLCM 24 + LBP 26 + Gabor 24 + ColourHist 32 + ColourMoments 9 + ABCD 5)",
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@app.post("/api/analyze")
|
| 113 |
+
async def analyze(file: UploadFile = File(...)):
|
| 114 |
+
"""
|
| 115 |
+
Upload a skin image and receive classification results.
|
| 116 |
+
|
| 117 |
+
Pipeline:
|
| 118 |
+
1. Decode image
|
| 119 |
+
2. Preprocess: hair removal β Gaussian blur β CLAHE β resize 256Γ256
|
| 120 |
+
3. Otsu segmentation mask
|
| 121 |
+
4. 120D feature extraction
|
| 122 |
+
5. ONNX Random Forest inference
|
| 123 |
+
6. Return label, probabilities, plain-language explanation
|
| 124 |
+
"""
|
| 125 |
+
raw = await read_limited_upload(file)
|
| 126 |
+
|
| 127 |
+
try:
|
| 128 |
+
t0 = time.perf_counter()
|
| 129 |
+
|
| 130 |
+
# Decode
|
| 131 |
+
img_bgr = decode_image(raw)
|
| 132 |
+
|
| 133 |
+
# Feature extraction (preprocess + mask + 120D)
|
| 134 |
+
features = extract_features(img_bgr)
|
| 135 |
+
|
| 136 |
+
# ONNX inference
|
| 137 |
+
result = predict(features)
|
| 138 |
+
|
| 139 |
+
elapsed_ms = round((time.perf_counter() - t0) * 1000)
|
| 140 |
+
|
| 141 |
+
return {
|
| 142 |
+
**result,
|
| 143 |
+
"filename": file.filename,
|
| 144 |
+
"elapsed_ms": elapsed_ms,
|
| 145 |
+
"feature_dims": 120,
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
except FileNotFoundError as e:
|
| 149 |
+
raise HTTPException(status_code=503, detail=str(e))
|
| 150 |
+
except InvalidImageError as e:
|
| 151 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 152 |
+
except Exception as e:
|
| 153 |
+
logger.exception("Feature extraction or inference failed")
|
| 154 |
+
raise HTTPException(
|
| 155 |
+
status_code=500,
|
| 156 |
+
detail="Feature extraction or inference failed."
|
| 157 |
+
) from e
|
model/final_model_Random_Forest.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:31acd90c059b90736e4c9bf5407b2b660049b357009e4a16f66141e11e8c89de
|
| 3 |
+
size 13496166
|
pyproject.toml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "backend"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "FastAPI backend for dermoscopic lesion classification with CV features and ONNX inference"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.12"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"fastapi~=0.136.1",
|
| 9 |
+
"numpy>=2.4,<3.0",
|
| 10 |
+
"onnxruntime~=1.26.0",
|
| 11 |
+
"opencv-python-headless~=4.13.0.92",
|
| 12 |
+
"pillow~=12.2.0",
|
| 13 |
+
"python-multipart~=0.0.29",
|
| 14 |
+
"scikit-image~=0.26.0",
|
| 15 |
+
"uvicorn[standard]>=0.47,<0.48",
|
| 16 |
+
]
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi~=0.136.1
|
| 2 |
+
uvicorn[standard]>=0.47,<0.48
|
| 3 |
+
python-multipart~=0.0.29
|
| 4 |
+
onnxruntime~=1.26.0
|
| 5 |
+
opencv-python-headless~=4.13.0.92
|
| 6 |
+
scikit-image~=0.26.0
|
| 7 |
+
numpy>=2.4,<3.0
|
| 8 |
+
Pillow~=12.2.0
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|