Serg4451D's picture
Update app.py
ce3af93 verified
raw
history blame
14.3 kB
#!/usr/bin/env python3
"""
multimodal gpt-oss 120b — Gradio app for Hugging Face Spaces
Функции:
- Загрузка собственной картинки (type="filepath")
- Галерея примеров (клик -> подставляет в загрузчик)
- Автогенерация "More Detailed Caption" через gradio_client Florence-2
- Streaming ответов от openai/gpt-oss-120b (через NVIDIA integrate / OpenAI-compatible)
- Кеширование подписи для одной и той же картинки
"""
import os
import traceback
from typing import Any, Dict, List, Tuple, Optional
import gradio as gr
from gradio_client import Client, handle_file
from openai import OpenAI
# (опционально) локальный .env при локальном запуске
try:
from dotenv import load_dotenv
load_dotenv()
except Exception:
pass
# --------------------- Конфигурация ---------------------
NV_API_KEY = os.environ.get("NV_API_KEY") # ОБЯЗАТЕЛЬНО прописать в Secrets HF Spaces
NV_BASE_URL = os.environ.get("NV_BASE_URL", "https://integrate.api.nvidia.com/v1")
if not NV_API_KEY:
raise RuntimeError(
"NV_API_KEY не задан. В Hugging Face Space зайди в Settings → Secrets и добавь NV_API_KEY."
)
# Florence-2 Gradio wrapper (публичный)
FLORENCE_WRAPPER = "gokaygokay/Florence-2"
# --------------------- Клиенты ---------------------
florence = Client(FLORENCE_WRAPPER)
llm = OpenAI(base_url=NV_BASE_URL, api_key=NV_API_KEY)
# --------------------- Хелперы ---------------------
def _normalize_florence_result(res: Any) -> str:
"""
Нормализует результат predict от Florence-2: возвращает текстовую подпись.
Подстраховываемся на разные форматы (строка, dict, list и т.д.).
"""
try:
if res is None:
return ""
if isinstance(res, str):
return res
# dict-like
if isinstance(res, dict):
# часто бывает ключ 'caption' или 'text' или 'generated_text'
for k in ("caption", "text", "generated_text", "output", "result"):
if k in res and isinstance(res[k], str):
return res[k]
# если есть nested fields, попробуем взять первое строковое значение
for v in res.values():
if isinstance(v, str):
return v
# fallback: str()
return str(res)
# list/tuple: join string elements
if isinstance(res, (list, tuple)):
pieces = [str(x) for x in res]
return "\n".join(pieces)
# other types: fallback to str
return str(res)
except Exception:
return f"[Ошибка нормализации подписи: {traceback.format_exc()}]"
def get_caption_for_image(image_path_or_url: str, safety_note: bool = False) -> str:
"""
Запрос к Florence-2: task_prompt="More Detailed Caption".
Принимает локальный путь или URL.
"""
try:
if not image_path_or_url:
return ""
# handle_file поддерживает URL и локальные пути
res = florence.predict(
image=handle_file(image_path_or_url),
task_prompt="More Detailed Caption",
text_input=None,
model_id="microsoft/Florence-2-large",
api_name="/process_image"
)
caption = _normalize_florence_result(res)
return caption
except Exception as e:
# логируем в stdout (HF Spaces покажет лог)
print("Ошибка Florence-2 predict:", e)
traceback.print_exc()
return f"[Ошибка при генерации подписи: {e}]"
def _extract_text_from_stream_chunk(chunk: Any) -> str:
"""
Универсально извлекает текстовые фрагменты из чанка стриминга LLM.
Работает с разными формами chunk (объект SDK, dict, ...)
"""
try:
# объектный стиль: chunk.choices[0].delta.content
if hasattr(chunk, "choices"):
choices = getattr(chunk, "choices")
if choices:
c0 = choices[0]
delta = getattr(c0, "delta", None)
if delta is not None:
# reasoning_content или content
txt = getattr(delta, "reasoning_content", None) or getattr(delta, "content", None)
if txt:
return str(txt)
# some SDK might put content in c0.get("text") etc.
text_attr = getattr(c0, "text", None)
if text_attr:
return str(text_attr)
# dict-style
if isinstance(chunk, dict):
choices = chunk.get("choices") or []
if choices:
delta = choices[0].get("delta") or {}
# try common keys
return str(delta.get("content") or delta.get("reasoning_content") or choices[0].get("text") or "")
except Exception:
pass
return ""
# --------------------- UI-логика ---------------------
# Кеш подписи (чтобы не вызывать Florence снова для той же картинки)
# Храним словарь: {"image_path": "...", "caption": "..."}
# Будем использовать gr.State для хранения этого словаря в сессии
def generate_and_cache_caption(image, cache: Optional[Dict[str, str]]):
"""
Вызывается при изменении image_input или при клике по галерее.
Возвращает (caption_text, new_cache_dict).
"""
try:
if not image:
return "", {"image_path": None, "caption": None}
# Готовим path/URL
img_path = image if isinstance(image, str) else getattr(image, "name", None) or image
# Проверка кеша
if cache and cache.get("image_path") == img_path and cache.get("caption"):
return cache.get("caption"), cache
# Иначе генерируем подпись
caption = get_caption_for_image(img_path)
new_cache = {"image_path": img_path, "caption": caption}
return caption, new_cache
except Exception as e:
print("generate_and_cache_caption exception:", e)
traceback.print_exc()
return f"[Ошибка генерации подписи: {e}]", {"image_path": None, "caption": None}
def chat_stream(image, user_message, history, cache: Dict[str, str]):
"""
Основной generator для кнопки Отправить / submit:
- Автоматически использует кеш подписи (если есть), иначе генерирует новую
- Возвращает по мере стриминга (history, caption) — соответствие outputs=[chatbot, raw_caption]
"""
history = history or []
# Проверки входа
if not user_message:
# ничего не делаем, просто возвращаем текущее состояние
yield history, (cache.get("caption") if cache else "")
return
if not image:
# если нет картинки — говорим пользователю
history.append([user_message, "Пожалуйста, загрузите изображение или выберите из галереи."])
yield history, (cache.get("caption") if cache else "")
return
# получить путь и подпись (используем кеш, если совпадает)
img_path = image if isinstance(image, str) else getattr(image, "name", None) or image
if cache and cache.get("image_path") == img_path and cache.get("caption"):
caption = cache.get("caption")
else:
caption = get_caption_for_image(img_path)
# обновляем кеш локально (не gr.State, а для текущего запроса)
cache = {"image_path": img_path, "caption": caption}
# система-промпт — даём контекст и просим указывать степень уверенности
system_prompt = (
"You are 'multimodal gpt-oss 120b', a helpful multimodal assistant. "
"Use the provided 'More Detailed Caption' as authoritative visual context. "
"If something is not visible or certain, say so explicitly.\n\n"
"Image Caption START >>>\n"
f"{caption}\n"
"<<< Image Caption END.\n"
"Answer the user's question based on the caption and general knowledge. "
"Be concise unless asked for details."
)
# добавляем пользовательский запрос в историю (пустой ответ пока)
history.append([user_message, ""]) # assistant текст будет заполняться по мере стрима
# первый yield чтобы UI сразу отобразил user's message и подпись
yield history, caption
assistant_accum = ""
try:
# Запускаем стриминг вызов
stream = llm.chat.completions.create(
model="openai/gpt-oss-120b",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message}
],
temperature=0.8,
top_p=1.0,
max_tokens=1024,
stream=True,
)
for chunk in stream:
piece = _extract_text_from_stream_chunk(chunk)
if not piece:
continue
assistant_accum += piece
# обновляем последний элемент истории (assistant part)
history[-1][1] = assistant_accum
yield history, caption
except Exception as e:
# Ошибка стриминга: попробуем получить финальный ответ без стрима, либо показать ошибку
print("Streaming error:", e)
traceback.print_exc()
# Пытаемся сделать не-стриминг вызов (fallback)
try:
resp = llm.chat.completions.create(
model="openai/gpt-oss-120b",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message}
],
temperature=0.8,
top_p=1.0,
max_tokens=1024,
stream=False,
)
# нормализуем возможный формат ответа
final_text = ""
# SDK может вернуть object-like resp.choices[0].message.content
if hasattr(resp, "choices"):
try:
final_text = getattr(resp.choices[0].message, "content", "") or getattr(resp.choices[0], "text", "") or ""
except Exception:
final_text = str(resp)
elif isinstance(resp, dict):
choices = resp.get("choices", [])
if choices:
m = choices[0].get("message") or choices[0]
final_text = m.get("content") or m.get("text") or str(m)
else:
final_text = str(resp)
else:
final_text = str(resp)
history[-1][1] = final_text
yield history, caption
except Exception as e2:
history[-1][1] = f"[Ошибка LLM: {e2}]"
yield history, caption
# финальный yield (гарантируем окончательное состояние)
yield history, caption
# --------------------- Примеры для галереи (список строк) ---------------------
EXAMPLE_IMAGES = [
"https://raw.githubusercontent.com/gradio-app/gradio/main/test/test_files/bus.png",
"https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/cats.png",
"https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/cheetah.jpg",
"https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/flowers.png",
]
# --------------------- UI ---------------------
css = """
.gradio-container { max-width: 1100px; margin: auto; }
#title { text-align: center; }
"""
with gr.Blocks(css=css, analytics_enabled=False) as demo:
gr.Markdown("<h2 id='title'>🖼️ multimodal gpt-oss 120b — визуальный чат</h2>")
with gr.Row():
with gr.Column(scale=4):
image_input = gr.Image(label="Загрузите картинку (файл / drag-n-drop / камера)", type="filepath")
raw_caption = gr.Textbox(label="More Detailed Caption (Florence-2)", interactive=False, lines=6)
user_input = gr.Textbox(label="Вопрос по изображению", placeholder="Например: Что происходит на фото?")
send_btn = gr.Button("Отправить")
clear_btn = gr.Button("Очистить чат")
gr.Markdown("**Галерея примеров (клик — подставить в загрузчик и получить подпись)**")
gallery = gr.Gallery(value=EXAMPLE_IMAGES, label="Примеры", columns=4, show_label=False).style(grid=[4])
with gr.Column(scale=6):
chatbot = gr.Chatbot(label="Чат с моделью", height=640)
# gr.State для кеша подписи
caption_cache = gr.State(value={"image_path": None, "caption": None})
# обработчик клика по галерее: сразу подставляет картинку, генерирует подпись и обновляет кеш
def on_gallery_select(elem, cach