giva-discovery / src /vision.py
Gautham98's picture
Speed: route understanding to Gemini flash-lite, explanations off by default
0b8ede4 verified
Raw
History Blame Contribute Delete
8.83 kB
"""
vision.py — "search by photo". Claude Vision looks at an uploaded image and
describes the jewellery as structured search intent, which we feed into the SAME
semantic search pipeline as text queries.
We deliberately do NOT use CLIP: torch is blocked by Windows App Control on this
machine, so image embeddings aren't possible. Claude Vision -> text description
-> local ONNX embedding is the workaround, and it doubles as an explainable
"here's what I see" moment for the demo.
Uses the AICredits gateway (OpenAI-compatible), so images are passed as an
`image_url` data-URL, exactly like the OpenAI vision format.
"""
import json
import os
from dotenv import load_dotenv
load_dotenv()
BASE_URL = os.environ.get("AICREDITS_BASE_URL", "https://api.aicredits.in/v1")
# Vision-capable Claude model; override with AICREDITS_VISION_MODEL if needed.
VISION_MODEL = os.environ.get(
"AICREDITS_VISION_MODEL",
os.environ.get("AICREDITS_MODEL", "anthropic/claude-haiku-4.5"),
)
_client = None
def _get_client():
global _client
if _client is None:
from openai import OpenAI
key = os.environ.get("AICREDITS_API_KEY")
if not key:
return None
_client = OpenAI(base_url=BASE_URL, api_key=key)
return _client
# ---------------------------------------------------------------------------
# Vision provider CHAIN: AICredits (Claude, primary) -> Gemini (free fallback).
# Order via VISION_PROVIDERS env (default "aicredits,gemini").
# ---------------------------------------------------------------------------
import requests # noqa: E402
GEMINI_VISION_MODEL = os.environ.get("GEMINI_VISION_MODEL", "gemini-flash-lite-latest")
def _parse_json(text):
if not text:
return None
t = text.strip()
if t.startswith("```"):
t = t.split("```")[1]
t = t[4:] if t.startswith("json") else t
t = t.strip()
try:
return json.loads(t)
except ValueError:
s, e = t.find("{"), t.rfind("}")
if s >= 0 and e > s:
try:
return json.loads(t[s:e + 1])
except ValueError:
return None
return None
def _aicredits_vision(system, user_text, data_url, max_tokens=700):
client = _get_client()
if client is None:
raise RuntimeError("no AICredits key")
resp = client.chat.completions.create(
model=VISION_MODEL, max_tokens=max_tokens, temperature=0.1,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": system},
{"role": "user", "content": [
{"type": "text", "text": user_text},
{"type": "image_url", "image_url": {"url": data_url}},
]},
],
)
return resp.choices[0].message.content
def _gemini_vision(system, user_text, data_url, max_tokens=700):
key = os.environ.get("GEMINI_API_KEY")
if not key:
raise RuntimeError("no GEMINI_API_KEY")
mime, b64 = "image/jpeg", data_url
if data_url.startswith("data:"):
head, b64 = data_url.split(",", 1)
mime = head.split(":")[1].split(";")[0]
url = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_VISION_MODEL}:generateContent"
r = requests.post(
url, headers={"x-goog-api-key": key, "Content-Type": "application/json"},
json={"contents": [{"parts": [
{"text": system + "\n\n" + user_text},
{"inline_data": {"mime_type": mime, "data": b64}},
]}], "generationConfig": {"responseMimeType": "application/json",
"maxOutputTokens": max_tokens}},
timeout=60)
if r.status_code != 200:
raise RuntimeError(f"gemini {r.status_code}: {r.text[:150]}")
return r.json()["candidates"][0]["content"]["parts"][0]["text"]
def vision_json(system, user_text, data_url, max_tokens=700):
"""Get a JSON dict from a vision model, trying providers in order. None on total failure."""
order = os.environ.get("VISION_PROVIDERS", "aicredits,gemini").split(",")
for prov in [p.strip().lower() for p in order]:
try:
if prov == "aicredits" and os.environ.get("AICREDITS_API_KEY"):
d = _parse_json(_aicredits_vision(system, user_text, data_url, max_tokens))
elif prov == "gemini" and os.environ.get("GEMINI_API_KEY"):
d = _parse_json(_gemini_vision(system, user_text, data_url, max_tokens))
else:
continue
if d:
return d
except Exception as e: # noqa: BLE001
print(f"[vision] {prov} failed, trying next: {str(e)[:120]}")
return None
# ---------------------------------------------------------------------------
# Design analysis for the quote pipeline (ported from sketch-to-quote TASKS.design)
# ---------------------------------------------------------------------------
_SCALE_HINT = (
"Typical real sizes, for scale: ring outer diameter 17-19 mm, ring band width "
"1.5-3 mm; stud earring 5-10 mm across; pendant 8-16 mm; bracelet 55-65 mm; "
"nose pin 4-7 mm. Melee diamonds are 0.9-1.6 mm across; a centre solitaire 3-6 mm."
)
_DESIGN_SYS = f"""You are a jewellery production analyst reading a product render.
Report only what is visible. {_SCALE_HINT}
Count stones across the WHOLE IMAGE (both earrings of a pair together). Count only
real stones SET INTO METAL with their own setting or prongs. Bright spots, specular
highlights, reflections, and the facet sparkle within one large stone are NOT
separate stones. If unsure, count fewer.
Do NOT estimate stone sizes in millimetres. Judge each group's stone diameter as a
FRACTION of the widest span of ONE piece. Halo melee might be 0.08 of the span; a
centre solitaire might be 0.45.
Set count_confidence honestly: "high" only when you could count every stone
individually; "medium"/"low" when dense enough that you estimated.
Reply with JSON only:
{{"category":"Ring|Earrings|Neckwear|Bracelet|Nose Pin",
"pieces_visible":<separate wearable pieces shown, e.g. 2 for a pair>,
"is_pair":<true if worn as a pair>,
"stone_groups":[{{"count":<stones of this size in WHOLE IMAGE>,
"span_fraction":<stone diameter as fraction of one piece span, 0.02-0.6>,
"role":"centre|accent|pave|halo"}}],
"metal":{{"form":"band|hoop|stud|pendant|chain|cuff",
"thickness_fraction":<0.02-0.3>,"note":"<short build note>"}},
"structural_defects":[<short strings>],
"physics_ok":<true if manufacturable as drawn>,
"count_confidence":"high|medium|low"}}"""
def analyze_design(data_url, note=""):
"""Vision pass over a design render -> stone count/size + structure (for the BOM).
Returns the dict spec.buildDesignedSpec expects, or None on failure.
Uses the vision provider chain (AICredits -> Gemini).
"""
user_text = f"Context: {note}" if note else "Analyse this design."
return vision_json(_DESIGN_SYS, user_text, data_url, max_tokens=700)
PROMPT = """You are a GIVA jewellery expert. Look at the jewellery in this image
and describe it for catalogue search. Return ONLY a JSON object:
- "description": one short human sentence of what you see (shown to the user).
- "query": search keywords — product type + motif + style + stones (e.g.
"rose gold floral ring with small diamonds"). This gets embedded, so be
descriptive but concise.
- "product_type": one of [ring, earrings, pendant, necklace, chain, bracelet,
anklet, nosepin, mangalsutra, rakhi, toe ring, charm, coin] or null.
- "colour": "Gold" | "Silver" | "Rose Gold" | "Yellow Gold" | null (the visible
metal colour).
- "motif": the main motif if any (heart, flower, evil eye, butterfly, star, ...) or null.
Judge only from the image. Do NOT guess price, material purity (solid vs plated),
or recipient."""
# Returned in the same shape understand_query() uses, so it drops into search().
def _empty(desc=""):
return {
"description": desc, "query": "", "product_type": None, "material": None,
"colour": None, "shop_for": None, "min_price": None, "max_price": None,
"sort": None, "exclude_stones": False, "motif": None,
}
def describe_image(data_url: str) -> dict:
"""image data-URL -> structured search intent. Never raises.
Uses the vision provider chain (AICredits -> Gemini)."""
data = vision_json(PROMPT, "Describe this jewellery.", data_url, max_tokens=400)
if not data:
return _empty("(couldn't analyse image — no vision provider available)")
out = _empty(data.get("description", ""))
out["query"] = data.get("query") or data.get("description") or "jewellery"
for k in ("product_type", "colour", "motif"):
if data.get(k):
out[k] = data[k]
return out