blushedCV / app.py
tariquef's picture
WebGL virtual makeup: FastAPI + Gemini, Docker Space, HUD polish
5277177
Raw
History Blame Contribute Delete
12.2 kB
"""
Serves the Vite `webgl/dist/` SPA and exposes a small vision API for AI makeup color reads.
**Where to put your Gemini API key (local):**
Create `.env` next to this file (`app.py`, repo root):
GEMINI_API_KEY=your-key-from-ai-studio
Create a key: https://aistudio.google.com/apikey
Never commit `.env` — it is listed in `.gitignore`.
Also supported: `GOOGLE_API_KEY` (same value) if you already use that name.
**Hugging Face Space:** add a repository secret named `GEMINI_API_KEY`.
Optional env: `GEMINI_VISION_MODEL` (default `gemini-2.5-flash-lite`). If you hit 429 quota on free tier,
try another model (e.g. `gemini-2.5-flash`) or enable billing in Google AI Studio / Cloud.
Without a key, `POST /api/analyze-makeup-colors` returns HTTP 503.
Local dev: `uvicorn app:app --reload --port 8000` from repo root, then `npm run dev` in `webgl/`
(Vite proxies `/api` → localhost:8000).
"""
from __future__ import annotations
import asyncio
import io
import json
import os
import re
from typing import Any
from pathlib import Path
from dotenv import load_dotenv
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.staticfiles import StaticFiles
_ROOT = Path(__file__).resolve().parent
# Load from repo root first, then optional `webgl/.env` (fills only keys not already set).
for _env_path in (_ROOT / ".env", _ROOT / "webgl" / ".env"):
if _env_path.is_file():
load_dotenv(_env_path, override=False)
_DIST = _ROOT / "webgl" / "dist"
if not _DIST.is_dir():
_DIST = _ROOT / "dist"
app = FastAPI(title="Doremi Virtual Makeup API")
MAX_IMAGE_BYTES = 2_500_000
SYSTEM_PROMPT = """You are a friendly digital beauty guide inside a virtual makeup try-on app.
You will receive ONE casual webcam-style photo where a face is usually visible.
Return ONLY valid JSON (no markdown fences) with exactly these keys:
- headline: string, max ~70 chars, warm and clear, no emoji
- vibe_tags: array of 3 to 6 short strings (micro-trend tags like "coquette blush" / "clean girl")
- undertone_read: string, 2-3 sentences, soft language ("reads warm-neutral…"), beauty read only — not clinical
- lip_colors: array of 2-4 strings (color directions + finishes, e.g. "dusty rose satin") — favor pink, rose, mauve, soft berry, MLBB rose; avoid peachy/coral/orange-forward lip reads unless the style brief says otherwise
- eye_colors: array of 2-4 strings (shadow/liner directions)
- blush_colors: array of 2-3 strings — favor pink, rose, cool pink, soft raspberry; avoid yellow-orange, terracotta-forward, or heavy coral unless the style brief says otherwise
- liner_brow: string, one friendly sentence for liner + brow harmony
- tips: array of 2-4 short actionable tips (blend edges, balance warmth, etc.)
- confidence_note: string, one sentence: lighting/angle limits the read
- disclaimer: string, always exactly "" (empty — do not add legal, medical, or disclaimer sentences)
- look_hex: object with EXACTLY these keys, each a CSS hex string "#RRGGBB" (6 hex digits, uppercase preferred):
- lip: main lipstick color that matches lip_colors (prefer pink-rose-mauve-red family in hex; not orange-yellow dominant)
- eye_shadow: primary eyeshadow for lids/crease that matches eye_colors
- liner: eyeliner (often deep brown or black)
- brow: brow fill that harmonizes with hair/photo
- blush: cheek color that matches blush_colors (prefer pink-red-rose hex; not hot yellow-orange dominant)
The five look_hex colors must be realistic makeup pigments (not neon unless the look calls for it) and must visually harmonize with the text color suggestions.
Tone: friendly, kind, body-positive, inclusive. No insults, no harsh judgments, no certainty about "what you are".
Do not mention model names or policies. English only.
The user message begins with a STYLE BRIEF the user chose (natural / glam / fun). Follow that brief for every field, including vibe_tags and look_hex."""
_ALLOWED_LOOK_VIBES = frozenset({"natural", "glam", "fun"})
_LOOK_VIBE_INSTRUCTIONS: dict[str, str] = {
"natural": (
"STYLE BRIEF — NATURAL / EVERYDAY: Cute, flattering, believable soft glam for real life — \"no-makeup makeup\" "
"or polished everyday. Lips and blush must lean PINK / ROSE / MAUVE / soft BERRY / MLBB rose — sweet, fresh, "
"romantic. Do NOT steer lips or blush toward yellow, orange, peach-coral, terracotta, or brown-orange; those "
"read dated or sallow on many faces here. Eyeshadow and liner stay soft and harmonious. look_hex.lip and "
"look_hex.blush must be clearly pink-red family (enough blue/magenta vs pure orange). Cohesive in daylight, "
"not heavy stage makeup."
),
"glam": (
"STYLE BRIEF — GLAM (BOLD): Evening-ready or special-occasion — richer pigment, smokier or deeper eyes, "
"bolder lip, sharper liner, defined brows — still flattering, not costume. Lips and blush stay in the CUTE "
"PINK–RED–ROSE–BERRY–MAUVE lane (bold is fine: deeper rose, wine-stain, fuchsia-rose blush). Avoid "
"orange-red, brick-orange, heavy coral, or yellow-peach blush/lip stories; no \"warm pumpkin\" cheeks. "
"look_hex.lip and look_hex.blush should read clearly pink or red-rose, not orange-dominant."
),
"fun": (
"STYLE BRIEF — FUN / PLAYFUL: Creative, expressive, trend-forward — colored liner, vivid blush, playful shadow. "
"When describing cheeks and lips, still default toward pink-red-rose playful tones rather than orange-peach "
"unless you deliberately pick one contrasting accent. Keep it joyful and tasteful. look_hex stays one "
"coordinated look."
),
}
_LOOK_HEX_KEYS = ("lip", "eye_shadow", "liner", "brow", "blush")
def _normalize_hex_color(value: str) -> str:
t = value.strip()
if not t.startswith("#"):
t = "#" + t
h = t[1:]
if len(h) == 3 and all(c in "0123456789abcdefABCDEF" for c in h):
h = "".join(c * 2 for c in h)
if len(h) != 6 or any(c not in "0123456789abcdefABCDEF" for c in h):
raise ValueError(f"invalid hex: {value!r}")
return "#" + h.upper()
def _validate_look_hex(data: dict[str, Any]) -> None:
lh = data.get("look_hex")
if not isinstance(lh, dict):
raise HTTPException(status_code=502, detail="Model JSON missing or invalid look_hex (expected object).")
out: dict[str, str] = {}
for k in _LOOK_HEX_KEYS:
v = lh.get(k)
if not isinstance(v, str):
raise HTTPException(status_code=502, detail=f"look_hex.{k} must be a string #RRGGBB.")
try:
out[k] = _normalize_hex_color(v)
except ValueError as e:
raise HTTPException(status_code=502, detail=f"look_hex.{k}: {e!s}") from e
data["look_hex"] = out
def _strip_json_fence(text: str) -> str:
t = text.strip()
if t.startswith("```"):
t = re.sub(r"^```[a-zA-Z0-9]*\s*", "", t)
t = re.sub(r"\s*```$", "", t.strip())
return t.strip()
def _parse_analysis_json(raw: str) -> dict[str, Any]:
try:
return json.loads(_strip_json_fence(raw))
except json.JSONDecodeError as e:
raise HTTPException(status_code=502, detail=f"Model returned invalid JSON: {e}") from e
@app.post("/api/analyze-makeup-colors")
async def analyze_makeup_colors(
image: UploadFile = File(...),
look_vibe: str = Form("natural"),
) -> dict[str, Any]:
api_key = (os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY") or "").strip()
if not api_key:
raise HTTPException(
status_code=503,
detail=(
"GEMINI_API_KEY is not set in the server process. "
"Local: add `GEMINI_API_KEY=...` to a `.env` file next to `app.py` (repo root), then restart uvicorn. "
"Or run `export GEMINI_API_KEY=...` in the same terminal before uvicorn. "
"Hugging Face: add a Space secret named GEMINI_API_KEY (`.env` is not shipped in Docker builds)."
),
)
body = await image.read()
if not body:
raise HTTPException(status_code=400, detail="Empty image upload.")
if len(body) > MAX_IMAGE_BYTES:
raise HTTPException(status_code=413, detail="Image too large (max ~2.5 MB).")
vibe_key = look_vibe.strip().lower()
if vibe_key not in _ALLOWED_LOOK_VIBES:
raise HTTPException(
status_code=400,
detail="look_vibe must be one of: natural, glam, fun.",
)
try:
import google.generativeai as genai
from PIL import Image
except ImportError as e:
raise HTTPException(
status_code=500,
detail="Server missing Gemini deps. Run: pip install google-generativeai pillow",
) from e
genai.configure(api_key=api_key)
# 2.5 Flash-Lite: fast, multimodal (incl. images), cost-efficient per Google AI docs.
model_name = os.environ.get("GEMINI_VISION_MODEL", "gemini-2.5-flash-lite").strip() or "gemini-2.5-flash-lite"
try:
pil = Image.open(io.BytesIO(body))
if pil.mode != "RGB":
pil = pil.convert("RGB")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Could not read image: {e!s}") from e
model = genai.GenerativeModel(
model_name=model_name,
system_instruction=SYSTEM_PROMPT,
)
style_block = _LOOK_VIBE_INSTRUCTIONS[vibe_key]
user_text = (
f"{style_block}\n\n"
"Look at this face snapshot. Suggest flattering makeup COLOR directions "
"(not exact products). Reply with JSON only, following the schema from your instructions. "
"look_hex must match the lip, eye, blush, liner, and brow directions you describe."
)
def _quota_retry_delay(exc: BaseException) -> float:
m = re.search(r"retry in ([\d.]+)s", str(exc), re.I)
if m:
return min(35.0, float(m.group(1)) + 0.5)
return 7.0
def _is_quota_error(exc: BaseException) -> bool:
s = str(exc).lower()
return (
"429" in str(exc)
or "quota" in s
or "resource exhausted" in s
or "rate limit" in s
)
response = None
last_exc: BaseException | None = None
for attempt in range(2):
try:
response = model.generate_content(
[user_text, pil],
generation_config=genai.GenerationConfig(
response_mime_type="application/json",
max_output_tokens=1024,
temperature=0.75,
),
)
break
except Exception as e:
last_exc = e
if attempt == 0 and _is_quota_error(e):
await asyncio.sleep(_quota_retry_delay(e))
continue
raise HTTPException(status_code=502, detail=f"Upstream Gemini error: {e!s}") from e
if response is None:
raise HTTPException(
status_code=502,
detail=f"Upstream Gemini error after retry: {last_exc!s}" if last_exc else "Empty Gemini response.",
)
try:
raw = response.text
except ValueError as e:
raise HTTPException(
status_code=502,
detail="Gemini returned no text (blocked or empty). Try another photo or model.",
) from e
if not raw or not raw.strip():
raise HTTPException(status_code=502, detail="Empty response from Gemini.")
data = _parse_analysis_json(raw)
required = [
"headline",
"vibe_tags",
"undertone_read",
"lip_colors",
"eye_colors",
"blush_colors",
"liner_brow",
"tips",
"confidence_note",
"disclaimer",
"look_hex",
]
missing = [k for k in required if k not in data]
if missing:
raise HTTPException(
status_code=502,
detail=f"Model JSON missing keys: {', '.join(missing)}",
)
_validate_look_hex(data)
data["disclaimer"] = ""
return {"ok": True, "analysis": data}
# Static SPA — register API routes above this line.
app.mount("/", StaticFiles(directory=str(_DIST), html=True), name="static")