"""Chhaya (छाया — "shade") — a skin & heat-health companion for people who work in the sun.
Built for the Hugging Face Build Small Hackathon (Backyard AI track).
Design principle: the model is the eyes, the guidelines are the medicine.
MedGemma-1.5-4B reads the photo and returns structured findings; all medical
guidance (hydration schedules, heat triage, when-to-see-a-doctor) comes from
curated, deterministic content based on NDMA India heat-action guidance, WHO
heat advice, and Cancer Council ABCDE self-check criteria.
Not a medical device. Chhaya never diagnoses — it observes, explains, and
points people to care.
"""
import io
import json
import os
import re
import time
from functools import partial
from typing import Any, Dict, List, Optional, Tuple
try:
import spaces # noqa: F401 — must be imported before torch for ZeroGPU patching
_HAS_SPACES = True
except ImportError:
_HAS_SPACES = False
import gradio as gr
import numpy as np
from PIL import Image
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
APP_TITLE = "Chhaya — shade for those who work in the sun"
MODEL_ID = os.getenv("MEDGEMMA_MODEL_ID", "google/medgemma-1.5-4b-it")
# Optional fine-tuned LoRA adapter — when set, loaded on top of the base model.
# Empty string => use base MedGemma with the verbose JSON-instruction prompt.
ADAPTER_ID = os.getenv("CHHAYA_ADAPTER_ID", "").strip()
FORCE_DEMO = os.getenv("CHHAYA_DEMO", "0").strip().lower() in {"1", "true", "yes"}
# ---------------------------------------------------------------------------
# Model backend — ZeroGPU on Spaces, graceful demo fallback locally
# ---------------------------------------------------------------------------
_model = None
_processor = None
_backend = "demo"
def _gpu_decorator(fn):
"""Wrap with @spaces.GPU when running on a ZeroGPU Space, no-op otherwise."""
if _HAS_SPACES:
return spaces.GPU(duration=120)(fn)
return fn
def _decide_backend() -> None:
"""Pick the backend WITHOUT touching CUDA — on ZeroGPU the GPU only exists
inside @spaces.GPU functions, so weights load lazily on first inference."""
global _backend
if FORCE_DEMO:
_backend = "demo"
return
try:
import torch # noqa: F401
if _HAS_SPACES or torch.cuda.is_available():
_backend = "medgemma" # weights loaded lazily in _ensure_model()
else:
_backend = "demo"
except Exception:
_backend = "demo"
def _ensure_model():
"""Load processor + model (+ adapter) once, inside a GPU context."""
global _model, _processor
if _model is not None:
return
import torch
from transformers import AutoModelForImageTextToText, AutoProcessor
_processor = AutoProcessor.from_pretrained(MODEL_ID)
model = AutoModelForImageTextToText.from_pretrained(
MODEL_ID, torch_dtype=torch.bfloat16
).to("cuda")
if ADAPTER_ID:
from peft import PeftModel
model = PeftModel.from_pretrained(model, ADAPTER_ID).merge_and_unload()
print(f"[chhaya] loaded fine-tuned adapter {ADAPTER_ID}")
_model = model
_decide_backend()
def _prewarm_weights() -> None:
"""On a GPU Space, pull the model (+ adapter) into the local disk cache at
startup — on CPU, no GPU needed. Without this the first @spaces.GPU call has
to download ~8GB inside the 120s GPU window, times out, and falls back to a
demo reading; with a warm cache that first inference loads from disk in time."""
if _backend != "medgemma":
return
try:
from huggingface_hub import snapshot_download
snapshot_download(MODEL_ID)
if ADAPTER_ID:
snapshot_download(ADAPTER_ID)
print("[chhaya] prewarmed model weights to local cache")
except Exception as exc:
print(f"[chhaya] prewarm skipped: {exc}")
_prewarm_weights()
SYSTEM_PROMPT = """You are the vision module of Chhaya, a skin and heat-health companion for
outdoor workers (drivers, delivery riders, construction workers, street vendors, farmers).
You are not a doctor and must not diagnose. Look at the skin photo and report only what is
visible, in plain language a non-medical person understands. Be honest about uncertainty and
image-quality limits. Never name a disease; describe what you see and how concerning it looks."""
def build_findings_prompt(occupation: str, hours: float, notes: str) -> str:
return f"""This photo was taken by an outdoor worker checking their own skin.
Context: occupation: {occupation}; typical hours in direct sun per day: {hours:.0f}; their note: {notes or "none"}.
Describe what is visible and return ONLY valid JSON with exactly these keys:
{{
"what_i_see": "one plain-language sentence describing the visible skin area or spot",
"spot": {{
"type": "kind of spot/area visible (e.g. mole, patch, rash, burn, dryness, normal skin)",
"color": "colors visible and whether even or uneven",
"borders": "edges: sharp/blurry, regular/irregular",
"symmetry": "symmetric or asymmetric",
"texture": "raised/flat, rough/smooth, or undetermined"
}},
"heat_sun_signals": ["visible signs related to sun or heat, e.g. sunburn redness, tan lines, heat rash bumps, dry cracked skin, peeling — empty list if none"],
"concern": "low | watch | see_doctor",
"concern_reason": "one sentence: why this concern level, mentioning any ABCDE-style features (asymmetry, irregular border, uneven colour, large size, signs of change)",
"image_quality": "good | limited",
"summary": "two friendly sentences the worker can read aloud to a doctor"
}}
Rules: use "see_doctor" if the spot is asymmetric with irregular borders and uneven colour,
looks like an open sore, bleeds, or looks very different from ordinary moles. Use "watch"
for spots worth re-photographing in a few weeks. Use "low" for ordinary skin, mild dryness,
tan, or mild sunburn. Do not diagnose. Do not add any text outside the JSON."""
def _run_medgemma(image: Image.Image, prompt: str) -> str:
import torch
_ensure_model() # lazy load on first call (GPU available here on ZeroGPU)
if ADAPTER_ID:
# Fine-tuned model: match training exactly — bare "skin check", no system
# prompt, JSON emitted directly (no chain-of-thought), so fewer tokens.
messages = [{
"role": "user",
"content": [
{"type": "image", "image": image},
{"type": "text", "text": "skin check"},
],
}]
max_new = 400
else:
messages = [
{"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT}]},
{
"role": "user",
"content": [
{"type": "image", "image": image}, # image BEFORE text — required
{"type": "text", "text": prompt},
],
},
]
max_new = 1024
inputs = _processor.apply_chat_template(
messages, add_generation_prompt=True, tokenize=True,
return_dict=True, return_tensors="pt",
).to(_model.device, dtype=torch.bfloat16)
input_len = inputs["input_ids"].shape[-1]
with torch.inference_mode():
generation = _model.generate(**inputs, max_new_tokens=max_new, do_sample=False)
return _processor.decode(generation[0][input_len:], skip_special_tokens=True)
run_model = _gpu_decorator(_run_medgemma)
# ---------------------------------------------------------------------------
# JSON extraction (robust against fences / minor malformations)
# ---------------------------------------------------------------------------
def _balanced_objects(text: str) -> List[str]:
objs, depth, start = [], 0, None
for i, ch in enumerate(text):
if ch == "{":
if depth == 0:
start = i
depth += 1
elif ch == "}" and depth > 0:
depth -= 1
if depth == 0 and start is not None:
objs.append(text[start : i + 1])
return objs
def extract_json(text: str) -> Dict[str, Any]:
"""Pull the answer JSON out of model output.
MedGemma-1.5 thinks out loud before answering and may draft JSON inside its
thought trace — so we scan for balanced {...} blocks and prefer the LAST one
that parses and carries the expected keys.
"""
candidates = _balanced_objects(text.strip())
parsed: List[Dict[str, Any]] = []
for cand in candidates:
for attempt in (cand, re.sub(r",\s*([}\]])", r"\1", cand)):
try:
obj = json.loads(attempt)
if isinstance(obj, dict):
parsed.append(obj)
break
except Exception:
continue
for obj in reversed(parsed):
if "concern" in obj:
return obj
if parsed:
return parsed[-1]
raise ValueError("no parseable JSON object in model output")
class QuotaExceeded(RuntimeError):
pass
def prose_findings(raw: str) -> Dict[str, Any]:
"""Model answered in prose instead of JSON — show its words honestly."""
text = re.sub(r"```\w*|```", "", raw or "").strip()
# drop a leading thought trace if the model thought out loud without answering
text = re.sub(r"^Thought\b[:\s]*", "", text).strip()
text = text or "The model returned no readable description for this photo."
return {
"what_i_see": text[:400],
"spot": {k: "see description above" for k in ["type", "color", "borders", "symmetry", "texture"]},
"heat_sun_signals": [],
"concern": "watch",
"concern_reason": "The model's reply could not be structured automatically, so Chhaya defaults to the cautious option.",
"image_quality": "limited",
"summary": text[:300],
}
def abcde_safety_backstop(findings: Dict[str, Any]) -> Dict[str, Any]:
"""Guidelines-as-safety-net: never let the model's 'low' stand when its OWN
description shows the high-risk ABCDE combo. Catches the under-warning case
where the model describes asymmetry/irregular borders/uneven colour yet calls
the spot ordinary. Cautious by design — only ever raises concern, never lowers."""
if str(findings.get("concern", "")).lower() != "low":
return findings
spot = findings.get("spot", {}) or {}
borders_t = str(spot.get("borders", "")).lower()
symm_t = str(spot.get("symmetry", "")).lower()
color_t = str(spot.get("color", "")).lower()
blob = " ".join([str(findings.get("what_i_see", "")),
str(findings.get("concern_reason", "")),
*(str(v) for v in spot.values())]).lower()
def has(text, term): # term present and not negated right before it
return term in text and f"no {term}" not in text and f"not {term}" not in text
asym = (has(symm_t, "asymmetr") or has(blob, "asymmetr")) and "symmetric" != symm_t.strip()
irreg = has(borders_t, "irregular") or (
has(blob, "irregular") and any(w in blob for w in ("border", "margin", "edge")))
uneven = any(has(color_t, t) or has(blob, t) for t in
("uneven", "varied", "variegated", "multiple colour", "multiple color",
"mottled", "non-uniform", "varying"))
if sum([asym, irreg, uneven]) >= 2:
findings["concern"] = "watch"
findings["concern_reason"] = (
"Chhaya flagged this for watching: the description shows "
+ ", ".join(t for t, on in [("asymmetry", asym), ("irregular borders", irreg),
("uneven colour", uneven)] if on)
+ " — features worth re-checking even though the area may look ordinary."
)
return findings
def medgemma_findings(image: Image.Image, occupation: str, hours: float, notes: str) -> Dict[str, Any]:
prompt = build_findings_prompt(occupation, hours, notes)
try:
raw = run_model(image, prompt)
except Exception as exc:
print(f"[chhaya] inference error: {exc}")
if "quota" in str(exc).lower():
raise QuotaExceeded(str(exc))
return demo_findings(image)
try:
return abcde_safety_backstop(extract_json(raw))
except Exception:
print(f"[chhaya] JSON parse failed; raw output: {raw[:800]!r}")
try:
raw2 = run_model(image, prompt + '\n\nIMPORTANT: reply with ONLY the JSON object. Your first character must be "{".')
return abcde_safety_backstop(extract_json(raw2))
except Exception as exc:
print(f"[chhaya] strict retry failed ({exc}); raw: {locals().get('raw2', '')[:800]!r}")
return prose_findings(raw)
def demo_findings(image: Image.Image) -> Dict[str, Any]:
"""Heuristic fallback so the app runs anywhere (no GPU, no token)."""
arr = np.asarray(image.convert("RGB").resize((224, 224))).astype(np.float32)
r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2]
redness = float(np.mean(r - (g + b) / 2.0))
contrast = float(arr.std())
sun_signals = []
if redness > 18:
sun_signals.append("noticeable redness — could be sunburn or irritation")
if contrast > 70:
sun_signals.append("uneven tone or texture variation")
return {
"what_i_see": "A patch of skin photographed in available light (demo analysis — connect MedGemma for the real reading).",
"spot": {
"type": "demo estimate from colour statistics only",
"color": f"average redness index {redness:.0f}",
"borders": "not assessed in demo mode",
"symmetry": "not assessed in demo mode",
"texture": f"contrast proxy {contrast:.0f}",
},
"heat_sun_signals": sun_signals,
"concern": "watch" if redness > 18 else "low",
"concern_reason": "Demo mode uses simple colour heuristics, not a medical model.",
"image_quality": "limited",
"summary": "This is a demo reading. On the live Space, MedGemma describes the spot so you can share it with a doctor.",
}
# ---------------------------------------------------------------------------
# Curated guidance — the deterministic "medicine" layer
# Sources: NDMA India heat-action guidance, WHO heat-health advice,
# Cancer Council ABCDE skin self-check criteria.
# ---------------------------------------------------------------------------
OCCUPATIONS = {
"Auto / cab / truck driver": {
"icon": "🛺",
"tips": [
"The window-side arm and face get far more UV — drivers often show more sun damage on one side. Keep that window's sun film/visor down and cover the right arm with a light sleeve.",
"Park in shade whenever possible; a closed cab heats 10–15°C above outside temperature within minutes.",
"Keep a 2-litre bottle in the cab and finish it across the shift — refill at every fuel or food stop.",
],
},
"Delivery rider": {
"icon": "🛵",
"tips": [
"Wear a breathable cotton layer or neck gaiter under the helmet — it cuts sun on the neck and keeps sweat from dripping.",
"Drink a few sips between every order, not just at breaks. Thirst comes after dehydration has started.",
"Forearms and the back of the neck burn first on a bike — full-sleeve light clothing beats sunscreen you can't reapply.",
],
},
"Construction worker": {
"icon": "👷",
"tips": [
"Ask for the heaviest tasks before 11 am or after 4 pm — NDMA guidance asks sites to reschedule peak-heat work.",
"Use the buddy system: watch a co-worker for confusion, stumbling, or stopped sweating. Those are emergency signs.",
"Keep ORS sachets at the site water point; one glass every 1–2 hours during heavy work in heat.",
],
},
"Street vendor": {
"icon": "🧺",
"tips": [
"Rig shade over the stall (umbrella, tarpaulin) and sit with your back to the sun's path — your face and chest get less direct UV.",
"Keep drinking water out of the sun and sip every 20–30 minutes, even without thirst.",
"A wet gamchha/cotton cloth on the neck cools the blood flowing to the head — re-wet it each hour.",
],
},
"Farmer / field worker": {
"icon": "🌾",
"tips": [
"Shift field work to early morning and late afternoon; rest in shade through the 12–3 pm peak.",
"A wide-brim hat or gamchha shades the ears and neck — common spots for sun damage that people never check.",
"Carry buttermilk (chaas), lemon-salt-sugar water (shikanji), or ORS to the field — they replace salt that plain water doesn't.",
],
},
"Other outdoor work": {
"icon": "🌤️",
"tips": [
"Plan the day so the hardest work avoids 12–3 pm direct sun.",
"Light-coloured, loose, full-sleeve cotton beats bare skin: it is cooler and blocks UV all day without reapplying anything.",
"Sip water every 20–30 minutes in heat — small and often beats large and rare.",
],
},
}
WATER_ACCESS = ["Easy — can refill anytime", "Limited — carry what I take", "Hard — long gaps without water"]
def hydration_plan(hours: float, water_access: str) -> List[str]:
plan = []
litres = min(1.0 + hours * 0.5, 5.0)
plan.append(f"Across a {hours:.0f}-hour sun shift, aim for roughly **{litres:.1f} litres** of fluid — sip every 20–30 minutes; don't wait for thirst.")
if hours >= 4:
plan.append("Add **one glass of ORS, shikanji, or chaas** mid-shift — heavy sweating loses salt that plain water can't replace.")
if "Limited" in water_access:
plan.append("Carry the full amount in the morning: a frozen bottle stays cool past midday and becomes drinking water as it melts.")
if "Hard" in water_access:
plan.append("Front-load: drink 500 ml before starting, and map every tap/stall on your route as a refill point. Going hours without water in heat is the single biggest heatstroke risk.")
plan.append("Skip alcohol and limit tea/coffee during peak heat — both push water out of the body.")
return plan
def protection_plan(hours: float) -> List[str]:
plan = [
"Light-coloured, loose, **full-sleeve cotton** + head cover (cap, gamchha, or helmet liner). Cloth is sunscreen that never wears off.",
"If skin stays exposed, **SPF 30+ sunscreen or zinc cream** on face, neck, and forearms; reapply about every 3 hours.",
]
if hours >= 5:
plan.append("Long sun days age and damage skin fastest between **12 and 3 pm** — move breaks, meals, and paperwork into that window.")
plan.append("Once a month, check your own skin in a mirror — especially ears, neck, forearms, and hands. Photograph any spot that is new, growing, or oddly shaped, and bring the photos here.")
return plan
CONCERN_META = {
"low": ("✅", "Looks ordinary", "concern-low",
"Nothing alarming visible. Keep up sun protection and re-check your skin monthly."),
"watch": ("👀", "Worth watching", "concern-watch",
"Photograph this same spot again in 3–4 weeks in similar light. If it grows, darkens, or changes shape, show a doctor."),
"see_doctor": ("🩺", "Show a doctor", "concern-doctor",
"Take this report and photo to a primary-care doctor or skin specialist soon — changing or irregular spots are best checked early, when treatment is simplest."),
}
URGENT_SIGNS = [
"A spot that bleeds, weeps, or never heals",
"A spot growing or changing colour over weeks",
"Sunburn with blisters covering a large area",
"Fever or spreading redness around any skin problem",
]
# ---------------------------------------------------------------------------
# Heat symptom checker — fully deterministic triage
# ---------------------------------------------------------------------------
MODERATE_SYMPTOMS = [
"Heavy sweating",
"Muscle cramps",
"Headache",
"Dizziness or light-headedness",
"Nausea",
"Unusual tiredness or weakness",
"Very thirsty, dark urine",
]
DANGER_SYMPTOMS = [
"Confusion or strange behaviour",
"Fainted or nearly fainted",
"Skin hot and DRY (sweating has stopped)",
"Body feels very hot to touch",
"Vomiting again and again",
"Very fast heartbeat or breathing",
]
def heat_triage(moderate: List[str], danger: List[str]) -> Tuple[str, str, List[str]]:
if danger:
return (
"🚨", "Possible heatstroke — act now",
[
"**Call 108 / 112 (ambulance) immediately.** Heatstroke can be fatal within the hour.",
"Move the person to shade or any cool room; remove extra clothing.",
"Cool fast: wet cloths or water on the body, fan them, ice/cold bottles at neck, armpits, and groin.",
"If fully awake, small sips of water. **Nothing by mouth if drowsy or confused.**",
"Do not leave them alone, and do not wait to 'see if it improves'.",
],
)
if len(moderate) >= 3:
return (
"⚠️", "Looks like heat exhaustion",
[
"Stop work now and rest in shade or a cool room for at least 30 minutes.",
"Sip ORS, shikanji, chaas, or water steadily — not all at once.",
"Loosen clothing; put wet cloths on the neck and face.",
"**If not clearly better within 30 minutes, or any danger sign appears, go to a hospital.**",
"Skip the rest of today's sun work — a second episode on the same day hits harder.",
],
)
if "Muscle cramps" in moderate:
return (
"💧", "Heat cramps — salt and water loss",
[
"Rest in shade; gently stretch the cramping muscle.",
"Drink ORS or shikanji (salt + sugar + water) — plain water alone won't fix cramps.",
"Wait until cramps fully stop before returning to heavy work.",
],
)
if moderate:
return (
"🌿", "Early heat strain",
[
"Take a shade break now; drink a full glass of water.",
"Sip every 20–30 minutes for the rest of the shift.",
"Re-run this check if anything new appears — early action prevents heat exhaustion.",
],
)
return (
"✅", "No heat-illness signs selected",
[
"Keep sipping water through the shift and use shade during 12–3 pm.",
"Learn the danger signs anyway — you may be the one who spots them in a co-worker.",
],
)
# ---------------------------------------------------------------------------
# HTML renderers
# ---------------------------------------------------------------------------
def _list_html(items: List[str]) -> str:
return "".join(f"
{md_bold(i)}
" for i in items)
def md_bold(text: str) -> str:
return re.sub(r"\*\*(.+?)\*\*", r"\1", str(text))
def render_findings(f: Dict[str, Any], occupation: str, hours: float, water_access: str) -> str:
concern = str(f.get("concern", "watch")).strip().lower()
if concern not in CONCERN_META:
concern = "watch"
icon, label, css_class, advice = CONCERN_META[concern]
spot = f.get("spot", {}) or {}
signals = f.get("heat_sun_signals", []) or []
occ = OCCUPATIONS.get(occupation, OCCUPATIONS["Other outdoor work"])
spot_rows = "".join(
f"
{key.title()}
{spot.get(key, '—')}
"
for key in ["type", "color", "borders", "symmetry", "texture"]
)
signals_html = (
"
"
+ "".join(f"☀ {md_bold(s)}" for s in signals)
+ "
"
if signals
else "
No obvious sun or heat damage signs in this photo.
"
)
quality_note = (
"
Image quality limited this reading — retake in daylight, close and steady, if you can.
Based on NDMA / WHO heat-illness first-aid guidance. When unsure, treat it as the more serious condition.
"""
def render_history(entries: List[Dict[str, Any]]) -> str:
if not entries:
return """
No saved checks yet.
Run a skin check and press Save to my record —
comparing the same spot across weeks is how change gets caught early.
"""
cards = ""
for e in reversed(entries):
icon, label, css_class, _ = CONCERN_META[e["concern"]]
cards += f"""
{e['body_part']}
{e['date']}
{label}
{e['summary']}
"""
return f"
{cards}
"
def thumb_data_url(image: Image.Image) -> str:
import base64
small = image.convert("RGB").copy()
small.thumbnail((280, 280))
buf = io.BytesIO()
small.save(buf, format="JPEG", quality=80)
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
# ---------------------------------------------------------------------------
# Read-aloud (Kokoro-82M, CPU — never touches ZeroGPU quota)
# The plan should reach workers who don't read English easily, so every
# verdict can be spoken. TTS only voices text Chhaya already produced — it
# adds no new medical content. English now; Hindi voice wiring is in place.
# ---------------------------------------------------------------------------
try:
import importlib.util as _ilu
_HAS_TTS = _ilu.find_spec("kokoro") is not None
except Exception:
_HAS_TTS = False
_tts_pipelines: Dict[str, Any] = {}
_TTS_VOICE = {"en": ("a", "af_heart"), "hi": ("h", "hf_alpha")} # lang -> (kokoro lang_code, voice)
def _strip_md(s: str) -> str:
return re.sub(r"\*\*(.+?)\*\*", r"\1", str(s)).replace("—", ", ").strip()
# Hindi guidance is hand-translated (the deterministic layer only — never the
# model's per-photo description). Numbers are spelled in words; no Latin/digits,
# which Kokoro's Hindi G2P reads most reliably.
_HI_CONCERN = {
"low": ("सामान्य दिखता है",
"कुछ ख़ास चिंता की बात नहीं दिखती। धूप से बचाव करते रहें और महीने में एक बार अपनी त्वचा देखें।"),
"watch": ("ध्यान देने योग्य",
"इसी जगह की फ़ोटो तीन से चार हफ़्ते बाद उसी रोशनी में दोबारा लें। अगर यह बढ़े, गहरा हो, या आकार बदले, तो डॉक्टर को दिखाएँ।"),
"see_doctor": ("डॉक्टर को दिखाएँ",
"यह रिपोर्ट और फ़ोटो जल्द किसी डॉक्टर या त्वचा विशेषज्ञ को दिखाएँ। बदलते या असामान्य दाग़ को जल्दी जँचवाना सबसे अच्छा है।"),
}
_HI_HYDRATION = ("अपनी धूप की पाली में हर बीस से तीस मिनट में थोड़ा-थोड़ा पानी पिएँ, प्यास का इंतज़ार न करें। "
"ज़्यादा पसीना आने पर ओआरएस, शिकंजी या छाछ लें।")
_HI_PROTECTION = ("हल्के रंग के, ढीले, पूरी बाँह के सूती कपड़े पहनें और सिर ढकें। खुली त्वचा पर सनस्क्रीन लगाएँ। "
"दोपहर बारह से तीन बजे की तेज़ धूप में छाँव में रहें।")
_HI_DISCLAIMER = ("याद रखें, छाया सिर्फ़ देखती है, बीमारी नहीं बताती। अगर त्वचा पर कुछ चिंता का कारण लगे, तो डॉक्टर को ज़रूर दिखाएँ।")
# Heat triage in Hindi, keyed by the icon heat_triage() returns.
_HI_TRIAGE = {
"🚨": ("संभावित हीटस्ट्रोक — तुरंत क़दम उठाएँ", [
"तुरंत एक सौ आठ या एक सौ बारह पर एम्बुलेंस बुलाएँ। हीटस्ट्रोक एक घंटे में जानलेवा हो सकता है।",
"व्यक्ति को छाँव या किसी ठंडे कमरे में ले जाएँ और अतिरिक्त कपड़े हटाएँ।",
"तेज़ी से ठंडा करें — शरीर पर गीला कपड़ा या पानी डालें, हवा करें, और गर्दन, बग़ल तथा जाँघ के पास बर्फ़ या ठंडी बोतल रखें।",
"अगर व्यक्ति पूरी तरह होश में है तो थोड़ा-थोड़ा पानी दें। बेहोशी या भ्रम की हालत में मुँह से कुछ न दें।",
"उन्हें अकेला न छोड़ें और सुधार का इंतज़ार न करें।",
]),
"⚠️": ("लगता है गर्मी से थकावट है", [
"अभी काम रोकें और कम से कम तीस मिनट छाँव या ठंडे कमरे में आराम करें।",
"ओआरएस, शिकंजी, छाछ या पानी धीरे-धीरे पिएँ, एक साथ नहीं।",
"कपड़े ढीले करें और गर्दन तथा चेहरे पर गीला कपड़ा रखें।",
"अगर तीस मिनट में साफ़ सुधार न हो, या कोई ख़तरे का संकेत दिखे, तो अस्पताल जाएँ।",
"आज बाक़ी धूप का काम छोड़ दें — एक ही दिन में दूसरी बार और भारी पड़ता है।",
]),
"💧": ("गर्मी से ऐंठन — नमक और पानी की कमी", [
"छाँव में आराम करें और ऐंठन वाली मांसपेशी को धीरे से खींचें।",
"ओआरएस या शिकंजी पिएँ — सिर्फ़ सादा पानी ऐंठन ठीक नहीं करता।",
"ऐंठन पूरी तरह बंद होने तक भारी काम पर न लौटें।",
]),
"🌿": ("गर्मी का शुरुआती असर", [
"अभी छाँव में रुकें और एक पूरा गिलास पानी पिएँ।",
"बाक़ी पाली में हर बीस से तीस मिनट में थोड़ा पानी पिएँ।",
"अगर कुछ नया महसूस हो तो यह जाँच दोबारा करें — जल्दी कदम उठाने से थकावट टलती है।",
]),
"✅": ("कोई गर्मी-बीमारी का संकेत नहीं चुना गया", [
"पाली भर पानी पीते रहें और दोपहर बारह से तीन बजे छाँव का इस्तेमाल करें।",
"ख़तरे के संकेत फिर भी सीखें — हो सकता है किसी साथी में आप ही उन्हें पहचानें।",
]),
}
def speech_for_findings(f: Dict[str, Any], occupation: str, hours: float, water_access: str, lang: str = "en") -> str:
concern = str(f.get("concern", "watch")).lower()
if concern not in CONCERN_META:
concern = "watch"
if lang == "hi":
label_hi, advice_hi = _HI_CONCERN[concern]
parts = [f"छाया की रिपोर्ट। {label_hi}।", advice_hi, _HI_HYDRATION, _HI_PROTECTION, _HI_DISCLAIMER]
return " ".join(parts)
_, label, _, advice = CONCERN_META[concern]
hyd = hydration_plan(hours, water_access)
prot = protection_plan(hours)
parts = [
f"Chhaya's reading. {label}.",
_strip_md(f.get("concern_reason", "")),
"What I see. " + _strip_md(f.get("what_i_see", "")),
_strip_md(advice),
("For hydration. " + _strip_md(hyd[0])) if hyd else "",
("For protection. " + _strip_md(prot[0])) if prot else "",
"Remember, Chhaya describes — it never diagnoses. If a spot worries you, see a doctor.",
]
return " ".join(p for p in parts if p)
def speech_for_triage(title: str, steps: List[str], icon: str = "", lang: str = "en") -> str:
if lang == "hi" and icon in _HI_TRIAGE:
title_hi, steps_hi = _HI_TRIAGE[icon]
return " ".join([title_hi + "।"] + steps_hi)
return " ".join([title + "."] + [_strip_md(s) for s in steps[:5]])
def synthesize_speech(text: str, lang: str = "en"):
"""Return (sample_rate, np.ndarray) for gr.Audio, or None on any failure."""
if not _HAS_TTS or not text or not text.strip():
return None
try:
from kokoro import KPipeline
lang_code, voice = _TTS_VOICE.get(lang, _TTS_VOICE["en"])
if lang_code not in _tts_pipelines:
# Force CPU: on ZeroGPU torch.cuda.is_available() is True (emulated),
# so Kokoro would try a CUDA init outside any @spaces.GPU context and crash.
_tts_pipelines[lang_code] = KPipeline(
lang_code=lang_code, repo_id="hexgrad/Kokoro-82M", device="cpu"
)
pipe = _tts_pipelines[lang_code]
chunks = [
(audio.detach().cpu().numpy() if hasattr(audio, "detach") else np.asarray(audio))
for _, _, audio in pipe(text, voice=voice, speed=1.0)
]
return (24000, np.concatenate(chunks)) if chunks else None
except Exception as exc:
print("[chhaya] tts error:", exc)
return None
def _lang_code(label) -> str:
"""Map the language radio's display value to a TTS lang code."""
return "hi" if label and ("ह" in str(label) or str(label).lower().startswith("hi")) else "en"
def read_findings_aloud(state, lang_label):
code = _lang_code(lang_label)
speak = (state or {}).get("speak", "")
text = speak.get(code, "") if isinstance(speak, dict) else speak
audio = synthesize_speech(text, code)
return gr.update(value=audio, visible=audio is not None)
def read_triage_aloud(speak, lang_label):
code = _lang_code(lang_label)
text = speak.get(code, "") if isinstance(speak, dict) else (speak or "")
audio = synthesize_speech(text, code)
return gr.update(value=audio, visible=audio is not None)
def _listen_busy():
return gr.update(value="⏳ Preparing audio…", interactive=False)
def _listen_idle():
return gr.update(value="🔊 Listen", interactive=True)
# ---------------------------------------------------------------------------
# App actions
# ---------------------------------------------------------------------------
BODY_PARTS = [
"Face", "Ear", "Neck", "Scalp", "Shoulder", "Chest", "Back",
"Right arm", "Left arm", "Hand", "Leg", "Foot", "Other",
]
_HIDE_AUDIO = lambda: gr.update(value=None, visible=False)
def analyze(image, occupation, hours, water_access, body_part, notes, state):
if image is None:
return (
"""
No photo yet.
Add one first — bright daylight, close and steady.
This Space runs on shared free GPUs, and this browser's free
quota is spent. Sign in to Hugging Face (top of the Space page) for a
bigger free quota, or come back a little later. Your photo was not analyzed.
""",
state, gr.update(visible=False), gr.update(visible=False), _HIDE_AUDIO(), gr.update(visible=False),
)
else:
findings = demo_findings(pil)
concern = str(findings.get("concern", "watch")).lower()
if concern not in CONCERN_META:
findings["concern"] = "watch"
state = dict(state or {})
state["pending"] = {
"thumb": thumb_data_url(pil),
"body_part": body_part,
"date": time.strftime("%d %b %Y, %H:%M"),
"concern": findings.get("concern", "watch"),
"summary": str(findings.get("what_i_see", ""))[:200],
}
state["speak"] = {
"en": speech_for_findings(findings, occupation, hours, water_access, "en"),
"hi": speech_for_findings(findings, occupation, hours, water_access, "hi"),
}
html = render_findings(findings, occupation, hours, water_access)
return html, state, gr.update(visible=True), gr.update(visible=_HAS_TTS), _HIDE_AUDIO(), gr.update(visible=_HAS_TTS)
def save_to_record(state, history):
state = dict(state or {})
history = list(history or [])
pending = state.pop("pending", None)
if pending:
history.append(pending)
return state, history, render_history(history), gr.update(visible=False)
def run_heat_check(moderate, danger):
icon, title, steps = heat_triage(moderate or [], danger or [])
speak = {
"en": speech_for_triage(title, steps, icon, "en"),
"hi": speech_for_triage(title, steps, icon, "hi"),
}
return (render_triage(icon, title, steps), speak,
gr.update(visible=_HAS_TTS), _HIDE_AUDIO(), gr.update(visible=_HAS_TTS))
# ---------------------------------------------------------------------------
# Mascot + CSS
# ---------------------------------------------------------------------------
MASCOT_SVG = """
"""
def _asset_data_url(name: str, mime: str = "image/jpeg") -> str:
"""Inline a small repo asset as a data URL (keeps the Space self-contained)."""
import base64
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", name)
try:
with open(path, "rb") as fh:
return f"data:{mime};base64," + base64.b64encode(fh.read()).decode()
except OSError:
return ""
SHADE_ILLO = _asset_data_url("shade-rest.jpg")
HERO_SCENE = _asset_data_url("hero-scene.jpg")
# ---------------------------------------------------------------------------
# Preloaded sample cases — named workers with a real skin photo + work-day
# context, so anyone can go landing -> real verdict in one click. Skin photos
# are from our public dataset (ISIC + Google SCIN, open-license); names/jobs
# are fictional. occupation/water/body_part values match the lists above.
# ---------------------------------------------------------------------------
CASES = [
{"name": "Kannan", "role": "Bike taxi", "img": "cases/case1.jpg",
"occupation": "Delivery rider", "hours": 8, "water": WATER_ACCESS[1],
"body_part": "Right arm", "note": "Red spots on my forearm, been there a while."},
{"name": "Ramesh", "role": "Auto driver", "img": "cases/case2.jpg",
"occupation": "Auto / cab / truck driver", "hours": 9, "water": WATER_ACCESS[1],
"body_part": "Shoulder", "note": "Right side gets the window sun all day."},
{"name": "Lakshmi", "role": "Street vendor", "img": "cases/case3.jpg",
"occupation": "Street vendor", "hours": 7, "water": WATER_ACCESS[0],
"body_part": "Foot", "note": "A mark on my foot I keep noticing."},
{"name": "Govind", "role": "Farmer", "img": "cases/case4.jpg",
"occupation": "Farmer / field worker", "hours": 10, "water": WATER_ACCESS[2],
"body_part": "Back", "note": "Big red patch on my lower back."},
]
def _case_image(i: int) -> Optional[Image.Image]:
"""Load a sample-case skin photo as PIL for the gr.Image input."""
try:
c = CASES[i]
except (IndexError, TypeError):
return None
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", c["img"])
try:
return Image.open(path).convert("RGB")
except OSError:
return None
def load_case(i: int):
"""Prefill the six skin-check inputs from sample case i (image, occupation,
hours, water, body part, note) — in analyze()'s input order."""
c = CASES[i]
return (
_case_image(i), c["occupation"], c["hours"],
c["water"], c["body_part"], c["note"],
)
def cases_cards_html() -> str:
"""A horizontal strip of clickable persona cards. Each card triggers the
matching hidden gr.Button via the page JS (same pattern as the body picker)."""
cards = []
for i, c in enumerate(CASES):
thumb = _asset_data_url(c["img"])
cards.append(
f''
)
return (
'
Snap a photo of your skin. Chhaya describes what it sees, tells you when a spot
deserves a doctor, and builds a hydration & protection plan around your work day.
Describes · never diagnosesMedGemma-1.5-4BFree · photos never stored
""")
session_state = gr.State({})
history_state = gr.State([])
with gr.Tab("Skin check"):
with gr.Row():
with gr.Column(scale=5):
gr.HTML('
01The photo — daylight, close, steady
')
image_input = gr.Image(
show_label=False, sources=["upload", "webcam"],
type="pil", height=300, elem_id="skin-photo",
)
gr.HTML(cases_cards_html())
case_triggers = [
gr.Button(c["name"], visible=True, elem_id=f"case-trigger-{i}")
for i, c in enumerate(CASES)
]
gr.HTML('
02Where on the body?
')
gr.HTML(BODY_PICKER_HTML)
body_part = gr.Radio(BODY_PARTS, value="Right arm", show_label=False, elem_id="body-pills")
notes_input = gr.Textbox(
label="Anything you noticed? (optional)",
placeholder="e.g. new spot, itches, been there for months…", lines=2,
)
gr.HTML('
03Your work day
')
occupation = gr.Radio(
list(OCCUPATIONS.keys()), value="Auto / cab / truck driver",
show_label=False, elem_id="occ-tiles",
)
hours_sun = gr.Slider(
1, 12, value=6, step=1, elem_id="sun-hours",
label="Hours in direct sun per day",
)
water_access = gr.Radio(
WATER_ACCESS, value=WATER_ACCESS[1],
label="Water access during work", elem_id="water-tiles",
)
analyze_btn = gr.Button("Check my skin", variant="primary", size="lg")
with gr.Column(scale=7):
results_html = gr.HTML(EMPTY_RESULT_HTML)
with gr.Row(elem_id="listen-row-skin"):
listen_btn = gr.Button("🔊 Listen", visible=False, size="sm", elem_id="listen-skin")
lang_skin = gr.Radio(
["English", "हिंदी"], value="English", show_label=False,
container=False, visible=False, elem_id="lang-skin",
)
save_btn = gr.Button("Save to my record", visible=False, elem_id="save-skin")
result_audio = gr.Audio(
visible=False, autoplay=True, show_label=False,
elem_id="result-audio",
)
with gr.Tab("Heat help"):
gr.HTML("""
Feeling unwell in the heat — you or a co-worker?
Tap what is happening right now. Pure first-aid logic from NDMA / WHO guidance — no AI in the loop, because emergencies are not the place for sampling.
""")
with gr.Row():
with gr.Column():
moderate_box = gr.CheckboxGroup(MODERATE_SYMPTOMS, label="Common signs", elem_id="mod-symptoms")
with gr.Column():
danger_box = gr.CheckboxGroup(DANGER_SYMPTOMS, label="Danger signs", elem_id="danger-symptoms")
triage_btn = gr.Button("What should I do?", variant="primary", size="lg")
triage_html = gr.HTML()
with gr.Row(elem_id="listen-row-heat"):
listen_btn_heat = gr.Button("🔊 Listen", visible=False, size="sm", elem_id="listen-heat")
lang_heat = gr.Radio(
["English", "हिंदी"], value="English", show_label=False,
container=False, visible=False, elem_id="lang-heat",
)
heat_audio = gr.Audio(
visible=False, autoplay=True, show_label=False,
elem_id="heat-audio",
)
heat_speak_state = gr.State("")
with gr.Tab("My record"):
gr.HTML("""
Saved skin checks · this session
The spot that changes is the spot that matters. Re-photograph the same spots every few weeks and compare.
""")
history_html = gr.HTML(render_history([]))
with gr.Tab("About"):
gr.HTML(f"""
Why Chhaya exists
Hundreds of millions of people — drivers, delivery riders, construction workers, street vendors,
farmers — spend their working lives in direct sun, and heat waves are making those hours harsher every
year. They are the least likely to get a skin check and the most likely to need one. Chhaya is a small,
free tool for exactly those hours: it looks at skin the way a careful friend would, explains what it
sees in plain words, and turns heat-safety guidance into a plan that fits a real working day.
How it is built (honestly)
The model is the eyes, the guidelines are the medicine. Google's
MedGemma-1.5-4B — a 4-billion-parameter medical vision-language model — reads the photo
and returns a structured description (type, colour, borders, symmetry, texture, concern level). Every
piece of medical guidance — hydration amounts, heat first-aid steps, when to see a doctor — is curated,
deterministic content from NDMA India heat-action guidance, WHO heat-health advice, and Cancer Council
ABCDE skin self-check criteria. The model never invents medical numbers.
Inspired by Sunny by Daniel Bourke —
a MedGemma skin tracker for Australians — reimagined for outdoor workers facing heat waves.
Built with Gradio on Hugging Face Spaces (ZeroGPU) for the Build Small Hackathon.
The sample-worker photos are open-license skin images from the ISIC archive and
Google's SCIN dataset; the names and jobs are fictional.
What Chhaya is not
Not a medical device, not a diagnosis, not a substitute for a doctor. Photos are processed in
memory and never stored on the server; your record lives only in your browser session. If something
on your skin worries you, that worry alone is reason enough to see a clinician.