ergoAI / app.py
Khanh0210's picture
Update app.py
29c2511 verified
Raw
History Blame Contribute Delete
11.6 kB
import os
os.environ["YOLO_CONFIG_DIR"] = "/tmp"
os.environ["MPLCONFIGDIR"] = "/tmp"
import spaces
import base64
import io
import math
import re
import cv2
import numpy as np
import gradio as gr
from fastapi import FastAPI, File, UploadFile, Form
from fastapi.middleware.cors import CORSMiddleware
from gtts import gTTS
from pydantic import BaseModel
from ultralytics import YOLO
from PIL import Image
try:
from google import genai
except ImportError:
genai = None
fastapi_app = FastAPI(title="ErgoAI - YOLOv8 System")
fastapi_app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
desk_model = None
pose_model = None
def get_desk_model():
global desk_model
if desk_model is None:
desk_model = YOLO('yolov8n.pt')
return desk_model
def get_pose_model():
global pose_model
if pose_model is None:
pose_model = YOLO('yolov8n-pose.pt')
return pose_model
@spaces.GPU
def infer_desk(img_out):
model = get_desk_model()
return model(img_out, conf=0.20, imgsz=640, verbose=False)
@spaces.GPU
def infer_pose(img):
model = get_pose_model()
return model(img, imgsz=320, verbose=False)
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "").strip()
gemini_client = None
if GEMINI_API_KEY and genai:
try:
gemini_client = genai.Client(api_key=GEMINI_API_KEY)
except Exception:
pass
AUDIO_CACHE = {}
MAX_CACHE_SIZE = 100
SCREEN_REAL_WIDTH_CM = 35.0
class ImageData(BaseModel):
image_base64: str
is_calibration: bool
baseline_eye_dist: float = 0.0
baseline_shoulder_y: float = 0.0
def calculate_distance(p1, p2):
return math.sqrt((p2[0] - p1[0])**2 + (p2[1] - p1[1])**2)
def cv2_to_base64(image):
_, buffer = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, 70])
return f"data:image/jpeg;base64,{base64.b64encode(buffer).decode('utf-8')}"
def clean_text_for_tts(text):
cleaned = re.sub(r'[\#\*\_\`\>\-\[\]\(\)]', ' ', text)
return re.sub(r'\s+', ' ', cleaned).strip()
def get_voice_base64(text: str, lang='en') -> str:
if not text:
return ""
if len(AUDIO_CACHE) > MAX_CACHE_SIZE:
AUDIO_CACHE.clear()
cache_key = f"{lang}_{text}"
if cache_key not in AUDIO_CACHE:
try:
tts = gTTS(text=text, lang=lang, slow=False)
fp = io.BytesIO()
tts.write_to_fp(fp)
fp.seek(0)
AUDIO_CACHE[cache_key] = base64.b64encode(fp.read()).decode('utf-8')
except Exception:
return ""
return AUDIO_CACHE[cache_key]
def process_single_image(img, user_height):
h_img, w_img, _ = img.shape
img_out = img.copy()
results = infer_desk(img_out)
object_coords = {}
for r in results:
for box in r.boxes:
label = get_desk_model().names[int(box.cls[0])].lower()
x_center, y_center, width, height = box.xywh[0].tolist()
if label not in object_coords:
object_coords[label] = []
object_coords[label].append({'center': (x_center, y_center), 'width_px': width})
x_origin, y_origin, w_origin_px = 0, 0, 0
if 'laptop' in object_coords:
x_origin, y_origin = object_coords['laptop'][0]['center']
w_origin_px = object_coords['laptop'][0]['width_px']
elif 'tvmonitor' in object_coords:
x_origin, y_origin = object_coords['tvmonitor'][0]['center']
w_origin_px = object_coords['tvmonitor'][0]['width_px']
else:
x_origin, y_origin = w_img / 2, h_img * 0.4
w_origin_px = w_img * 0.35
raw_r_normal = w_origin_px * ((user_height * 0.24) / SCREEN_REAL_WIDTH_CM)
raw_r_max = w_origin_px * ((user_height * 0.35) / SCREEN_REAL_WIDTH_CM)
max_limit = min(w_img, h_img) * 0.35
R_NORMAL_PX = int(min(raw_r_normal, max_limit))
R_MAX_PX = int(min(raw_r_max, max_limit * 1.3))
overlay = img_out.copy()
cv2.circle(overlay, (int(x_origin), int(y_origin)), R_NORMAL_PX, (0, 255, 0), -1)
cv2.circle(img_out, (int(x_origin), int(y_origin)), R_MAX_PX, (0, 0, 255), 3)
cv2.addWeighted(overlay, 0.20, img_out, 0.80, 0, img_out)
violations = []
deductions = 0
for label_name, object_list in object_coords.items():
for obj in object_list:
x_obj, y_obj = obj['center']
if abs(x_obj - x_origin) < 20 and abs(y_obj - y_origin) < 20:
continue
distance_px = math.sqrt((x_obj - x_origin)**2 + (y_obj - y_origin)**2)
if distance_px == 0:
continue
distance_cm = (distance_px / w_origin_px) * SCREEN_REAL_WIDTH_CM
if label_name in ['cup', 'bottle'] and distance_px < R_MAX_PX:
violations.append(f"**{label_name.title()}**: In danger zone ({distance_cm:.1f}cm)")
deductions += 15
elif label_name in ['mouse', 'keyboard'] and distance_px > R_NORMAL_PX:
violations.append(f"**{label_name.title()}**: Too far ({distance_cm:.1f}cm)")
deductions += 10
score = max(0, 100 - deductions)
return img_out, list(dict.fromkeys(violations)), score
@fastapi_app.get("/")
def read_root():
return {"status": "online", "message": "ErgoAI Production Backend is running!"}
@fastapi_app.post("/api/assess_desk")
def assess_desk(
file: UploadFile = File(...),
user_height: float = Form(170.0)
):
contents = file.file.read()
nparr = np.frombuffer(contents, np.uint8)
img_bgr = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
annotated_bgr, violations, score = process_single_image(img_bgr, user_height)
violations_text = "\n".join([f"* {v}" for v in violations]) if violations else "* Setup is optimal."
spatial_section = f"### Spatial Alerts\n{violations_text}\n"
gemini_insights = ""
if gemini_client:
try:
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
raw_img = Image.fromarray(img_rgb)
raw_img.thumbnail((1024, 1024))
prompt = f"User Height: {user_height}cm. Score: {score}. Issues: {violations_text}. Be concise in English."
response = gemini_client.models.generate_content(model='gemini-2.5-flash', contents=[prompt, raw_img])
gemini_insights = response.text if response.text else ""
except Exception:
pass
final_report = f"### Ergonomic Score: **{score}/100**\n\n{spatial_section}\n---\n{gemini_insights}"
tts_text = clean_text_for_tts(gemini_insights if gemini_insights else "Analysis complete.")
audio_base64_str = get_voice_base64(tts_text, lang='en')
return {
"feedback": final_report,
"processed_image": cv2_to_base64(annotated_bgr),
"audio_base64": f"data:audio/mp3;base64,{audio_base64_str}" if audio_base64_str else ""
}
@fastapi_app.post("/api/analyze_frame")
def analyze_frame(data: ImageData):
try:
encoded_data = data.image_base64.split(',')[1] if ',' in data.image_base64 else data.image_base64
nparr = np.frombuffer(base64.b64decode(encoded_data), np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img is None:
raise ValueError("Invalid Image Data")
except Exception:
return {"status": "Invalid Image", "is_bad_posture": False, "eye_dist": 0, "shoulder_y": 0, "is_success": False}
results = infer_pose(img)
status = "No person detected"
current_eye_dist = 0.0
current_shoulder_y = 0.0
color = (0, 0, 255)
is_success = False
is_bad_posture = False
if len(results) > 0 and results[0].keypoints is not None and len(results[0].keypoints.xy) > 0:
kpts = results[0].keypoints.xy[0].cpu().numpy()
confs = results[0].keypoints.conf[0].cpu().numpy() if results[0].keypoints.conf is not None else np.ones(len(kpts))
CONF_THRESHOLD = 0.5
if len(kpts) >= 7:
left_eye, right_eye = kpts[1], kpts[2]
left_shoulder, right_shoulder = kpts[5], kpts[6]
if (confs[1] > CONF_THRESHOLD and confs[2] > CONF_THRESHOLD and
confs[5] > CONF_THRESHOLD and confs[6] > CONF_THRESHOLD and
np.any(left_eye) and np.any(right_eye) and
np.any(left_shoulder) and np.any(right_shoulder)):
current_eye_dist = calculate_distance(left_eye, right_eye)
current_shoulder_y = float((left_shoulder[1] + right_shoulder[1]) / 2)
shoulder_tilt = abs(left_shoulder[1] - right_shoulder[1])
eye_tilt = abs(left_eye[1] - right_eye[1])
if data.is_calibration:
if current_eye_dist > 60:
status = "Error: Too CLOSE! Move back 50-70cm and recalibrate."
color = (0, 0, 255)
elif current_eye_dist < 30:
status = "Error: Too FAR! Move closer 50-70cm and recalibrate."
color = (0, 0, 255)
elif shoulder_tilt > 12:
status = "Error: SHOULDERS TILTED! Please sit straight."
color = (0, 0, 255)
else:
status = "Success: Standard posture saved!"
color = (0, 255, 0)
is_success = True
else:
if data.baseline_eye_dist > 0 and current_eye_dist > (data.baseline_eye_dist * 1.25):
status = "Warning: Too close to screen!"
is_bad_posture = True
color = (0, 0, 255)
elif data.baseline_shoulder_y > 0 and current_shoulder_y > (data.baseline_shoulder_y + 15):
status = "Warning: Bad posture (Slouching)!"
is_bad_posture = True
color = (0, 0, 255)
elif shoulder_tilt > 18 or eye_tilt > 15:
status = "Warning: Bad posture (Leaning)!"
is_bad_posture = True
color = (0, 0, 255)
else:
status = "Good posture"
is_bad_posture = False
color = (0, 255, 0)
cv2.putText(img, status, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
cv2.circle(img, (int(left_eye[0]), int(left_eye[1])), 4, (255, 0, 0), -1)
cv2.circle(img, (int(right_eye[0]), int(right_eye[1])), 4, (255, 0, 0), -1)
cv2.line(img, (int(left_shoulder[0]), int(left_shoulder[1])),
(int(right_shoulder[0]), int(right_shoulder[1])), (0, 255, 0), 2)
_, buffer = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 70])
processed_base64 = base64.b64encode(buffer).decode('utf-8')
return {
"image": f"data:image/jpeg;base64,{processed_base64}",
"status": status,
"is_bad_posture": is_bad_posture,
"eye_dist": current_eye_dist,
"shoulder_y": current_shoulder_y,
"is_success": is_success
}
with gr.Blocks() as dummy_ui:
gr.Markdown("## ErgoAI Backend is Running")
gr.Markdown("API and ZeroGPU are active.")
dummy_img = gr.Image(visible=False)
dummy_btn = gr.Button("Init", visible=False)
dummy_btn.click(fn=infer_desk, inputs=dummy_img, outputs=None)
dummy_btn.click(fn=infer_pose, inputs=dummy_img, outputs=None)
app = gr.mount_gradio_app(fastapi_app, dummy_ui, path="/gradio")