Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
@@ -1,37 +1,282 @@
|
|
1 |
#!/usr/bin/env python3
|
|
|
2 |
"""
|
3 |
-
multimodal gpt-oss 120b — Gradio app:
|
4 |
-
|
5 |
-
-
|
6 |
-
|
7 |
-
|
|
|
|
|
|
|
|
|
|
|
8 |
"""
|
9 |
|
10 |
import os
|
|
|
|
|
|
|
11 |
import traceback
|
12 |
-
|
|
|
|
|
|
|
|
|
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
|
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 |
-
|
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
|
|
|
|
|
|
|
|
|
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 (
|
320 |
interactive=True,
|
321 |
lines=6,
|
322 |
-
placeholder="Подпись появится тут (
|
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 |
-
#
|
347 |
def on_gallery_select(evt: gr.SelectData):
|
348 |
img = EXAMPLE_IMAGES[evt.index]
|
349 |
-
|
|
|
350 |
|
351 |
gallery.select(
|
352 |
on_gallery_select,
|
353 |
inputs=None,
|
354 |
-
outputs=[image_input, raw_caption]
|
355 |
)
|
356 |
|
357 |
-
# Изменение картинки:
|
358 |
image_input.change(
|
359 |
-
|
360 |
-
inputs=[image_input
|
361 |
-
outputs=[raw_caption]
|
362 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
363 |
)
|
364 |
|
365 |
-
#
|
366 |
-
|
367 |
-
|
368 |
-
inputs=[image_input,
|
369 |
-
outputs=[
|
370 |
-
js=WEBGPU_CAPTION_JS
|
371 |
)
|
372 |
|
373 |
-
# Отправка
|
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],
|