"""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.

    " if str(f.get("image_quality", "good")).lower() == "limited" else "" ) return f"""
    {label}

    {f.get('concern_reason', '')}

    {advice}

    What Chhaya sees

    {f.get('what_i_see', '')}

    {spot_rows}
    {quality_note}
    Sun & heat signals
    {signals_html}
    Hydration for your shift
      {_list_html(hydration_plan(hours, water_access))}
    Protection that fits
      {_list_html(protection_plan(hours))}
    For your work · {occupation}
      {_list_html(occ['tips'])}
    See a doctor without waiting if
      {_list_html(URGENT_SIGNS)}
    Note for your doctor

    “{f.get('summary', '')}”

    Chhaya describes — it never diagnoses. This report and photo are meant to be shown to a clinician.

    """ def render_triage(icon: str, title: str, steps: List[str]) -> str: cls = "concern-doctor" if icon == "🚨" else ("concern-watch" if icon == "⚠️" else "concern-low") return f"""
    {title}
      {_list_html(steps)}

    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"""
    saved skin photo
    {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.

    """, state, gr.update(visible=False), gr.update(visible=False), _HIDE_AUDIO(), gr.update(visible=False), ) pil = image.convert("RGB") if isinstance(image, Image.Image) else Image.fromarray(np.asarray(image)).convert("RGB") if _backend == "medgemma": try: findings = medgemma_findings(pil, occupation, hours, notes) except QuotaExceeded: return ( """
    Out of GPU minutes

    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 ( '
    Or try a sample — tap a worker
    ' '
    ' + "".join(cards) + "
    " ) # Seamless paper-grain tile (SVG turbulence) — gives the flat sand a matte texture. _PAPER_NOISE = ( "url(\"data:image/svg+xml;utf8," "" "" "" "\")" ) CSS = """ @import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,560;9..144,680&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@500;600&display=swap'); :root { --sand: #FBF4E6; --sand-deep: #F3E7CF; --ink: #2A2118; --ink-soft: #5A4B38; --teal: #1F5F5B; --teal-deep: #174946; --sun: #F5A623; --dusk: #E8763A; --card: #FFFDF8; --line: #E7D9BC; --mono: 'IBM Plex Mono', ui-monospace, monospace; } /* ---- the page is the sun; the app sits in the shade ---- */ body { background: var(--sand) !important; } .gradio-container { font-family: 'Inter', ui-sans-serif, system-ui, sans-serif !important; background: __PAPER_NOISE__, radial-gradient(1100px 480px at 78% -180px, rgba(245,166,35,0.30), rgba(245,166,35,0) 62%), linear-gradient(180deg, #F8E9C8 0%, var(--sand) 520px) !important; background-size: 180px 180px, auto, auto !important; color: var(--ink); max-width: 1120px !important; margin: 0 auto !important; padding-bottom: 0 !important; } /* ---- awning: striped teal canopy with scalloped edge ---- */ #awning { height: 26px; margin: 0 -8px; background: repeating-linear-gradient(90deg, var(--teal) 0 46px, var(--teal-deep) 46px 92px); border-radius: 0 0 4px 4px; position: relative; } #awning::after { content: ""; position: absolute; left: 0; right: 0; top: 100%; height: 14px; background: radial-gradient(circle at 23px -4px, var(--teal) 22px, transparent 23px) repeat-x; background-size: 92px 18px; filter: drop-shadow(0 3px 3px rgba(90,70,40,0.18)); } /* ---- hero: full-bleed illustrated shade scene ---- */ #hero { position: relative; margin: 0 -8px; min-height: 380px; padding: 52px 44px 40px; display: flex; align-items: center; overflow: hidden; border-radius: 0 0 18px 18px; isolation: isolate; background-image: linear-gradient(90deg, var(--sand) 0%, rgba(251,244,230,0.92) 34%, rgba(251,244,230,0.45) 56%, rgba(251,244,230,0) 74%), url("__HERO_SCENE__"); background-size: cover, cover; background-position: center, right center; background-repeat: no-repeat, no-repeat; } #hero::after { /* paper grain over the scene so it matches the page */ content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; background-image: __PAPER_NOISE__; background-size: 180px 180px; opacity: 0.6; } #hero .hero-text { position: relative; max-width: 600px; } #hero .mascot-wrap { display: none; } #hero .mascot-sun { animation: floaty 4.5s ease-in-out infinite; transform-origin: 62px 74px; } @keyframes floaty { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } } #hero h1 { font-family: 'Fraunces', serif; font-weight: 680; font-size: clamp(38px, 5.4vw, 58px); line-height: 1.04; margin: 0 0 14px; color: var(--ink); letter-spacing: -1px; } #hero h1 em { font-style: italic; color: var(--teal); } #hero .wordmark { font-family: var(--mono); font-size: 13px; font-weight: 600; letter-spacing: 3px; text-transform: uppercase; color: var(--dusk); margin-bottom: 18px; display: block; line-height: 1.7; } #hero .wordmark .hindi { letter-spacing: 0; font-family: 'Fraunces', serif; font-size: 15px; margin: 0 4px; color: var(--dusk) !important; } #hero p.sub { margin: 0; font-size: 16.5px; color: var(--ink-soft); max-width: 560px; line-height: 1.55; } #hero p.sub em { color: var(--teal-deep) !important; } .chip-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } .chip { font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: 1.2px; text-transform: uppercase; padding: 5px 11px; border-radius: 999px; border: 1.5px solid var(--ink); color: var(--ink); background: transparent; } .chip.chip-accent { background: var(--ink); color: var(--sand); } @media (max-width: 700px) { #hero { min-height: 340px; padding: 28px 22px 24px; align-items: flex-start; background-image: linear-gradient(180deg, rgba(251,244,230,0.96) 0%, rgba(251,244,230,0.7) 52%, rgba(251,244,230,0.2) 100%), url("__HERO_SCENE__"); background-position: center, center bottom; } #hero .wordmark { letter-spacing: 2px; } } /* ---- tabs ---- */ .tab-nav { border-bottom: 2px solid var(--line) !important; } .tab-nav button { font-family: var(--mono) !important; font-size: 12.5px !important; font-weight: 600 !important; letter-spacing: 1.6px !important; text-transform: uppercase !important; color: var(--ink-soft) !important; border-radius: 0 !important; } .tab-nav button.selected { color: var(--teal) !important; border-bottom: 3px solid var(--dusk) !important; background: transparent !important; } .tab-container[role="tablist"] button[role="tab"] { font-family: var(--mono) !important; font-size: 12.5px !important; font-weight: 600 !important; letter-spacing: 1.6px !important; text-transform: uppercase !important; color: var(--ink-soft) !important; opacity: 1 !important; border-radius: 0 !important; background: transparent !important; } .tab-container[role="tablist"] button[role="tab"].selected, .tab-container[role="tablist"] button[role="tab"][aria-selected="true"] { color: var(--dusk) !important; border-bottom: 3px solid var(--dusk) !important; } /* ---- step kickers + section labels ---- */ .kicker { font-family: var(--mono); font-size: 11.5px; font-weight: 600; letter-spacing: 2px; text-transform: uppercase; color: var(--teal); margin: 0 0 10px; display: flex; align-items: center; gap: 8px; } .kicker-sub { margin-top: 18px; } .step-kicker { font-family: var(--mono); font-size: 11.5px; font-weight: 600; letter-spacing: 2px; text-transform: uppercase; color: var(--ink-soft); margin: 18px 0 6px; padding: 0 2px; } .step-kicker .step-no { color: var(--dusk); margin-right: 8px; } /* ---- cards ---- */ .card { background: var(--card); border: 1.5px solid var(--line); border-radius: 16px; padding: 20px 22px; margin-bottom: 14px; box-shadow: 0 2px 10px rgba(90, 70, 40, 0.05); } .card .lede { font-size: 16px; line-height: 1.55; margin: 0 0 6px; } .card-list { margin: 0; padding-left: 19px; } .card-list li { margin-bottom: 8px; line-height: 1.55; font-size: 14.5px; } .card-list li:last-child { margin-bottom: 0; } .muted { color: #8A7A60; font-size: 13px; } .muted em, .step-kicker em { color: inherit !important; font-style: italic; } /* ---- verdict stamp ---- */ .verdict-card { border-width: 2px; padding-top: 24px; } .verdict-stamp { display: inline-block; font-family: var(--mono); font-weight: 600; font-size: 19px; letter-spacing: 3px; text-transform: uppercase; padding: 7px 16px; border: 3px solid currentColor; border-radius: 6px; transform: rotate(-2deg); margin: 0 0 14px 2px; opacity: 0.9; transform-origin: center; animation: stamp-down 0.5s cubic-bezier(0.2, 0.7, 0.25, 1) both; } /* the stamp slams down: drops in big + rotated, presses past, settles */ @keyframes stamp-down { 0% { opacity: 0; transform: rotate(-15deg) scale(2.3); } 55% { opacity: 0.9; transform: rotate(-2deg) scale(0.88); } 72% { opacity: 0.9; transform: rotate(-2deg) scale(1.07); } 100% { opacity: 0.9; transform: rotate(-2deg) scale(1); } } @media (prefers-reduced-motion: reduce) { .verdict-stamp { animation: none; } } .concern-reason { font-size: 14.5px; color: var(--ink-soft); margin: 0 0 8px; } .concern-advice { margin: 0; font-size: 15.5px; line-height: 1.55; } .concern-low { border-color: #9DC18B; background: #F6FAF0; } .concern-low .verdict-stamp, .concern-low .dot { color: #3E7A33; background-color: transparent; } .concern-watch { border-color: #E8B64C; background: #FDF7E8; } .concern-watch .verdict-stamp, .concern-watch .dot { color: #A4700E; } .concern-doctor { border-color: #E0764F; background: #FDF1EB; } .concern-doctor .verdict-stamp, .concern-doctor .dot { color: #B5431F; } /* ---- findings detail ---- */ .spot-table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 14px; } .spot-table td { padding: 7px 10px; border-top: 1px solid #F0E6D2; line-height: 1.45; } .spot-table .spot-key { font-family: var(--mono); font-size: 11.5px; font-weight: 600; letter-spacing: 1.5px; text-transform: uppercase; color: var(--ink-soft); width: 112px; } .signal-chips { display: flex; flex-wrap: wrap; gap: 8px; } .signal-chip { font-size: 13px; padding: 5px 12px; border-radius: 999px; background: #FDF1E4; border: 1px solid #EFC9A0; color: #8C5A22; line-height: 1.4; } .plan-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; } .plan-grid .card { margin-bottom: 0; } @media (max-width: 760px) { .plan-grid { grid-template-columns: 1fr; } } .card-urgent { border-color: #E8C2B0; background: #FEFAF6; } .card-urgent .kicker { color: #B5431F; } .card-summary { background: #F2F7F5; border-color: #CFE0DA; } .doctor-note { font-family: 'Fraunces', serif; font-size: 17px; font-style: italic; line-height: 1.5; margin: 0 0 8px; } .triage-steps { padding-left: 22px; } .triage-steps li { font-size: 15.5px; margin-bottom: 10px; } /* ---- gamified inputs: tiles, pills, sun slider ---- */ #occ-tiles .wrap, #water-tiles .wrap, #body-pills .wrap { gap: 8px !important; } #occ-tiles label, #water-tiles label { border: 1.5px solid var(--line) !important; background: var(--card) !important; border-radius: 12px !important; padding: 10px 14px !important; font-size: 13.5px !important; font-weight: 500 !important; color: var(--ink-soft) !important; opacity: 1 !important; box-shadow: 0 1px 3px rgba(90,70,40,0.05) !important; transition: transform .08s ease, border-color .12s ease !important; cursor: pointer; } #occ-tiles label span, #water-tiles label span, #body-pills label span, #mod-symptoms label span, #danger-symptoms label span { color: var(--ink-soft) !important; opacity: 1 !important; } #occ-tiles label:hover, #water-tiles label:hover, #body-pills label:hover { transform: translateY(-1px); border-color: #CBB98E !important; } #occ-tiles label:has(input:checked), #water-tiles label:has(input:checked), #occ-tiles label.selected, #water-tiles label.selected { border-color: var(--teal) !important; background: #EDF4F2 !important; color: var(--teal-deep) !important; box-shadow: inset 0 0 0 1px var(--teal), 0 1px 3px rgba(31,95,91,0.18) !important; } #occ-tiles label:has(input:checked) span, #water-tiles label:has(input:checked) span, #occ-tiles label.selected span, #water-tiles label.selected span { color: var(--teal-deep) !important; } #body-pills label { border: 1.5px solid var(--line) !important; background: var(--card) !important; border-radius: 999px !important; padding: 6px 14px !important; color: var(--ink-soft) !important; font-size: 13px !important; cursor: pointer; opacity: 1 !important; transition: transform .08s ease, border-color .12s ease !important; } #body-pills label:has(input:checked), #body-pills label.selected { border-color: var(--dusk) !important; background: var(--dusk) !important; color: #fff !important; } #body-pills label:has(input:checked) span, #body-pills label.selected span { color: #fff !important; } #occ-tiles input[type=radio], #water-tiles input[type=radio], #body-pills input[type=radio] { display: none !important; } /* ---- sample persona cards ---- */ #case-trigger-0, #case-trigger-1, #case-trigger-2, #case-trigger-3 { display: none !important; } .case-row { display: flex; gap: 10px; margin: 8px 0 2px; padding-bottom: 4px; overflow-x: auto; scrollbar-width: thin; } .case-card { flex: 0 0 auto; display: flex; align-items: center; gap: 10px; background: var(--card); border: 1.5px solid var(--line); border-radius: 14px; padding: 7px 12px 7px 7px; cursor: pointer; text-align: left; box-shadow: 0 1px 3px rgba(90,70,40,0.05); transition: transform .08s ease, border-color .12s ease, box-shadow .12s ease; } .case-card:hover { transform: translateY(-2px); border-color: var(--dusk); box-shadow: 0 4px 12px rgba(90,70,40,0.12); } .case-thumb { width: 46px; height: 46px; object-fit: cover; border-radius: 10px; flex: 0 0 auto; } .case-text { display: flex; flex-direction: column; line-height: 1.2; } .case-name { font-family: 'Fraunces', serif; font-weight: 650; font-size: 15px; color: var(--ink); } .case-role { font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; color: var(--ink-soft); margin-top: 2px; } /* ---- icon glyphs on the work-day + symptom choices ---- */ /* Custom flat glyphs (not brand logos) — they also help low-literacy users parse each choice. Radios carry data-testid; checkboxes use source order. */ #occ-tiles label, #water-tiles label, #mod-symptoms label, #danger-symptoms label { display: inline-flex !important; align-items: center; gap: 9px; } #occ-tiles label::before, #water-tiles label::before { font-size: 17px; line-height: 1; filter: saturate(0.9); } #mod-symptoms label::before, #danger-symptoms label::before { font-size: 15px; line-height: 1; } #occ-tiles label[data-testid^="Auto"]::before { content: "\\1F6FA"; } /* auto-rickshaw */ #occ-tiles label[data-testid^="Delivery"]::before { content: "\\1F6F5"; } /* scooter */ #occ-tiles label[data-testid^="Construction"]::before { content: "\\1F477"; } /* worker */ #occ-tiles label[data-testid^="Street"]::before { content: "\\1F9FA"; } /* basket */ #occ-tiles label[data-testid^="Farmer"]::before { content: "\\1F33E"; } /* wheat */ #occ-tiles label[data-testid^="Other"]::before { content: "\\1F324"; } /* sun behind cloud */ #water-tiles label[data-testid^="Easy"]::before { content: "\\1F4A7"; } /* droplet */ #water-tiles label[data-testid^="Limited"]::before { content: "\\1F9F4"; } /* bottle */ #water-tiles label[data-testid^="Hard"]::before { content: "\\1F335"; } /* cactus */ #mod-symptoms label:nth-of-type(1)::before { content: "\\1F4A6"; } /* sweat */ #mod-symptoms label:nth-of-type(2)::before { content: "\\1F9B5"; } /* leg/cramp */ #mod-symptoms label:nth-of-type(3)::before { content: "\\1F915"; } /* head ache */ #mod-symptoms label:nth-of-type(4)::before { content: "\\1F635"; } /* dizzy */ #mod-symptoms label:nth-of-type(5)::before { content: "\\1F922"; } /* nausea */ #mod-symptoms label:nth-of-type(6)::before { content: "\\1F971"; } /* tired */ #mod-symptoms label:nth-of-type(7)::before { content: "\\1F6B1"; } /* no-water/thirst */ #danger-symptoms label:nth-of-type(1)::before { content: "\\1F9E0"; } /* brain/confusion */ #danger-symptoms label:nth-of-type(2)::before { content: "\\1F4AB"; } /* faint */ #danger-symptoms label:nth-of-type(3)::before { content: "\\1F975"; } /* hot face */ #danger-symptoms label:nth-of-type(4)::before { content: "\\1F321"; } /* thermometer */ #danger-symptoms label:nth-of-type(5)::before { content: "\\1F92E"; } /* vomiting */ #danger-symptoms label:nth-of-type(6)::before { content: "\\1F493"; } /* fast heart */ /* ---- read-aloud control bar: Listen button + segmented language toggle ---- */ #listen-row-skin, #listen-row-heat { align-items: center !important; gap: 12px !important; flex-wrap: nowrap !important; } #listen-skin, #listen-heat { flex: 0 0 auto !important; width: auto !important; min-width: 0 !important; background: var(--card) !important; border: 1.5px solid var(--ink) !important; color: var(--ink) !important; box-shadow: 0 2px 0 rgba(42,33,24,0.18) !important; } #listen-skin:hover, #listen-heat:hover { background: #FBF1DF !important; } #save-skin { flex: 0 0 auto !important; width: auto !important; margin-left: auto !important; } /* segmented EN | हिं toggle — one connected pill, not two floating ones */ #lang-skin, #lang-heat { flex: 0 0 auto !important; min-width: 0 !important; } #lang-skin .wrap, #lang-heat .wrap { display: inline-flex !important; gap: 0 !important; border: 1.5px solid var(--line); border-radius: 999px; overflow: hidden; background: var(--card); box-shadow: 0 2px 0 rgba(42,33,24,0.10); } #lang-skin label, #lang-heat label { margin: 0 !important; border: 0 !important; border-radius: 0 !important; background: transparent !important; padding: 6px 15px !important; font-family: var(--mono) !important; font-size: 12px !important; letter-spacing: 0.5px; color: var(--ink-soft) !important; cursor: pointer; transition: background .12s ease; } #lang-skin label + label, #lang-heat label + label { border-left: 1.5px solid var(--line) !important; } #lang-skin label:hover, #lang-heat label:hover { background: #FBF1DF !important; } #lang-skin label:has(input:checked), #lang-heat label:has(input:checked), #lang-skin label.selected, #lang-heat label.selected { background: var(--dusk) !important; color: #fff !important; } #lang-skin label:has(input:checked) span, #lang-heat label:has(input:checked) span, #lang-skin label.selected span, #lang-heat label.selected span { color: #fff !important; } #lang-skin input[type=radio], #lang-heat input[type=radio] { display: none !important; } /* ---- darken faint Gradio controls (image toolbar, slider reset, verdict body) ---- */ #skin-photo .icon, #skin-photo button[aria-label] { opacity: 1 !important; color: var(--ink) !important; } #skin-photo .icon svg, #skin-photo button[aria-label] svg { opacity: 1 !important; color: var(--ink) !important; } #sun-hours .tab-like-container button, #sun-hours button { opacity: 1 !important; color: var(--ink-soft) !important; } #sun-hours svg { opacity: 1 !important; color: var(--ink-soft) !important; } #sun-hours input[type=number] { color: var(--ink) !important; } .verdict-card .concern-advice, .verdict-card .concern-advice strong { color: var(--ink) !important; } .verdict-card li, .verdict-card li strong, .verdict-card .card-list li { color: var(--ink) !important; } /* result + plan card body text — Gradio 5 prose defaults to a light colour on the Space */ .card p, .card li, .card td, .card .lede, .doctor-note, .card p strong, .card li strong { color: var(--ink) !important; } .card p.muted, .card .muted { color: #8A7A60 !important; } .spot-table .spot-key { color: var(--ink-soft) !important; } .signal-chip, .signal-chip strong { color: #8C5A22 !important; } /* 3D body picker; the radio below stays as the accessible/fallback input. */ .body-picker-shell { background: rgba(255,253,248,0.72); border: 1.5px solid var(--line); border-radius: 16px; box-sizing: border-box; max-width: 100%; padding: 12px; box-shadow: 0 2px 10px rgba(90,70,40,0.05); margin: 0 0 12px; } .body-picker-shell * { box-sizing: border-box; } .body-picker-stage { position: relative; height: 340px; border-radius: 12px; overflow: hidden; background: radial-gradient(circle at 50% 22%, rgba(245,166,35,0.18), rgba(245,166,35,0) 42%), linear-gradient(180deg, #FFF8EA 0%, #FFFDF8 100%); border: 1px solid #EEDDBA; } #body-picker-canvas { width: 100%; height: 100%; display: block; touch-action: none; cursor: grab; } #body-picker-canvas:active { cursor: grabbing; } .body-picker-loading { position: absolute; inset: 0; display: grid; place-items: center; font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: 1.4px; text-transform: uppercase; color: var(--ink-soft); pointer-events: none; } .body-picker-ui { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; } .body-picker-ui button { border: 1.5px solid var(--line) !important; background: var(--card) !important; color: var(--ink-soft) !important; border-radius: 999px !important; padding: 7px 12px !important; font-size: 12.5px !important; font-weight: 600 !important; box-shadow: 0 1px 3px rgba(90,70,40,0.05) !important; } .body-picker-ui button.active { border-color: var(--teal) !important; background: #EDF4F2 !important; color: var(--teal-deep) !important; } .body-picker-selected { margin-left: auto; font-family: var(--mono); font-size: 11.5px; font-weight: 600; letter-spacing: 1.2px; text-transform: uppercase; color: var(--ink-soft); } .body-picker-selected strong { color: var(--dusk); } .body-picker-error { margin-top: 8px; color: #8E3013; font-size: 12.5px; display: none; } .body-picker-ready #body-pills { display: none !important; } @media (max-width: 700px) { .body-picker-shell { width: min(100%, calc(100vw - 28px)); } .body-picker-stage { height: 310px; } .body-picker-selected { width: 100%; margin-left: 0; } } /* sun-arc slider: dawn → noon track */ #sun-hours input[type=range] { background: linear-gradient(90deg, #FBE3B5 0%, #F5A623 55%, #E8763A 100%) !important; height: 10px !important; border-radius: 999px !important; } #sun-hours input[type=range]::-webkit-slider-thumb { background: var(--sun) !important; border: 3px solid #fff !important; box-shadow: 0 0 0 2px var(--sun), 0 0 14px rgba(245,166,35,0.75) !important; width: 22px !important; height: 22px !important; } #sun-hours input[type=range]::-moz-range-thumb { background: var(--sun) !important; border: 3px solid #fff !important; box-shadow: 0 0 0 2px var(--sun), 0 0 14px rgba(245,166,35,0.75) !important; } #sun-hours label, #sun-hours span, #sun-hours output { color: var(--ink-soft) !important; opacity: 1 !important; } #sun-hours input[type=number], #sun-hours input[type=text] { color: var(--ink) !important; background: var(--card) !important; } /* symptom chips */ #mod-symptoms .wrap, #danger-symptoms .wrap { gap: 8px !important; } #mod-symptoms label, #danger-symptoms label { border: 1.5px solid var(--line) !important; background: var(--card) !important; border-radius: 999px !important; padding: 7px 14px !important; color: var(--ink-soft) !important; font-size: 13.5px !important; cursor: pointer; opacity: 1 !important; } #mod-symptoms label:has(input:checked), #mod-symptoms label.selected { border-color: #A4700E !important; background: #FDF7E8 !important; color: #7A540B !important; box-shadow: inset 0 0 0 1px #A4700E !important; } #mod-symptoms label:has(input:checked) span, #mod-symptoms label.selected span { color: #7A540B !important; } #danger-symptoms label:has(input:checked), #danger-symptoms label.selected { border-color: #B5431F !important; background: #FDF1EB !important; color: #8E3013 !important; box-shadow: inset 0 0 0 1px #B5431F !important; } #danger-symptoms label:has(input:checked) span, #danger-symptoms label.selected span { color: #8E3013 !important; } #mod-symptoms input[type=checkbox], #danger-symptoms input[type=checkbox] { display: none !important; } /* ---- gradio chrome quieting ---- */ .gradio-container .block { border: none !important; background: transparent !important; } .gradio-container .form { border: none !important; background: transparent !important; box-shadow: none !important; } #skin-photo { border: 2px dashed #CBB98E !important; border-radius: 16px !important; background: var(--card) !important; } #skin-photo:hover { border-color: var(--dusk) !important; } #skin-photo .wrap.default, #skin-photo .image-container, #skin-photo .upload-container { background: var(--card) !important; } #skin-photo .wrap.default.hide { display: none !important; } #skin-photo, #skin-photo * { color: var(--ink-soft) !important; opacity: 1 !important; } #skin-photo button, #skin-photo button *, #skin-photo [role=button], #skin-photo [role=button] * { color: var(--ink) !important; } #skin-photo .icon, #skin-photo svg { color: var(--dusk) !important; stroke: currentColor !important; } span[data-testid="block-info"] { font-family: var(--mono) !important; font-size: 11px !important; font-weight: 600 !important; letter-spacing: 1.5px !important; text-transform: uppercase !important; color: var(--ink-soft) !important; background: transparent !important; padding: 0 !important; } textarea { background: var(--card) !important; border: 1.5px solid var(--line) !important; color: var(--ink) !important; border-radius: 12px !important; } textarea::placeholder, input::placeholder { color: #6E5B40 !important; opacity: 1 !important; } .gradio-container label, .gradio-container label span { color: var(--ink-soft) !important; opacity: 1 !important; } button.primary, .primary { background: var(--dusk) !important; border-color: var(--dusk) !important; font-family: var(--mono) !important; font-weight: 600 !important; letter-spacing: 2px !important; text-transform: uppercase !important; border-radius: 12px !important; box-shadow: 0 4px 0 #C25A24 !important; transition: transform .08s, box-shadow .08s !important; } button.primary:hover { transform: translateY(1px); box-shadow: 0 3px 0 #C25A24 !important; } button.primary:active { transform: translateY(4px); box-shadow: 0 0 0 #C25A24 !important; } button.secondary { font-family: var(--mono) !important; letter-spacing: 1.5px !important; text-transform: uppercase !important; font-size: 12px !important; border: 1.5px solid var(--ink) !important; background: transparent !important; color: var(--ink) !important; border-radius: 10px !important; } button.secondary * { color: var(--ink) !important; } footer { display: none !important; } /* ---- empty state ---- */ .empty-state { text-align: center; padding: 30px 26px 34px; color: var(--ink-soft); } .empty-state .lede { font-family: 'Fraunces', serif; font-size: 21px; color: var(--ink); margin-bottom: 6px; } .empty-state p { color: var(--ink-soft) !important; opacity: 1 !important; } .empty-state strong { color: var(--teal-deep) !important; font-weight: 600; } .empty-state img.illo { width: min(360px, 86%); border-radius: 14px; margin: 0 auto 18px; display: block; box-shadow: 0 6px 24px rgba(90,70,40,0.14); } /* ---- history ---- */ .history-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: 14px; } .history-card { display: flex; gap: 14px; align-items: flex-start; } .history-card img { width: 92px; height: 92px; object-fit: cover; border-radius: 12px; } .history-meta { font-size: 13.5px; } .history-part { font-weight: 600; font-size: 15px; } .history-date { font-family: var(--mono); font-size: 11px; letter-spacing: 1px; color: #8A7A60; margin: 2px 0 5px; } .history-concern { font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: 1.5px; text-transform: uppercase; margin-bottom: 5px; display: flex; align-items: center; gap: 6px; } .history-concern .dot { width: 9px; height: 9px; border-radius: 50%; background: currentColor; display: inline-block; } /* ---- about + footer ---- */ #about-block .card h3, #about-block h3 { font-family: 'Fraunces', serif; color: var(--teal); margin: 0 0 10px; } #about-block p { color: var(--ink) !important; opacity: 1 !important; font-size: 15px; line-height: 1.6; margin: 0; } #about-block p strong { color: var(--ink) !important; } #about-block a { color: var(--dusk) !important; text-decoration: underline; font-weight: 600; } #app-footer { margin: 34px -8px 0; padding: 22px 26px 26px; background: var(--ink); color: #CBB98E; border-radius: 14px 14px 0 0; font-family: var(--mono); font-size: 11.5px; letter-spacing: 1.4px; text-transform: uppercase; text-align: center; line-height: 2; } #app-footer strong { color: var(--sand); font-weight: 600; } """ # --------------------------------------------------------------------------- # UI # --------------------------------------------------------------------------- EMPTY_RESULT_HTML = f"""
    {f'Two workers resting in the shade of an awning, drinking water' if SHADE_ILLO else ''}

    Take two minutes in the shade.

    Add a photo and press Check my skin.
    Chhaya reads it with MedGemma and writes a plan around your work day.

    """ BODY_PICKER_HTML = """
    Loading body picker...
    Selected: Right arm
    3D body picker did not load. Use the body buttons below.
    """ BODY_PICKER_JS = r""" async () => { // Sample persona cards -> click the matching hidden Gradio trigger button. if (!window.__chhayaCaseDelegation) { window.__chhayaCaseDelegation = true; document.addEventListener("click", (e) => { const card = e.target.closest(".case-card"); if (!card) return; e.preventDefault(); const i = card.getAttribute("data-case"); const el = document.querySelector("#case-trigger-" + i); if (!el) return; const btn = el.tagName === "BUTTON" ? el : el.querySelector("button"); if (btn) btn.click(); }, true); } const waitForElement = (selector) => new Promise((resolve, reject) => { let tries = 0; const tick = () => { const node = document.querySelector(selector); if (node) resolve(node); else if (tries++ > 120) reject(new Error(`${selector} not found`)); else setTimeout(tick, 100); }; tick(); }); const root = await waitForElement("#body-picker-root"); if (!root || root.dataset.ready === "1") return; root.dataset.ready = "1"; const canvas = document.getElementById("body-picker-canvas"); const loading = document.getElementById("body-picker-loading"); const selectedLabel = document.getElementById("body-picker-selected"); const errorBox = document.getElementById("body-picker-error"); const THREE_URL = "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"; const waitForRadio = () => new Promise((resolve, reject) => { let tries = 0; const tick = () => { const labels = Array.from(document.querySelectorAll("#body-pills label")); if (labels.length) resolve(labels); else if (tries++ > 80) reject(new Error("Body part radio not found")); else setTimeout(tick, 100); }; tick(); }); try { const [THREE, labels] = await Promise.all([import(THREE_URL), waitForRadio()]); const partMeshes = []; const partMaterials = new Map(); let selectedPart = "Right arm"; let hoverPart = null; let dragStarted = false; let pointerDown = false; let pointerStart = { x: 0, y: 0 }; let baseRotationY = 0; let viewRotationY = 0; const scene = new THREE.Scene(); scene.background = null; const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 100); camera.position.set(0, 1.55, 9.4); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); const group = new THREE.Group(); group.position.y = -0.1; scene.add(group); scene.add(new THREE.HemisphereLight(0xfff3d8, 0x245652, 2.3)); const key = new THREE.DirectionalLight(0xffffff, 2.1); key.position.set(3, 5, 4); scene.add(key); const rim = new THREE.DirectionalLight(0x2f6d68, 0.8); rim.position.set(-4, 3, -3); scene.add(rim); const baseColor = 0xf3d3ae; const sideColor = 0xe7c09a; const selectedColor = 0xe8763a; const hoverColor = 0x1f5f5b; const lineColor = 0x8d704e; const makeMat = (color = baseColor) => new THREE.MeshStandardMaterial({ color, roughness: 0.72, metalness: 0.02, transparent: false, }); const outlineMat = new THREE.LineBasicMaterial({ color: lineColor, transparent: true, opacity: 0.35 }); const register = (mesh, part) => { mesh.userData.part = part; partMeshes.push(mesh); partMaterials.set(mesh.uuid, mesh.material); group.add(mesh); const edges = new THREE.LineSegments(new THREE.EdgesGeometry(mesh.geometry, 18), outlineMat); edges.position.copy(mesh.position); edges.rotation.copy(mesh.rotation); edges.scale.copy(mesh.scale); edges.userData.follows = mesh.uuid; group.add(edges); return mesh; }; const sphere = (part, pos, scale, color = baseColor, segments = 32) => { const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, segments, segments), makeMat(color)); mesh.position.set(...pos); mesh.scale.set(...scale); return register(mesh, part); }; const decorSphere = (pos, scale, color = lineColor, segments = 16) => { const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, segments, segments), makeMat(color)); mesh.position.set(...pos); mesh.scale.set(...scale); group.add(mesh); return mesh; }; const decorBox = (pos, scale, rot = [0, 0, 0], color = lineColor) => { const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), makeMat(color)); mesh.position.set(...pos); mesh.scale.set(...scale); mesh.rotation.set(...rot); group.add(mesh); return mesh; }; const capsule = (part, pos, scale, rot = [0, 0, 0], color = baseColor) => { const mesh = new THREE.Mesh(new THREE.CapsuleGeometry(0.42, 1.0, 12, 24), makeMat(color)); mesh.position.set(...pos); mesh.scale.set(...scale); mesh.rotation.set(...rot); return register(mesh, part); }; const box = (part, pos, scale, rot = [0, 0, 0], color = baseColor) => { const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), makeMat(color)); mesh.position.set(...pos); mesh.scale.set(...scale); mesh.rotation.set(...rot); return register(mesh, part); }; sphere("Face", [0, 2.55, 0.14], [0.43, 0.5, 0.22]); sphere("Scalp", [0, 2.9, 0.02], [0.46, 0.22, 0.34], 0x5f4b39, 24); decorSphere([-0.13, 2.62, 0.37], [0.035, 0.045, 0.02], 0x2a2118, 12); decorSphere([0.13, 2.62, 0.37], [0.035, 0.045, 0.02], 0x2a2118, 12); decorBox([0, 2.43, 0.38], [0.17, 0.022, 0.018], [0, 0, 0.03], 0x8d704e); sphere("Ear", [-0.47, 2.58, 0.02], [0.1, 0.18, 0.08], sideColor, 20); sphere("Ear", [0.47, 2.58, 0.02], [0.1, 0.18, 0.08], sideColor, 20); capsule("Neck", [0, 2.02, 0], [0.42, 0.38, 0.42], [0, 0, 0], sideColor); sphere("Chest", [0, 1.32, 0.18], [0.68, 0.82, 0.24]); sphere("Back", [0, 1.32, -0.2], [0.7, 0.84, 0.24], sideColor); sphere("Shoulder", [-0.72, 1.75, 0.02], [0.28, 0.25, 0.25], sideColor); sphere("Shoulder", [0.72, 1.75, 0.02], [0.28, 0.25, 0.25], sideColor); capsule("Right arm", [-1.03, 1.2, 0.02], [0.36, 0.72, 0.36], [0, 0, -0.22]); capsule("Left arm", [1.03, 1.2, 0.02], [0.36, 0.72, 0.36], [0, 0, 0.22]); sphere("Hand", [-1.16, 0.35, 0.03], [0.18, 0.22, 0.16], sideColor, 20); sphere("Hand", [1.16, 0.35, 0.03], [0.18, 0.22, 0.16], sideColor, 20); capsule("Leg", [-0.32, -0.05, 0.02], [0.36, 0.84, 0.36], [0, 0, 0.06]); capsule("Leg", [0.32, -0.05, 0.02], [0.36, 0.84, 0.36], [0, 0, -0.06]); box("Foot", [-0.34, -0.86, 0.18], [0.42, 0.16, 0.62], [0.05, 0, 0]); box("Foot", [0.34, -0.86, 0.18], [0.42, 0.16, 0.62], [0.05, 0, 0]); const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); const radioLabels = () => Array.from(document.querySelectorAll("#body-pills label")); const setRadio = (part) => { const label = radioLabels().find((node) => node.textContent.trim().toLowerCase() === part.toLowerCase()); if (label) label.click(); }; const paint = () => { partMeshes.forEach((mesh) => { const mat = partMaterials.get(mesh.uuid); if (!mat) return; if (mesh.userData.part === selectedPart) mat.color.setHex(selectedColor); else if (mesh.userData.part === hoverPart) mat.color.setHex(hoverColor); else mat.color.setHex(mesh.position.x || mesh.position.z < 0 ? sideColor : baseColor); }); }; const selectPart = (part, sync = true) => { selectedPart = part; selectedLabel.textContent = part; paint(); if (sync) setRadio(part); }; // Keep the 3D model in step when the radio is set elsewhere (e.g. a sample // case prefilling body part). Gradio's programmatic value set doesn't always // fire a change event, so poll as a safety net. const syncFromRadio = () => { const checked = document.querySelector("#body-pills input:checked"); if (checked && checked.value && checked.value !== selectedPart) { selectPart(checked.value, false); } }; document.querySelector("#body-pills")?.addEventListener("change", syncFromRadio, true); setInterval(syncFromRadio, 500); const resize = () => { const box = canvas.getBoundingClientRect(); const width = Math.max(240, Math.floor(box.width)); const height = Math.max(260, Math.floor(box.height)); renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); }; const setPointer = (event) => { const rect = canvas.getBoundingClientRect(); pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(pointer, camera); return raycaster.intersectObjects(partMeshes, false); }; canvas.addEventListener("pointermove", (event) => { if (pointerDown) { const dx = event.clientX - pointerStart.x; const dy = event.clientY - pointerStart.y; if (Math.abs(dx) + Math.abs(dy) > 5) dragStarted = true; group.rotation.y = viewRotationY + dx * 0.01; group.rotation.x = Math.max(-0.25, Math.min(0.2, dy * 0.004)); return; } const hit = setPointer(event)[0]; hoverPart = hit ? hit.object.userData.part : null; paint(); }); canvas.addEventListener("pointerdown", (event) => { pointerDown = true; dragStarted = false; pointerStart = { x: event.clientX, y: event.clientY }; baseRotationY = group.rotation.y; canvas.setPointerCapture(event.pointerId); }); canvas.addEventListener("pointerup", (event) => { pointerDown = false; viewRotationY = group.rotation.y; group.rotation.x = 0; try { canvas.releasePointerCapture(event.pointerId); } catch (_) {} if (dragStarted) return; const hit = setPointer(event)[0]; if (hit) selectPart(hit.object.userData.part); }); canvas.addEventListener("pointerleave", () => { if (!pointerDown) { hoverPart = null; paint(); } }); const setView = (name) => { viewRotationY = name === "back" ? Math.PI : 0; group.rotation.y = viewRotationY; group.rotation.x = 0; root.querySelectorAll("[data-body-view]").forEach((btn) => { btn.classList.toggle("active", btn.dataset.bodyView === name); }); }; root.querySelector('[data-body-view="front"]').addEventListener("click", () => setView("front")); root.querySelector('[data-body-view="back"]').addEventListener("click", () => setView("back")); root.querySelector("[data-body-reset]").addEventListener("click", () => { setView("front"); selectPart("Right arm"); }); root.querySelector("[data-body-other]").addEventListener("click", () => selectPart("Other")); const animate = () => { requestAnimationFrame(animate); renderer.render(scene, camera); }; resize(); window.addEventListener("resize", resize); setView("front"); selectPart("Right arm", false); loading.style.display = "none"; document.documentElement.classList.add("body-picker-ready"); animate(); } catch (err) { console.warn("Body picker failed to initialize", err); if (loading) loading.style.display = "none"; if (errorBox) errorBox.style.display = "block"; } } """ # Gradio 6 moved css/theme/js from Blocks() to launch(); Spaces (Gradio 5) wants them on Blocks(). _GRADIO_MAJOR = int(gr.__version__.split(".")[0]) CSS = CSS.replace("__HERO_SCENE__", HERO_SCENE).replace("__PAPER_NOISE__", _PAPER_NOISE) _BODY_PICKER_JS_FOR_GRADIO = f"({BODY_PICKER_JS})();" if _GRADIO_MAJOR >= 6 else BODY_PICKER_JS _STYLE_KWARGS = { "css": CSS, "js": _BODY_PICKER_JS_FOR_GRADIO, "theme": gr.themes.Soft(primary_hue="orange", neutral_hue="stone"), } _BLOCKS_KWARGS = {"title": APP_TITLE, **({} if _GRADIO_MAJOR >= 6 else _STYLE_KWARGS)} _LAUNCH_KWARGS = _STYLE_KWARGS if _GRADIO_MAJOR >= 6 else {} with gr.Blocks(**_BLOCKS_KWARGS) as demo: gr.HTML(f"""
    Chhaya छाया · shade for those who work in the sun

    Step into the shade.
    Let's look at that spot.

    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 diagnoses MedGemma-1.5-4B Free · 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.

    """) gr.HTML(""" """) analyze_btn.click( analyze, inputs=[image_input, occupation, hours_sun, water_access, body_part, notes_input, session_state], outputs=[results_html, session_state, save_btn, listen_btn, result_audio, lang_skin], ) # Sample cases: prefill the six inputs, then run the same analyze path. for _i, _trigger in enumerate(case_triggers): _trigger.click( partial(load_case, _i), outputs=[image_input, occupation, hours_sun, water_access, body_part, notes_input], ).then( analyze, inputs=[image_input, occupation, hours_sun, water_access, body_part, notes_input, session_state], outputs=[results_html, session_state, save_btn, listen_btn, result_audio, lang_skin], ) listen_btn.click(_listen_busy, outputs=[listen_btn]).then( read_findings_aloud, inputs=[session_state, lang_skin], outputs=[result_audio] ).then(_listen_idle, outputs=[listen_btn]) save_btn.click( save_to_record, inputs=[session_state, history_state], outputs=[session_state, history_state, history_html, save_btn], ) triage_btn.click( run_heat_check, inputs=[moderate_box, danger_box], outputs=[triage_html, heat_speak_state, listen_btn_heat, heat_audio, lang_heat], ) listen_btn_heat.click(_listen_busy, outputs=[listen_btn_heat]).then( read_triage_aloud, inputs=[heat_speak_state, lang_heat], outputs=[heat_audio] ).then(_listen_idle, outputs=[listen_btn_heat]) if __name__ == "__main__": demo.launch( server_name=os.getenv("GRADIO_SERVER_NAME", "127.0.0.1"), server_port=int(os.getenv("PORT", "7860")), **_LAUNCH_KWARGS, )