| """Model loading and all GPU-decorated inference for the ZeroGPU Space. |
| |
| This is the ONLY module that imports torch/transformers. Models are placed on |
| `cuda` at module level — the required ZeroGPU pattern (CUDA is emulated outside |
| `@spaces.GPU` functions and real inside them). |
| |
| Presets (env `SHOWCASE_PRESET`): |
| full gemma-4-12b-it bf16 (~24 GB, multimodal) — for the ZeroGPU Space (48 GB slice) |
| small gemma-4-12b-it in bitsandbytes 4-bit (~8 GB, multimodal) — local rehearsal |
| |
| The `small` preset is the same 12B multimodal checkpoint as `full`, only loaded |
| with 4-bit NF4 quantization so it fits a consumer GPU; vision tabs work in both. |
| Quantization can be forced/disabled independently of the preset with |
| `SHOWCASE_QUANTIZE=1`/`0`. It requires a CUDA GPU (bitsandbytes is CUDA-only). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import threading |
| from collections.abc import Iterator |
|
|
| import numpy as np |
| import torch |
| from prompts import CHAT_SYSTEM, OCR_SYSTEM, build_translate_messages, language_label |
| from transformers import ( |
| AutoModelForCausalLM, |
| AutoModelForImageTextToText, |
| AutoProcessor, |
| AutoTokenizer, |
| BitsAndBytesConfig, |
| TextIteratorStreamer, |
| pipeline, |
| ) |
| from zerogpu import GPU |
|
|
| PRESETS = { |
| "full": "google/gemma-4-12b-it", |
| "small": "google/gemma-4-12b-it", |
| } |
| PRESET = os.environ.get("SHOWCASE_PRESET", "full") |
| GEMMA_ID = os.environ.get("SHOWCASE_GEMMA_ID") or PRESETS.get(PRESET, PRESETS["full"]) |
|
|
| |
| |
| QUANTIZE_4BIT = os.environ.get("SHOWCASE_QUANTIZE", "1" if PRESET == "small" else "0") == "1" |
| WHISPER_ID = os.environ.get("SHOWCASE_WHISPER_ID", "openai/whisper-large-v3-turbo") |
|
|
| |
| |
| DEVICE = "cuda" if torch.cuda.is_available() else "cpu" |
|
|
| |
| |
| |
| try: |
| processor = AutoProcessor.from_pretrained(GEMMA_ID) |
| except (ValueError, OSError): |
| processor = AutoTokenizer.from_pretrained(GEMMA_ID) |
| MULTIMODAL = hasattr(processor, "image_processor") |
| _tokenizer = getattr(processor, "tokenizer", processor) |
|
|
| if QUANTIZE_4BIT: |
| if DEVICE != "cuda": |
| raise RuntimeError( |
| "4-bit (bitsandbytes) quantization requires a CUDA GPU. Install a CUDA " |
| "build of torch, or set SHOWCASE_QUANTIZE=0 to load in bf16 instead." |
| ) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| quantization_config = BitsAndBytesConfig( |
| load_in_4bit=True, |
| bnb_4bit_quant_type="nf4", |
| bnb_4bit_compute_dtype=torch.bfloat16, |
| bnb_4bit_use_double_quant=True, |
| llm_int8_skip_modules=["lm_head", "model.embed_vision", "model.embed_audio"], |
| ) |
| _model_kwargs: dict = {"quantization_config": quantization_config, "device_map": "auto"} |
| else: |
| _model_kwargs = {"dtype": torch.bfloat16} |
|
|
| try: |
| gemma = AutoModelForImageTextToText.from_pretrained(GEMMA_ID, **_model_kwargs) |
| except ValueError: |
| gemma = AutoModelForCausalLM.from_pretrained(GEMMA_ID, **_model_kwargs) |
| |
| |
| if not QUANTIZE_4BIT: |
| gemma = gemma.to(DEVICE) |
|
|
| asr = pipeline( |
| "automatic-speech-recognition", |
| model=WHISPER_ID, |
| dtype=torch.bfloat16, |
| device=DEVICE, |
| ) |
|
|
| _generate_lock = threading.Lock() |
|
|
| |
| |
| |
| |
| |
| |
| _ANTI_REPEAT = {"repetition_penalty": 1.3, "no_repeat_ngram_size": 3} |
|
|
| |
| |
| |
| |
| |
| ASR_SAMPLE_RATE = 16000 |
|
|
| |
| |
| |
| |
| OCR_MAX_EDGE = 1536 |
|
|
|
|
| def _resample_to_16k(audio: np.ndarray, sample_rate: int) -> np.ndarray: |
| """Mono float32 → 16 kHz via linear interpolation (no torchaudio/scipy). |
| |
| Callers already pass mono float32 (to_mono_float32); this only changes rate. |
| """ |
| audio = np.asarray(audio, dtype=np.float32).reshape(-1) |
| if sample_rate == ASR_SAMPLE_RATE or audio.size == 0: |
| return audio |
| duration = audio.size / sample_rate |
| target_len = max(int(round(duration * ASR_SAMPLE_RATE)), 1) |
| src_t = np.linspace(0.0, duration, audio.size, endpoint=False) |
| dst_t = np.linspace(0.0, duration, target_len, endpoint=False) |
| return np.interp(dst_t, src_t, audio).astype(np.float32) |
|
|
|
|
| def _fit_image(image): |
| """Downscale so the long edge is at most OCR_MAX_EDGE (keeps aspect ratio).""" |
| from PIL import Image |
|
|
| width, height = image.size |
| longest = max(width, height) |
| if longest <= OCR_MAX_EDGE: |
| return image |
| scale = OCR_MAX_EDGE / longest |
| return image.resize((max(round(width * scale), 1), max(round(height * scale), 1)), Image.LANCZOS) |
|
|
|
|
| def _flatten_messages(messages: list[dict]) -> list[dict]: |
| """Collapse content-part lists to plain strings for tokenizer-only templates.""" |
| flattened = [] |
| for message in messages: |
| content = message["content"] |
| if isinstance(content, list): |
| content = "\n".join( |
| part["text"] for part in content if part.get("type") == "text" |
| ) |
| flattened.append({"role": message["role"], "content": content}) |
| return flattened |
|
|
|
|
| def _gemma_generate( |
| messages: list[dict], *, max_new_tokens: int = 512, **gen_kwargs |
| ) -> str: |
| if not MULTIMODAL: |
| messages = _flatten_messages(messages) |
| inputs = processor.apply_chat_template( |
| messages, |
| add_generation_prompt=True, |
| tokenize=True, |
| return_dict=True, |
| return_tensors="pt", |
| ).to(gemma.device) |
| with _generate_lock, torch.inference_mode(): |
| output = gemma.generate( |
| **inputs, max_new_tokens=max_new_tokens, do_sample=False, **gen_kwargs |
| ) |
| new_tokens = output[0][inputs["input_ids"].shape[-1] :] |
| return processor.decode(new_tokens, skip_special_tokens=True).strip() |
|
|
|
|
| def _utterance_duration(audio: np.ndarray, sample_rate: int, *_args) -> int: |
| """Dynamic @spaces.GPU duration: short hints improve visitor queue priority.""" |
| seconds = len(audio) / max(sample_rate, 1) |
| return int(min(40, 10 + seconds * 1.5)) |
|
|
|
|
| @GPU(duration=_utterance_duration) |
| def transcribe_and_translate( |
| audio: np.ndarray, |
| sample_rate: int, |
| source_lang: str | None, |
| target_lang: str, |
| tone: str, |
| context: list[tuple[str, str]], |
| ) -> tuple[str, str]: |
| """One live-interpreter utterance: Whisper STT + Gemma MT in a single GPU call.""" |
| asr_kwargs: dict = {"task": "transcribe", **_ANTI_REPEAT} |
| if source_lang: |
| asr_kwargs["language"] = language_label(source_lang).lower() |
| audio = _resample_to_16k(audio, sample_rate) |
| transcript = asr( |
| {"array": audio, "sampling_rate": ASR_SAMPLE_RATE}, |
| generate_kwargs=asr_kwargs, |
| )["text"].strip() |
| if not transcript: |
| return "", "" |
|
|
| translation = _gemma_generate( |
| build_translate_messages( |
| transcript, |
| source_lang=source_lang, |
| target_lang=target_lang, |
| tone=tone, |
| context=context, |
| ), |
| max_new_tokens=256, |
| **_ANTI_REPEAT, |
| ) |
| return transcript, translation |
|
|
|
|
| @GPU(duration=120) |
| def transcribe_file(audio: np.ndarray, sample_rate: int, source_lang: str | None) -> str: |
| asr_kwargs: dict = {"task": "transcribe", **_ANTI_REPEAT} |
| if source_lang: |
| asr_kwargs["language"] = language_label(source_lang).lower() |
| audio = _resample_to_16k(audio, sample_rate) |
| return asr( |
| {"array": audio, "sampling_rate": ASR_SAMPLE_RATE}, |
| chunk_length_s=30, |
| generate_kwargs=asr_kwargs, |
| )["text"].strip() |
|
|
|
|
| @GPU(duration=60) |
| def translate_text( |
| text: str, source_lang: str | None, target_lang: str, tone: str |
| ) -> str: |
| return _gemma_generate( |
| build_translate_messages(text, source_lang=source_lang, target_lang=target_lang, tone=tone), |
| max_new_tokens=1024, |
| **_ANTI_REPEAT, |
| ) |
|
|
|
|
| @GPU(duration=90) |
| def chat_stream(history: list[dict], image=None) -> Iterator[str]: |
| """Streaming chat; `history` is [{'role','content'}] text turns, optional PIL image.""" |
| if image is not None and not MULTIMODAL: |
| raise NotImplementedError( |
| f"The current demo preset ({GEMMA_ID}) is text-only — image understanding " |
| "needs the multimodal 12B preset (or the full local app)." |
| ) |
| messages: list[dict] = [ |
| {"role": "system", "content": [{"type": "text", "text": CHAT_SYSTEM}]} |
| ] |
| for i, turn in enumerate(history): |
| content: list[dict] = [] |
| if image is not None and i == len(history) - 1 and turn["role"] == "user": |
| content.append({"type": "image", "image": image}) |
| content.append({"type": "text", "text": turn["content"]}) |
| messages.append({"role": turn["role"], "content": content}) |
| if not MULTIMODAL: |
| messages = _flatten_messages(messages) |
|
|
| inputs = processor.apply_chat_template( |
| messages, |
| add_generation_prompt=True, |
| tokenize=True, |
| return_dict=True, |
| return_tensors="pt", |
| ).to(gemma.device) |
| streamer = TextIteratorStreamer( |
| _tokenizer, skip_prompt=True, skip_special_tokens=True |
| ) |
| kwargs = dict(**inputs, max_new_tokens=1024, do_sample=False, streamer=streamer) |
| with _generate_lock: |
| thread = threading.Thread(target=lambda: gemma.generate(**kwargs)) |
| thread.start() |
| reply = "" |
| for token in streamer: |
| reply += token |
| yield reply |
| thread.join() |
|
|
|
|
| @GPU(duration=120) |
| def ocr_images(images: list, instructions: str = "") -> str: |
| """Gemma vision OCR over one or more page images.""" |
| if not MULTIMODAL: |
| raise NotImplementedError( |
| f"The current demo preset ({GEMMA_ID}) is text-only — OCR needs the " |
| "multimodal 12B preset (or the full local app)." |
| ) |
| pages: list[str] = [] |
| for image in images: |
| user_text = "Extract all text from this image." |
| if instructions.strip(): |
| user_text += f" Additional instructions: {instructions.strip()}" |
| messages = [ |
| {"role": "system", "content": [{"type": "text", "text": OCR_SYSTEM}]}, |
| { |
| "role": "user", |
| "content": [ |
| {"type": "image", "image": _fit_image(image)}, |
| {"type": "text", "text": user_text}, |
| ], |
| }, |
| ] |
| |
| |
| pages.append(_gemma_generate(messages, max_new_tokens=1024)) |
| return "\n\n---\n\n".join(pages) |
|
|