refactor(vqa): convert to class-based singleton

Refactor VQA module from module-level globals to a VQA class singleton
  pattern with self-contained per-model loading methods.

Changes:
- Add VQA class with model/processor state and detection data storage
- Extract load methods for clean model pre-loading via UI
- Interrogate to return string only; store detection data on instance
- Add vqa_draw.py for bounding box/point annotation utilities
    Stub, further transfer of drawing functions to follow
- Update moondream3.py to store detection data on VQA singleton
- Update endpoints.py and ui_caption.py for new return type
This commit is contained in:
CalamitousFelicitousness
2025-12-05 20:53:18 +00:00
parent d1b1d574a6
commit 5193285bc7
5 changed files with 1155 additions and 1123 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ def post_vqa(req: models.ReqVQA):
image = helpers.decode_base64_to_image(req.image)
image = image.convert('RGB')
from modules.interrogate import vqa
answer, _ = vqa.interrogate(req.question, req.system, '', image, req.model)
answer = vqa.interrogate(req.question, req.system, '', image, req.model)
return models.ResVQA(answer=answer)
def post_unload_checkpoint():
+13 -7
View File
@@ -320,7 +320,7 @@ def predict(question: str, image: Image.Image, repo: str, model_name: str = None
**kwargs: Additional parameters (max_objects for detect, etc.)
Returns:
Response string or tuple (text, annotated_image) for detect/point modes
Response string (detection data stored on VQA singleton instance.last_detection_data)
(or generator if stream=True for query/caption modes)
"""
debug(f'VQA interrogate: handler=moondream3 model_name="{model_name}" repo="{repo}" question="{question}" image_size={image.size if image else None} mode={mode} stream={stream}')
@@ -386,7 +386,7 @@ def predict(question: str, image: Image.Image, repo: str, model_name: str = None
debug(f'VQA interrogate: handler=moondream3 point_extracted_object="{object_name}"')
result = point(image, object_name, repo)
if result:
# Handle multiple instances - return text and points for drawing
# Handle multiple instances - return text and store points for drawing
if len(result) == 1:
text = f"Found at coordinates: ({result[0][0]:.3f}, {result[0][1]:.3f})"
else:
@@ -395,8 +395,11 @@ def predict(question: str, image: Image.Image, repo: str, model_name: str = None
for i, (x, y) in enumerate(result, 1):
lines.append(f" {i}. ({x:.3f}, {y:.3f})")
text = '\n'.join(lines)
return (text, {'points': result}) # Return text and points data
return ("Object not found", None)
# Store detection data on VQA singleton for annotation
from modules.interrogate import vqa
vqa.get_instance().last_detection_data = {'points': result}
return text
return "Object not found"
elif mode == 'detect':
# Extract object name from question - case insensitive
object_name = question
@@ -413,13 +416,16 @@ def predict(question: str, image: Image.Image, repo: str, model_name: str = None
debug(f'VQA interrogate: handler=moondream3 detect_extracted_object="{object_name}"')
results = detect(image, object_name, repo, max_objects=kwargs.get('max_objects', 10))
# Format as string for display and return detections for drawing
# Format as string for display and store detections for drawing
if results:
lines = [f"{det['label']}: [{det['bbox'][0]:.3f}, {det['bbox'][1]:.3f}, {det['bbox'][2]:.3f}, {det['bbox'][3]:.3f}] (confidence: {det['confidence']:.2f})"
for det in results]
text = '\n'.join(lines)
return (text, {'detections': results}) # Return text and detection data
return ("No objects detected", None)
# Store detection data on VQA singleton for annotation
from modules.interrogate import vqa
vqa.get_instance().last_detection_data = {'detections': results}
return text
return "No objects detected"
else: # mode == 'query'
if len(question) < 2:
question = "Describe this image."
+1059 -1107
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
# VQA Image Annotation Utilities
# Drawing functions for bounding boxes, points, and other visual annotations
from PIL import Image, ImageDraw, ImageFont
from modules import shared
def draw_bounding_boxes(image: Image.Image, detections: list, points: list = None) -> Image.Image:
"""
Draw bounding boxes and/or points on an image.
Args:
image: PIL Image to annotate
detections: List of detection dicts with format:
[{'label': str, 'bbox': [x1, y1, x2, y2], 'confidence': float}, ...]
where coordinates are normalized 0-1
points: Optional list of (x, y) tuples with normalized 0-1 coordinates
Returns:
Annotated PIL Image with boxes and labels drawn, or None if no annotations
"""
if not detections and not points:
return None
# Create a copy to avoid modifying original
annotated = image.copy()
draw = ImageDraw.Draw(annotated)
width, height = image.size
# Try to load a font, fall back to default if unavailable
try:
font_size = max(12, int(min(width, height) * 0.02))
font_path = shared.opts.font or "javascript/notosans-nerdfont-regular.ttf"
font = ImageFont.truetype(font_path, size=font_size)
except Exception:
font = ImageFont.load_default()
# Draw bounding boxes
if detections:
colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF', '#FFA500', '#800080']
for idx, det in enumerate(detections):
bbox = det['bbox']
label = det.get('label', 'object')
confidence = det.get('confidence', 1.0)
# Convert normalized coordinates to pixel coordinates
x1 = int(bbox[0] * width)
y1 = int(bbox[1] * height)
x2 = int(bbox[2] * width)
y2 = int(bbox[3] * height)
# Choose color
color = colors[idx % len(colors)]
# Draw box
draw.rectangle([x1, y1, x2, y2], outline=color, width=max(2, int(min(width, height) * 0.003)))
# Draw label with background
label_text = f"{label} {confidence:.2f}" if confidence < 1.0 else label
bbox_font = draw.textbbox((x1, y1), label_text, font=font)
text_width = bbox_font[2] - bbox_font[0]
text_height = bbox_font[3] - bbox_font[1]
draw.rectangle([x1, y1 - text_height - 4, x1 + text_width + 4, y1], fill=color)
draw.text((x1 + 2, y1 - text_height - 2), label_text, fill='white', font=font)
# Draw points
if points:
point_radius = max(3, int(min(width, height) * 0.01))
for px, py in points:
x = int(px * width)
y = int(py * height)
# Draw point as a circle
draw.ellipse([x - point_radius, y - point_radius, x + point_radius, y + point_radius],
fill='#FF0000', outline='#FFFFFF', width=2)
return annotated
+6 -8
View File
@@ -4,15 +4,13 @@ from modules.interrogate import openclip
def vlm_caption_wrapper(question, system_prompt, prompt, image, model_name, prefill, thinking_mode):
"""Wrapper to handle tuple returns from vqa.interrogate with annotated images."""
"""Wrapper for vqa.interrogate that handles annotated image display."""
from modules.interrogate import vqa
result = vqa.interrogate(question, system_prompt, prompt, image, model_name, prefill, thinking_mode)
if isinstance(result, tuple):
text, annotated_image = result
if annotated_image is not None:
return text, gr.update(value=annotated_image, visible=True)
return text, gr.update(visible=False)
return result, gr.update(visible=False)
answer = vqa.interrogate(question, system_prompt, prompt, image, model_name, prefill, thinking_mode)
annotated_image = vqa.get_last_annotated_image()
if annotated_image is not None:
return answer, gr.update(value=annotated_image, visible=True)
return answer, gr.update(visible=False)
def update_vlm_prompts_for_model(model_name):