| """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 |
| _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") |
| |
| |
| ADAPTER_ID = os.getenv("CHHAYA_ADAPTER_ID", "").strip() |
| FORCE_DEMO = os.getenv("CHHAYA_DEMO", "0").strip().lower() in {"1", "true", "yes"} |
|
|
| |
| |
| |
|
|
| _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 |
|
|
| if _HAS_SPACES or torch.cuda.is_available(): |
| _backend = "medgemma" |
| 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() |
| if ADAPTER_ID: |
| |
| |
| 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}, |
| {"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) |
|
|
|
|
| |
| |
| |
|
|
| 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() |
| |
| 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): |
| 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.", |
| } |
|
|
|
|
| |
| |
| |
| |
| |
|
|
| 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", |
| ] |
|
|
|
|
| |
| |
| |
|
|
| 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.", |
| ], |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def _list_html(items: List[str]) -> str: |
| return "".join(f"<li>{md_bold(i)}</li>" for i in items) |
|
|
|
|
| def md_bold(text: str) -> str: |
| return re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", 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"<tr><td class='spot-key'>{key.title()}</td><td>{spot.get(key, '—')}</td></tr>" |
| for key in ["type", "color", "borders", "symmetry", "texture"] |
| ) |
| signals_html = ( |
| "<div class='signal-chips'>" |
| + "".join(f"<span class='signal-chip'>☀ {md_bold(s)}</span>" for s in signals) |
| + "</div>" |
| if signals |
| else "<p class='muted'>No obvious sun or heat damage signs in this photo.</p>" |
| ) |
| quality_note = ( |
| "<p class='muted'>Image quality limited this reading — retake in daylight, close and steady, if you can.</p>" |
| if str(f.get("image_quality", "good")).lower() == "limited" else "" |
| ) |
|
|
| return f""" |
| <div class="result-stack"> |
| <div class="card verdict-card {css_class}"> |
| <span class="verdict-stamp">{label}</span> |
| <p class="concern-reason">{f.get('concern_reason', '')}</p> |
| <p class="concern-advice">{advice}</p> |
| </div> |
| |
| <div class="card"> |
| <div class="kicker">What Chhaya sees</div> |
| <p class="lede">{f.get('what_i_see', '')}</p> |
| <table class="spot-table">{spot_rows}</table> |
| {quality_note} |
| <div class="kicker kicker-sub">Sun & heat signals</div> |
| {signals_html} |
| </div> |
| |
| <div class="plan-grid"> |
| <div class="card"> |
| <div class="kicker">Hydration for your shift</div> |
| <ul class="card-list">{_list_html(hydration_plan(hours, water_access))}</ul> |
| </div> |
| <div class="card"> |
| <div class="kicker">Protection that fits</div> |
| <ul class="card-list">{_list_html(protection_plan(hours))}</ul> |
| </div> |
| <div class="card"> |
| <div class="kicker">For your work · {occupation}</div> |
| <ul class="card-list">{_list_html(occ['tips'])}</ul> |
| </div> |
| <div class="card card-urgent"> |
| <div class="kicker">See a doctor without waiting if</div> |
| <ul class="card-list">{_list_html(URGENT_SIGNS)}</ul> |
| </div> |
| </div> |
| |
| <div class="card card-summary"> |
| <div class="kicker">Note for your doctor</div> |
| <p class="doctor-note">“{f.get('summary', '')}”</p> |
| <p class="muted">Chhaya describes — it never diagnoses. This report and photo are meant to be shown to a clinician.</p> |
| </div> |
| </div>""" |
|
|
|
|
| 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""" |
| <div class="card verdict-card {cls}"> |
| <span class="verdict-stamp">{title}</span> |
| <ol class="card-list triage-steps">{_list_html(steps)}</ol> |
| <p class="muted">Based on NDMA / WHO heat-illness first-aid guidance. When unsure, treat it as the more serious condition.</p> |
| </div>""" |
|
|
|
|
| def render_history(entries: List[Dict[str, Any]]) -> str: |
| if not entries: |
| return """<div class="card empty-state"> |
| <p class="lede">No saved checks yet.</p> |
| <p>Run a skin check and press <strong>Save to my record</strong> — |
| comparing the same spot across weeks is how change gets caught early.</p></div>""" |
| cards = "" |
| for e in reversed(entries): |
| icon, label, css_class, _ = CONCERN_META[e["concern"]] |
| cards += f""" |
| <div class="card history-card {css_class}"> |
| <img src="{e['thumb']}" alt="saved skin photo"/> |
| <div class="history-meta"> |
| <div class="history-part">{e['body_part']}</div> |
| <div class="history-date">{e['date']}</div> |
| <div class="history-concern"><span class="dot"></span>{label}</div> |
| <p>{e['summary']}</p> |
| </div> |
| </div>""" |
| return f"<div class='history-grid'>{cards}</div>" |
|
|
|
|
| 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() |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| 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")} |
|
|
|
|
| def _strip_md(s: str) -> str: |
| return re.sub(r"\*\*(.+?)\*\*", r"\1", str(s)).replace("—", ", ").strip() |
|
|
|
|
| |
| |
| |
| _HI_CONCERN = { |
| "low": ("सामान्य दिखता है", |
| "कुछ ख़ास चिंता की बात नहीं दिखती। धूप से बचाव करते रहें और महीने में एक बार अपनी त्वचा देखें।"), |
| "watch": ("ध्यान देने योग्य", |
| "इसी जगह की फ़ोटो तीन से चार हफ़्ते बाद उसी रोशनी में दोबारा लें। अगर यह बढ़े, गहरा हो, या आकार बदले, तो डॉक्टर को दिखाएँ।"), |
| "see_doctor": ("डॉक्टर को दिखाएँ", |
| "यह रिपोर्ट और फ़ोटो जल्द किसी डॉक्टर या त्वचा विशेषज्ञ को दिखाएँ। बदलते या असामान्य दाग़ को जल्दी जँचवाना सबसे अच्छा है।"), |
| } |
| _HI_HYDRATION = ("अपनी धूप की पाली में हर बीस से तीस मिनट में थोड़ा-थोड़ा पानी पिएँ, प्यास का इंतज़ार न करें। " |
| "ज़्यादा पसीना आने पर ओआरएस, शिकंजी या छाछ लें।") |
| _HI_PROTECTION = ("हल्के रंग के, ढीले, पूरी बाँह के सूती कपड़े पहनें और सिर ढकें। खुली त्वचा पर सनस्क्रीन लगाएँ। " |
| "दोपहर बारह से तीन बजे की तेज़ धूप में छाँव में रहें।") |
| _HI_DISCLAIMER = ("याद रखें, छाया सिर्फ़ देखती है, बीमारी नहीं बताती। अगर त्वचा पर कुछ चिंता का कारण लगे, तो डॉक्टर को ज़रूर दिखाएँ।") |
|
|
| |
| _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: |
| |
| |
| _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) |
|
|
|
|
| |
| |
| |
|
|
| 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 ( |
| """<div class='card empty-state'><p class='lede'>No photo yet.</p> |
| <p>Add one first — bright daylight, close and steady.</p></div>""", |
| 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 ( |
| """<div class='card verdict-card concern-watch'> |
| <span class='verdict-stamp'>Out of GPU minutes</span> |
| <p class='concern-advice'>This Space runs on shared free GPUs, and this browser's free |
| quota is spent. <strong>Sign in to Hugging Face</strong> (top of the Space page) for a |
| bigger free quota, or come back a little later. Your photo was not analyzed.</p></div>""", |
| 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_SVG = """ |
| <svg viewBox="0 0 120 120" width="132" height="132" aria-label="Chhaya mascot: a happy sun resting under a little umbrella"> |
| <g class="mascot-sun"> |
| <circle cx="62" cy="74" r="26" fill="#F5A623"/> |
| <g stroke="#F5A623" stroke-width="5" stroke-linecap="round"> |
| <line x1="62" y1="38" x2="62" y2="30"/><line x1="88" y1="50" x2="94" y2="44"/> |
| <line x1="96" y1="74" x2="105" y2="74"/><line x1="88" y1="98" x2="94" y2="104"/> |
| <line x1="62" y1="108" x2="62" y2="116"/><line x1="36" y1="98" x2="30" y2="104"/> |
| <line x1="28" y1="74" x2="19" y2="74"/> |
| </g> |
| <circle cx="53" cy="70" r="3.4" fill="#3A2A18"/> |
| <circle cx="71" cy="70" r="3.4" fill="#3A2A18"/> |
| <path d="M52 80 Q62 90 72 80" stroke="#3A2A18" stroke-width="3.2" fill="none" stroke-linecap="round"/> |
| <circle cx="47" cy="77" r="4" fill="#F2784B" opacity="0.55"/> |
| <circle cx="77" cy="77" r="4" fill="#F2784B" opacity="0.55"/> |
| </g> |
| <g class="mascot-umbrella"> |
| <path d="M14 38 Q40 6 78 24 Q60 22 48 30 Q36 22 26 32 Q20 34 14 38 Z" fill="#1F5F5B"/> |
| <path d="M14 38 Q46 18 78 24 L76 29 Q46 24 16 42 Z" fill="#2A7A74"/> |
| <line x1="44" y1="28" x2="38" y2="62" stroke="#7A5230" stroke-width="4" stroke-linecap="round"/> |
| </g> |
| </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") |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| 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'<button type="button" class="case-card" data-case="{i}" ' |
| f'aria-label="Try sample case: {c["name"]}, {c["role"]}">' |
| f'<img class="case-thumb" src="{thumb}" alt=""/>' |
| f'<span class="case-text"><span class="case-name">{c["name"]}</span>' |
| f'<span class="case-role">{c["role"]}</span></span></button>' |
| ) |
| return ( |
| '<div class="step-kicker" style="margin-top:16px">Or try a sample — tap a worker</div>' |
| '<div class="case-row">' + "".join(cards) + "</div>" |
| ) |
|
|
| |
| _PAPER_NOISE = ( |
| "url(\"data:image/svg+xml;utf8," |
| "<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'>" |
| "<filter id='p'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/>" |
| "<feColorMatrix values='0 0 0 0 0.16 0 0 0 0 0.13 0 0 0 0 0.09 0 0 0 0.5 0'/></filter>" |
| "<rect width='180' height='180' filter='url(%23p)' opacity='0.5'/></svg>\")" |
| ) |
|
|
| 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; } |
| """ |
|
|
|
|
| |
| |
| |
|
|
| EMPTY_RESULT_HTML = f""" |
| <div class="card empty-state"> |
| {f'<img class="illo" src="{SHADE_ILLO}" alt="Two workers resting in the shade of an awning, drinking water"/>' if SHADE_ILLO else ''} |
| <p class="lede">Take two minutes in the shade.</p> |
| <p>Add a photo and press <strong>Check my skin</strong>.<br> |
| Chhaya reads it with MedGemma and writes a plan around your work day.</p> |
| </div>""" |
|
|
|
|
| BODY_PICKER_HTML = """ |
| <div id="body-picker-root" class="body-picker-shell" aria-label="Clickable body part picker"> |
| <div class="body-picker-stage"> |
| <canvas id="body-picker-canvas" aria-label="3D body picker"></canvas> |
| <div id="body-picker-loading" class="body-picker-loading">Loading body picker...</div> |
| </div> |
| <div class="body-picker-ui" aria-label="Body picker controls"> |
| <button type="button" data-body-view="front" class="active">Front</button> |
| <button type="button" data-body-view="back">Back</button> |
| <button type="button" data-body-reset>Reset</button> |
| <button type="button" data-body-other>Other</button> |
| <div class="body-picker-selected">Selected: <strong id="body-picker-selected">Right arm</strong></div> |
| </div> |
| <div id="body-picker-error" class="body-picker-error"> |
| 3D body picker did not load. Use the body buttons below. |
| </div> |
| </div> |
| """ |
|
|
|
|
| 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_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""" |
| <div id="awning" aria-hidden="true"></div> |
| <section id="hero"> |
| <div class="hero-text"> |
| <div class="wordmark">Chhaya <span class="hindi">छाया</span> · shade for those who work in the sun</div> |
| <h1>Step into the shade.<br>Let's look at <em>that spot.</em></h1> |
| <p class="sub">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 <em>your</em> work day.</p> |
| <div class="chip-row"> |
| <span class="chip chip-accent">Describes · never diagnoses</span> |
| <span class="chip">MedGemma-1.5-4B</span> |
| <span class="chip">Free · photos never stored</span> |
| </div> |
| </div> |
| </section>""") |
|
|
| session_state = gr.State({}) |
| history_state = gr.State([]) |
|
|
| with gr.Tab("Skin check"): |
| with gr.Row(): |
| with gr.Column(scale=5): |
| gr.HTML('<div class="step-kicker"><span class="step-no">01</span>The photo — daylight, close, steady</div>') |
| 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('<div class="step-kicker"><span class="step-no">02</span>Where on the body?</div>') |
| 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('<div class="step-kicker"><span class="step-no">03</span>Your work day</div>') |
| 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("""<div class="step-kicker" style="margin-top:14px">Feeling unwell in the heat — you or a co-worker?</div> |
| <p class="muted" style="margin:0 2px 6px">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.</p>""") |
| 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("""<div class="step-kicker" style="margin-top:14px">Saved skin checks · this session</div> |
| <p class="muted" style="margin:0 2px 10px">The spot that <em>changes</em> is the spot that matters. Re-photograph the same spots every few weeks and compare.</p>""") |
| history_html = gr.HTML(render_history([])) |
|
|
| with gr.Tab("About"): |
| gr.HTML(f""" |
| <div id="about-block"> |
| <div class="card"><h3>Why Chhaya exists</h3> |
| <p>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.</p></div> |
| |
| <div class="card"><h3>How it is built (honestly)</h3> |
| <p><strong>The model is the eyes, the guidelines are the medicine.</strong> Google's |
| <strong>MedGemma-1.5-4B</strong> — 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.</p> |
| <p>Inspired by <a href="https://github.com/mrdbourke/sunny" target="_blank">Sunny</a> 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.</p> |
| <p class="muted">The sample-worker photos are open-license skin images from the ISIC archive and |
| Google's SCIN dataset; the names and jobs are fictional.</p></div> |
| |
| <div class="card card-urgent"><h3>What Chhaya is not</h3> |
| <p>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.</p></div> |
| </div>""") |
|
|
| gr.HTML(""" |
| <div id="app-footer"> |
| <strong>Chhaya छाया</strong> · built for the Build Small Hackathon<br> |
| MedGemma-1.5-4B + Chhaya LoRA · Gradio · ZeroGPU runtime · guidance: NDMA · WHO · Cancer Council ABCDE |
| </div>""") |
|
|
| 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], |
| ) |
| |
| 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, |
| ) |
|
|