Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
@@ -1,97 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
import os
|
2 |
-
|
|
|
3 |
|
4 |
import gradio as gr
|
5 |
from gradio_client import Client, handle_file
|
6 |
from openai import OpenAI
|
7 |
|
8 |
-
#
|
9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
if not NV_API_KEY:
|
11 |
-
raise RuntimeError(
|
|
|
|
|
12 |
|
13 |
-
# Florence-2 (публичный
|
14 |
-
|
15 |
|
16 |
-
#
|
17 |
-
|
|
|
18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
|
20 |
-
def
|
21 |
-
"""
|
|
|
|
|
|
|
22 |
try:
|
23 |
-
|
24 |
-
|
25 |
-
|
|
|
|
|
26 |
task_prompt="More Detailed Caption",
|
27 |
text_input=None,
|
28 |
model_id="microsoft/Florence-2-large",
|
29 |
-
api_name="/process_image"
|
30 |
)
|
31 |
-
|
32 |
-
return
|
33 |
except Exception as e:
|
|
|
|
|
|
|
34 |
return f"[Ошибка при генерац��и подписи: {e}]"
|
35 |
|
36 |
-
|
37 |
-
|
38 |
-
|
|
|
|
|
39 |
try:
|
40 |
-
#
|
41 |
if hasattr(chunk, "choices"):
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
if isinstance(chunk, dict):
|
49 |
-
choices = chunk.get("choices"
|
50 |
if choices:
|
51 |
-
delta = choices[0].get("delta"
|
52 |
-
|
|
|
53 |
except Exception:
|
54 |
-
|
55 |
return ""
|
56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
57 |
|
58 |
-
def chat_stream(
|
59 |
"""
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
"""
|
64 |
history = history or []
|
|
|
|
|
|
|
|
|
|
|
65 |
|
66 |
-
if not
|
67 |
-
|
68 |
-
|
|
|
69 |
return
|
70 |
|
71 |
-
#
|
72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
73 |
|
74 |
-
#
|
75 |
system_prompt = (
|
76 |
-
"You are 'multimodal gpt-oss 120b'
|
|
|
|
|
77 |
"Image Caption START >>>\n"
|
78 |
f"{caption}\n"
|
79 |
"<<< Image Caption END.\n"
|
80 |
-
"
|
|
|
81 |
)
|
82 |
|
83 |
-
#
|
84 |
-
history.append([user_message, ""])
|
85 |
-
#
|
86 |
yield history, caption
|
87 |
|
88 |
-
|
89 |
try:
|
|
|
90 |
stream = llm.chat.completions.create(
|
91 |
model="openai/gpt-oss-120b",
|
92 |
messages=[
|
93 |
{"role": "system", "content": system_prompt},
|
94 |
-
{"role": "user", "content": user_message}
|
95 |
],
|
96 |
temperature=0.8,
|
97 |
top_p=1.0,
|
@@ -100,62 +216,89 @@ def chat_stream(image_path: str, user_message: str, history: List[Tuple[str, str
|
|
100 |
)
|
101 |
|
102 |
for chunk in stream:
|
103 |
-
piece =
|
104 |
if not piece:
|
105 |
continue
|
106 |
-
|
107 |
-
|
|
|
108 |
yield history, caption
|
109 |
|
110 |
except Exception as e:
|
111 |
-
#
|
112 |
-
|
113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
114 |
|
115 |
-
#
|
116 |
yield history, caption
|
117 |
|
118 |
-
|
119 |
-
# --- UI (для HF Spaces) ---
|
120 |
EXAMPLE_IMAGES = [
|
121 |
-
# список простых строк (URL или локальные пути). НИКАКИХ вложенных списков!
|
122 |
"https://raw.githubusercontent.com/gradio-app/gradio/main/test/test_files/bus.png",
|
123 |
"https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/cats.png",
|
124 |
"https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/cheetah.jpg",
|
125 |
"https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/flowers.png",
|
126 |
]
|
127 |
|
|
|
128 |
css = """
|
129 |
-
#title {text-align:center; margin-bottom: -18px;}
|
130 |
.gradio-container { max-width: 1100px; margin: auto; }
|
|
|
131 |
"""
|
132 |
|
133 |
-
with gr.Blocks(
|
134 |
gr.Markdown("<h2 id='title'>🖼️ multimodal gpt-oss 120b — визуальный чат</h2>")
|
135 |
with gr.Row():
|
136 |
with gr.Column(scale=4):
|
137 |
-
image_input = gr.Image(label="Загрузите картинку
|
138 |
-
raw_caption = gr.Textbox(label="More Detailed Caption (Florence-2)", interactive=False)
|
139 |
-
user_input = gr.Textbox(label="Вопрос по изображению", placeholder="Например:
|
140 |
send_btn = gr.Button("Отправить")
|
141 |
clear_btn = gr.Button("Очистить чат")
|
142 |
-
gr.Markdown("**Галерея примеров (клик — подставить в загрузчик)**")
|
143 |
-
gallery = gr.Gallery(value=EXAMPLE_IMAGES,
|
144 |
|
145 |
with gr.Column(scale=6):
|
146 |
-
chatbot = gr.Chatbot(label="Чат с моделью", height=
|
147 |
-
|
148 |
-
# Клик по картинке в галерее -> вставляем URL/путь в image_input
|
149 |
-
def pick_example(img_url: str):
|
150 |
-
return img_url
|
151 |
-
|
152 |
-
gallery.select(fn=pick_example, inputs=[gallery], outputs=[image_input])
|
153 |
-
|
154 |
-
# Кнопка отправки: привязываем генератор, который возвращает (chat_history, caption)
|
155 |
-
send_btn.click(fn=chat_stream, inputs=[image_input, user_input, chatbot], outputs=[chatbot, raw_caption])
|
156 |
|
157 |
-
|
|
|
158 |
|
159 |
-
|
160 |
-
|
161 |
|
|
|
1 |
+
#!/usr/bin/env python3
|
2 |
+
"""
|
3 |
+
multimodal gpt-oss 120b — Gradio app for Hugging Face Spaces
|
4 |
+
|
5 |
+
Функции:
|
6 |
+
- Загрузка собственной картинки (type="filepath")
|
7 |
+
- Галерея примеров (клик -> подставляет в загрузчик)
|
8 |
+
- Автогенерация "More Detailed Caption" через gradio_client Florence-2
|
9 |
+
- Streaming ответов от openai/gpt-oss-120b (через NVIDIA integrate / OpenAI-compatible)
|
10 |
+
- Кеширование подписи для одной и той же картинки
|
11 |
+
"""
|
12 |
+
|
13 |
import os
|
14 |
+
import traceback
|
15 |
+
from typing import Any, Dict, List, Tuple, Optional
|
16 |
|
17 |
import gradio as gr
|
18 |
from gradio_client import Client, handle_file
|
19 |
from openai import OpenAI
|
20 |
|
21 |
+
# (опционально) локальный .env при локальном запуске
|
22 |
+
try:
|
23 |
+
from dotenv import load_dotenv
|
24 |
+
load_dotenv()
|
25 |
+
except Exception:
|
26 |
+
pass
|
27 |
+
|
28 |
+
# --------------------- Конфигурация ---------------------
|
29 |
+
NV_API_KEY = os.environ.get("NV_API_KEY") # ОБЯЗАТЕЛЬНО прописать в Secrets HF Spaces
|
30 |
+
NV_BASE_URL = os.environ.get("NV_BASE_URL", "https://integrate.api.nvidia.com/v1")
|
31 |
+
|
32 |
if not NV_API_KEY:
|
33 |
+
raise RuntimeError(
|
34 |
+
"NV_API_KEY не задан. В Hugging Face Space зайди в Settings → Secrets и добавь NV_API_KEY."
|
35 |
+
)
|
36 |
|
37 |
+
# Florence-2 Gradio wrapper (публичный)
|
38 |
+
FLORENCE_WRAPPER = "gokaygokay/Florence-2"
|
39 |
|
40 |
+
# --------------------- Клиенты ---------------------
|
41 |
+
florence = Client(FLORENCE_WRAPPER)
|
42 |
+
llm = OpenAI(base_url=NV_BASE_URL, api_key=NV_API_KEY)
|
43 |
|
44 |
+
# --------------------- Хелперы ---------------------
|
45 |
+
def _normalize_florence_result(res: Any) -> str:
|
46 |
+
"""
|
47 |
+
Нормализует результат predict от Florence-2: возвращает текстовую подпись.
|
48 |
+
Подстраховываемся на разные форматы (строка, dict, list и т.д.).
|
49 |
+
"""
|
50 |
+
try:
|
51 |
+
if res is None:
|
52 |
+
return ""
|
53 |
+
if isinstance(res, str):
|
54 |
+
return res
|
55 |
+
# dict-like
|
56 |
+
if isinstance(res, dict):
|
57 |
+
# часто бывает ключ 'caption' или 'text' или 'generated_text'
|
58 |
+
for k in ("caption", "text", "generated_text", "output", "result"):
|
59 |
+
if k in res and isinstance(res[k], str):
|
60 |
+
return res[k]
|
61 |
+
# если есть nested fields, попробуем взять первое строковое значение
|
62 |
+
for v in res.values():
|
63 |
+
if isinstance(v, str):
|
64 |
+
return v
|
65 |
+
# fallback: str()
|
66 |
+
return str(res)
|
67 |
+
# list/tuple: join string elements
|
68 |
+
if isinstance(res, (list, tuple)):
|
69 |
+
pieces = [str(x) for x in res]
|
70 |
+
return "\n".join(pieces)
|
71 |
+
# other types: fallback to str
|
72 |
+
return str(res)
|
73 |
+
except Exception:
|
74 |
+
return f"[Ошибка нормализации подписи: {traceback.format_exc()}]"
|
75 |
|
76 |
+
def get_caption_for_image(image_path_or_url: str, safety_note: bool = False) -> str:
|
77 |
+
"""
|
78 |
+
Запрос к Florence-2: task_prompt="More Detailed Caption".
|
79 |
+
Принимает локальный путь или URL.
|
80 |
+
"""
|
81 |
try:
|
82 |
+
if not image_path_or_url:
|
83 |
+
return ""
|
84 |
+
# handle_file поддерживает URL и локальные пути
|
85 |
+
res = florence.predict(
|
86 |
+
image=handle_file(image_path_or_url),
|
87 |
task_prompt="More Detailed Caption",
|
88 |
text_input=None,
|
89 |
model_id="microsoft/Florence-2-large",
|
90 |
+
api_name="/process_image"
|
91 |
)
|
92 |
+
caption = _normalize_florence_result(res)
|
93 |
+
return caption
|
94 |
except Exception as e:
|
95 |
+
# логируем в stdout (HF Spaces покажет лог)
|
96 |
+
print("Ошибка Florence-2 predict:", e)
|
97 |
+
traceback.print_exc()
|
98 |
return f"[Ошибка при генерац��и подписи: {e}]"
|
99 |
|
100 |
+
def _extract_text_from_stream_chunk(chunk: Any) -> str:
|
101 |
+
"""
|
102 |
+
Универсально извлекает текстовые фрагменты из чанка стриминга LLM.
|
103 |
+
Работает с разными формами chunk (объект SDK, dict, ...)
|
104 |
+
"""
|
105 |
try:
|
106 |
+
# объектный стиль: chunk.choices[0].delta.content
|
107 |
if hasattr(chunk, "choices"):
|
108 |
+
choices = getattr(chunk, "choices")
|
109 |
+
if choices:
|
110 |
+
c0 = choices[0]
|
111 |
+
delta = getattr(c0, "delta", None)
|
112 |
+
if delta is not None:
|
113 |
+
# reasoning_content или content
|
114 |
+
txt = getattr(delta, "reasoning_content", None) or getattr(delta, "content", None)
|
115 |
+
if txt:
|
116 |
+
return str(txt)
|
117 |
+
# some SDK might put content in c0.get("text") etc.
|
118 |
+
text_attr = getattr(c0, "text", None)
|
119 |
+
if text_attr:
|
120 |
+
return str(text_attr)
|
121 |
+
# dict-style
|
122 |
if isinstance(chunk, dict):
|
123 |
+
choices = chunk.get("choices") or []
|
124 |
if choices:
|
125 |
+
delta = choices[0].get("delta") or {}
|
126 |
+
# try common keys
|
127 |
+
return str(delta.get("content") or delta.get("reasoning_content") or choices[0].get("text") or "")
|
128 |
except Exception:
|
129 |
+
pass
|
130 |
return ""
|
131 |
|
132 |
+
# --------------------- UI-логика ---------------------
|
133 |
+
# Кеш подписи (чтобы не вызывать Florence снова для той же картинки)
|
134 |
+
# Храним словарь: {"image_path": "...", "caption": "..."}
|
135 |
+
# Будем использовать gr.State для хранения этого словаря в сессии
|
136 |
+
def generate_and_cache_caption(image, cache: Optional[Dict[str, str]]):
|
137 |
+
"""
|
138 |
+
Вызывается при изменении image_input или при клике по галерее.
|
139 |
+
Возвращает (caption_text, new_cache_dict).
|
140 |
+
"""
|
141 |
+
try:
|
142 |
+
if not image:
|
143 |
+
return "", {"image_path": None, "caption": None}
|
144 |
+
# Готовим path/URL
|
145 |
+
img_path = image if isinstance(image, str) else getattr(image, "name", None) or image
|
146 |
+
# Проверка кеша
|
147 |
+
if cache and cache.get("image_path") == img_path and cache.get("caption"):
|
148 |
+
return cache.get("caption"), cache
|
149 |
+
# Иначе генерируем подпись
|
150 |
+
caption = get_caption_for_image(img_path)
|
151 |
+
new_cache = {"image_path": img_path, "caption": caption}
|
152 |
+
return caption, new_cache
|
153 |
+
except Exception as e:
|
154 |
+
print("generate_and_cache_caption exception:", e)
|
155 |
+
traceback.print_exc()
|
156 |
+
return f"[Ошибка генерации подписи: {e}]", {"image_path": None, "caption": None}
|
157 |
|
158 |
+
def chat_stream(image, user_message, history, cache: Dict[str, str]):
|
159 |
"""
|
160 |
+
Основной generator для кнопки Отправить / submit:
|
161 |
+
- Автоматически использует кеш подписи (если есть), иначе генерирует новую
|
162 |
+
- Возвращает по мере стриминга (history, caption) — соответствие outputs=[chatbot, raw_caption]
|
163 |
"""
|
164 |
history = history or []
|
165 |
+
# Проверки входа
|
166 |
+
if not user_message:
|
167 |
+
# ничего не делаем, просто возвращаем текущее состояние
|
168 |
+
yield history, (cache.get("caption") if cache else "")
|
169 |
+
return
|
170 |
|
171 |
+
if not image:
|
172 |
+
# если нет картинки — говорим пользователю
|
173 |
+
history.append([user_message, "Пожалуйста, загрузите изображение или выберите из галереи."])
|
174 |
+
yield history, (cache.get("caption") if cache else "")
|
175 |
return
|
176 |
|
177 |
+
# получить путь и подпись (используем кеш, если совпадает)
|
178 |
+
img_path = image if isinstance(image, str) else getattr(image, "name", None) or image
|
179 |
+
if cache and cache.get("image_path") == img_path and cache.get("caption"):
|
180 |
+
caption = cache.get("caption")
|
181 |
+
else:
|
182 |
+
caption = get_caption_for_image(img_path)
|
183 |
+
# обновляем кеш локально (не gr.State, а для текущего запроса)
|
184 |
+
cache = {"image_path": img_path, "caption": caption}
|
185 |
|
186 |
+
# система-промпт — даём контекст и просим указывать степень уверенности
|
187 |
system_prompt = (
|
188 |
+
"You are 'multimodal gpt-oss 120b', a helpful multimodal assistant. "
|
189 |
+
"Use the provided 'More Detailed Caption' as authoritative visual context. "
|
190 |
+
"If something is not visible or certain, say so explicitly.\n\n"
|
191 |
"Image Caption START >>>\n"
|
192 |
f"{caption}\n"
|
193 |
"<<< Image Caption END.\n"
|
194 |
+
"Answer the user's question based on the caption and general knowledge. "
|
195 |
+
"Be concise unless asked for details."
|
196 |
)
|
197 |
|
198 |
+
# добавляем пользовательский запрос в историю (пустой ответ пока)
|
199 |
+
history.append([user_message, ""]) # assistant текст будет заполняться по мере стрима
|
200 |
+
# первый yield чтобы UI сразу отобразил user's message и подпись
|
201 |
yield history, caption
|
202 |
|
203 |
+
assistant_accum = ""
|
204 |
try:
|
205 |
+
# Запускаем стриминг вызов
|
206 |
stream = llm.chat.completions.create(
|
207 |
model="openai/gpt-oss-120b",
|
208 |
messages=[
|
209 |
{"role": "system", "content": system_prompt},
|
210 |
+
{"role": "user", "content": user_message}
|
211 |
],
|
212 |
temperature=0.8,
|
213 |
top_p=1.0,
|
|
|
216 |
)
|
217 |
|
218 |
for chunk in stream:
|
219 |
+
piece = _extract_text_from_stream_chunk(chunk)
|
220 |
if not piece:
|
221 |
continue
|
222 |
+
assistant_accum += piece
|
223 |
+
# обновляем последний элемент истории (assistant part)
|
224 |
+
history[-1][1] = assistant_accum
|
225 |
yield history, caption
|
226 |
|
227 |
except Exception as e:
|
228 |
+
# Ошибка стриминга: попробуем получить финальный ответ без стрима, либо показать ошибку
|
229 |
+
print("Streaming error:", e)
|
230 |
+
traceback.print_exc()
|
231 |
+
# Пытаемся сделать не-стриминг вызов (fallback)
|
232 |
+
try:
|
233 |
+
resp = llm.chat.completions.create(
|
234 |
+
model="openai/gpt-oss-120b",
|
235 |
+
messages=[
|
236 |
+
{"role": "system", "content": system_prompt},
|
237 |
+
{"role": "user", "content": user_message}
|
238 |
+
],
|
239 |
+
temperature=0.8,
|
240 |
+
top_p=1.0,
|
241 |
+
max_tokens=1024,
|
242 |
+
stream=False,
|
243 |
+
)
|
244 |
+
# нормализуем возможный формат ответа
|
245 |
+
final_text = ""
|
246 |
+
# SDK может вернуть object-like resp.choices[0].message.content
|
247 |
+
if hasattr(resp, "choices"):
|
248 |
+
try:
|
249 |
+
final_text = getattr(resp.choices[0].message, "content", "") or getattr(resp.choices[0], "text", "") or ""
|
250 |
+
except Exception:
|
251 |
+
final_text = str(resp)
|
252 |
+
elif isinstance(resp, dict):
|
253 |
+
choices = resp.get("choices", [])
|
254 |
+
if choices:
|
255 |
+
m = choices[0].get("message") or choices[0]
|
256 |
+
final_text = m.get("content") or m.get("text") or str(m)
|
257 |
+
else:
|
258 |
+
final_text = str(resp)
|
259 |
+
else:
|
260 |
+
final_text = str(resp)
|
261 |
+
history[-1][1] = final_text
|
262 |
+
yield history, caption
|
263 |
+
except Exception as e2:
|
264 |
+
history[-1][1] = f"[Ошибка LLM: {e2}]"
|
265 |
+
yield history, caption
|
266 |
|
267 |
+
# финальный yield (гарантируем окончательное состояние)
|
268 |
yield history, caption
|
269 |
|
270 |
+
# --------------------- Примеры для галереи (список строк) ---------------------
|
|
|
271 |
EXAMPLE_IMAGES = [
|
|
|
272 |
"https://raw.githubusercontent.com/gradio-app/gradio/main/test/test_files/bus.png",
|
273 |
"https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/cats.png",
|
274 |
"https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/cheetah.jpg",
|
275 |
"https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/flowers.png",
|
276 |
]
|
277 |
|
278 |
+
# --------------------- UI ---------------------
|
279 |
css = """
|
|
|
280 |
.gradio-container { max-width: 1100px; margin: auto; }
|
281 |
+
#title { text-align: center; }
|
282 |
"""
|
283 |
|
284 |
+
with gr.Blocks(css=css, analytics_enabled=False) as demo:
|
285 |
gr.Markdown("<h2 id='title'>🖼️ multimodal gpt-oss 120b — визуальный чат</h2>")
|
286 |
with gr.Row():
|
287 |
with gr.Column(scale=4):
|
288 |
+
image_input = gr.Image(label="Загрузите картинку (файл / drag-n-drop / камера)", type="filepath")
|
289 |
+
raw_caption = gr.Textbox(label="More Detailed Caption (Florence-2)", interactive=False, lines=6)
|
290 |
+
user_input = gr.Textbox(label="Вопрос по изображению", placeholder="Например: Что происходит на фото?")
|
291 |
send_btn = gr.Button("Отправить")
|
292 |
clear_btn = gr.Button("Очистить чат")
|
293 |
+
gr.Markdown("**Галерея примеров (клик — подставить в загрузчик и получить подпись)**")
|
294 |
+
gallery = gr.Gallery(value=EXAMPLE_IMAGES, label="Примеры", columns=4, show_label=False).style(grid=[4])
|
295 |
|
296 |
with gr.Column(scale=6):
|
297 |
+
chatbot = gr.Chatbot(label="Чат с моделью", height=640)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
298 |
|
299 |
+
# gr.State для кеша подписи
|
300 |
+
caption_cache = gr.State(value={"image_path": None, "caption": None})
|
301 |
|
302 |
+
# обработчик клика по галерее: сразу подставляет картинку, генерирует подпись и обновляет кеш
|
303 |
+
def on_gallery_select(elem, cach
|
304 |
|