Serg4451D commited on
Commit
3061ea7
·
verified ·
1 Parent(s): 92e249a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +393 -178
app.py CHANGED
@@ -1,37 +1,282 @@
1
  #!/usr/bin/env python3
 
2
  """
3
- multimodal gpt-oss 120b — Gradio app: Florence в браузере (WebGPU), LLM через NVIDIA Integrate
4
-
5
- - Подпись к изображению генерится на клиенте (Transformers.js + WebGPU / wasm фоллбэк).
6
- - Сервер НЕ грузит torch/Florence.
7
- - LLM-стриминг как раньше (openai/gpt-oss-120b).
 
 
 
 
 
8
  """
9
 
10
  import os
 
 
 
11
  import traceback
12
- from typing import Any, Optional, List
 
 
 
 
13
  import gradio as gr
 
14
  from openai import OpenAI
15
 
16
- # (опционально) локальный .env при локальном запуске
17
- try:
18
- from dotenv import load_dotenv
19
- load_dotenv()
20
- except Exception:
21
- pass
22
-
23
  # --------------------- Конфигурация ---------------------
24
  NV_API_KEY = os.environ.get("NV_API_KEY") # ОБЯЗАТЕЛЬНО прописать в Secrets HF Spaces
25
  NV_BASE_URL = os.environ.get("NV_BASE_URL", "https://integrate.api.nvidia.com/v1")
 
 
 
 
26
 
27
  if not NV_API_KEY:
28
  raise RuntimeError(
29
  "NV_API_KEY не задан. В Hugging Face Space зайди в Settings → Secrets и добавь NV_API_KEY."
30
  )
31
 
32
- # OpenAI совместимый клиент для LLM
33
  llm = OpenAI(base_url=NV_BASE_URL, api_key=NV_API_KEY)
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
  def _extract_text_from_stream_chunk(chunk: Any) -> str:
37
  """
@@ -59,11 +304,11 @@ def _extract_text_from_stream_chunk(chunk: Any) -> str:
59
  pass
60
  return ""
61
 
62
-
63
  def chat_stream(image, user_message: str, history: Optional[List[List[str]]], caption_text: str):
64
  """
65
  Основной generator для стриминга ответов LLM.
66
- Теперь caption_text приходит напрямую из браузера (WebGPU/wasm).
67
  """
68
  history = history or []
69
 
@@ -152,6 +397,88 @@ def chat_stream(image, user_message: str, history: Optional[List[List[str]]], ca
152
 
153
  yield history, caption
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
  # --------------------- Примеры для галереи ---------------------
157
  EXAMPLE_IMAGES = [
@@ -167,169 +494,31 @@ css = """
167
  #title { text-align: center; }
168
  """
169
 
170
- # JS: надёжная загрузка Transformers.js (ESM → UMD → локальный), WebGPU/wasm, Florence-2 large→base
171
- WEBGPU_CAPTION_JS = r"""
172
- async (image, use_client) => {
173
- const loadWithScript = (url) => new Promise((res, rej) => {
174
- const s = document.createElement('script');
175
- s.src = url; s.async = true;
176
- s.onload = () => res();
177
- s.onerror = (e) => rej(e);
178
- document.head.appendChild(s);
179
- });
180
-
181
- const loadTransformers = async () => {
182
- // 1) ESM: несколько CDN
183
- const esms = [
184
- 'https://cdn.jsdelivr.net/npm/@xenova/[email protected]',
185
- 'https://cdn.jsdelivr.net/npm/@xenova/[email protected]?module',
186
- 'https://unpkg.com/@xenova/[email protected]?module',
187
- 'https://esm.run/@xenova/[email protected]',
188
- ];
189
- for (const url of esms) {
190
- try { return await import(url); }
191
- catch (e) { console.warn('ESM import failed:', url, e); }
192
- }
193
- // 2) UMD: глобальная window.transformers
194
- const umds = [
195
- 'https://cdn.jsdelivr.net/npm/@xenova/[email protected]/dist/transformers.min.js',
196
- 'https://unpkg.com/@xenova/[email protected]/dist/transformers.min.js',
197
- // локальный фоллбэк (если положите файл в репозиторий)
198
- '/file=assets/transformers-3.2.2.min.js',
199
- ];
200
- for (const url of umds) {
201
- try {
202
- await loadWithScript(url);
203
- if (window.transformers) return window.transformers;
204
- } catch (e) { console.warn('UMD load failed:', url, e); }
205
- }
206
- throw new Error('Transformers.js: все CDN/UMD источники недоступны');
207
- };
208
-
209
- try {
210
- if (!use_client) return null;
211
-
212
- // WebGPU наличие
213
- const hasWebGPU = 'gpu' in navigator;
214
- if (!hasWebGPU) {
215
- console.warn('WebGPU недоступен, будет попытка wasm-фоллбэка.');
216
- }
217
-
218
- const toHTMLImage = async (imgVal) => {
219
- if (!imgVal) throw new Error("Нет изображения");
220
- let src = null;
221
- if (typeof imgVal === 'string') src = imgVal;
222
- else if (imgVal?.image) src = imgVal.image;
223
- else if (imgVal?.data) src = imgVal.data;
224
- if (!src) throw new Error("Не удалось прочитать изображение");
225
- const im = new Image();
226
- im.crossOrigin = 'anonymous';
227
- im.src = src;
228
- await im.decode();
229
- return im;
230
- };
231
-
232
- const tjs = await loadTransformers();
233
- const pipeline = tjs.pipeline ?? tjs?.default?.pipeline;
234
- const env = tjs.env ?? tjs?.default?.env;
235
- if (!pipeline || !env) throw new Error("Transformers.js загрузился без pipeline/env");
236
-
237
- // Настройка бэкенда и кэша
238
- env.allowRemoteModels = true;
239
- env.useBrowserCache = true;
240
- env.backends ??= {};
241
- env.backends.onnx ??= {};
242
- // Путь к wasm-артефактам (на всякий)
243
- env.backends.onnx.wasm ??= {};
244
- env.backends.onnx.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/@xenova/[email protected]/dist/wasm/';
245
- env.backends.onnx.wasm.numThreads = 1;
246
-
247
- // Пробуем WebGPU, fallback → wasm
248
- let backend = 'webgpu';
249
- if (!hasWebGPU) backend = 'wasm';
250
-
251
- // Инициализация captioner (один раз)
252
- if (!window.__web_captioner || window.__web_captioner_backend !== backend) {
253
- const candidates = backend === 'webgpu'
254
- ? ['Xenova/Florence-2-large-ft', 'Xenova/Florence-2-base-ft']
255
- : ['Xenova/Florence-2-base-ft'];
256
-
257
- let lastErr = null;
258
- for (const model of candidates) {
259
- try {
260
- if (backend === 'webgpu') {
261
- env.backends.onnx.backend = 'webgpu';
262
- window.__web_captioner = await pipeline(
263
- 'image-to-text', model,
264
- { device: 'webgpu', dtype: 'fp16', quantized: true }
265
- );
266
- } else {
267
- env.backends.onnx.backend = 'wasm';
268
- window.__web_captioner = await pipeline(
269
- 'image-to-text', model,
270
- { device: 'wasm', quantized: true }
271
- );
272
- }
273
- window.__web_captioner_backend = backend;
274
- break;
275
- } catch (e) {
276
- lastErr = e;
277
- console.warn(`Init failed (${backend}) for ${model}`, e);
278
- }
279
- }
280
- if (!window.__web_captioner) {
281
- // Последняя попытка: если WebGPU падал — откат на wasm
282
- if (backend === 'webgpu') {
283
- try {
284
- env.backends.onnx.backend = 'wasm';
285
- window.__web_captioner = await pipeline(
286
- 'image-to-text', 'Xenova/Florence-2-base-ft',
287
- { device: 'wasm', quantized: true }
288
- );
289
- window.__web_captioner_backend = 'wasm';
290
- } catch (e2) {
291
- throw lastErr || e2;
292
- }
293
- } else {
294
- throw lastErr || new Error('Не удалось инициализировать captioner');
295
- }
296
- }
297
- }
298
-
299
- const imgEl = await toHTMLImage(image);
300
- const out = await window.__web_captioner(imgEl, { text: '<MORE_DETAILED_CAPTION>' });
301
- const text = Array.isArray(out)
302
- ? (out[0]?.generated_text ?? out[0]?.text ?? JSON.stringify(out[0]))
303
- : (out?.generated_text ?? out?.text ?? String(out));
304
- return text;
305
- } catch (e) {
306
- return `[WebGPU caption error: ${e?.message || e}]`;
307
- }
308
- }
309
- """
310
-
311
  with gr.Blocks(css=css, analytics_enabled=False) as demo:
312
- gr.Markdown("<h2 id='title'>🖼️ multimodal gpt-oss 120b — визуальный чат (Florence в браузере / WebGPU)</h2>")
 
 
 
 
313
 
314
  with gr.Row():
315
  with gr.Column(scale=4):
316
  image_input = gr.Image(label="Загрузите картинку", type="filepath")
317
- use_webgpu = gr.Checkbox(value=True, label="Генерировать подпись к изображению в браузере (WebGPU/wasm)")
318
  raw_caption = gr.Textbox(
319
- label="More Detailed Caption (клиентский Florence-2)",
320
  interactive=True,
321
  lines=6,
322
- placeholder="Подпись появится тут (WebGPU/wasm)"
323
  )
324
  user_input = gr.Textbox(
325
- label="Вопрос по изображению",
326
  placeholder="Например: Что происходит на фото?"
327
  )
328
  with gr.Row():
329
  send_btn = gr.Button("Отправить", variant="primary")
330
  clear_btn = gr.Button("Очистить чат")
331
 
332
- gr.Markdown("Галерея примеров (клик — подставить в загрузчик, подпись посчитается на клиенте)")
333
  gallery = gr.Gallery(
334
  value=EXAMPLE_IMAGES,
335
  label="Примеры",
@@ -340,37 +529,63 @@ with gr.Blocks(css=css, analytics_enabled=False) as demo:
340
  object_fit="contain"
341
  )
342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  with gr.Column(scale=6):
344
  chatbot = gr.Chatbot(label="Чат с моделью", height=640)
345
 
346
- # Клик по галерее: подставить изображение и очистить подпись (капшенер сработает на change)
347
  def on_gallery_select(evt: gr.SelectData):
348
  img = EXAMPLE_IMAGES[evt.index]
349
- return img, ""
 
350
 
351
  gallery.select(
352
  on_gallery_select,
353
  inputs=None,
354
- outputs=[image_input, raw_caption]
355
  )
356
 
357
- # Изменение картинки: считаем подпись на клиенте (WebGPU/wasm)
358
  image_input.change(
359
- None,
360
- inputs=[image_input, use_webgpu],
361
- outputs=[raw_caption],
362
- js=WEBGPU_CAPTION_JS
 
 
 
 
 
 
363
  )
364
 
365
- # Переключение флажка: если включили — пересчитать подпись; если выключили — очистить
366
- use_webgpu.change(
367
- None,
368
- inputs=[image_input, use_webgpu],
369
- outputs=[raw_caption],
370
- js=WEBGPU_CAPTION_JS
371
  )
372
 
373
- # Отправка сообщения: берём caption прямо из текстбокса (клиентский Florence)
374
  send_btn.click(
375
  chat_stream,
376
  inputs=[image_input, user_input, chatbot, raw_caption],
 
1
  #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
  """
4
+ multimodal gpt-oss 120b — Gradio app:
5
+ - Florence-2 (VLM) вызывается через NIM API (сервер, без WebGPU/wasm).
6
+ - LLM-стриминг через NVIDIA Integrate (OpenAI-совместимый API).
7
+
8
+ Что есть:
9
+ - Автогенерация подписи к изображению (<MORE_DETAILED_CAPTION>) на сервере Florence-2,
10
+ результат сразу используется как визуальный контекст для LLM.
11
+ - Раннер всех 14 задач Florence-2 с загрузкой изображения, текст-подсказкой и (при необходимости)
12
+ координатами региона в нормализованных 0..999 координатах.
13
+ - Вывод JSON/TXT + галерея изображений результатов (если модель вернёт предикты-изображения).
14
  """
15
 
16
  import os
17
+ import io
18
+ import json
19
+ import time
20
  import traceback
21
+ import zipfile
22
+ import mimetypes
23
+ from typing import Any, Dict, List, Optional, Tuple
24
+
25
+ import requests
26
  import gradio as gr
27
+ from PIL import Image
28
  from openai import OpenAI
29
 
 
 
 
 
 
 
 
30
  # --------------------- Конфигурация ---------------------
31
  NV_API_KEY = os.environ.get("NV_API_KEY") # ОБЯЗАТЕЛЬНО прописать в Secrets HF Spaces
32
  NV_BASE_URL = os.environ.get("NV_BASE_URL", "https://integrate.api.nvidia.com/v1")
33
+ # Официальный Florence-2 VLM endpoint (NIM API)
34
+ NV_VLM_URL = os.environ.get("NV_VLM_URL", "https://ai.api.nvidia.com/v1/vlm/microsoft/florence-2")
35
+ # Эндпоинт загрузки ассетов (NVCF assets)
36
+ NVCF_ASSETS_URL = "https://api.nvcf.nvidia.com/v2/nvcf/assets"
37
 
38
  if not NV_API_KEY:
39
  raise RuntimeError(
40
  "NV_API_KEY не задан. В Hugging Face Space зайди в Settings → Secrets и добавь NV_API_KEY."
41
  )
42
 
43
+ # OpenAI-совместимый клиент для LLM (NVIDIA Integrate)
44
  llm = OpenAI(base_url=NV_BASE_URL, api_key=NV_API_KEY)
45
 
46
+ # --------------------- Florence-2: задачи ---------------------
47
+ # Отображаемые названия -> токены задач Florence-2
48
+ FLORENCE_TASKS = [
49
+ ("Caption", "<CAPTION>"),
50
+ ("Detailed Caption", "<DETAILED_CAPTION>"),
51
+ ("More Detailed Caption", "<MORE_DETAILED_CAPTION>"),
52
+ ("Object Detection (OD)", "<OD>"),
53
+ ("Dense Region Caption", "<DENSE_REGION_CAPTION>"),
54
+ ("Region Proposal", "<REGION_PROPOSAL>"),
55
+ ("Caption to Phrase Grounding", "<CAPTION_TO_PHRASE_GROUNDING>"),
56
+ ("Referring Expression Segmentation", "<REFERRING_EXPRESSION_SEGMENTATION>"),
57
+ ("Region to Segmentation", "<REGION_TO_SEGMENTATION>"),
58
+ ("Open Vocabulary Detection", "<OPEN_VOCABULARY_DETECTION>"),
59
+ ("Region to Category", "<REGION_TO_CATEGORY>"),
60
+ ("Region to Description", "<REGION_TO_DESCRIPTION>"),
61
+ ("OCR", "<OCR>"),
62
+ ("OCR with Region", "<OCR_WITH_REGION>"),
63
+ ]
64
+ TASK_LABEL_TO_TOKEN = {label: token for (label, token) in FLORENCE_TASKS}
65
+
66
+ # Какие задачи требуют текстовую подсказку
67
+ TEXT_REQUIRED_TASKS = {
68
+ "<CAPTION_TO_PHRASE_GROUNDING>",
69
+ "<REFERRING_EXPRESSION_SEGMENTATION>",
70
+ "<OPEN_VOCABULARY_DETECTION>",
71
+ }
72
+ # Какие задачи требуют регион (нормализованные 0..999 координаты)
73
+ REGION_REQUIRED_TASKS = {
74
+ "<REGION_TO_SEGMENTATION>",
75
+ "<REGION_TO_CATEGORY>",
76
+ "<REGION_TO_DESCRIPTION>",
77
+ "<OCR_WITH_REGION>",
78
+ }
79
+
80
+ # --------------------- Вспомогательные функции ---------------------
81
+ def guess_mime_from_path(path: str) -> str:
82
+ mime, _ = mimetypes.guess_type(path)
83
+ if mime is None:
84
+ # По умолчанию JPEG
85
+ return "image/jpeg"
86
+ return mime
87
+
88
+ def nvcf_upload_asset(image_path: str, description: str = "User Image") -> str:
89
+ """
90
+ Загружает бинарный ассет (изображение) в NVCF и возвращает asset_id.
91
+ """
92
+ content_type = guess_mime_from_path(image_path)
93
+ auth_resp = requests.post(
94
+ NVCF_ASSETS_URL,
95
+ headers={
96
+ "Authorization": f"Bearer {NV_API_KEY}",
97
+ "Content-Type": "application/json",
98
+ "accept": "application/json",
99
+ },
100
+ json={"contentType": content_type, "description": description},
101
+ timeout=30,
102
+ )
103
+ auth_resp.raise_for_status()
104
+ up_url = auth_resp.json().get("uploadUrl")
105
+ asset_id = str(auth_resp.json().get("assetId"))
106
+
107
+ with open(image_path, "rb") as f:
108
+ put_resp = requests.put(
109
+ up_url,
110
+ data=f,
111
+ headers={
112
+ "x-amz-meta-nvcf-asset-description": description,
113
+ "content-type": content_type,
114
+ },
115
+ timeout=300,
116
+ )
117
+ put_resp.raise_for_status()
118
+ return asset_id
119
+
120
+ def build_region_prompt(x1: int, y1: int, x2: int, y2: int) -> str:
121
+ """
122
+ Формат региона (нормализованные координаты 0..999):
123
+ <loc_x1><loc_y1><loc_x2><loc_y2>
124
+ """
125
+ for v in [x1, y1, x2, y2]:
126
+ if not (0 <= int(v) <= 999):
127
+ raise ValueError("Координаты должны быть в диапазоне 0..999")
128
+ return f"<loc_{int(x1)}><loc_{int(y1)}><loc_{int(x2)}><loc_{int(y2)}>"
129
+
130
+ def build_vlm_content(
131
+ task_token: str,
132
+ asset_id: str,
133
+ text_prompt: Optional[str] = None,
134
+ region: Optional[Tuple[int, int, int, int]] = None,
135
+ ) -> str:
136
+ """
137
+ Собирает content-строку для Florence-2:
138
+ "<TASK_PROMPT><text_prompt (only when needed)><img>"
139
+ Для задач REGION_* вместо text_prompt подставляется формат координат.
140
+ """
141
+ parts = [task_token]
142
+ if region is not None:
143
+ parts.append(build_region_prompt(*region))
144
+ if (text_prompt is not None) and (text_prompt.strip()):
145
+ parts.append(text_prompt.strip())
146
+ parts.append(f'<img src="data:image/jpeg;asset_id,{asset_id}" />')
147
+ return "".join(parts)
148
+
149
+ def call_florence_vlm(content: str, asset_id: str) -> Tuple[str, List[Image.Image], Dict[str, str]]:
150
+ """
151
+ Вызывает Florence-2 VLM.
152
+ Возвращает: (primary_text, images_list, text_files_dict)
153
+ - primary_text: лучший извлечённый текстовый ответ/описание
154
+ - images_list: список PIL.Image (если вернуло изображения)
155
+ - text_files_dict: словарь {filename: text/json_str} из архива
156
+ """
157
+ payload = {"messages": [{"role": "user", "content": content}]}
158
+ headers = {
159
+ "Authorization": f"Bearer {NV_API_KEY}",
160
+ "Accept": "application/json, application/zip, */*",
161
+ "Content-Type": "application/json",
162
+ # Пробрасываем asset_id в заголовки:
163
+ "NVCF-INPUT-ASSET-REFERENCES": asset_id,
164
+ "NVCF-FUNCTION-ASSET-IDS": asset_id,
165
+ }
166
+
167
+ resp = requests.post(NV_VLM_URL, headers=headers, json=payload, timeout=300)
168
+ if not resp.ok:
169
+ # Попробуем дать более содержательное сообщение
170
+ try:
171
+ return f"[VLM HTTP {resp.status_code}] {resp.text}", [], {}
172
+ except Exception:
173
+ resp.raise_for_status()
174
+
175
+ ct = (resp.headers.get("content-type") or "").lower()
176
+ data = resp.content
177
+
178
+ # Хелперы для парсинга
179
+ def _extract_primary_from_json(obj: Any) -> Optional[str]:
180
+ # Рекурсивно ищем информативные текстовые значения
181
+ keys_priority = ["more_detailed_caption", "detailed_caption", "caption", "text", "ocr", "description"]
182
+ def walk(o):
183
+ results = []
184
+ if isinstance(o, dict):
185
+ # приоритет по ключам
186
+ for k in keys_priority:
187
+ if k in o and isinstance(o[k], str) and o[k].strip():
188
+ results.append(o[k].strip())
189
+ # иначе рекурсивно
190
+ for v in o.values():
191
+ results.extend(walk(v))
192
+ elif isinstance(o, list):
193
+ for it in o:
194
+ results.extend(walk(it))
195
+ elif isinstance(o, str):
196
+ if o.strip():
197
+ results.append(o.strip())
198
+ return results
199
+
200
+ arr = walk(obj)
201
+ return arr[0] if arr else None
202
+
203
+ def _to_images_and_texts_from_zip(zbytes: bytes) -> Tuple[str, List[Image.Image], Dict[str, str]]:
204
+ images: List[Image.Image] = []
205
+ texts: Dict[str, str] = {}
206
+ primary_text: Optional[str] = None
207
+
208
+ with zipfile.ZipFile(io.BytesIO(zbytes), "r") as z:
209
+ for name in z.namelist():
210
+ try:
211
+ with z.open(name) as f:
212
+ raw = f.read()
213
+ except Exception:
214
+ continue
215
+
216
+ lower = name.lower()
217
+ if lower.endswith((".png", ".jpg", ".jpeg", ".bmp", ".webp")):
218
+ try:
219
+ img = Image.open(io.BytesIO(raw)).convert("RGBA")
220
+ images.append(img)
221
+ except Exception:
222
+ pass
223
+ elif lower.endswith(".json"):
224
+ try:
225
+ obj = json.loads(raw.decode("utf-8", errors="ignore"))
226
+ texts[name] = json.dumps(obj, ensure_ascii=False, indent=2)
227
+ if primary_text is None:
228
+ cand = _extract_primary_from_json(obj)
229
+ if cand:
230
+ primary_text = cand
231
+ except Exception:
232
+ texts[name] = raw.decode("utf-8", errors="ignore")
233
+ elif lower.endswith(".txt"):
234
+ txt = raw.decode("utf-8", errors="ignore").strip()
235
+ texts[name] = txt
236
+ if primary_text is None and txt:
237
+ primary_text = txt
238
+
239
+ if primary_text is None:
240
+ # Если ничего "осмысленного" не нашли — соберём обзор
241
+ if texts:
242
+ primary_text = next(iter(texts.values()))
243
+ elif images:
244
+ primary_text = f"[Получено {len(images)} изображений-результатов]"
245
+ else:
246
+ primary_text = "[Результат пуст]"
247
+
248
+ return primary_text, images, texts
249
+
250
+ # Если JSON:
251
+ if "application/json" in ct and not (data[:2] == b"PK"):
252
+ try:
253
+ obj = resp.json()
254
+ primary_text = _extract_primary_from_json(obj) or json.dumps(obj, ensure_ascii=False, indent=2)
255
+ return primary_text, [], {"response.json": json.dumps(obj, ensure_ascii=False, indent=2)}
256
+ except Exception:
257
+ # fallback: попробовать как zip
258
+ pass
259
+
260
+ # Иначе пробуем как ZIP
261
+ if data[:2] == b"PK" or "zip" in ct or "octet-stream" in ct:
262
+ return _to_images_and_texts_from_zip(data)
263
+
264
+ # В самом худшем случае — отдать как текст
265
+ try:
266
+ text = data.decode("utf-8", errors="ignore")
267
+ except Exception:
268
+ text = f"[Не удалось декодировать ответ: {len(data)} bytes]"
269
+ return text, [], {"raw.txt": text}
270
+
271
+ def florence_more_detailed_caption(image_path: str) -> Tuple[str, str]:
272
+ """
273
+ Получает <MORE_DETAILED_CAPTION> для изображения.
274
+ Возвращает (caption_text, asset_id).
275
+ """
276
+ asset_id = nvcf_upload_asset(image_path, "Auto caption image")
277
+ content = build_vlm_content("<MORE_DETAILED_CAPTION>", asset_id)
278
+ caption_text, _, _ = call_florence_vlm(content, asset_id)
279
+ return caption_text, asset_id
280
 
281
  def _extract_text_from_stream_chunk(chunk: Any) -> str:
282
  """
 
304
  pass
305
  return ""
306
 
307
+ # --------------------- LLM чат ---------------------
308
  def chat_stream(image, user_message: str, history: Optional[List[List[str]]], caption_text: str):
309
  """
310
  Основной generator для стриминга ответов LLM.
311
+ caption_text подпись, сгенерированная Florence-2 на сервере.
312
  """
313
  history = history or []
314
 
 
397
 
398
  yield history, caption
399
 
400
+ # --------------------- UI вспомогательные колбэки ---------------------
401
+ def on_image_change(image_path: Optional[str]):
402
+ """
403
+ При изменении изображения: считаем подпись Florence-2 (<MORE_DETAILED_CAPTION>).
404
+ Возвращаем: caption_text, asset_id, (width, height) — последние два в state.
405
+ """
406
+ if not image_path:
407
+ return gr.update(value=""), "", None
408
+ try:
409
+ caption, asset_id = florence_more_detailed_caption(image_path)
410
+ # Размеры изображения — могут пригодиться
411
+ try:
412
+ im = Image.open(image_path)
413
+ size = (im.width, im.height)
414
+ except Exception:
415
+ size = None
416
+ return caption, asset_id, size
417
+ except Exception as e:
418
+ return f"[Ошибка автокапшена: {e}]", "", None
419
+
420
+ def update_task_inputs(selected_label: str):
421
+ """
422
+ Управляет видимостью полей text prompt / region по выбранной задаче.
423
+ """
424
+ token = TASK_LABEL_TO_TOKEN.get(selected_label, "")
425
+ need_text = token in TEXT_REQUIRED_TASKS
426
+ need_region = token in REGION_REQUIRED_TASKS
427
+
428
+ return (
429
+ gr.update(visible=need_text), # text prompt
430
+ gr.update(visible=need_region), # x1
431
+ gr.update(visible=need_region), # y1
432
+ gr.update(visible=need_region), # x2
433
+ gr.update(visible=need_region), # y2
434
+ gr.update(visible=True), # run button
435
+ )
436
+
437
+ def run_florence_task(
438
+ image_path: Optional[str],
439
+ asset_id: str,
440
+ selected_label: str,
441
+ text_prompt: str,
442
+ x1: int, y1: int, x2: int, y2: int
443
+ ):
444
+ """
445
+ Запуск произвольной задачи Florence-2 на текущем изображении.
446
+ Возвращает: галерея изображений, текстовый результат.
447
+ """
448
+ if not image_path:
449
+ return [], "[Ошибка] Загрузите изображение."
450
+ try:
451
+ token = TASK_LABEL_TO_TOKEN.get(selected_label, "<MORE_DETAILED_CAPTION>")
452
+
453
+ # Если asset_id пуст — загрузим прямо сейчас
454
+ if not asset_id:
455
+ asset_id = nvcf_upload_asset(image_path, f"Task: {selected_label}")
456
+
457
+ region = None
458
+ if token in REGION_REQUIRED_TASKS:
459
+ region = (int(x1), int(y1), int(x2), int(y2))
460
+
461
+ # Для задач, где текст обязателен, пустую строку лучше не подставлять
462
+ effective_text = text_prompt if (token in TEXT_REQUIRED_TASKS) else None
463
+
464
+ content = build_vlm_content(token, asset_id, text_prompt=effective_text, region=region)
465
+ primary_text, imgs, texts = call_florence_vlm(content, asset_id)
466
+
467
+ # Галерея изображений: список (numpy/PIL/urls) — PIL подходит
468
+ gallery_items = imgs
469
+
470
+ # Сформируем сводный текст
471
+ dump_parts = [f"# Task: {selected_label} ({token})", f"## Content:\n{content}\n", "## Primary text:\n" + str(primary_text)]
472
+ if texts:
473
+ dump_parts.append("## Files:")
474
+ for k, v in texts.items():
475
+ dump_parts.append(f"\n--- {k} ---\n{v}")
476
+ result_text = "\n".join(dump_parts)
477
+
478
+ return gallery_items, result_text
479
+
480
+ except Exception as e:
481
+ return [], f"[Ошибка Florence-2: {e}]"
482
 
483
  # --------------------- Примеры для галереи ---------------------
484
  EXAMPLE_IMAGES = [
 
494
  #title { text-align: center; }
495
  """
496
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
497
  with gr.Blocks(css=css, analytics_enabled=False) as demo:
498
+ gr.Markdown("<h2 id='title'>🖼️ multimodal gpt-oss 120b — визуальный чат (Florence-2 через NIM API, без WebGPU)</h2>")
499
+
500
+ # Состояние: asset_id и размер картинки
501
+ asset_state = gr.State(value="")
502
+ img_size_state = gr.State(value=None)
503
 
504
  with gr.Row():
505
  with gr.Column(scale=4):
506
  image_input = gr.Image(label="Загрузите картинку", type="filepath")
 
507
  raw_caption = gr.Textbox(
508
+ label="More Detailed Caption (серверный Florence-2)",
509
  interactive=True,
510
  lines=6,
511
+ placeholder="Подпись появится тут (серверный Florence-2)"
512
  )
513
  user_input = gr.Textbox(
514
+ label="Вопрос по изображению",
515
  placeholder="Например: Что происходит на фото?"
516
  )
517
  with gr.Row():
518
  send_btn = gr.Button("Отправить", variant="primary")
519
  clear_btn = gr.Button("Очистить чат")
520
 
521
+ gr.Markdown("Галерея примеров (клик — подставить в загрузчик, подпись посчитается на сервере)")
522
  gallery = gr.Gallery(
523
  value=EXAMPLE_IMAGES,
524
  label="Примеры",
 
529
  object_fit="contain"
530
  )
531
 
532
+ with gr.Accordion("Florence-2: 14 задач", open=False):
533
+ task_dropdown = gr.Dropdown(
534
+ choices=[label for (label, _) in FLORENCE_TASKS],
535
+ value="More Detailed Caption",
536
+ label="Задача Florence-2",
537
+ )
538
+ task_text_prompt = gr.Textbox(
539
+ label="Text prompt (для некоторых задач)",
540
+ placeholder="Например: a black and brown dog",
541
+ visible=False
542
+ )
543
+ with gr.Row():
544
+ x1_in = gr.Slider(0, 999, step=1, value=100, label="x1 (0..999)", visible=False)
545
+ y1_in = gr.Slider(0, 999, step=1, value=100, label="y1 (0..999)", visible=False)
546
+ x2_in = gr.Slider(0, 999, step=1, value=800, label="x2 (0..999)", visible=False)
547
+ y2_in = gr.Slider(0, 999, step=1, value=800, label="y2 (0..999)", visible=False)
548
+ run_task_btn = gr.Button("Запустить задачу", visible=True)
549
+ task_gallery = gr.Gallery(label="Результирующие изображения", columns=3, height=320)
550
+ task_text_out = gr.Textbox(label="Результат (JSON/TXT)", lines=16)
551
+
552
  with gr.Column(scale=6):
553
  chatbot = gr.Chatbot(label="Чат с моделью", height=640)
554
 
555
+ # Галерея: выбор примера подставляем URL в загрузчик
556
  def on_gallery_select(evt: gr.SelectData):
557
  img = EXAMPLE_IMAGES[evt.index]
558
+ # обнуляем caption и состояние
559
+ return img, "", "", None
560
 
561
  gallery.select(
562
  on_gallery_select,
563
  inputs=None,
564
+ outputs=[image_input, raw_caption, asset_state, img_size_state]
565
  )
566
 
567
+ # Изменение картинки: автогенерация подписи Florence-2 на сервере
568
  image_input.change(
569
+ on_image_change,
570
+ inputs=[image_input],
571
+ outputs=[raw_caption, asset_state, img_size_state]
572
+ )
573
+
574
+ # Изменение выбора задачи → показать/скрыть поля
575
+ task_dropdown.change(
576
+ update_task_inputs,
577
+ inputs=[task_dropdown],
578
+ outputs=[task_text_prompt, x1_in, y1_in, x2_in, y2_in, run_task_btn]
579
  )
580
 
581
+ # Запуск произвольной задачи Florence-2
582
+ run_task_btn.click(
583
+ run_florence_task,
584
+ inputs=[image_input, asset_state, task_dropdown, task_text_prompt, x1_in, y1_in, x2_in, y2_in],
585
+ outputs=[task_gallery, task_text_out]
 
586
  )
587
 
588
+ # Отправка сообщения в чат
589
  send_btn.click(
590
  chat_stream,
591
  inputs=[image_input, user_input, chatbot, raw_caption],