aira-v2 / app.py
jonathan4e's picture
Deploy AIRA v2 - Qwen vision + voice + heat safety
ca5137c verified
Raw History Blame Contribute Delete
28.4 kB
"""
app.py — AIRA: Arid-climate Intelligent Risk Assistant
Complete Gradio app for Hugging Face Spaces.
Qwen stack: Qwen2-VL-2B (vision) + Qwen3-8B-Instruct (reasoning) + Whisper-Tiny (STT) + gTTS (TTS)
"""
from __future__ import annotations
import math
import threading
import requests
from datetime import datetime, timezone
from typing import Optional, Dict, Any, List, Tuple
import gradio as gr
from PIL import Image, ImageDraw
# ---------------- Optional ML deps (lazy load) ----------------
try:
import torch
from transformers import (
Qwen2VLForConditionalGeneration,
AutoProcessor,
AutoModelForCausalLM,
AutoTokenizer,
pipeline,
)
_TORCH = True
except Exception:
_TORCH = False
try:
from gtts import gTTS
_GTTS = True
except Exception:
_GTTS = False
# ---------------- Config ----------------
QATAR_CITIES: Dict[str, Dict[str, float]] = {
"Doha": {"lat": 25.2867, "lon": 51.5343},
"Al Wakrah": {"lat": 25.1715, "lon": 51.6047},
"Al Khor": {"lat": 25.6819, "lon": 51.5010},
"Mesaieed": {"lat": 24.7728, "lon": 51.5045},
"Dukhan": {"lat": 25.4219, "lon": 50.7531},
"Al Rayyan": {"lat": 25.2333, "lon": 51.4333},
"Lusail": {"lat": 25.3628, "lon": 51.5010},
}
LANGS: Dict[str, Dict[str, Any]] = {
"en": {"name": "English", "tts": "en", "rtl": False},
"ar": {"name": "\u0627\u0644\u0639\u0631\u0628\u064a\u0629", "tts": "ar", "rtl": True},
"hi": {"name": "\u0939\u093f\u0928\u094d\u0926\u093f", "tts": "hi", "rtl": False},
"ur": {"name": "\u0627\u0631\u062f\u0648", "tts": "ur", "rtl": False},
}
VISION_MODEL_ID = "Qwen/Qwen2-VL-2B-Instruct"
REASON_MODEL_ID = "Qwen/Qwen3-4B-Instruct" # Qwen3 reasoning engine
ASR_MODEL_ID = "openai/whisper-tiny"
VISION_PROMPT = (
"You are AIRA, a workplace heat-safety assistant for Qatar outdoor workers. "
"Inspect the image for heat-stress indicators: sweat, flushed/pale skin, dizziness posture, "
"lack of shade, direct sun on workers, missing water, heavy PPE, hot surfaces, unsafe conditions. "
"Classify into: Safe_Shaded, Direct_Sun_Exposure, High_Physical_Exertion, Visible_Heat_Distress. "
"Be concise, practical, safety-first. Reply in the user's language."
)
QATAR_SYSTEM_PROMPT = (
"You are AIRA, a heat-safety reasoning engine for Qatar outdoor workers, aligned with Qatar "
"Ministry of Public Health (MoPH) Wet Bulb Globe Temperature (WBGT) guidelines. "
"Given temperature, humidity, work intensity, and vision findings, output: "
"(1) WBGT estimate, (2) Heat risk level (Low/Moderate/High/Extreme), "
"(3) Hydration plan (Litres per hour), (4) Work-rest cycle (minutes work / minutes rest in shade), "
"(5) Immediate safety actions in Arabic, Hindi, Urdu, or English as requested. "
"Be concise, actionable, and safety-first."
)
# Model singletons (lazy, thread-safe)
_lock = threading.Lock()
_vm = None
_vp = None
_rm = None
_rt = None
_asr = None
def _load_vision():
global _vm, _vp
with _lock:
if _vm is None and _TORCH:
_vm = Qwen2VLForConditionalGeneration.from_pretrained(
VISION_MODEL_ID,
torch_dtype=torch.bfloat16,
device_map="auto",
trust_remote_code=True,
)
_vp = AutoProcessor.from_pretrained(VISION_MODEL_ID, trust_remote_code=True)
return _vm, _vp
def _load_reason():
global _rm, _rt
with _lock:
if _rm is None and _TORCH:
_rm = AutoModelForCausalLM.from_pretrained(
REASON_MODEL_ID,
torch_dtype=torch.bfloat16,
device_map="auto",
trust_remote_code=True,
)
_rt = AutoTokenizer.from_pretrained(REASON_MODEL_ID, trust_remote_code=True)
return _rm, _rt
def _load_asr():
global _asr
with _lock:
if _asr is None and _TORCH:
_asr = pipeline(
"automatic-speech-recognition",
model=ASR_MODEL_ID,
device=0 if torch.cuda.is_available() else -1,
)
return _asr
# ---------------- Weather (Open-Meteo, free, no key) ----------------
def get_qatar_weather(city: str = "Doha") -> Dict[str, Any]:
c = QATAR_CITIES.get(city, QATAR_CITIES["Doha"])
try:
r = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": c["lat"],
"longitude": c["lon"],
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,wind_speed_10m",
"timezone": "Asia/Qatar",
},
timeout=15,
)
r.raise_for_status()
d = r.json()["current"]
return {
"city": city,
"temp": d.get("temperature_2m"),
"feels_like": d.get("apparent_temperature"),
"humidity": d.get("relative_humidity_2m"),
"wind": d.get("wind_speed_10m"),
"ok": True,
}
except Exception as e:
return {"city": city, "temp": 44, "feels_like": 52, "humidity": 45, "wind": 12, "ok": False, "e": str(e)}
# ---------------- WBGT / Safety Engine (Qatar MoPH aligned) ----------------
def _wet_bulb(temp_c: float, rh: float) -> float:
"""Stull approximation of wet-bulb temperature (°C)."""
t = temp_c
h = rh
tw = (
t * math.atan(0.151977 * math.sqrt(h + 8.313659))
+ math.atan(t + h)
- math.atan(h - 1.676331)
+ 0.00391838 * h ** (3 / 2) * math.atan(0.023101 * h)
- 4.686035
)
return tw
def wbgt_estimate(temp_c: float, rh: float, sun_exposed: bool = True) -> float:
"""Estimate WBGT (°C). If sun-exposed, add solar radiation load (~+2 to +4°C globe)."""
tw = _wet_bulb(temp_c, rh)
# Simplified WBGT = 0.7*Tw + 0.2*Tg + 0.1*T ; approximate Tg ≈ T + solar load
tg = temp_c + (3.0 if sun_exposed else 1.0)
wbgt = 0.7 * tw + 0.2 * tg + 0.1 * temp_c
return round(wbgt, 1)
def wbgt_guidance(wbgt: float, intensity: str = "moderate") -> Dict[str, Any]:
"""Qatar MoPH-style work/rest & hydration guidance from WBGT."""
if wbgt < 29:
level, label, color = "low", "Low", "#22c55e"
work, rest = 60, 0
water = 0.5
elif wbgt < 31:
level, label, color = "moderate", "Moderate", "#eab308"
work, rest = 45, 15
water = 0.75
elif wbgt < 33:
level, label, color = "high", "High", "#f97316"
work, rest = 30, 30
water = 1.0
else:
level, label, color = "extreme", "Extreme", "#ef4444"
work, rest = 0, 60
water = 1.25
# Adjust for intensity
mult = {"light": 0.9, "moderate": 1.0, "heavy": 1.2}.get(intensity, 1.0)
water = round(water * mult, 2)
return {
"wbgt": wbgt,
"level": level,
"label": label,
"color": color,
"work_min": work,
"rest_min": rest,
"water_l_hr": water,
"intensity": intensity,
}
def safety_report(temp: float, hum: float, intensity: str, vision_text: str, lang: str) -> str:
"""Generate localized safety report using Qwen3 reasoning (fallback to template)."""
wbgt = wbgt_estimate(temp, hum, sun_exposed="direct sun" in vision_text.lower())
guidance = wbgt_guidance(wbgt, intensity)
rm, rt = _load_reason()
if rm is not None and rt is not None:
try:
user_msg = (
f"Temperature: {temp}°C, Humidity: {hum}%, WBGT: {wbgt}°C, "
f"Intensity: {intensity}, Vision: {vision_text}. "
f"Give a concise heat-safety report in {lang}."
)
messages = [
{"role": "system", "content": QATAR_SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
]
text = rt.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = rt(text=[text], return_tensors="pt").to(rm.device)
out = rm.generate(**inputs, max_new_tokens=300, do_sample=False)
result = rt.batch_decode(out, skip_special_tokens=True)[0]
if "assistant" in result:
result = result.split("assistant")[-1].strip()
return result or _template_report(guidance, vision_text, lang)
except Exception as e:
return f"(Qwen3 reasoning unavailable: {e})\n\n" + _template_report(guidance, vision_text, lang)
return _template_report(guidance, vision_text, lang)
def _template_report(guidance: Dict, vision_text: str, lang: str) -> str:
templates = {
"en": (
f"**WBGT:** {guidance['wbgt']}°C | **Risk:** {guidance['label']} \n"
f"**Hydration:** {guidance['water_l_hr']} L/hr | **Work:** {guidance['work_min']} min / "
f"**Rest:** {guidance['rest_min']} min in shade \n"
f"**Vision findings:** {vision_text} \n"
f"**Action:** {guidance['note'] if 'note' in guidance else 'Monitor worker closely.'}"
),
"ar": (
f"**WBGT:** {guidance['wbgt']}°C | **المخاطر:** {guidance['label']} \n"
f"**الترطيب:** {guidance['water_l_hr']} لتر/ساعة | **العمل:** {guidance['work_min']} دقيقة / "
f"**الراحة:** {guidance['rest_min']} دقيقة في الظل \n"
f"**ملاحظات الرؤية:** {vision_text}"
),
"hi": (
f"**WBGT:** {guidance['wbgt']}°C | **जोखिम:** {guidance['label']} \n"
f"**हाइड्रेशन:** {guidance['water_l_hr']} L/घंटा | **काम:** {guidance['work_min']} मिनट / "
f"**आराम:** {guidance['rest_min']} मिनट छांव में \n"
f"**विज़न निष्कर्ष:** {vision_text}"
),
"ur": (
f"**WBGT:** {guidance['wbgt']}°C | **خطرہ:** {guidance['label']} \n"
f"**پانی:** {guidance['water_l_hr']} L/گھنٹہ | **کام:** {guidance['work_min']} منٹ / "
f"** आरام:** {guidance['rest_min']} منٹ سائے میں \n"
f"**بینائی کے نتائج:** {vision_text}"
),
}
return templates.get(lang, templates["en"])
# ---------------- Vision analysis (Qwen2-VL) ----------------
def analyze_image(image: Image.Image, lang: str = "en") -> str:
vm, vp = _load_vision()
if vm is None or vp is None:
return _vision_fallback(image, lang)
try:
messages = [
{
"role": "user",
"content": [
{"type": "image", "image": image},
{"type": "text", "text": VISION_PROMPT},
],
}
]
text = vp.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = vp(text=[text], images=[image], padding=True, return_tensors="pt").to(vm.device)
out = vm.generate(**inputs, max_new_tokens=256, do_sample=False)
result = vp.batch_decode(out, skip_special_tokens=True)[0]
if "assistant" in result:
result = result.split("assistant")[-1].strip()
return result or _vision_fallback(image, lang)
except Exception as e:
return f"(Vision error: {e})\n\n" + _vision_fallback(image, lang)
def _vision_fallback(image: Image.Image, lang: str) -> str:
wd, ht = image.size
msgs = {
"en": f"Vision fallback ({wd}x{ht}). Check sweat, skin, shade, water. If unwell, stop & cool down.",
"ar": f"\u0628\u062f\u064a\u0644 \u0627\u0644\u0631\u0624\u064a\u0629 ({wd}\u00d7{ht}). \u062a\u062d\u0642\u0642 \u0645\u0646 \u0627\u0644\u0639\u0631\u0642, \u0627\u0644\u0628\u0634\u0627\u0631\u0629, \u0627\u0644\u0638\u0644, \u0627\u0644\u0645\u0627\u0621.",
"hi": f"\u0935\u093f\u091c\u094d\u091e\u093e \u092c\u093e\u0902\u0921 ({wd}\u00d7{ht}). \u092a\u0938\u094d\u0925\u093e\u0928, \u0924\u094d\u092f\u093e\u0916, \u091b\u092a\u093e\u0930, \u092a\u093e\u0928\u093e \u091c\u093e\u0924\u094d\u092e\u093e.",
"ur": f"\u0628\u06cc\u0646\u0627\u0626\u06cc \u0645\u062a\u0628\u0627\u062f\u0644 ({wd}\u00d7{ht}). \u067e\u0633\u06cc\u0646\u0627, \u062c\u0644\u062f, \u0633\u0627\u064a\u0647, \u067e\u0627\u0646\u06cc \u062a\u0627\u0645 \u06a9\u06cc\u0646.",
}
return msgs.get(lang, msgs["en"])
# ---------------- Voice: STT + TTS ----------------
def transcribe(audio_path: Optional[str], lang: str) -> str:
if not audio_path:
return ""
asr = _load_asr()
if asr is None:
return "(Whisper unavailable — type instead.)"
try:
res = asr(audio_path, generate_kwargs={"language": lang if lang == "en" else None})
return res.get("text", "").strip()
except Exception as e:
return f"(STT error: {e})"
def tts_to_file(text: str, lang: str) -> Optional[str]:
if not text or not _GTTS:
return None
try:
path = "/tmp/aira_tts.mp3"
gTTS(text=text, lang=LANGS.get(lang, {}).get("tts", "en")).save(path)
return path
except Exception:
return None
# ---------------- UI strings ----------------
UI: Dict[str, Dict[str, str]] = {
"en": {
"t": "AIRA v2", "s": "Voice • Vision • Safety Copilot — Qatar Heat Workers",
"tag": "Qwen2-VL • Qwen3 Reasoning • Whisper STT • gTTS • HF Spaces",
"w": "Live Qatar Weather", "c": "City", "rf": "Refresh",
"v": "Vision Scan", "vo": "Voice Copilot", "sf": "Safety Planner",
"vp": "Concern? (optional)", "vb": "Analyze Image",
"vb2": "Record & Translate", "tts": "Speak Safety Advice",
"sn": "Hydration & Work-Rest Plan", "wt": "Weight (kg)",
"wi": "Work intensity", "l": "Light", "m": "Moderate", "h": "Heavy",
"lg": "Language", "st": "Temp (°C)", "hu": "Humidity (%)",
"wbgt": "WBGT & Safety", "f": "Qatar Vision 2030 • Quality of Life • Open Source",
},
"ar": {
"t": "أيرا v2", "s": "مساعد صوت وبصر وأمان — عمال حرارة قطر",
"tag": "كيوين2-في إل • كيوين3 reasoning • ويسبر • جي تي تي إس • هجنج فيس سبيسز",
"w": "\u0627\u0644\u0637\u0642\u0633", "c": "\u0627\u0644\u0645\u062f\u064a\u0646\u0629", "rf": "\u062a\u062d\u062f\u064a\u062f",
"v": "\u062a\u062d\u0644\u064a\u0644 \u0628\u0635\u0631\u064a", "vo": "\u0627\u0644\u0635\u0648\u062a", "sf": "\u0627\u0644\u0623\u0645\u0646",
"vp": "\u0645\u0627\u0630\u0627 \u064a\u0642\u0644\u0642\u0643? ", "vb": "\u062a\u062d\u0644\u064a\u0644 \u0627\u0644\u0635\u0648\u0627\u0631",
"vb2": "\u062a\u0633\u062c\u064a\u0644 \u0648\u062a\u0631\u062c\u0645\u0629", "tts": "\u0627\u0633\u062a\u0645\u0639 \u0644\u0644\u0646\u0635\u064a\u062d\u0629",
"sn": "\u062e\u0637\u0629 \u0627\u0644\u062a\u0631\u0637\u064a\u0628 \u0648\u0627\u0644\u0648\u0642\u0641", "wt": "\u0627\u0644\u0648\u0632\u0646 (\u0643\u063a)",
"wi": "\u0634\u062f\u0629 \u0627\u0644\u0639\u0645\u0644", "l": "\u062e\u0641\u064a\u0641", "m": "\u0645\u062a\u0648\u0633\u0637", "h": "\u062b\u0642\u064a\u0644",
"lg": "\u0627\u0644\u0644\u063a\u0629", "st": "\u062f\u0631\u062c\u0629 \u0627\u0644\ud83e\udd21\ufe0f", "hu": "\u0646\u0633\u0628\u0629 \u0627\u0644\u0647\u0648\u0627\u0621 (%)",
"wbgt": "\u0648\u0628\u062c\u064a\u0634\u064a \u0648\u0627\u0644\u0623\u0645\u0646", "f": "\u0631\u0624\u064a\u0629 \u0642\u0637\u0631 2030 \u2022 \u0645\u062d\u062a\u0648\u0649 \u0627\u0644\u062d\u064a\u0627\u0629 \u2022 \u0645\u0641\u062a\u0648\u062d \u0627\u0644\u0645\u062d\u062f\u0648",
},
"hi": {
"t": "आईरा v2", "s": "आवाज़ • दृष्टि • सुरक्षा सहायक — कतर के गर्मी श्रमिक",
"tag": "क्यूइन2-वीएल • क्यूइन3 तर्क • व्हिस्पर • जीटीटीएस • एचएफ स्पेस",
"w": "\u092e\u094c\u092e\u093e", "c": "\u0936\u0939\u0930", "rf": "\u0924\u093e\u091a\u093e\u0921\u093c",
"v": "\u0935\u093f\u091c\u094d\u091e\u093e \u0938\u0915\u092c\u0942", "vo": "\u0906\u0935\u094d\u092f\u093e", "sf": "\u092f\u0947\u0928\u094d\u0927\u0915\u093e\u0930\u094d\u091c",
"vp": "\u091a\u094d\u092f\u093e\u0924\u093f? ", "vb": "\u091b\u093e\u0935\u093f \u092a\u094d\u0930\u0935\u093e\u0928",
"vb2": "\u0930\u093f\u0902\u0915\u094d\u0917 \u640 \u0905\u0928\u0941\u0926\u093e\u0921", "tts": "\u0938\u092e\u093e \u0938\u0942\u0928\u093e",
"sn": "\u091c\u0932\u094d\u092f\u094b\u092b\u094c\u0928 \u2022 \u0935\u093e\u0930\u094d\u092f\u093e-\u0935\u094d\u0930\u092e\u093f\u0930\u094d\u092e\u093e", "wt": "\u0935\u0928\u0928 (\u0915\u093f\u0902\u0917)",
"wi": "\u0915\u093e\u0930\u094d\u0924 \u0924\u093f\u0935\u093e\u091f", "l": "\u0939\u092e\u0932", "m": "\u092e\u0902\u0921\u0935\u094d\u092e\u094d\u092e", "h": "\u092d\u093e\u0930\u0940",
"lg": "\u092d\u093e\u0907\u091c", "st": "\u0926\u094d\u0935\u093e\u091f (\u092e\u094d\u092e\u093e\u0902)", "hu": "\u0928\u094d\u092f\u093e\u0930\u094d\u0925\u093e\u092f (%)",
"wbgt": "डब्ल्यूबीजीटी और सुरक्षा", "f": "\u0915\u093e\u0924\u0930 \u0935\u093f\u0936\u094d\u0935\u0935\u093e\u0928 2030 \u2022 \u091c\u0940\u092e\u093e\u0928 \u0917\u0941\u0928\u093f\u092e \u2022 \u0914\u092a\u0923 \u0938\u094c\u092e\u0938",
},
"ur": {
"t": "ایرا v2", "s": "آواز • بینائی • حفاظتی معاون — گرمی کے مزدور",
"tag": "کوئیں2-وی ایل • کوئیں3 استدلال • ويسپر • جی ٹی ٹی ایس • ایچ ایف سلیٹس",
"w": "\u0645\u0648\u0633\u0645", "c": "\u0634\u0647\u0631", "rf": "\u062a\u0627\u0632\u0647 \u0643\u06cc\u064a\u0627",
"v": "\u0628\u06cc\u0646\u0627\u0626\u06cc", "vo": "\u0622\u0627\u0632", "sf": "\u067e\u0644\u0627\u0646 \u0627\u0645\u0646",
"vp": "\u06a9\u06cc\u0627 \u0646\u06cc\u06ba\u0634? ", "vb": "\u062a\u0633\u0648\u06cc\u0631 \u0635\u0648\u06cc\u0631",
"vb2": "\u0631\u06cc\u06a9\u0631\u0627\u062f \u0648 \u062a\u0631\u062c\u0645\u0629", "tts": "\u0645\u0634\u0648\u0631\u0647 \u0633\u0646\u06cc\u0646",
"sn": "\u067e\u0627\u0646\u06cc \u0648 \u0648\u0642\u0641\u0647", "wt": "\u0648\u0632\u0646 (\u06a9\u06cc\u0644\u0648\u063a\u0631\u0627\u0645)",
"wi": "\u06a9\u0627\u0645 \u0643\u06cc \u0634\u062f\u062a", "l": "\u0647\u0644\u0643\u0627", "m": "\u0645\u062a\u0648\u0633\u0648\u062a", "h": "\u0628\u062d\u0627\u0631\u06cc",
"lg": "\u0632\u0628\u0627\u0646", "st": "درجہ حرارت (\u0628\u0648)", "hu": "نمی (\u0641\u06ccصد)",
"wbgt": "وی بی جی ٹی اور حفاظت", "f": "\u0642\u0637\u0631 \u0648\u06cc\u0632\u06cc\u0646 2030 \u2022 \u0632\u06cc\u0646\u06af\u06cc \u0643\u0627 \u0645\u06cc\u0627\u0631 \u2022 \u0627\u0648\u067e\u0646 \u0633\u0648\u0631\u0633",
},
}
def tr(lang: str, k: str) -> str:
return UI.get(lang, UI["en"]).get(k, UI["en"][k])
# ---------------- CSS ----------------
CSS = """
:root{--deep:#78350f;--desert:#d97706;--maroon:#8d2f3a;}
.gradio-container{font-family:system-ui,sans-serif;background:linear-gradient(135deg,#fdf3e3 0%,#e8d4b3 60%,#d97706 100%);min-height:100vh}
body[dir=rtl],.gradio-container[dir=rtl]{direction:rtl}
.aira-header{text-align:center;padding:2rem 1rem 1rem;border-bottom:3px solid var(--maroon);margin-bottom:1rem}
.aira-title{font-size:2.5rem;font-weight:800;color:var(--deep);margin:0}
.aira-subtitle{font-size:1.1rem;color:#374151;margin:.25rem 0 .5rem}
.aira-tagline{font-size:.9rem;color:var(--desert);font-weight:600;text-transform:uppercase;letter-spacing:1px}
.weather-card{background:rgba(255,255,255,.85);border-radius:16px;padding:1.25rem;box-shadow:0 10px 25px -5px rgba(0,0,0,.1);margin-bottom:1rem;border-left:5px solid var(--maroon)}
.weather-temp{font-size:3rem;font-weight:900;color:var(--deep)}
.risk-badge{display:inline-block;padding:.35rem 1rem;border-radius:999px;font-weight:800;font-size:.95rem;color:#fff}
.plan-card{background:rgba(255,255,255,.85);border-radius:16px;padding:1.25rem;box-shadow:0 10px 25px -5px rgba(0,0,0,.1);margin-bottom:1rem}
footer{text-align:center;padding:1.5rem;color:#6b7280;font-size:.85rem;border-top:1px solid rgba(255,255,255,.3);margin-top:2rem}
@media(max-width:640px){.aira-title{font-size:1.75rem}.weather-temp{font-size:2.25rem}}
# ---------------- Gradio App ----------------
def build_app() -> gr.Blocks:
with gr.Blocks(title="AIRA v2", css=CSS, theme=gr.themes.Soft(primary_hue="orange")) as demo:
# State
lang = gr.State("en")
city = gr.State("Doha")
ws = gr.State({})
guidance = gr.State({})
# Header
gr.HTML(
f'<div class="aira-header">'
f'<div class="aira-title">\U0001f321\ufe0f {tr("en","t")}</div>'
f'<div class="aira-subtitle">{tr("en","s")}</div>'
f'<div class="aira-tagline">{tr("en","tag")}</div>'
f'</div>'
)
# Weather row
with gr.Row(equal_height=True):
cd = gr.Dropdown(choices=list(QATAR_CITIES), value="Doha", label=tr("en","c"), scale=1)
rb = gr.Button(tr("en","rf"), variant="primary", scale=0)
wd = gr.HTML(value=_weather_html("Doha", get_qatar_weather("Doha")))
# Tabs
with gr.Tabs():
# --- Vision Tab ---
with gr.TabItem(tr("en","v"), id="vision"):
with gr.Row():
with gr.Column(scale=1):
vi = gr.Image(type="pil", label="Worker / job-site photo")
vp2 = gr.Textbox(label=tr("en","vp"), placeholder="e.g. sweating, no shade...", lines=2)
vb = gr.Button(tr("en","vb"), variant="primary")
with gr.Column(scale=1):
vo = gr.Markdown(label="Aira Vision Assessment")
# --- Voice Tab ---
with gr.TabItem(tr("en","vo"), id="voice"):
with gr.Row():
with gr.Column(scale=1):
va = gr.Audio(sources=["microphone"], type="filepath", label="Speak AR/HI/UR/EN")
vb2 = gr.Button(tr("en","vb2"), variant="primary")
vt = gr.Textbox(label="Transcribed", lines=3, interactive=False)
with gr.Column(scale=1):
tb = gr.Button(tr("en","tts"), variant="secondary")
ta = gr.Audio(label="Safety audio (auto-play)", autoplay=True)
# --- Safety Tab ---
with gr.TabItem(tr("en","sf"), id="safety"):
with gr.Row():
with gr.Column(scale=1):
st_in = gr.Number(label=tr("en","st"), value=40, minimum=0, maximum=60, step=1)
hu_in = gr.Number(label=tr("en","hu"), value=50, minimum=0, maximum=100, step=1)
wi_in = gr.Radio([tr("en","l"), tr("en","m"), tr("en","h")], value=tr("en","m"), label=tr("en","wi"))
sl_in = gr.Dropdown([f"{x['name']} ({k})" for k, x in LANGS.items()], value="English (en)", label=tr("en","lg"))
with gr.Column(scale=1):
rh_out = gr.HTML(label=tr("en","wbgt"))
ph_out = gr.HTML(label=tr("en","sn"))
gr.HTML(f"<footer>{tr('en','f')}</footer>")
# ---------------- Helpers ----------------
def _weather_html(ct: str, w: Dict) -> str:
if not w.get("ok"):
return f'<div class="weather-card"><div class="weather-temp">{w.get("temp","—")}\u00b0C</div><div style="color:#6b7280">{ct} — weather unavailable</div></div>'
r = wbgt_guidance(wbgt_estimate(w.get("temp"), w.get("humidity"), True), "moderate")
return (
f'<div class="weather-card">'
f'<div style="display:flex;justify-content:space-between;align-items:start;">'
f'<div><div class="weather-temp">{w.get("temp")}\u00b0C</div>'
f'<div style="font-size:.9rem;color:#4b5563">Feels {w.get("feels_like")}\u00b0C • Hum {w.get("humidity")}% • Wind {w.get("wind")} km/h • {ct}</div></div>'
f'<div class="risk-badge" style="background:{r["color"]}">{r["label"]} risk</div>'
f'</div></div>'
)
def _safety_html(temp, hum, intensity, lang_code, vision_text=""):
wbgt = wbgt_estimate(temp, hum, sun_exposed="direct sun" in vision_text.lower())
g = wbgt_guidance(wbgt, intensity)
rep = safety_report(temp, hum, intensity, vision_text, lang_code)
html = (
f'<div class="plan-card" style="border-left:5px solid {g["color"]}">'
f'<div style="display:flex;justify-content:space-between;margin-bottom:.5rem;">'
f'<strong>WBGT:</strong> {g["wbgt"]}°C <strong>Risk:</strong> <span class="risk-badge" style="background:{g["color"]}">{g["label"]}</span>'
f'</div>'
f'<div style="margin-top:.5rem;line-height:1.6;">{rep}</div>'
f'</div>'
)
return html, g
# ---------------- Event handlers ----------------
def oref(c):
w = get_qatar_weather(c)
ws.value = w
return _weather_html(c, w)
def olg(ch):
lang.value = ch.split("(")[-1].strip(")") if "(" in ch else "en"
def ov(img, pr, l_code, demo_flag):
if demo_flag or img is None:
return _vision_fallback(Image.new("RGB", (400, 300), (200, 200, 200)), l_code)
return analyze_image(img, l_code)
def ovc(audio, l_code):
tx = transcribe(audio, l_code)
w = ws.value or get_qatar_weather(city.value or "Doha")
g = guidance.value or wbgt_guidance(wbgt_estimate(w.get("temp"), w.get("humidity"), True), "moderate")
rep = safety_report(w.get("temp"), w.get("humidity"), g["intensity"], tx or "no voice input", l_code)
if tx:
rep = f'You said: "{tx}"\n\n{rep}'
tts_path = tts_to_file(rep, l_code)
return rep, tts_path
def osafety(temp, hum, wi_label, lang_choice):
l_code = lang_choice.split("(")[-1].strip(")") if "(" in lang_choice else "en"
lang.value = l_code
intensity = {"light": "light", "moderate": "moderate", "heavy": "heavy"}.get(
{tr("en","l"):"light", tr("en","m"):"moderate", tr("en","h"):"heavy"}.get(wi_label, "moderate"), "moderate"
)
html, g = _safety_html(temp, hum, intensity, l_code)
guidance.value = g
return html
def ovoice_tts(txt, l_code):
return tts_to_file(txt, l_code)
# Wiring
rb.click(oref, [cd], [wd])
cd.change(oref, [cd], [wd])
sl_in.change(olg, [sl_in], [])
vb.click(ov, [vi, vp2, lang, gr.Checkbox(value=False, visible=False)], [vo])
vb2.click(ovc, [va, lang], [vt, ta])
tb.click(ovoice_tts, [vt, lang], [ta])
st_in.change(osafety, [st_in, hu_in, wi_in, sl_in], [rh_out])
hu_in.change(osafety, [st_in, hu_in, wi_in, sl_in], [rh_out])
wi_in.change(osafety, [st_in, hu_in, wi_in, sl_in], [rh_out])
sl_in.change(osafety, [st_in, hu_in, wi_in, sl_in], [rh_out])
demo.load(osafety, [st_in, hu_in, wi_in, sl_in], [rh_out])
return demo
if __name__ == "__main__":
build_app().launch(server_name="0.0.0.0", server_port=7860)