sync: force deploy vision HF-only from origin/main

#50
by Baida07 - opened
Files changed (2) hide show
  1. api/vision.py +81 -36
  2. tests/test_vision_hf_only.py +96 -0
api/vision.py CHANGED
@@ -3,21 +3,26 @@ vision.py — Generazione e analisi immagini + ricerca immagini.
3
 
4
  Endpoints:
5
  POST /api/vision/generate — FLUX.1-schnell (HF Inference API)
6
- POST /api/vision/analyze — Groq llama-3.2-vision / GPT-4o-mini / BLIP fallback
7
  GET /api/vision/search — Pexels > Pixabay > Unsplash Source (zero API key)
8
 
9
  Problematiche HF Inference API:
10
  - 503 "loading": cold-start fino a 60s → retry con backoff
11
  - Output generate: raw bytes PNG (non JSON)
12
- - BLIP: captioning solo, non risponde a domande aperte
13
  - Rate limit senza HF_TOKEN: ~10 req/hr per IP
14
 
15
  Fallback chain analyze_image:
16
  1. Groq llama-3.2-11b-vision-preview (free tier, veloce, richiede GROQ_API_KEY)
17
- 2. GPT-4o-mini vision (richiede OPENAI_API_KEY)
18
- 3. BLIP-large captioning (HF Inference, libero ma solo didascalia)
 
 
 
 
19
  """
20
- import asyncio, base64, os, httpx, logging
 
21
  from fastapi import APIRouter, Depends
22
  from .auth_guard import require_role, AuthRole
23
  from pydantic import BaseModel
@@ -25,7 +30,8 @@ from pydantic import BaseModel
25
  router = APIRouter(prefix="/api/vision", tags=["vision"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
26
  _logger = logging.getLogger("vision")
27
 
28
- _HF_API = "https://api-inference.huggingface.co"
 
29
  _USER_AGENT = "Mozilla/5.0 (compatible; AgenteAI/3.0)"
30
 
31
  _MODEL_MAP: dict[str, str] = {
@@ -36,6 +42,10 @@ _MODEL_MAP: dict[str, str] = {
36
  "flux-schnell": "black-forest-labs/FLUX.1-schnell",
37
  }
38
 
 
 
 
 
39
 
40
  def _hf_headers(content_type: str = "application/json") -> dict:
41
  token = os.getenv("HF_TOKEN", "")
@@ -63,6 +73,13 @@ class AnalyzeImageRequest(BaseModel):
63
  question: str = "Descrivi questa immagine in dettaglio in italiano."
64
 
65
 
 
 
 
 
 
 
 
66
  # ─── /generate ────────────────────────────────────────────────────────────────
67
 
68
  @router.post("/generate")
@@ -125,6 +142,37 @@ async def generate_image(req: GenerateImageRequest):
125
  return {"ok": False, "error": "Impossibile generare dopo 2 tentativi."}
126
 
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  # ─── /analyze ─────────────────────────────────────────────────────────────────
129
 
130
  @router.post("/analyze")
@@ -134,8 +182,8 @@ async def analyze_image(req: AnalyzeImageRequest):
134
 
135
  Chain:
136
  1. Groq llama-3.2-11b-vision (free tier, 30 img/min)
137
- 2. GPT-4o-mini vision
138
- 3. BLIP-large captioning (HF, puro captioning senza Q&A)
139
  """
140
  # Scarica immagine se URL
141
  image_b64 = req.base64_image
@@ -185,8 +233,8 @@ async def analyze_image(req: AnalyzeImageRequest):
185
  _logger.debug("analyze_image: groq vision failed (%s)", type(_e).__name__)
186
 
187
  # 2. Gemini Vision (free tier — GEMINI_API_KEY da aistudio.google.com)
188
- # GAP-TOOL-2-fix: Gemini 1.5 Flash supporta vision, è gratuito su AI Studio, non richiede dominio.
189
- # Inserito prima di GPT-4o-mini (paid) come primo fallback gratuito di Groq.
190
  _gemini_key = os.getenv("GEMINI_API_KEY", "")
191
  if _gemini_key:
192
  try:
@@ -214,32 +262,32 @@ async def analyze_image(req: AnalyzeImageRequest):
214
  except Exception as _e:
215
  _logger.debug("analyze_image: gemini vision failed (%s)", type(_e).__name__)
216
 
217
- # 3. OpenAI GPT-4o-mini vision
218
- _openai_key = os.getenv("OPENAI_API_KEY", "")
219
- _openai_base = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
220
- if _openai_key:
221
- try:
222
- async with httpx.AsyncClient(timeout=30) as c:
223
- r = await c.post(
224
- f"{_openai_base}/chat/completions",
225
- headers={"Authorization": f"Bearer {_openai_key}", "Content-Type": "application/json"},
226
- json={"model": "gpt-4o-mini", "max_tokens": 600, "messages": vision_body_msgs},
227
- )
228
- if r.status_code == 200:
229
- # S750-GAP-H: guard choices[] provider può ritornare {"error":...}
230
- _chs2 = r.json().get("choices") or []
231
- _desc2 = (_chs2[0].get("message",{}).get("content") or "") if _chs2 else ""
232
- if _desc2:
233
- return {"ok": True, "description": _desc2, "provider": "gpt-4o-mini"}
234
- except Exception as _e:
235
- _logger.debug("analyze_image: openai vision failed (%s)", type(_e).__name__)
236
 
237
- # 4. HF BLIP-large (captioning only — ultimo fallback)
238
  try:
239
  img_bytes = base64.b64decode(image_b64)
240
  async with httpx.AsyncClient(timeout=30) as c:
241
  r = await c.post(
242
- f"{_HF_API}/models/Salesforce/blip-image-captioning-large",
243
  headers={k: v for k, v in _hf_headers("application/octet-stream").items()},
244
  content=img_bytes,
245
  )
@@ -247,9 +295,7 @@ async def analyze_image(req: AnalyzeImageRequest):
247
  results = r.json()
248
  caption = (results[0].get("generated_text", "") if isinstance(results, list) and results else "")
249
  if caption:
250
- note = ("\n\n_BLIP fornisce solo didascalia base. Per Q&A su immagini, "
251
- "aggiungi GROQ_API_KEY (gratuito su console.groq.com)._")
252
- return {"ok": True, "description": caption + note, "provider": "blip-large"}
253
  elif r.status_code == 503:
254
  return {"ok": False, "error": "BLIP in avvio (cold-start ~30s). Riprova tra qualche secondo.",
255
  "hint": "Aggiungi GROQ_API_KEY per analisi rapida e senza limiti di cold-start."}
@@ -258,8 +304,7 @@ async def analyze_image(req: AnalyzeImageRequest):
258
 
259
  return {
260
  "ok": False, "error": "Analisi immagini non disponibile.",
261
- "hint": ("Aggiungi GROQ_API_KEY (free su console.groq.com) o OPENAI_API_KEY "
262
- "nelle variabili del tuo HF Space."),
263
  }
264
 
265
 
 
3
 
4
  Endpoints:
5
  POST /api/vision/generate — FLUX.1-schnell (HF Inference API)
6
+ POST /api/vision/analyze — Groq llama-3.2-vision / Gemini Vision / HF VQA + BLIP fallback
7
  GET /api/vision/search — Pexels > Pixabay > Unsplash Source (zero API key)
8
 
9
  Problematiche HF Inference API:
10
  - 503 "loading": cold-start fino a 60s → retry con backoff
11
  - Output generate: raw bytes PNG (non JSON)
12
+ - BLIP VQA risponde a domande semplici; BLIP captioning fornisce una didascalia di fallback
13
  - Rate limit senza HF_TOKEN: ~10 req/hr per IP
14
 
15
  Fallback chain analyze_image:
16
  1. Groq llama-3.2-11b-vision-preview (free tier, veloce, richiede GROQ_API_KEY)
17
+ 2. Gemini 2.5 Flash Vision (free tier, richiede GEMINI_API_KEY)
18
+ 3. HF BLIP VQA + captioning (richiede solo HF_TOKEN)
19
+
20
+ Image generation and editing use exclusively Hugging Face Inference API:
21
+ - FLUX.1-schnell for text-to-image generation
22
+ - FLUX.1-Kontext-dev for prompt-guided image editing
23
  """
24
+ import asyncio, base64, io, os, httpx, logging
25
+ from huggingface_hub import InferenceClient
26
  from fastapi import APIRouter, Depends
27
  from .auth_guard import require_role, AuthRole
28
  from pydantic import BaseModel
 
30
  router = APIRouter(prefix="/api/vision", tags=["vision"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: router-level auth
31
  _logger = logging.getLogger("vision")
32
 
33
+ # Router Inference Providers: l’host api-inference legacy non è più disponibile.
34
+ _HF_API = "https://router.huggingface.co/hf-inference"
35
  _USER_AGENT = "Mozilla/5.0 (compatible; AgenteAI/3.0)"
36
 
37
  _MODEL_MAP: dict[str, str] = {
 
42
  "flux-schnell": "black-forest-labs/FLUX.1-schnell",
43
  }
44
 
45
+ _EDIT_MODEL = "timbrooks/instruct-pix2pix"
46
+ _HF_VQA_MODEL = "Salesforce/blip-vqa-base"
47
+ _HF_CAPTION_MODEL = "Salesforce/blip-image-captioning-large"
48
+
49
 
50
  def _hf_headers(content_type: str = "application/json") -> dict:
51
  token = os.getenv("HF_TOKEN", "")
 
73
  question: str = "Descrivi questa immagine in dettaglio in italiano."
74
 
75
 
76
+ class EditImageRequest(BaseModel):
77
+ prompt: str
78
+ base64_image: str
79
+ negative_prompt: str = ""
80
+ steps: int = 5
81
+
82
+
83
  # ─── /generate ────────────────────────────────────────────────────────────────
84
 
85
  @router.post("/generate")
 
142
  return {"ok": False, "error": "Impossibile generare dopo 2 tentativi."}
143
 
144
 
145
+ # ─── /edit ────────────────────────────────────────────────────────────────────
146
+
147
+ @router.post("/edit")
148
+ async def edit_image(req: EditImageRequest):
149
+ """Modifica un’immagine con un provider Hugging Face selezionato automaticamente."""
150
+ try:
151
+ source = base64.b64decode(req.base64_image)
152
+ prompt = req.prompt.strip()[:400]
153
+ steps = min(max(req.steps, 1), 8)
154
+
155
+ def _run_edit():
156
+ client = InferenceClient(token=os.getenv("HF_TOKEN"), provider="auto", timeout=120)
157
+ return client.image_to_image(
158
+ image=source,
159
+ prompt=prompt,
160
+ model="black-forest-labs/FLUX.1-Kontext-dev",
161
+ negative_prompt=req.negative_prompt[:200] if req.negative_prompt else None,
162
+ num_inference_steps=steps,
163
+ )
164
+
165
+ edited = await asyncio.to_thread(_run_edit)
166
+ output = io.BytesIO()
167
+ edited.save(output, format="PNG")
168
+ return {"ok": True, "image_b64": base64.b64encode(output.getvalue()).decode(), "mime": "image/png", "model": "FLUX.1-Kontext-dev"}
169
+ except TimeoutError:
170
+ return {"ok": False, "error": "Timeout 120s — modello image-to-image in cold-start."}
171
+ except Exception as e:
172
+ _logger.warning("HF image edit failed: %s", type(e).__name__)
173
+ return {"ok": False, "error": f"HF image edit unavailable: {str(e)[:300]}"}
174
+
175
+
176
  # ─── /analyze ─────────────────────────────────────────────────────────────────
177
 
178
  @router.post("/analyze")
 
182
 
183
  Chain:
184
  1. Groq llama-3.2-11b-vision (free tier, 30 img/min)
185
+ 2. Gemini 2.5 Flash Vision (free tier)
186
+ 3. HF BLIP VQA, poi BLIP-large captioning
187
  """
188
  # Scarica immagine se URL
189
  image_b64 = req.base64_image
 
233
  _logger.debug("analyze_image: groq vision failed (%s)", type(_e).__name__)
234
 
235
  # 2. Gemini Vision (free tier — GEMINI_API_KEY da aistudio.google.com)
236
+ # Gemini 2.5 Flash supporta vision ed è disponibile nel tier gratuito AI Studio.
237
+ # Viene usato come fallback gratuito dopo Groq.
238
  _gemini_key = os.getenv("GEMINI_API_KEY", "")
239
  if _gemini_key:
240
  try:
 
262
  except Exception as _e:
263
  _logger.debug("analyze_image: gemini vision failed (%s)", type(_e).__name__)
264
 
265
+ # 3. Hugging Face BLIP VQA (Q&A) e captioning (fallback senza provider a pagamento)
266
+ try:
267
+ async with httpx.AsyncClient(timeout=45) as c:
268
+ vqa = await c.post(
269
+ f"{_HF_API}/models/{_HF_VQA_MODEL}",
270
+ headers=_hf_headers(),
271
+ json={"inputs": {"image": image_b64, "question": question}},
272
+ )
273
+ if vqa.status_code == 200:
274
+ results = vqa.json()
275
+ answer = ""
276
+ if isinstance(results, list) and results:
277
+ answer = str(results[0].get("answer", "") or results[0].get("generated_text", ""))
278
+ elif isinstance(results, dict):
279
+ answer = str(results.get("answer", "") or results.get("generated_text", ""))
280
+ if answer.strip():
281
+ return {"ok": True, "description": answer.strip(), "provider": "blip-vqa"}
282
+ except Exception as _e:
283
+ _logger.debug("analyze_image: HF VQA failed (%s)", type(_e).__name__)
284
 
285
+ # 4. HF BLIP-large captioning (ultimo fallback)
286
  try:
287
  img_bytes = base64.b64decode(image_b64)
288
  async with httpx.AsyncClient(timeout=30) as c:
289
  r = await c.post(
290
+ f"{_HF_API}/models/{_HF_CAPTION_MODEL}",
291
  headers={k: v for k, v in _hf_headers("application/octet-stream").items()},
292
  content=img_bytes,
293
  )
 
295
  results = r.json()
296
  caption = (results[0].get("generated_text", "") if isinstance(results, list) and results else "")
297
  if caption:
298
+ return {"ok": True, "description": caption, "provider": "blip-large"}
 
 
299
  elif r.status_code == 503:
300
  return {"ok": False, "error": "BLIP in avvio (cold-start ~30s). Riprova tra qualche secondo.",
301
  "hint": "Aggiungi GROQ_API_KEY per analisi rapida e senza limiti di cold-start."}
 
304
 
305
  return {
306
  "ok": False, "error": "Analisi immagini non disponibile.",
307
+ "hint": "Configura HF_TOKEN per il fallback Hugging Face oppure un provider gratuito Groq/Gemini.",
 
308
  }
309
 
310
 
tests/test_vision_hf_only.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import base64
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ from PIL import Image
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
10
+ from api import vision
11
+
12
+
13
+ class FakeResponse:
14
+ def __init__(self, status_code=200, payload=None, content=b"png-bytes", text=""):
15
+ self.status_code = status_code
16
+ self._payload = payload
17
+ self.content = content
18
+ self.text = text
19
+
20
+ def json(self):
21
+ if isinstance(self._payload, Exception):
22
+ raise self._payload
23
+ return self._payload
24
+
25
+
26
+ class FakeClient:
27
+ calls = []
28
+ responses = []
29
+
30
+ def __init__(self, *args, **kwargs):
31
+ self.calls = []
32
+
33
+ async def __aenter__(self):
34
+ FakeClient.active = self
35
+ return self
36
+
37
+ async def __aexit__(self, *args):
38
+ return False
39
+
40
+ async def post(self, url, **kwargs):
41
+ self.calls.append((url, kwargs))
42
+ FakeClient.calls.append((url, kwargs))
43
+ return FakeClient.responses.pop(0)
44
+
45
+
46
+ def test_analyze_uses_hf_vqa_without_openai(monkeypatch):
47
+ FakeClient.calls = []
48
+ FakeClient.responses = [FakeResponse(payload=[{"answer": "un gatto"}])]
49
+ monkeypatch.setattr(vision.httpx, "AsyncClient", FakeClient)
50
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
51
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
52
+ monkeypatch.delenv("GROQ_API_KEY", raising=False)
53
+ monkeypatch.delenv("GEMINI_API_KEY", raising=False)
54
+
55
+ result = asyncio.run(vision.analyze_image(
56
+ vision.AnalyzeImageRequest(base64_image=base64.b64encode(b"image").decode(), question="Cosa vedi?")
57
+ ))
58
+
59
+ assert result == {"ok": True, "description": "un gatto", "provider": "blip-vqa"}
60
+ assert len(FakeClient.calls) == 1
61
+ assert vision._HF_VQA_MODEL in FakeClient.calls[0][0]
62
+ assert all("openai.com" not in call[0] for call in FakeClient.calls)
63
+
64
+
65
+ def test_generate_uses_huggingface(monkeypatch):
66
+ FakeClient.calls = []
67
+ FakeClient.responses = [FakeResponse(payload=None, content=b"generated-png")]
68
+ monkeypatch.setattr(vision.httpx, "AsyncClient", FakeClient)
69
+
70
+ result = asyncio.run(vision.generate_image(vision.GenerateImageRequest(prompt="un paesaggio")))
71
+
72
+ assert result["ok"] is True
73
+ assert result["mime"] == "image/png"
74
+ assert result["image_b64"] == base64.b64encode(b"generated-png").decode()
75
+ assert "router.huggingface.co/hf-inference" in FakeClient.calls[0][0]
76
+
77
+
78
+ def test_edit_uses_inference_client_hf(monkeypatch):
79
+ class FakeInferenceClient:
80
+ def __init__(self, **kwargs):
81
+ self.kwargs = kwargs
82
+
83
+ def image_to_image(self, **kwargs):
84
+ assert kwargs["model"] == "black-forest-labs/FLUX.1-Kontext-dev"
85
+ return Image.new("RGB", (1, 1), (0, 120, 255))
86
+
87
+ monkeypatch.setattr(vision, "InferenceClient", FakeInferenceClient)
88
+
89
+ result = asyncio.run(vision.edit_image(
90
+ vision.EditImageRequest(prompt="rendi il cielo blu", base64_image="aW1hZ2U=")
91
+ ))
92
+
93
+ assert result["ok"] is True
94
+ assert result["model"] == "FLUX.1-Kontext-dev"
95
+ assert result["mime"] == "image/png"
96
+ assert len(base64.b64decode(result["image_b64"])) > 0