Spaces:
Sleeping
Add Beam serverless-GPU captioner (Qwen2.5-VL) with batching
Browse filesBLIP-base is a 0.25B COCO captioner with no OCR, so on tutorial screenshots
it produces generic text ("a computer screen with a website on it") that adds
nothing to a step-by-step guide. Serve Qwen2.5-VL-7B-Instruct on a Beam
serverless GPU instead; it reads on-screen text and understands UI elements.
- beam_app.py: batched endpoint, scale-to-zero, weights cached on a Volume.
torch is pinned to 2.7.1 deliberately: Beam hosts run a CUDA 12.9 driver and
an unpinned torch resolves to a cu13 wheel, which fails as a silent
torch.cuda.is_available() == False and a bare HTTP 500 at request time.
- vision.py: Beam backend plus caption_batch(), chunked to keep request
bodies bounded. Falls back to the HF Inference API and then local BLIP, so
an unreachable or scaled-to-zero endpoint degrades instead of breaking.
- guide.py: caption the frame pool in one batched call rather than N calls.
- .beamignore: exclude work/ (32MB of frames) and .env from image uploads.
Captioning is opt-in: without DOCUMAKER_BEAM_CAPTION_URL, behaviour is
unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- .beamignore +38 -0
- .env.example +10 -0
- README.md +71 -1
- beam_app.py +204 -0
- scripts/test_beam_captions.py +64 -0
- src/config.py +14 -0
- src/guide.py +11 -8
- src/vision.py +90 -4
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Generated by Beam SDK
|
| 2 |
+
.beamignore
|
| 3 |
+
.git
|
| 4 |
+
.idea
|
| 5 |
+
.python-version
|
| 6 |
+
.vscode
|
| 7 |
+
.venv
|
| 8 |
+
venv
|
| 9 |
+
__pycache__
|
| 10 |
+
.DS_Store
|
| 11 |
+
.config
|
| 12 |
+
drive/MyDrive
|
| 13 |
+
.coverage
|
| 14 |
+
.pytest_cache
|
| 15 |
+
.ipynb
|
| 16 |
+
.ruff_cache
|
| 17 |
+
.dockerignore
|
| 18 |
+
.ipynb_checkpoints
|
| 19 |
+
.env.local
|
| 20 |
+
.envrc
|
| 21 |
+
**/__pycache__/
|
| 22 |
+
**/.pytest_cache/
|
| 23 |
+
**/node_modules/
|
| 24 |
+
**/.venv/
|
| 25 |
+
*.pyc
|
| 26 |
+
.next/
|
| 27 |
+
.circleci
|
| 28 |
+
|
| 29 |
+
# --- DocuMaker additions ---
|
| 30 |
+
# Runtime artifacts: uploaded videos, extracted frames, audio, generated DOCX.
|
| 31 |
+
# Without this the whole work/ tree (tens of MB) is uploaded on every deploy.
|
| 32 |
+
work/
|
| 33 |
+
# Secrets must never be baked into the container image. The Beam SDK ignores
|
| 34 |
+
# .env.local but not .env, which is where DocuMaker keeps its tokens.
|
| 35 |
+
.env
|
| 36 |
+
.gradio/
|
| 37 |
+
.cache/
|
| 38 |
+
dep5.log
|
|
@@ -22,6 +22,16 @@ DOCUMAKER_ENABLE_VISION=1 # set 0 to skip captioning entirely
|
|
| 22 |
# DOCUMAKER_VLM_PROVIDER=
|
| 23 |
# DOCUMAKER_LOCAL_CAPTION_MODEL=Salesforce/blip-image-captioning-base
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
# --- Whisper (local faster-whisper) ---
|
| 26 |
DOCUMAKER_WHISPER_MODEL=small # tiny | base | small | medium | large-v3
|
| 27 |
DOCUMAKER_WHISPER_DEVICE=auto # auto | cuda | cpu
|
|
|
|
| 22 |
# DOCUMAKER_VLM_PROVIDER=
|
| 23 |
# DOCUMAKER_LOCAL_CAPTION_MODEL=Salesforce/blip-image-captioning-base
|
| 24 |
|
| 25 |
+
# --- Beam serverless-GPU captioner (see beam_app.py) ---
|
| 26 |
+
# When set, this is the preferred captioner: a real VLM (Qwen2.5-VL) on a GPU,
|
| 27 |
+
# instead of the 0.25B BLIP fallback that cannot read on-screen text. The HF
|
| 28 |
+
# Inference API and local BLIP stay as fallbacks, so an unreachable endpoint
|
| 29 |
+
# degrades rather than breaks the run.
|
| 30 |
+
# Get both values from: beam deploy beam_app.py:caption
|
| 31 |
+
# DOCUMAKER_BEAM_CAPTION_URL=
|
| 32 |
+
# DOCUMAKER_BEAM_TOKEN=
|
| 33 |
+
# DOCUMAKER_BEAM_TIMEOUT=300 # a cold container pages in weights first
|
| 34 |
+
|
| 35 |
# --- Whisper (local faster-whisper) ---
|
| 36 |
DOCUMAKER_WHISPER_MODEL=small # tiny | base | small | medium | large-v3
|
| 37 |
DOCUMAKER_WHISPER_DEVICE=auto # auto | cuda | cpu
|
|
@@ -148,6 +148,72 @@ demo); set `DOCUMAKER_WHISPER_MODEL=base` in the Space *Settings → Variables*
|
|
| 148 |
snappier transcription. You can push to **both** GitHub and the Space (add both as
|
| 149 |
git remotes).
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
## Configuration
|
| 152 |
|
| 153 |
All settings are environment variables (see [.env.example](.env.example)). Highlights:
|
|
@@ -156,7 +222,11 @@ All settings are environment variables (see [.env.example](.env.example)). Highl
|
|
| 156 |
|---|---|---|
|
| 157 |
| `DOCUMAKER_LLM_MODEL` | `Qwen/Qwen2.5-7B-Instruct` | Text LLM (any HF instruct model) |
|
| 158 |
| `DOCUMAKER_VLM_MODEL` | `Qwen/Qwen2-VL-7B-Instruct` | API vision model tried before local BLIP |
|
| 159 |
-
| `DOCUMAKER_LOCAL_CAPTION_MODEL` | `Salesforce/blip-image-captioning-base` | Local captioner |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
| `DOCUMAKER_ENABLE_VISION` | `1` | Set `0` to skip captioning |
|
| 161 |
| `DOCUMAKER_WHISPER_MODEL` | `small` | `tiny`…`large-v3` |
|
| 162 |
| `DOCUMAKER_WHISPER_DEVICE` | `auto` | `auto` / `cuda` / `cpu` |
|
|
|
|
| 148 |
snappier transcription. You can push to **both** GitHub and the Space (add both as
|
| 149 |
git remotes).
|
| 150 |
|
| 151 |
+
## Serverless GPU captions (Beam)
|
| 152 |
+
|
| 153 |
+
The default local captioner, BLIP-base, is a 0.25B model trained on COCO photos.
|
| 154 |
+
It has no OCR, so on tutorial screenshots it produces generic text like *"a
|
| 155 |
+
computer screen with a website on it"* — near-useless for a step-by-step guide.
|
| 156 |
+
|
| 157 |
+
[`beam_app.py`](beam_app.py) serves **Qwen2.5-VL-7B-Instruct** on a serverless
|
| 158 |
+
GPU instead. It reads on-screen text and understands UI affordances, and it
|
| 159 |
+
scales to zero when idle.
|
| 160 |
+
|
| 161 |
+
```bash
|
| 162 |
+
uv tool install beam-client && beam configure default --token <YOUR_TOKEN>
|
| 163 |
+
beam deploy beam_app.py:caption
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
Put the printed URL and your Beam token in `.env`:
|
| 167 |
+
|
| 168 |
+
```
|
| 169 |
+
DOCUMAKER_BEAM_CAPTION_URL=https://<your-endpoint>.app.beam.cloud
|
| 170 |
+
DOCUMAKER_BEAM_TOKEN=<your-beam-token>
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
Captioning then prefers Beam, falling back to the HF Inference API and then
|
| 174 |
+
local BLIP — an unreachable or scaled-to-zero endpoint degrades the output
|
| 175 |
+
rather than breaking the run. The whole frame pool is sent in **one** batched
|
| 176 |
+
request (see `vision.caption_batch`), so a cold start is paid once per document,
|
| 177 |
+
not once per frame.
|
| 178 |
+
|
| 179 |
+
### Choosing a GPU
|
| 180 |
+
|
| 181 |
+
`DOCUMAKER_BEAM_GPU` selects the card. Qwen2.5-VL-7B in bf16 is ~16.5GB of
|
| 182 |
+
weights, so 24GB is the practical floor.
|
| 183 |
+
|
| 184 |
+
**No 16GB card is usable on Beam today**, which is why the default is 24GB:
|
| 185 |
+
`A4000` deploys but reports *"No compute capacity currently supports A4000"* and
|
| 186 |
+
never gets hardware, and `T4`/`V100` are rejected outright with *"This GPU type
|
| 187 |
+
is not supported. Please use an A10G or RTX 4090 instead."*
|
| 188 |
+
|
| 189 |
+
| GPU | VRAM | Notes |
|
| 190 |
+
|---|---|---|
|
| 191 |
+
| `A10G` | 24GB | **Default.** Ampere, native bf16, explicitly supported by Beam |
|
| 192 |
+
| `RTX4090` | 24GB | The other explicitly supported option; Ada, faster |
|
| 193 |
+
| `L4` | 24GB | Ada, efficient — check capacity before relying on it |
|
| 194 |
+
| `A100_40` | 40GB | Overkill for a 7B VLM |
|
| 195 |
+
| `A4000` | 16GB | ⚠️ Deploys, but currently has no capacity |
|
| 196 |
+
| `T4` / `V100` | 16GB | ⚠️ Rejected by Beam. `V100` would also fail on CUDA support |
|
| 197 |
+
|
| 198 |
+
To fit a smaller card, set `DOCUMAKER_BEAM_MODEL=Qwen/Qwen2.5-VL-3B-Instruct`
|
| 199 |
+
(~7GB in bf16). `DOCUMAKER_BEAM_MAX_PIXELS` bounds VRAM either way — Qwen2.5-VL
|
| 200 |
+
scales visual tokens with input resolution, so an uncapped screenshot can
|
| 201 |
+
balloon usage.
|
| 202 |
+
|
| 203 |
+
### Why torch is pinned
|
| 204 |
+
|
| 205 |
+
`beam_app.py` pins `torch==2.7.1` deliberately. Beam's hosts run a **CUDA 12.9
|
| 206 |
+
driver**; an unpinned `torch` resolves to a cu13 wheel that the driver is too old
|
| 207 |
+
to run. The failure is silent and misleading — `torch.cuda.is_available()` just
|
| 208 |
+
returns `False`, the model load fails on `device_map="cuda:0"`, and the endpoint
|
| 209 |
+
returns a bare `HTTP 500`. If you bump torch, verify the wheel's CUDA version
|
| 210 |
+
still matches the host driver.
|
| 211 |
+
|
| 212 |
+
If the AWQ dependency chain ever breaks, the no-quantization fallback is
|
| 213 |
+
`DOCUMAKER_BEAM_MODEL=Qwen/Qwen2.5-VL-3B-Instruct` (~7GB in bf16, still far
|
| 214 |
+
better than BLIP). `DOCUMAKER_BEAM_MAX_PIXELS` bounds VRAM — Qwen2.5-VL scales
|
| 215 |
+
visual tokens with input resolution, so an uncapped screenshot can balloon usage.
|
| 216 |
+
|
| 217 |
## Configuration
|
| 218 |
|
| 219 |
All settings are environment variables (see [.env.example](.env.example)). Highlights:
|
|
|
|
| 222 |
|---|---|---|
|
| 223 |
| `DOCUMAKER_LLM_MODEL` | `Qwen/Qwen2.5-7B-Instruct` | Text LLM (any HF instruct model) |
|
| 224 |
| `DOCUMAKER_VLM_MODEL` | `Qwen/Qwen2-VL-7B-Instruct` | API vision model tried before local BLIP |
|
| 225 |
+
| `DOCUMAKER_LOCAL_CAPTION_MODEL` | `Salesforce/blip-image-captioning-base` | Local captioner (last-resort fallback) |
|
| 226 |
+
| `DOCUMAKER_BEAM_CAPTION_URL` | *(unset)* | Beam GPU captioner; preferred when set |
|
| 227 |
+
| `DOCUMAKER_BEAM_TOKEN` | *(unset)* | Beam auth token |
|
| 228 |
+
| `DOCUMAKER_BEAM_GPU` | `A10G` | GPU for the Beam endpoint (24GB) |
|
| 229 |
+
| `DOCUMAKER_BEAM_MODEL` | `Qwen/Qwen2.5-VL-7B-Instruct` | VLM served on Beam |
|
| 230 |
| `DOCUMAKER_ENABLE_VISION` | `1` | Set `0` to skip captioning |
|
| 231 |
| `DOCUMAKER_WHISPER_MODEL` | `small` | `tiny`…`large-v3` |
|
| 232 |
| `DOCUMAKER_WHISPER_DEVICE` | `auto` | `auto` / `cuda` / `cpu` |
|
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Beam serverless-GPU captioner for DocuMaker.
|
| 2 |
+
|
| 3 |
+
Replaces the local BLIP fallback in ``src/vision.py`` with a real vision-language
|
| 4 |
+
model. BLIP is a 0.25B COCO captioner with no OCR — on tutorial screenshots it
|
| 5 |
+
emits generic text like "a computer screen with a website on it". Qwen2.5-VL
|
| 6 |
+
reads on-screen text and understands UI affordances, which is what a step-by-step
|
| 7 |
+
guide actually needs.
|
| 8 |
+
|
| 9 |
+
The endpoint is *batched*: DocuMaker captions one frame per step (see
|
| 10 |
+
``src/guide.py``), so sending the whole set in one request turns N cold-start
|
| 11 |
+
round-trips into one.
|
| 12 |
+
|
| 13 |
+
Deploy:
|
| 14 |
+
beam deploy beam_app.py:caption
|
| 15 |
+
|
| 16 |
+
Model weights are cached on a Beam Volume, so only the first container pays the
|
| 17 |
+
download cost.
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import base64
|
| 22 |
+
import io
|
| 23 |
+
import os
|
| 24 |
+
|
| 25 |
+
from beam import Image, QueueDepthAutoscaler, Volume, endpoint
|
| 26 |
+
|
| 27 |
+
# --- Tunables ---------------------------------------------------------------
|
| 28 |
+
# Full bf16 weights (~16.5GB) — comfortable on the 24GB A10G. The AWQ build was
|
| 29 |
+
# only needed to fit 16GB, and AutoAWQ is deprecated (last tested on torch 2.6 /
|
| 30 |
+
# transformers 4.51), so dropping it removes a fragile dependency. For a smaller
|
| 31 |
+
# card, Qwen/Qwen2.5-VL-3B-Instruct is ~7GB and still far better than BLIP.
|
| 32 |
+
MODEL_ID = os.getenv("DOCUMAKER_BEAM_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
|
| 33 |
+
# A10G (24GB). No 16GB card is usable here: A4000 reports no capacity, and Beam
|
| 34 |
+
# rejects T4/V100 outright ("use an A10G or RTX 4090 instead"). The 7B bf16
|
| 35 |
+
# weights need ~16.5GB, leaving headroom for the vision encoder and KV cache.
|
| 36 |
+
GPU = os.getenv("DOCUMAKER_BEAM_GPU", "A10G")
|
| 37 |
+
CACHE_DIR = "./hf-cache"
|
| 38 |
+
|
| 39 |
+
# Qwen2.5-VL scales its visual token count with input resolution, so an
|
| 40 |
+
# unbounded screenshot can balloon VRAM. Cap it: 1280 * 28 * 28 keeps a typical
|
| 41 |
+
# 1080p screenshot well inside budget while preserving legible UI text.
|
| 42 |
+
MAX_PIXELS = int(os.getenv("DOCUMAKER_BEAM_MAX_PIXELS", str(1280 * 28 * 28)))
|
| 43 |
+
MIN_PIXELS = int(os.getenv("DOCUMAKER_BEAM_MIN_PIXELS", str(256 * 28 * 28)))
|
| 44 |
+
|
| 45 |
+
DEFAULT_PROMPT = (
|
| 46 |
+
"In one concise sentence, describe what this screenshot from a tutorial shows, "
|
| 47 |
+
"focusing on the on-screen UI element or the action being performed. "
|
| 48 |
+
"Do not begin with phrases like 'The image shows'."
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
image = Image(
|
| 52 |
+
python_version="python3.11",
|
| 53 |
+
python_packages=[
|
| 54 |
+
# PIN torch, do not float it. Beam's hosts run a CUDA 12.9 driver, and an
|
| 55 |
+
# unpinned `torch` resolves to a cu13 wheel whose CUDA runtime the driver
|
| 56 |
+
# is too old for — torch.cuda.is_available() silently returns False and
|
| 57 |
+
# the container dies on device_map="cuda:0" with a bare 500.
|
| 58 |
+
# torch 2.7.1 ships cu126 on PyPI, which the 12.9 driver runs fine.
|
| 59 |
+
"torch==2.7.1",
|
| 60 |
+
"torchvision==0.22.1",
|
| 61 |
+
"transformers==4.53.2",
|
| 62 |
+
"accelerate",
|
| 63 |
+
"qwen-vl-utils",
|
| 64 |
+
"pillow",
|
| 65 |
+
],
|
| 66 |
+
).with_envs([f"HF_HOME={CACHE_DIR}"])
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def load_model():
|
| 70 |
+
"""Runs once per container (``on_start``), not once per request."""
|
| 71 |
+
import torch
|
| 72 |
+
from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration
|
| 73 |
+
|
| 74 |
+
# Fail loudly here rather than with an opaque 500 from the request handler:
|
| 75 |
+
# a driver/wheel CUDA mismatch shows up exactly as "no CUDA available".
|
| 76 |
+
if not torch.cuda.is_available():
|
| 77 |
+
raise RuntimeError(
|
| 78 |
+
f"CUDA unavailable (torch {torch.__version__}). The pinned torch build "
|
| 79 |
+
"must match Beam's host driver — see the pin note on `image` above."
|
| 80 |
+
)
|
| 81 |
+
print(f"[documaker-captioner] torch {torch.__version__} on "
|
| 82 |
+
f"{torch.cuda.get_device_name(0)}")
|
| 83 |
+
|
| 84 |
+
processor = AutoProcessor.from_pretrained(
|
| 85 |
+
MODEL_ID, min_pixels=MIN_PIXELS, max_pixels=MAX_PIXELS, cache_dir=CACHE_DIR
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# transformers v5 renamed ``torch_dtype`` to ``dtype``; v4 only knows the old
|
| 89 |
+
# spelling. Try the new one first so this works on either.
|
| 90 |
+
# bfloat16: A10G is Ampere, so bf16 is native and avoids the fp16 overflow
|
| 91 |
+
# Qwen2.5-VL is prone to. Falls back to fp16 on pre-Ampere cards.
|
| 92 |
+
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
| 93 |
+
common = {"device_map": "cuda:0", "cache_dir": CACHE_DIR}
|
| 94 |
+
try:
|
| 95 |
+
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
|
| 96 |
+
MODEL_ID, dtype=dtype, **common
|
| 97 |
+
)
|
| 98 |
+
except TypeError:
|
| 99 |
+
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
|
| 100 |
+
MODEL_ID, torch_dtype=dtype, **common
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
model.eval()
|
| 104 |
+
print(f"[documaker-captioner] loaded {MODEL_ID} on {model.device}")
|
| 105 |
+
return processor, model
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _decode_image(raw: str):
|
| 109 |
+
"""Accept a bare base64 string or a full ``data:image/...;base64,`` URI."""
|
| 110 |
+
from PIL import Image as PILImage
|
| 111 |
+
|
| 112 |
+
if not raw:
|
| 113 |
+
raise ValueError("empty image payload")
|
| 114 |
+
if raw.startswith("data:"):
|
| 115 |
+
raw = raw.split(",", 1)[1]
|
| 116 |
+
return PILImage.open(io.BytesIO(base64.b64decode(raw))).convert("RGB")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@endpoint(
|
| 120 |
+
name="documaker-captioner",
|
| 121 |
+
image=image,
|
| 122 |
+
gpu=GPU,
|
| 123 |
+
cpu=2,
|
| 124 |
+
memory="16Gi",
|
| 125 |
+
on_start=load_model,
|
| 126 |
+
volumes=[Volume(name="documaker-hf-cache", mount_path=CACHE_DIR)],
|
| 127 |
+
# Weights take ~40s to page in on a cold container. Staying warm for 5
|
| 128 |
+
# minutes means a user processing several videos in a sitting pays that
|
| 129 |
+
# once, while an idle endpoint still scales to zero.
|
| 130 |
+
keep_warm_seconds=300,
|
| 131 |
+
timeout=600,
|
| 132 |
+
autoscaler=QueueDepthAutoscaler(max_containers=2, tasks_per_container=1),
|
| 133 |
+
)
|
| 134 |
+
def caption(context, **inputs):
|
| 135 |
+
"""Caption a batch of frames.
|
| 136 |
+
|
| 137 |
+
Input::
|
| 138 |
+
|
| 139 |
+
{"items": [{"image": "<b64|data-uri>", "context": "optional step text"}],
|
| 140 |
+
"prompt": "optional override",
|
| 141 |
+
"max_new_tokens": 96}
|
| 142 |
+
|
| 143 |
+
Output::
|
| 144 |
+
|
| 145 |
+
{"captions": ["...", ...], "model": "...", "count": N}
|
| 146 |
+
|
| 147 |
+
Captions are returned positionally, so ``captions[i]`` belongs to
|
| 148 |
+
``items[i]``. A frame that fails to decode or generate yields ``""`` rather
|
| 149 |
+
than failing the whole batch — DocuMaker treats an empty caption as "no
|
| 150 |
+
caption" and the guide still builds.
|
| 151 |
+
"""
|
| 152 |
+
import torch
|
| 153 |
+
|
| 154 |
+
processor, model = context.on_start_value
|
| 155 |
+
|
| 156 |
+
items = inputs.get("items") or []
|
| 157 |
+
if not items:
|
| 158 |
+
return {"captions": [], "model": MODEL_ID, "count": 0}
|
| 159 |
+
|
| 160 |
+
base_prompt = inputs.get("prompt") or DEFAULT_PROMPT
|
| 161 |
+
max_new_tokens = int(inputs.get("max_new_tokens") or 96)
|
| 162 |
+
|
| 163 |
+
captions: list[str] = []
|
| 164 |
+
for item in items:
|
| 165 |
+
try:
|
| 166 |
+
img = _decode_image(item.get("image", ""))
|
| 167 |
+
prompt = base_prompt
|
| 168 |
+
step_context = (item.get("context") or "").strip()
|
| 169 |
+
if step_context:
|
| 170 |
+
prompt += f" For context, this step is about: {step_context[:200]}"
|
| 171 |
+
|
| 172 |
+
messages = [
|
| 173 |
+
{
|
| 174 |
+
"role": "user",
|
| 175 |
+
"content": [
|
| 176 |
+
{"type": "image", "image": img},
|
| 177 |
+
{"type": "text", "text": prompt},
|
| 178 |
+
],
|
| 179 |
+
}
|
| 180 |
+
]
|
| 181 |
+
text = processor.apply_chat_template(
|
| 182 |
+
messages, tokenize=False, add_generation_prompt=True
|
| 183 |
+
)
|
| 184 |
+
model_inputs = processor(
|
| 185 |
+
text=[text], images=[img], padding=True, return_tensors="pt"
|
| 186 |
+
).to(model.device)
|
| 187 |
+
|
| 188 |
+
with torch.no_grad():
|
| 189 |
+
generated = model.generate(
|
| 190 |
+
**model_inputs,
|
| 191 |
+
max_new_tokens=max_new_tokens,
|
| 192 |
+
do_sample=False,
|
| 193 |
+
)
|
| 194 |
+
# Strip the prompt tokens before decoding.
|
| 195 |
+
trimmed = generated[0][model_inputs.input_ids.shape[1]:]
|
| 196 |
+
caption_text = processor.decode(
|
| 197 |
+
trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=True
|
| 198 |
+
).strip()
|
| 199 |
+
captions.append(caption_text)
|
| 200 |
+
except Exception as exc: # one bad frame must not sink the batch
|
| 201 |
+
print(f"[documaker-captioner] frame failed: {exc}")
|
| 202 |
+
captions.append("")
|
| 203 |
+
|
| 204 |
+
return {"captions": captions, "model": MODEL_ID, "count": len(captions)}
|
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Smoke-test the Beam GPU captioner against real frames.
|
| 2 |
+
|
| 3 |
+
Usage (from the repo root, with DOCUMAKER_BEAM_CAPTION_URL / DOCUMAKER_BEAM_TOKEN
|
| 4 |
+
set in .env or the environment):
|
| 5 |
+
|
| 6 |
+
python scripts/test_beam_captions.py [frame.png ...]
|
| 7 |
+
|
| 8 |
+
With no arguments it picks a few frames out of ``work/``. Prints the caption each
|
| 9 |
+
backend produces so you can see the Qwen2.5-VL vs BLIP difference directly.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import sys
|
| 14 |
+
import time
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 18 |
+
|
| 19 |
+
from src import config, vision # noqa: E402
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def find_frames(limit: int = 3) -> list[Path]:
|
| 23 |
+
work = Path(__file__).resolve().parent.parent / "work"
|
| 24 |
+
frames = sorted(work.glob("*/frames/*.png"))
|
| 25 |
+
return frames[:limit]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def main() -> int:
|
| 29 |
+
paths = [Path(a) for a in sys.argv[1:]] or find_frames()
|
| 30 |
+
paths = [p for p in paths if p.exists()]
|
| 31 |
+
if not paths:
|
| 32 |
+
print("No frames found. Pass image paths explicitly.")
|
| 33 |
+
return 1
|
| 34 |
+
|
| 35 |
+
print(f"Beam URL : {config.BEAM_CAPTION_URL or '(unset)'}")
|
| 36 |
+
print(f"Beam token : {'set' if config.BEAM_CAPTION_TOKEN else '(unset)'}")
|
| 37 |
+
print(f"Frames : {len(paths)}\n")
|
| 38 |
+
|
| 39 |
+
if not config.BEAM_CAPTION_URL:
|
| 40 |
+
print("DOCUMAKER_BEAM_CAPTION_URL is unset — caption_batch would fall back "
|
| 41 |
+
"to the HF API / local BLIP. Set it to test the GPU path.")
|
| 42 |
+
return 1
|
| 43 |
+
|
| 44 |
+
start = time.time()
|
| 45 |
+
captions = vision.caption_batch([(p, "") for p in paths])
|
| 46 |
+
elapsed = time.time() - start
|
| 47 |
+
|
| 48 |
+
for path, caption in zip(paths, captions):
|
| 49 |
+
print(f"--- {path.name}")
|
| 50 |
+
print(f" {caption or '(empty)'}\n")
|
| 51 |
+
|
| 52 |
+
ok = sum(1 for c in captions if c)
|
| 53 |
+
print(f"{ok}/{len(paths)} captioned in {elapsed:.1f}s "
|
| 54 |
+
f"({elapsed / max(len(paths), 1):.1f}s per frame)")
|
| 55 |
+
# _BEAM_DISABLED flips on the first hard failure (bad URL/token/endpoint).
|
| 56 |
+
if vision._BEAM_DISABLED:
|
| 57 |
+
print("\nNOTE: the Beam backend errored and was disabled for this run — "
|
| 58 |
+
"captions above (if any) came from the HF API or local BLIP.")
|
| 59 |
+
return 1
|
| 60 |
+
return 0 if ok else 1
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
if __name__ == "__main__":
|
| 64 |
+
raise SystemExit(main())
|
|
@@ -116,6 +116,20 @@ LOCAL_CAPTION_MODEL = os.getenv(
|
|
| 116 |
"DOCUMAKER_LOCAL_CAPTION_MODEL", "Salesforce/blip-image-captioning-base"
|
| 117 |
)
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
# --- Whisper (local faster-whisper) -----------------------------------------
|
| 120 |
WHISPER_MODEL = os.getenv("DOCUMAKER_WHISPER_MODEL", "small")
|
| 121 |
WHISPER_DEVICE = os.getenv("DOCUMAKER_WHISPER_DEVICE", "auto").strip().lower()
|
|
|
|
| 116 |
"DOCUMAKER_LOCAL_CAPTION_MODEL", "Salesforce/blip-image-captioning-base"
|
| 117 |
)
|
| 118 |
|
| 119 |
+
# --- Beam serverless-GPU captioner (see beam_app.py) -------------------------
|
| 120 |
+
# When BEAM_CAPTION_URL is set it becomes the preferred captioner, ahead of the
|
| 121 |
+
# HF Inference API and local BLIP. Both remain as fallbacks, so an unreachable
|
| 122 |
+
# or scaled-to-zero endpoint degrades instead of breaking the run.
|
| 123 |
+
BEAM_CAPTION_URL = os.getenv("DOCUMAKER_BEAM_CAPTION_URL", "").strip()
|
| 124 |
+
BEAM_CAPTION_TOKEN = os.getenv("DOCUMAKER_BEAM_TOKEN", "").strip()
|
| 125 |
+
# A cold container must download/page in the weights before it answers; the
|
| 126 |
+
# first call of a session can legitimately take a couple of minutes.
|
| 127 |
+
BEAM_CAPTION_TIMEOUT = float(os.getenv("DOCUMAKER_BEAM_TIMEOUT", "300"))
|
| 128 |
+
# Frames per request. A long video can yield dozens of frames; sending them all
|
| 129 |
+
# at once makes a multi-MB body and a generation run long enough to hit the
|
| 130 |
+
# endpoint timeout. Chunking keeps each request bounded.
|
| 131 |
+
BEAM_CAPTION_BATCH_SIZE = int(os.getenv("DOCUMAKER_BEAM_BATCH_SIZE", "12"))
|
| 132 |
+
|
| 133 |
# --- Whisper (local faster-whisper) -----------------------------------------
|
| 134 |
WHISPER_MODEL = os.getenv("DOCUMAKER_WHISPER_MODEL", "small")
|
| 135 |
WHISPER_DEVICE = os.getenv("DOCUMAKER_WHISPER_DEVICE", "auto").strip().lower()
|
|
@@ -157,14 +157,17 @@ def assemble_guide(
|
|
| 157 |
# Caption the pool up front (once per frame, context-free to keep the
|
| 158 |
# relevance signal unbiased) so captions feed both selection and figures.
|
| 159 |
if do_caption and frames_sorted:
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
rec.
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
used: set[str] = set()
|
| 170 |
steps: list[GuideStep] = []
|
|
|
|
| 157 |
# Caption the pool up front (once per frame, context-free to keep the
|
| 158 |
# relevance signal unbiased) so captions feed both selection and figures.
|
| 159 |
if do_caption and frames_sorted:
|
| 160 |
+
pending = [rec for rec in frames_sorted if rec.caption is None]
|
| 161 |
+
if pending:
|
| 162 |
+
if progress:
|
| 163 |
+
progress(0.05, f"Captioning {len(pending)} frames…")
|
| 164 |
+
# One batched request when the Beam GPU endpoint is configured;
|
| 165 |
+
# caption_batch falls back to per-frame captioning otherwise.
|
| 166 |
+
captions = vision.caption_batch(
|
| 167 |
+
[(rec.path, "") for rec in pending], token=token
|
| 168 |
+
)
|
| 169 |
+
for rec, cap in zip(pending, captions):
|
| 170 |
+
rec.caption = cap or ""
|
| 171 |
|
| 172 |
used: set[str] = set()
|
| 173 |
steps: list[GuideStep] = []
|
|
@@ -20,6 +20,10 @@ _LOCAL_FAILED = False
|
|
| 20 |
# Many free HF accounts have no provider that serves a vision-chat model. Once
|
| 21 |
# the API VLM fails, stop retrying it for the session and use local BLIP.
|
| 22 |
_API_VLM_DISABLED = False
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
_CAPTION_PROMPT = (
|
| 25 |
"In one concise sentence, describe what this screenshot from a tutorial shows, "
|
|
@@ -69,6 +73,84 @@ def _caption_via_api(image_path: str | Path, prompt: str, token: str | None) ->
|
|
| 69 |
return (resp.choices[0].message.content or "").strip()
|
| 70 |
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
def _load_local_captioner() -> None:
|
| 73 |
"""Load the BLIP captioner directly (the image-to-text pipeline task was
|
| 74 |
removed in transformers 5). Uses the GPU if a CUDA build of torch is present.
|
|
@@ -129,10 +211,10 @@ def caption_image(
|
|
| 129 |
) -> str | None:
|
| 130 |
"""Return a one-line caption for a frame, or None if captioning is off/failed.
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
BLIP needs no token.
|
| 136 |
"""
|
| 137 |
global _API_VLM_DISABLED
|
| 138 |
if not config.ENABLE_VISION:
|
|
@@ -141,6 +223,10 @@ def caption_image(
|
|
| 141 |
if context:
|
| 142 |
prompt += f" For context, this step is about: {context[:200]}"
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
if token and not _API_VLM_DISABLED:
|
| 145 |
try:
|
| 146 |
caption = _caption_via_api(image_path, prompt, token)
|
|
|
|
| 20 |
# Many free HF accounts have no provider that serves a vision-chat model. Once
|
| 21 |
# the API VLM fails, stop retrying it for the session and use local BLIP.
|
| 22 |
_API_VLM_DISABLED = False
|
| 23 |
+
# Same idea for the Beam GPU endpoint: one hard failure (bad URL, bad token,
|
| 24 |
+
# endpoint deleted) disables it for the session rather than paying the timeout
|
| 25 |
+
# on every frame.
|
| 26 |
+
_BEAM_DISABLED = False
|
| 27 |
|
| 28 |
_CAPTION_PROMPT = (
|
| 29 |
"In one concise sentence, describe what this screenshot from a tutorial shows, "
|
|
|
|
| 73 |
return (resp.choices[0].message.content or "").strip()
|
| 74 |
|
| 75 |
|
| 76 |
+
def _beam_post(items: list[tuple[str | Path, str]], prompt: str) -> list[str] | None:
|
| 77 |
+
"""One request to the Beam endpoint. ``None`` on any failure."""
|
| 78 |
+
global _BEAM_DISABLED
|
| 79 |
+
|
| 80 |
+
import requests
|
| 81 |
+
|
| 82 |
+
payload = {
|
| 83 |
+
"items": [
|
| 84 |
+
{"image": _data_uri(path), "context": ctx or ""} for path, ctx in items
|
| 85 |
+
],
|
| 86 |
+
"prompt": prompt,
|
| 87 |
+
}
|
| 88 |
+
headers = {"Content-Type": "application/json"}
|
| 89 |
+
if config.BEAM_CAPTION_TOKEN:
|
| 90 |
+
headers["Authorization"] = f"Bearer {config.BEAM_CAPTION_TOKEN}"
|
| 91 |
+
|
| 92 |
+
try:
|
| 93 |
+
resp = requests.post(
|
| 94 |
+
config.BEAM_CAPTION_URL,
|
| 95 |
+
json=payload,
|
| 96 |
+
headers=headers,
|
| 97 |
+
timeout=config.BEAM_CAPTION_TIMEOUT,
|
| 98 |
+
)
|
| 99 |
+
resp.raise_for_status()
|
| 100 |
+
captions = resp.json().get("captions")
|
| 101 |
+
if not isinstance(captions, list) or len(captions) != len(items):
|
| 102 |
+
return None
|
| 103 |
+
return [str(c or "").strip() for c in captions]
|
| 104 |
+
except Exception:
|
| 105 |
+
# Auth/URL problems repeat on every call, so stop trying this session.
|
| 106 |
+
_BEAM_DISABLED = True
|
| 107 |
+
return None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _beam_caption_batch(
|
| 111 |
+
items: list[tuple[str | Path, str]], prompt: str
|
| 112 |
+
) -> list[str] | None:
|
| 113 |
+
"""Caption frames via the Beam GPU endpoint, chunked.
|
| 114 |
+
|
| 115 |
+
``items`` is a list of ``(image_path, context)``. Returns captions aligned to
|
| 116 |
+
``items``, or ``None`` if the endpoint is unconfigured or any chunk fails —
|
| 117 |
+
the caller then falls back to the HF API and local BLIP for the whole set,
|
| 118 |
+
which keeps the outcome predictable rather than half-Beam/half-BLIP.
|
| 119 |
+
"""
|
| 120 |
+
if _BEAM_DISABLED or not config.BEAM_CAPTION_URL or not items:
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
size = max(1, config.BEAM_CAPTION_BATCH_SIZE)
|
| 124 |
+
captions: list[str] = []
|
| 125 |
+
for start in range(0, len(items), size):
|
| 126 |
+
chunk = _beam_post(items[start:start + size], prompt)
|
| 127 |
+
if chunk is None:
|
| 128 |
+
return None
|
| 129 |
+
captions.extend(chunk)
|
| 130 |
+
return captions
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def caption_batch(
|
| 134 |
+
items: list[tuple[str | Path, str]], *, token: str | None = None
|
| 135 |
+
) -> list[str]:
|
| 136 |
+
"""Caption a list of ``(image_path, context)`` pairs.
|
| 137 |
+
|
| 138 |
+
Prefers one batched call to the Beam GPU endpoint. Without it, falls back to
|
| 139 |
+
per-frame captioning via :func:`caption_image` so behaviour is unchanged when
|
| 140 |
+
Beam is not configured.
|
| 141 |
+
"""
|
| 142 |
+
if not config.ENABLE_VISION or not items:
|
| 143 |
+
return ["" for _ in items]
|
| 144 |
+
|
| 145 |
+
captions = _beam_caption_batch(items, _CAPTION_PROMPT)
|
| 146 |
+
if captions is not None:
|
| 147 |
+
return captions
|
| 148 |
+
|
| 149 |
+
return [
|
| 150 |
+
caption_image(path, token=token, context=ctx) or "" for path, ctx in items
|
| 151 |
+
]
|
| 152 |
+
|
| 153 |
+
|
| 154 |
def _load_local_captioner() -> None:
|
| 155 |
"""Load the BLIP captioner directly (the image-to-text pipeline task was
|
| 156 |
removed in transformers 5). Uses the GPU if a CUDA build of torch is present.
|
|
|
|
| 211 |
) -> str | None:
|
| 212 |
"""Return a one-line caption for a frame, or None if captioning is off/failed.
|
| 213 |
|
| 214 |
+
Order of preference: the Beam GPU endpoint (if ``DOCUMAKER_BEAM_CAPTION_URL``
|
| 215 |
+
is set), then an API vision-chat model (if any provider serves one), then
|
| 216 |
+
local BLIP. After a backend fails once it is skipped for the rest of the
|
| 217 |
+
session to avoid repeated dead calls. Local BLIP needs no token.
|
| 218 |
"""
|
| 219 |
global _API_VLM_DISABLED
|
| 220 |
if not config.ENABLE_VISION:
|
|
|
|
| 223 |
if context:
|
| 224 |
prompt += f" For context, this step is about: {context[:200]}"
|
| 225 |
|
| 226 |
+
beam = _beam_caption_batch([(image_path, context)], _CAPTION_PROMPT)
|
| 227 |
+
if beam and beam[0]:
|
| 228 |
+
return beam[0]
|
| 229 |
+
|
| 230 |
if token and not _API_VLM_DISABLED:
|
| 231 |
try:
|
| 232 |
caption = _caption_via_api(image_path, prompt, token)
|