Files

166 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Batch visual QA of rendered document pages/slides via direct vLLM API calls.
Bypasses the vision_analyze tool — useful when:
- vision_analyze fails due to config caching / model resolution issues
- You need to QA many images efficiently in one pass
- The active chat model is text-only but a multimodal model is on the same endpoint
Usage:
python batch_vision_qa.py /path/to/images/ <num_images> [options]
Environment variables:
NORIS_API_KEY - API key for the vLLM endpoint (required)
VISION_BASE_URL - Full chat completions URL (default: https://ai.noris.de/v1/chat/completions)
VISION_MODEL - Model name (default: vllm/release/gemma-4-31b-it)
Example:
NORIS_API_KEY=sk-bf-xxx python batch_vision_qa.py /home/debian/ai101/qa/ 17
Expects files named slide-01.jpg, slide-02.jpg, ..., slide-NN.jpg.
Outputs per-slide status and a summary of any issues found.
Before using: verify the model accepts images (see references/model-capabilities.md).
"""
import sys
import os
import base64
import json
import time
import urllib.request
API_KEY = os.environ.get("NORIS_API_KEY", "")
BASE_URL = os.environ.get(
"VISION_BASE_URL", "https://ai.noris.de/v1/chat/completions"
)
MODEL = os.environ.get("VISION_MODEL", "vllm/release/gemma-4-31b-it")
# Expanded 6-category prompt: overlap, overflow, contrast, layout, content, symbols
QA_PROMPT_TEMPLATE = """You are a visual QA reviewer for a professional presentation slide.
Analyze this slide carefully for ALL of these issues:
1. TEXT OVERLAP: Any text overlapping other text or graphics?
2. TEXT OVERFLOW: Any text extending beyond edges or getting cut off?
3. CONTRAST: Any low contrast between text and background making text hard to read?
4. LAYOUT: Misalignment, elements too close, insufficient margins, awkward spacing?
5. CONTENT: Unclear messaging, missing information, placeholder text, spelling errors, logical inconsistencies?
6. SYMBOLS: Any broken/missing Unicode characters showing as boxes or empty circles?
Respond in this exact JSON format:
{{"slide": {slide_num}, "overlap": "none|<description>", "overflow": "none|<description>", "contrast": "ok|<description>", "layout": "ok|<description>", "content": "ok|<description>", "symbols": "ok|<description>", "overall": "clean|issues found"}}
Be precise and thorough. If no issues, say "clean". If issues exist, describe them specifically."""
def qa_single_image(image_path: str, slide_num: int) -> dict:
"""Send one image to the vision model and return parsed QA result."""
with open(image_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
payload = {
"model": MODEL,
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": QA_PROMPT_TEMPLATE.format(slide_num=slide_num),
},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{img_b64}"
},
},
],
}
],
"max_tokens": 500,
}
req = urllib.request.Request(
BASE_URL,
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
)
resp = urllib.request.urlopen(req, timeout=60)
data = json.loads(resp.read())
content = data["choices"][0]["message"].get("content", "")
# Strip markdown code fences if present
content = content.strip()
if content.startswith("```"):
content = content.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
try:
return json.loads(content)
except json.JSONDecodeError:
return {
"slide": slide_num,
"overlap": "parse error",
"overflow": "parse error",
"contrast": "parse error",
"layout": "parse error",
"content": "parse error",
"symbols": "parse error",
"overall": content[:200],
}
def main():
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <images_dir> <num_images>")
print(f"Example: {sys.argv[0]} /home/debian/ai101/qa/ 17")
sys.exit(1)
images_dir = sys.argv[1]
num_slides = int(sys.argv[2])
if not API_KEY:
print("ERROR: Set NORIS_API_KEY environment variable.")
sys.exit(1)
results = []
issues_found = []
for i in range(1, num_slides + 1):
slide_path = os.path.join(images_dir, f"slide-{i:02d}.jpg")
if not os.path.exists(slide_path):
print(f"Slide {i}: file not found — {slide_path}")
continue
try:
result = qa_single_image(slide_path, i)
results.append(result)
status = result.get("overall", "?")
marker = "⚠️" if "issue" in status.lower() else "✅"
print(f"Slide {i}/{num_slides} {marker} {status}")
if "issue" in status.lower():
issues_found.append(result)
except Exception as e:
print(f"Slide {i} FAILED: {e}")
results.append({"slide": i, "error": str(e)})
time.sleep(0.5)
print("\n" + "=" * 60)
if issues_found:
print(f"ISSUES ON {len(issues_found)} SLIDE(S):")
for r in issues_found:
print(f" Slide {r['slide']}:")
for key in ["overlap", "overflow", "contrast", "layout", "content", "symbols"]:
val = r.get(key, "")
if val and val not in ("none", "ok", ""):
print(f" {key}: {val}")
else:
print("ALL SLIDES CLEAN ✅")
if __name__ == "__main__":
main()