A complete guide to how the non-invasive anemia detection system works.
View on GitHub
Anemia is a condition where your blood doesn’t have enough hemoglobin (Hb) — the protein in red blood cells that carries oxygen around your body. When hemoglobin is too low, you may feel tired, weak, or dizzy.
Normally, to check for anemia, a nurse draws blood from your arm and sends it to a laboratory. This takes time, costs money, and requires a needle. AneDet tries to do the same check without any needles or blood — just by looking at your fingernail with a camera.
The colour of your fingernail bed (the pink area under your nail) changes depending on how much hemoglobin is in your blood:
This is actually the same thing doctors check during a physical exam — they press your nail and look at the colour. AneDet does this automatically with a camera and artificial intelligence.
The system is a small device with three parts:
Everything runs on this small device. It does not need an internet connection — all the intelligence is built in.
A typical test takes about 26 seconds and works like this:
| Result on Screen | What It Means | What to Do |
|---|---|---|
| Normal | Hemoglobin is above 12.2 g/dL. Blood appears healthy. | No action needed for anemia. |
| Borderline | Hemoglobin is between 12.0 and 12.2 g/dL. Too close to call. | Consider a follow-up lab test to be sure. |
| Mild Anemia | Hemoglobin is between 10.0 and 11.9 g/dL. | Refer to a doctor for a confirmatory blood test. |
| Moderate Anemia | Hemoglobin is between 8.0 and 9.9 g/dL. | Refer to a doctor promptly. |
| Severe Anemia | Hemoglobin is below 8.0 g/dL. | Urgent medical attention needed. |
Based on testing with 673 patients:
This means the system is useful for screening (quickly checking many people) but is not a replacement for laboratory blood tests. Some healthy people may be flagged as anemic, and some anemic people may be missed.
Think of it like training a new doctor:
The AI also looks at the skin colour next to the nail as a reference. This helps compensate for different lighting conditions — just like how a human would compare “is the nail paler than the surrounding skin?” rather than judging nail colour in isolation.
Imagine you’re trying to weigh yourself on a shaky scale. One reading might say 70 kg, the next might say 72 kg, and the next 69 kg. None of them are perfectly accurate. But if you take 20 readings and use the middle value, you get a much more reliable answer.
The AI works the same way — each individual prediction has some uncertainty, so the system takes many readings, throws out the obvious outliers, and uses the stable middle value as the final result.
The system has several built-in safety measures:
AneDet is a non-invasive anemia screening tool. Instead of drawing blood, it:
The system runs entirely on-device — no internet connection is needed. The whole process takes about 26 seconds per reading.
| Status | Hb Range |
|---|---|
| Normal | > 12.2 g/dL |
| Borderline | 12.0 – 12.2 g/dL |
| Mild Anemia | 10.0 – 11.9 g/dL |
| Moderate Anemia | 8.0 – 9.9 g/dL |
| Severe Anemia | < 8.0 g/dL |
The system runs on three physical components:
The Raspberry Pi Camera Module 3 has autofocus, which is critical for close-up nail imaging at 10–15 cm distance.
| Setting | Value | Why |
|---|---|---|
| Resolution | 640 × 480 | Fast enough for real-time processing on Pi 5 |
| Format | RGB888 | Direct BGR in memory — no conversion needed for OpenCV |
| Autofocus Mode | Continuous + Fast | Keeps the nail sharp as the user adjusts position |
| Fallback Focus | Manual, LensPosition = 8.0 | ~12.5 cm focal distance if autofocus is unavailable |
The system is a Flask web application that runs locally on the Pi. The user interacts with it through a Chromium browser (typically launched in kiosk mode on the 5-inch display).
There are 3 concurrent execution contexts:
| Thread | Role | Heavy Work? |
|---|---|---|
| Flask Main | Serves HTML, handles API calls (/start_session, /get_result,
/save_measurement)
|
No — lightweight HTTP |
| Inference Worker | Runs YOLO nail detection, evaluates focus sharpness, runs ONNX Hb model | Yes — all AI runs here |
| Frame Generator | Captures camera frames, draws bounding boxes + status overlay, encodes JPEG, streams via MJPEG | Moderate — image encoding only |
The InferenceState dataclass acts as the communication channel between the inference worker and frame
generator:
| Field | Type | Purpose |
|---|---|---|
cached_boxes |
list | YOLO bounding boxes for overlay drawing |
smoothed_nail_box |
list | EMA-smoothed nail position for stable inference |
best_live_nail_conf |
float | Highest nail detection confidence |
focus_ok |
bool | Is the image sharp enough? |
smoothed_hb |
float | EMA-smoothed Hb value for display |
hb_history |
deque(15) | Rolling window of raw Hb predictions |
quality_ok |
bool | Combined nail + focus quality check |
quality_score |
int | 0–100 score for UI progress bar |
quality_tip |
str | User guidance text |
| File | Purpose |
|---|---|
app2.py |
Main application — Flask server, camera control, YOLO + ONNX inference, all routes |
hb_regressor.onnx |
FP32 ONNX hemoglobin regression model (4.0 MB) |
hb_regressor_config.json |
Model configuration: skin feature normalisation stats, calibration params, threshold |
best.pt |
YOLO nail detection model (expected in the working directory) |
templates/index.html |
Web UI template (Flask renders this) |
static/style.css |
UI styling |
static/script.js |
Frontend JavaScript (session control, polling, virtual keyboard) |
| File | Purpose |
|---|---|
train_hb_regressor.py |
Training script that produces the ONNX model and config JSON |
hb_regressor_best.pt |
PyTorch checkpoint (best model weights) |
hb_regressor_training_summary.json |
Full training report: metrics, cross-validation, seed stability |
hb_regressor_cv_summary.json |
3-fold cross-validation results |
hb_regressor_seed_runs.csv |
Per-seed training metrics comparison |
| Path | Content |
|---|---|
data2/metadata.csv |
673 patients, Hb levels, bounding box annotations |
data2/photo/ |
Patient fingernail images |
The system uses two models in sequence: one to find the nail, one to predict hemoglobin.
best.pt)| Property | Value |
|---|---|
| Purpose | Locate the fingernail in the camera frame |
| Architecture | YOLOv8 (Ultralytics) |
| Input | Full camera frame (640×480) |
| Output | Bounding boxes with class ID and confidence score |
| Class 0 | Nail |
| Live inference size | 416 pixels (faster for real-time) |
| Session inference size | 640 pixels (more accurate for Hb prediction) |
| Run frequency | Every 0.4 seconds |
Every 0.4 seconds, the inference worker runs YOLO on the latest camera frame. It finds all nail bounding boxes, picks the one with the highest confidence, and smooths its position using an exponential moving average (EMA) to reduce jitter.
hb_regressor.onnx)| Property | Value |
|---|---|
| Purpose | Predict hemoglobin level from nail + skin colour |
| Architecture | MobileNetV3-Small backbone + linear regression head |
| Input 1 | image: nail crop resized to 224×224×3, ImageNet-normalised |
| Input 2 | skin_features: 6 Lab colour values (nail L,a,b + skin L,a,b) |
| Output | hb_prediction: single float in g/L |
| Runtime | ONNX Runtime, CPUExecutionProvider, 4 threads |
| Run frequency | Every 0.8 seconds during acquisition phase |
def compute_nail_skin_ratio(nail_patch, skin_patch):
nail_lab = cv2.cvtColor(nail_patch, cv2.COLOR_BGR2Lab)
skin_lab = cv2.cvtColor(skin_patch, cv2.COLOR_BGR2Lab)
nail_mean = nail_lab.reshape(-1, 3).mean(axis=0) # → 3 values (L, a, b)
skin_mean = skin_lab.reshape(-1, 3).mean(axis=0) # → 3 values (L, a, b)
return [nail_L, nail_a, nail_b, skin_L, skin_a, skin_b] # → 6 total
These features are then z-normalised using training-set statistics from
hb_regressor_config.json:
| Feature | Mean | Std | Interpretation |
|---|---|---|---|
| Nail L | 153.4 | 18.3 | Nail lightness |
| Nail a | 140.3 | 2.2 | Nail red-green (key anemia indicator) |
| Nail b | 131.9 | 6.0 | Nail blue-yellow |
| Skin L | 159.2 | 21.4 | Skin lightness (normalisation reference) |
| Skin a | 133.3 | 4.4 | Skin red-green |
| Skin b | 141.4 | 4.3 | Skin blue-yellow |
a channel in Lab space is the most clinically important — it represents
redness. Anemic nails have pale nail beds with lower a values. The skin features act as a
lighting normalisation reference: by comparing nail colour to adjacent skin colour, the model
becomes less sensitive to ambient lighting conditions.
A measurement has 3 phases that the user sees on screen:
quality_ok = true (nail detected with sufficient confidence + image is sharp).
| Problem | What User Sees | System Response |
|---|---|---|
| No nail detected | Quality bar stays at 0% | “Place index fingernail in the center” |
| Nail detected but blurry | Quality bar partial | “Hold still and move your finger to a sharper distance” |
| Too few valid samples | “No Valid Reading” | User should retry with better positioning |
| Readings are inconsistent | Result still shown | IQR filtering removes outliers automatically |
When app2.py starts, it performs these steps in order:
Every cycle (~20ms sleep + processing time), the worker thread does:
_camera_lock)focus_ok = median(sharpness) ≥ 25.0quality_ok = nail_detected AND nail_conf ≥ 0.18 AND focus_okOnce quality is confirmed and 10 seconds have elapsed, the system transitions to acquiring. Every 0.8 seconds, the worker checks: Is there a smoothed nail box? Is confidence ≥ 0.34?
If yes, it runs the full Hb inference pipeline:
When 16 seconds of acquisition have elapsed:
Key safety features:
MIN_VALID_SAMPLES = 6: If fewer than 6 good samples were collected, the result
is “No Valid Reading”Raw model predictions are noisy. The system applies four layers of smoothing:
delta = history_median - smoothed_hb
if delta >= 0:
delta = min(delta, 0.05) # slow upward moves (conservative)
else:
delta = -min(abs(delta), 0.18) # faster downward correction (safety)
candidate = smoothed_hb + 0.10 * delta
if abs(candidate - smoothed_hb) >= 0.10: # deadband
smoothed_hb = candidate
| Parameter | Value | Purpose |
|---|---|---|
SMOOTHING_ALPHA |
0.10 | How much each new reading influences the display |
MAX_HB_STEP_UP |
0.05 g/dL | Maximum increase per update (prevents false normal jumps) |
MAX_HB_STEP_DOWN |
0.18 g/dL | Maximum decrease per update (allows fast anemia detection) |
HB_DEADBAND |
0.10 g/dL | Minimum change before display updates (prevents flicker) |
The model is trained on data labelled in g/L (grams per litre), but clinical Hb is reported in g/dL (grams per decilitre):
g/dL = g/L ÷ 10
So a model output of 120 g/L becomes 12.0 g/dL.
The system has four calibration stages:
Default runtime: The ÷10 conversion, slope correction (1.3× stretch), and final clip are active. The config calibration and legacy curve are both disabled by default.
The UI is served as a single-page Flask template (index.html) with CSS and JavaScript.
| Endpoint | Method | Purpose |
|---|---|---|
/ |
GET | Serve the main HTML page |
/video_feed |
GET | MJPEG stream (live camera with overlays) |
/start_session |
POST | Begin a new measurement (→ positioning phase) |
/stop_session |
POST | Abort current measurement (→ idle) |
/get_session_state |
GET | Polled by frontend every ~500ms for phase, quality, Hb, status |
/get_result |
GET | Quick fetch of current Hb + status |
/save_measurement |
POST | Save a reading to the database (with optional patient name) |
/admin/exit |
POST | Password-protected: exit to desktop |
/admin/export_records |
POST | Password-protected: export database to CSV |
/admin/reset_database |
POST | Password-protected: delete all readings |
The JavaScript polls /get_session_state continuously while a session is active. Example response:
{
"phase": "acquiring",
"remaining": 8.3,
"quality_ok": true,
"quality_reason": "Quality OK",
"quality_score": 100,
"quality_tip": "Great position. Keep still until analysis completes.",
"samples": 12,
"hb": 11.7,
"status": "Mild Anemia"
}
Results are stored in a local SQLite database (data/readings.db):
| Column | Type | Content |
|---|---|---|
id |
INTEGER | Auto-incrementing primary key |
created_at |
TEXT | UTC timestamp |
measurement_time |
TEXT | Time of measurement |
predicted_hb |
REAL | Hemoglobin value in g/dL |
predicted_status |
TEXT | “Normal”, “Mild Anemia”, etc. |
patient_name_enc |
TEXT | Patient name (encrypted) |
Patient names are encrypted at rest using a custom stream cipher with HMAC-SHA256 integrity:
Plaintext → XOR with SHA-256 keystream → prepend 16-byte nonce
→ append 32-byte HMAC → Base64-encode
data/.anedet_secure.key (permissions 0600)
ANEDET_DATA_KEY environment variableAll admin operations require authentication:
databaseRecords/
The training pipeline (train_hb_regressor.py) produces the ONNX model that runs on the Pi.
class HBRegressor(nn.Module):
# Backbone: MobileNetV3-Small (ImageNet pretrained)
features = mobilenet_v3_small().features # → 576-dim feature vector
pool = AdaptiveAvgPool2d(1, 1)
# Regression head: concatenate 576 CNN features + 6 skin features
regressor = Sequential(
Linear(576 + 6, 128), # fusion layer
ReLU(),
Dropout(0.3),
Linear(128, 1), # → single Hb prediction (g/L)
)
| Phase | Epochs | What's Trained | Learning Rate |
|---|---|---|---|
| Head-only | 10 | regressor only (backbone frozen) |
1e-3 |
| Fine-tuning | 20 | Last 4 backbone blocks + regressor |
3e-5 backbone, 1e-4 head |
ThresholdAwareHuberLoss — Huber loss (δ=10) with extra
weight near the 120 g/L anemia threshold| Metric | Test Set (303 samples) | Patient-Level (101 patients) |
|---|---|---|
| MAE | 12.7 g/L (1.27 g/dL) | 11.4 g/L (1.14 g/dL) |
| RMSE | 18.1 g/L | 16.8 g/L |
| R² | 0.32 | 0.41 |
| Sensitivity | 82.1% | 89.3% |
| Specificity | 80.4% | 80.8% |
| Accuracy | 80.9% | 83.2% |
| Metric | Mean ± Std |
|---|---|
| MAE | 13.7 ± 0.9 g/L |
| Sensitivity | 82.1% ± 7.2% |
| Specificity | 82.5% ± 3.9% |
All runtime parameters can be overridden via environment variables without changing code:
| Env Variable | Default | Description |
|---|---|---|
HB_BIAS_OFFSET |
0.0 | Manual g/dL offset added to every prediction |
HB_SCAN_INTERVAL |
0.8 | Seconds between Hb inference runs |
HB_SMOOTHING_ALPHA |
0.10 | EMA weight for display smoothing |
HB_MAX_HB_STEP_UP |
0.05 | Max g/dL increase per update |
HB_MAX_HB_STEP_DOWN |
0.18 | Max g/dL decrease per update |
HB_DEADBAND |
0.10 | Minimum change before display updates |
HB_HISTORY_SIZE |
15 | Rolling window size for median filter |
| Env Variable | Default | Description |
|---|---|---|
PREP_DURATION |
10.0 | Positioning phase minimum duration (seconds) |
HB_ACQUIRE_DURATION |
16.0 | Acquisition phase duration (seconds) |
HB_MIN_VALID_SAMPLES |
6 | Minimum samples required for a valid result |
| Env Variable | Default | Purpose |
|---|---|---|
HB_LIVE_NAIL_CONF |
0.15 | Minimum YOLO confidence to draw a bounding box |
HB_SESSION_NAIL_CONF |
0.18 | Minimum confidence for positioning quality check |
HB_ACQUIRE_NAIL_CONF |
0.34 | Minimum confidence to accept an Hb sample |
| Env Variable | Default | Purpose |
|---|---|---|
CAMERA_FRAME_WIDTH |
640 | Capture width in pixels |
CAMERA_FRAME_HEIGHT |
480 | Capture height in pixels |
LENS_POSITION |
8.0 | Manual focus position (dioptres, 1/metres) |
| Env Variable | Default | Purpose |
|---|---|---|
HB_USE_SKIN_FEATURE_NORM |
True | Use z-normalisation on skin features |
HB_USE_CONFIG_CALIBRATION |
False | Apply slope/intercept from config JSON |
HB_USE_LEGACY_CALIBRATION_CURVE |
False | Apply piecewise linear calibration curve |
HB_ANEMIA_THRESHOLD_GDL |
12.20 | Decision threshold in g/dL |
cd /path/to/oneModel
python app2.py
The Flask server starts on http://0.0.0.0:5000. On the Pi's display, Chromium should open in kiosk
mode pointing to http://localhost:5000.
load models
loaded hb config: /path/to/hb_regressor_config.json
runtime hb config | skin_norm=True | cfg_cal=False (slope=0.7000,
intercept=20.00) | legacy_curve=False | anemia_threshold=12.20
models loaded
camera initialized (Picamera2, Camera Module 3, size=640x480)
autofocus: Continuous + Fast (Camera Module 3)
* Running on http://0.0.0.0:5000