Spaces:
Sleeping
Sleeping
| """Beam serverless-GPU captioner for DocuMaker. | |
| Replaces the local BLIP fallback in ``src/vision.py`` with a real vision-language | |
| model. BLIP is a 0.25B COCO captioner with no OCR — on tutorial screenshots it | |
| emits generic text like "a computer screen with a website on it". Qwen2.5-VL | |
| reads on-screen text and understands UI affordances, which is what a step-by-step | |
| guide actually needs. | |
| The endpoint is *batched*: DocuMaker captions one frame per step (see | |
| ``src/guide.py``), so sending the whole set in one request turns N cold-start | |
| round-trips into one. | |
| Deploy: | |
| beam deploy beam_app.py:caption | |
| Model weights are cached on a Beam Volume, so only the first container pays the | |
| download cost. | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import io | |
| import os | |
| from beam import Image, QueueDepthAutoscaler, Volume, endpoint | |
| # --- Tunables --------------------------------------------------------------- | |
| # Full bf16 weights (~16.5GB) — comfortable on the 24GB A10G. The AWQ build was | |
| # only needed to fit 16GB, and AutoAWQ is deprecated (last tested on torch 2.6 / | |
| # transformers 4.51), so dropping it removes a fragile dependency. For a smaller | |
| # card, Qwen/Qwen2.5-VL-3B-Instruct is ~7GB and still far better than BLIP. | |
| MODEL_ID = os.getenv("DOCUMAKER_BEAM_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct") | |
| # A10G (24GB). No 16GB card is usable here: A4000 reports no capacity, and Beam | |
| # rejects T4/V100 outright ("use an A10G or RTX 4090 instead"). The 7B bf16 | |
| # weights need ~16.5GB, leaving headroom for the vision encoder and KV cache. | |
| GPU = os.getenv("DOCUMAKER_BEAM_GPU", "A10G") | |
| CACHE_DIR = "./hf-cache" | |
| # Qwen2.5-VL scales its visual token count with input resolution, so an | |
| # unbounded screenshot can balloon VRAM. Cap it: 1280 * 28 * 28 keeps a typical | |
| # 1080p screenshot well inside budget while preserving legible UI text. | |
| MAX_PIXELS = int(os.getenv("DOCUMAKER_BEAM_MAX_PIXELS", str(1280 * 28 * 28))) | |
| MIN_PIXELS = int(os.getenv("DOCUMAKER_BEAM_MIN_PIXELS", str(256 * 28 * 28))) | |
| DEFAULT_PROMPT = ( | |
| "In one concise sentence, describe what this screenshot from a tutorial shows, " | |
| "focusing on the on-screen UI element or the action being performed. " | |
| "Do not begin with phrases like 'The image shows'." | |
| ) | |
| image = Image( | |
| python_version="python3.11", | |
| python_packages=[ | |
| # PIN torch, do not float it. Beam's hosts run a CUDA 12.9 driver, and an | |
| # unpinned `torch` resolves to a cu13 wheel whose CUDA runtime the driver | |
| # is too old for — torch.cuda.is_available() silently returns False and | |
| # the container dies on device_map="cuda:0" with a bare 500. | |
| # torch 2.7.1 ships cu126 on PyPI, which the 12.9 driver runs fine. | |
| "torch==2.7.1", | |
| "torchvision==0.22.1", | |
| "transformers==4.53.2", | |
| "accelerate", | |
| "qwen-vl-utils", | |
| "pillow", | |
| ], | |
| ).with_envs([f"HF_HOME={CACHE_DIR}"]) | |
| def load_model(): | |
| """Runs once per container (``on_start``), not once per request.""" | |
| import torch | |
| from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration | |
| # Fail loudly here rather than with an opaque 500 from the request handler: | |
| # a driver/wheel CUDA mismatch shows up exactly as "no CUDA available". | |
| if not torch.cuda.is_available(): | |
| raise RuntimeError( | |
| f"CUDA unavailable (torch {torch.__version__}). The pinned torch build " | |
| "must match Beam's host driver — see the pin note on `image` above." | |
| ) | |
| print(f"[documaker-captioner] torch {torch.__version__} on " | |
| f"{torch.cuda.get_device_name(0)}") | |
| processor = AutoProcessor.from_pretrained( | |
| MODEL_ID, min_pixels=MIN_PIXELS, max_pixels=MAX_PIXELS, cache_dir=CACHE_DIR | |
| ) | |
| # transformers v5 renamed ``torch_dtype`` to ``dtype``; v4 only knows the old | |
| # spelling. Try the new one first so this works on either. | |
| # bfloat16: A10G is Ampere, so bf16 is native and avoids the fp16 overflow | |
| # Qwen2.5-VL is prone to. Falls back to fp16 on pre-Ampere cards. | |
| dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16 | |
| common = {"device_map": "cuda:0", "cache_dir": CACHE_DIR} | |
| try: | |
| model = Qwen2_5_VLForConditionalGeneration.from_pretrained( | |
| MODEL_ID, dtype=dtype, **common | |
| ) | |
| except TypeError: | |
| model = Qwen2_5_VLForConditionalGeneration.from_pretrained( | |
| MODEL_ID, torch_dtype=dtype, **common | |
| ) | |
| model.eval() | |
| print(f"[documaker-captioner] loaded {MODEL_ID} on {model.device}") | |
| return processor, model | |
| def _decode_image(raw: str): | |
| """Accept a bare base64 string or a full ``data:image/...;base64,`` URI.""" | |
| from PIL import Image as PILImage | |
| if not raw: | |
| raise ValueError("empty image payload") | |
| if raw.startswith("data:"): | |
| raw = raw.split(",", 1)[1] | |
| return PILImage.open(io.BytesIO(base64.b64decode(raw))).convert("RGB") | |
| def caption(context, **inputs): | |
| """Caption a batch of frames. | |
| Input:: | |
| {"items": [{"image": "<b64|data-uri>", "context": "optional step text"}], | |
| "prompt": "optional override", | |
| "max_new_tokens": 96} | |
| Output:: | |
| {"captions": ["...", ...], "model": "...", "count": N} | |
| Captions are returned positionally, so ``captions[i]`` belongs to | |
| ``items[i]``. A frame that fails to decode or generate yields ``""`` rather | |
| than failing the whole batch — DocuMaker treats an empty caption as "no | |
| caption" and the guide still builds. | |
| """ | |
| import torch | |
| processor, model = context.on_start_value | |
| items = inputs.get("items") or [] | |
| if not items: | |
| return {"captions": [], "model": MODEL_ID, "count": 0} | |
| base_prompt = inputs.get("prompt") or DEFAULT_PROMPT | |
| max_new_tokens = int(inputs.get("max_new_tokens") or 96) | |
| captions: list[str] = [] | |
| for item in items: | |
| try: | |
| img = _decode_image(item.get("image", "")) | |
| prompt = base_prompt | |
| step_context = (item.get("context") or "").strip() | |
| if step_context: | |
| prompt += f" For context, this step is about: {step_context[:200]}" | |
| messages = [ | |
| { | |
| "role": "user", | |
| "content": [ | |
| {"type": "image", "image": img}, | |
| {"type": "text", "text": prompt}, | |
| ], | |
| } | |
| ] | |
| text = processor.apply_chat_template( | |
| messages, tokenize=False, add_generation_prompt=True | |
| ) | |
| model_inputs = processor( | |
| text=[text], images=[img], padding=True, return_tensors="pt" | |
| ).to(model.device) | |
| with torch.no_grad(): | |
| generated = model.generate( | |
| **model_inputs, | |
| max_new_tokens=max_new_tokens, | |
| do_sample=False, | |
| ) | |
| # Strip the prompt tokens before decoding. | |
| trimmed = generated[0][model_inputs.input_ids.shape[1]:] | |
| caption_text = processor.decode( | |
| trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=True | |
| ).strip() | |
| captions.append(caption_text) | |
| except Exception as exc: # one bad frame must not sink the batch | |
| print(f"[documaker-captioner] frame failed: {exc}") | |
| captions.append("") | |
| return {"captions": captions, "model": MODEL_ID, "count": len(captions)} | |