mirror of
https://github.com/hanzoai/video.git
synced 2026-10-30 18:18:27 +00:00
11 production pipelines, 47 tools, 124 agent skills. Supports cloud APIs (fal.ai, OpenAI, ElevenLabs, Suno, HeyGen, Runway) and free local providers (diffusers, Piper TTS, WAN 2.1, Hunyuan, CogVideo). Co-authored-by: Hanzo Dev <[email protected]>
598 lines
21 KiB
Python
598 lines
21 KiB
Python
"""Video/image understanding tool using vision-language models.
|
|
|
|
Analyzes images or video frames using CLIP, BLIP-2, or LLaVA. Supports
|
|
frame description, visual question answering, quality assessment, and
|
|
scene classification. Primary use case: visual QA for automated video review.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from tools.base_tool import (
|
|
BaseTool,
|
|
Determinism,
|
|
ExecutionMode,
|
|
ResourceProfile,
|
|
ToolResult,
|
|
ToolRuntime,
|
|
ToolStability,
|
|
ToolStatus,
|
|
ToolTier,
|
|
)
|
|
|
|
VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".webm"}
|
|
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".webp"}
|
|
|
|
SCENE_CATEGORIES = [
|
|
"indoor", "outdoor", "landscape", "cityscape", "portrait",
|
|
"action", "close-up", "aerial", "underwater", "night",
|
|
"studio", "nature", "urban", "abstract", "text-overlay",
|
|
]
|
|
|
|
|
|
class VideoUnderstand(BaseTool):
|
|
name = "video_understand"
|
|
version = "0.1.0"
|
|
tier = ToolTier.ANALYZE
|
|
capability = "analysis"
|
|
provider = "transformers"
|
|
stability = ToolStability.EXPERIMENTAL
|
|
execution_mode = ExecutionMode.SYNC
|
|
determinism = Determinism.DETERMINISTIC
|
|
runtime = ToolRuntime.LOCAL_GPU
|
|
|
|
dependencies = ["python:transformers", "python:torch"]
|
|
install_instructions = (
|
|
"pip install transformers torch # For CLIP/BLIP-2 visual understanding"
|
|
)
|
|
agent_skills = ["video-understand"]
|
|
|
|
capabilities = [
|
|
"image_description",
|
|
"visual_qa",
|
|
"quality_assessment",
|
|
"scene_classification",
|
|
"object_detection",
|
|
]
|
|
|
|
input_schema = {
|
|
"type": "object",
|
|
"required": ["input_path"],
|
|
"properties": {
|
|
"input_path": {
|
|
"type": "string",
|
|
"description": "Path to image or video file",
|
|
},
|
|
"query": {
|
|
"type": "string",
|
|
"description": "Question to answer about the visual content (for VQA mode)",
|
|
},
|
|
"mode": {
|
|
"type": "string",
|
|
"enum": ["describe", "qa", "quality", "classify"],
|
|
"default": "describe",
|
|
"description": (
|
|
"describe: generate caption; qa: answer query about content; "
|
|
"quality: assess technical quality; classify: classify scene type"
|
|
),
|
|
},
|
|
"model": {
|
|
"type": "string",
|
|
"enum": ["clip", "blip2", "llava"],
|
|
"default": "clip",
|
|
"description": "Which vision-language model to use",
|
|
},
|
|
"frame_indices": {
|
|
"type": "array",
|
|
"items": {"type": "integer"},
|
|
"description": (
|
|
"For video input, which frames to analyze. "
|
|
"If not provided, samples key frames at even intervals."
|
|
),
|
|
},
|
|
"max_frames": {
|
|
"type": "integer",
|
|
"default": 5,
|
|
"description": "Maximum number of frames to analyze from video",
|
|
},
|
|
},
|
|
}
|
|
|
|
output_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"frames": {
|
|
"type": "array",
|
|
"description": "Per-frame analysis results",
|
|
},
|
|
"summary": {"type": "string"},
|
|
"mode": {"type": "string"},
|
|
"model": {"type": "string"},
|
|
},
|
|
}
|
|
|
|
resource_profile = ResourceProfile(
|
|
cpu_cores=2,
|
|
ram_mb=4096,
|
|
vram_mb=2048,
|
|
disk_mb=1000,
|
|
network_required=False,
|
|
)
|
|
|
|
idempotency_key_fields = ["input_path", "mode", "model", "query"]
|
|
side_effects = []
|
|
fallback = None
|
|
user_visible_verification = [
|
|
"Compare generated descriptions against actual visual content",
|
|
"Verify quality scores match perceived image quality",
|
|
]
|
|
|
|
def get_status(self) -> ToolStatus:
|
|
"""Check if transformers and torch are both importable."""
|
|
try:
|
|
import transformers # noqa: F401
|
|
import torch # noqa: F401
|
|
return ToolStatus.AVAILABLE
|
|
except ImportError:
|
|
return ToolStatus.UNAVAILABLE
|
|
|
|
def estimate_runtime(self, inputs: dict[str, Any]) -> float:
|
|
"""Estimate runtime in seconds based on mode and frame count."""
|
|
max_frames = inputs.get("max_frames", 5)
|
|
mode = inputs.get("mode", "describe")
|
|
if mode == "quality":
|
|
return max_frames * 0.5 # quality metrics are fast
|
|
return max_frames * 5.0 # VLM inference per frame
|
|
|
|
def execute(self, inputs: dict[str, Any]) -> ToolResult:
|
|
input_path = Path(inputs["input_path"])
|
|
mode = inputs.get("mode", "describe")
|
|
model_name = inputs.get("model", "clip")
|
|
query = inputs.get("query")
|
|
frame_indices = inputs.get("frame_indices")
|
|
max_frames = inputs.get("max_frames", 5)
|
|
|
|
if not input_path.exists():
|
|
return ToolResult(
|
|
success=False,
|
|
error=f"Input file not found: {input_path}",
|
|
)
|
|
|
|
suffix = input_path.suffix.lower()
|
|
is_video = suffix in VIDEO_EXTENSIONS
|
|
is_image = suffix in IMAGE_EXTENSIONS
|
|
|
|
if not is_video and not is_image:
|
|
return ToolResult(
|
|
success=False,
|
|
error=(
|
|
f"Unsupported file type: {suffix}. "
|
|
f"Supported: {sorted(VIDEO_EXTENSIONS | IMAGE_EXTENSIONS)}"
|
|
),
|
|
)
|
|
|
|
if mode == "qa" and not query:
|
|
return ToolResult(
|
|
success=False,
|
|
error="Query is required for 'qa' mode.",
|
|
)
|
|
|
|
start = time.time()
|
|
|
|
# --- Load frames ---
|
|
try:
|
|
frames = self._load_frames(
|
|
input_path, is_video, frame_indices, max_frames
|
|
)
|
|
except Exception as e:
|
|
return ToolResult(success=False, error=f"Failed to load frames: {e}")
|
|
|
|
if not frames:
|
|
return ToolResult(success=False, error="No frames could be extracted.")
|
|
|
|
# --- Analyze each frame ---
|
|
try:
|
|
if mode == "quality":
|
|
frame_results = self._analyze_quality(frames)
|
|
elif mode == "describe":
|
|
frame_results = self._analyze_describe(frames, model_name)
|
|
elif mode == "qa":
|
|
frame_results = self._analyze_qa(frames, model_name, query)
|
|
elif mode == "classify":
|
|
frame_results = self._analyze_classify(frames, model_name)
|
|
else:
|
|
return ToolResult(success=False, error=f"Unknown mode: {mode}")
|
|
except ImportError as e:
|
|
return ToolResult(
|
|
success=False,
|
|
error=f"Missing dependency for {model_name}: {e}",
|
|
)
|
|
except Exception as e:
|
|
return ToolResult(success=False, error=f"Analysis failed: {e}")
|
|
|
|
elapsed = time.time() - start
|
|
|
|
# --- Build summary ---
|
|
summary = self._build_summary(frame_results, mode)
|
|
|
|
return ToolResult(
|
|
success=True,
|
|
data={
|
|
"frames": frame_results,
|
|
"summary": summary,
|
|
"mode": mode,
|
|
"model": model_name if mode != "quality" else "metrics",
|
|
"frame_count": len(frame_results),
|
|
},
|
|
duration_seconds=round(elapsed, 2),
|
|
model=model_name if mode != "quality" else None,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Frame extraction
|
|
# ------------------------------------------------------------------
|
|
|
|
def _load_frames(
|
|
self,
|
|
input_path: Path,
|
|
is_video: bool,
|
|
frame_indices: list[int] | None,
|
|
max_frames: int,
|
|
) -> list:
|
|
"""Load PIL Image objects from an image or video file."""
|
|
from PIL import Image
|
|
|
|
if not is_video:
|
|
return [Image.open(input_path).convert("RGB")]
|
|
|
|
return self._extract_video_frames(input_path, frame_indices, max_frames)
|
|
|
|
def _extract_video_frames(
|
|
self,
|
|
video_path: Path,
|
|
frame_indices: list[int] | None,
|
|
max_frames: int,
|
|
) -> list:
|
|
"""Extract frames from a video file using ffmpeg."""
|
|
from PIL import Image
|
|
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
tmp = Path(tmp_dir)
|
|
|
|
if frame_indices:
|
|
# Extract specific frames using select filter
|
|
frames_to_extract = frame_indices[:max_frames]
|
|
select_expr = "+".join(
|
|
f"eq(n\\,{idx})" for idx in frames_to_extract
|
|
)
|
|
cmd = [
|
|
"ffmpeg", "-i", str(video_path),
|
|
"-vf", f"select='{select_expr}'",
|
|
"-vsync", "vfr",
|
|
str(tmp / "frame_%04d.png"),
|
|
"-y", "-loglevel", "error",
|
|
]
|
|
else:
|
|
# Get total frame count first
|
|
probe_cmd = [
|
|
"ffmpeg", "-i", str(video_path),
|
|
"-map", "0:v:0", "-c", "copy", "-f", "null", "-",
|
|
]
|
|
# Sample at even intervals using fps filter
|
|
# Use a select filter that picks frames at even intervals
|
|
cmd = [
|
|
"ffmpeg", "-i", str(video_path),
|
|
"-frames:v", str(max_frames),
|
|
"-vf", f"thumbnail={max_frames}",
|
|
str(tmp / "frame_%04d.png"),
|
|
"-y", "-loglevel", "error",
|
|
]
|
|
|
|
subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
|
|
# Load extracted frames
|
|
frame_files = sorted(tmp.glob("frame_*.png"))
|
|
images = []
|
|
for f in frame_files[:max_frames]:
|
|
images.append(Image.open(f).convert("RGB"))
|
|
|
|
return images
|
|
|
|
# ------------------------------------------------------------------
|
|
# Quality assessment (uses PIL/numpy, no VLM needed)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _analyze_quality(self, frames: list) -> list[dict[str, Any]]:
|
|
"""Assess technical quality using simple image metrics."""
|
|
import numpy as np
|
|
|
|
results = []
|
|
for i, img in enumerate(frames):
|
|
arr = np.array(img, dtype=np.float64)
|
|
gray = np.mean(arr, axis=2)
|
|
|
|
# Blur detection: Laplacian variance (low = blurry)
|
|
# Manual Laplacian approximation using numpy
|
|
laplacian_kernel = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]])
|
|
from scipy.signal import convolve2d
|
|
laplacian = convolve2d(gray, laplacian_kernel, mode="valid")
|
|
blur_score = float(np.var(laplacian))
|
|
|
|
# Brightness: mean pixel value (0-255 scale)
|
|
brightness = float(np.mean(arr))
|
|
|
|
# Contrast: standard deviation of pixel values
|
|
contrast = float(np.std(arr))
|
|
|
|
# Classify quality
|
|
quality_issues = []
|
|
if blur_score < 100:
|
|
quality_issues.append("blurry")
|
|
if brightness < 40:
|
|
quality_issues.append("underexposed")
|
|
elif brightness > 220:
|
|
quality_issues.append("overexposed")
|
|
if contrast < 30:
|
|
quality_issues.append("low_contrast")
|
|
|
|
quality_label = "good" if not quality_issues else "issues_detected"
|
|
|
|
results.append({
|
|
"frame_index": i,
|
|
"blur_score": round(blur_score, 2),
|
|
"brightness": round(brightness, 2),
|
|
"contrast": round(contrast, 2),
|
|
"quality": quality_label,
|
|
"issues": quality_issues,
|
|
"resolution": f"{img.width}x{img.height}",
|
|
})
|
|
|
|
return results
|
|
|
|
# ------------------------------------------------------------------
|
|
# VLM-based analysis modes
|
|
# ------------------------------------------------------------------
|
|
|
|
def _load_model(self, model_name: str):
|
|
"""Load the requested vision-language model and processor."""
|
|
import torch
|
|
from transformers import (
|
|
CLIPProcessor,
|
|
CLIPModel,
|
|
BlipProcessor,
|
|
BlipForConditionalGeneration,
|
|
Blip2Processor,
|
|
Blip2ForConditionalGeneration,
|
|
AutoProcessor,
|
|
AutoModelForCausalLM,
|
|
)
|
|
|
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
|
|
if model_name == "clip":
|
|
model_id = "openai/clip-vit-base-patch32"
|
|
processor = CLIPProcessor.from_pretrained(model_id)
|
|
model = CLIPModel.from_pretrained(model_id).to(device)
|
|
return model, processor, device
|
|
|
|
if model_name == "blip2":
|
|
model_id = "Salesforce/blip2-opt-2.7b"
|
|
processor = Blip2Processor.from_pretrained(model_id)
|
|
model = Blip2ForConditionalGeneration.from_pretrained(
|
|
model_id, torch_dtype=torch.float16 if device == "cuda" else torch.float32
|
|
).to(device)
|
|
return model, processor, device
|
|
|
|
if model_name == "llava":
|
|
model_id = "llava-hf/llava-1.5-7b-hf"
|
|
processor = AutoProcessor.from_pretrained(model_id)
|
|
model = AutoModelForCausalLM.from_pretrained(
|
|
model_id, torch_dtype=torch.float16 if device == "cuda" else torch.float32
|
|
).to(device)
|
|
return model, processor, device
|
|
|
|
raise ValueError(f"Unknown model: {model_name}")
|
|
|
|
def _analyze_describe(
|
|
self, frames: list, model_name: str
|
|
) -> list[dict[str, Any]]:
|
|
"""Generate captions for each frame."""
|
|
import torch
|
|
|
|
model, processor, device = self._load_model(model_name)
|
|
results = []
|
|
|
|
for i, img in enumerate(frames):
|
|
if model_name == "clip":
|
|
# CLIP is not a captioning model; use zero-shot classification
|
|
# with generic scene descriptions as a caption proxy
|
|
candidate_texts = [
|
|
"a photo of a person", "a photo of a landscape",
|
|
"a photo of an object", "a photo of text",
|
|
"a photo of an animal", "a photo of a building",
|
|
"a photo of food", "a photo of a vehicle",
|
|
"an abstract image", "a dark scene", "a bright scene",
|
|
]
|
|
clip_inputs = processor(
|
|
text=candidate_texts, images=img, return_tensors="pt", padding=True
|
|
).to(device)
|
|
with torch.no_grad():
|
|
outputs = model(**clip_inputs)
|
|
probs = outputs.logits_per_image.softmax(dim=1)[0]
|
|
top_idx = probs.argmax().item()
|
|
caption = candidate_texts[top_idx]
|
|
confidence = round(probs[top_idx].item(), 3)
|
|
results.append({
|
|
"frame_index": i,
|
|
"description": caption,
|
|
"confidence": confidence,
|
|
})
|
|
|
|
elif model_name in ("blip2", "llava"):
|
|
inputs = processor(images=img, return_tensors="pt").to(device)
|
|
with torch.no_grad():
|
|
generated_ids = model.generate(**inputs, max_new_tokens=50)
|
|
caption = processor.batch_decode(
|
|
generated_ids, skip_special_tokens=True
|
|
)[0].strip()
|
|
results.append({
|
|
"frame_index": i,
|
|
"description": caption,
|
|
})
|
|
|
|
return results
|
|
|
|
def _analyze_qa(
|
|
self, frames: list, model_name: str, query: str
|
|
) -> list[dict[str, Any]]:
|
|
"""Answer a question about each frame."""
|
|
import torch
|
|
|
|
model, processor, device = self._load_model(model_name)
|
|
results = []
|
|
|
|
for i, img in enumerate(frames):
|
|
if model_name == "clip":
|
|
# Use query as one candidate and its negation as another
|
|
candidates = [query, f"not {query}"]
|
|
clip_inputs = processor(
|
|
text=candidates, images=img, return_tensors="pt", padding=True
|
|
).to(device)
|
|
with torch.no_grad():
|
|
outputs = model(**clip_inputs)
|
|
probs = outputs.logits_per_image.softmax(dim=1)[0]
|
|
yes_prob = probs[0].item()
|
|
results.append({
|
|
"frame_index": i,
|
|
"query": query,
|
|
"answer": "yes" if yes_prob > 0.5 else "no",
|
|
"confidence": round(max(yes_prob, 1 - yes_prob), 3),
|
|
})
|
|
|
|
elif model_name in ("blip2", "llava"):
|
|
prompt = f"Question: {query} Answer:"
|
|
inputs = processor(
|
|
images=img, text=prompt, return_tensors="pt"
|
|
).to(device)
|
|
with torch.no_grad():
|
|
generated_ids = model.generate(**inputs, max_new_tokens=50)
|
|
answer = processor.batch_decode(
|
|
generated_ids, skip_special_tokens=True
|
|
)[0].strip()
|
|
# Remove the prompt echo if present
|
|
if answer.startswith(prompt):
|
|
answer = answer[len(prompt):].strip()
|
|
results.append({
|
|
"frame_index": i,
|
|
"query": query,
|
|
"answer": answer,
|
|
})
|
|
|
|
return results
|
|
|
|
def _analyze_classify(
|
|
self, frames: list, model_name: str
|
|
) -> list[dict[str, Any]]:
|
|
"""Classify each frame into scene categories."""
|
|
import torch
|
|
|
|
model, processor, device = self._load_model(model_name)
|
|
results = []
|
|
|
|
for i, img in enumerate(frames):
|
|
if model_name == "clip":
|
|
candidate_texts = [f"a {cat} scene" for cat in SCENE_CATEGORIES]
|
|
clip_inputs = processor(
|
|
text=candidate_texts, images=img, return_tensors="pt", padding=True
|
|
).to(device)
|
|
with torch.no_grad():
|
|
outputs = model(**clip_inputs)
|
|
probs = outputs.logits_per_image.softmax(dim=1)[0]
|
|
|
|
scored = sorted(
|
|
zip(SCENE_CATEGORIES, probs.tolist()),
|
|
key=lambda x: x[1],
|
|
reverse=True,
|
|
)
|
|
results.append({
|
|
"frame_index": i,
|
|
"top_category": scored[0][0],
|
|
"confidence": round(scored[0][1], 3),
|
|
"categories": [
|
|
{"label": label, "score": round(score, 3)}
|
|
for label, score in scored[:5]
|
|
],
|
|
})
|
|
|
|
elif model_name in ("blip2", "llava"):
|
|
prompt = (
|
|
"Classify this image into one of these categories: "
|
|
+ ", ".join(SCENE_CATEGORIES)
|
|
+ ". Category:"
|
|
)
|
|
inputs = processor(
|
|
images=img, text=prompt, return_tensors="pt"
|
|
).to(device)
|
|
with torch.no_grad():
|
|
generated_ids = model.generate(**inputs, max_new_tokens=20)
|
|
category = processor.batch_decode(
|
|
generated_ids, skip_special_tokens=True
|
|
)[0].strip()
|
|
if category.startswith(prompt):
|
|
category = category[len(prompt):].strip()
|
|
results.append({
|
|
"frame_index": i,
|
|
"top_category": category,
|
|
})
|
|
|
|
return results
|
|
|
|
# ------------------------------------------------------------------
|
|
# Summary
|
|
# ------------------------------------------------------------------
|
|
|
|
def _build_summary(
|
|
self, frame_results: list[dict[str, Any]], mode: str
|
|
) -> str:
|
|
"""Build a human-readable summary from per-frame results."""
|
|
n = len(frame_results)
|
|
|
|
if mode == "describe":
|
|
descriptions = [r.get("description", "") for r in frame_results]
|
|
if n == 1:
|
|
return descriptions[0]
|
|
return f"Analyzed {n} frames. Descriptions: " + "; ".join(descriptions)
|
|
|
|
if mode == "qa":
|
|
answers = [r.get("answer", "") for r in frame_results]
|
|
if n == 1:
|
|
return answers[0]
|
|
return f"Analyzed {n} frames. Answers: " + "; ".join(answers)
|
|
|
|
if mode == "quality":
|
|
issues_all = []
|
|
for r in frame_results:
|
|
issues_all.extend(r.get("issues", []))
|
|
if not issues_all:
|
|
return f"All {n} frame(s) passed quality checks."
|
|
unique_issues = sorted(set(issues_all))
|
|
return (
|
|
f"Analyzed {n} frame(s). Issues found: {', '.join(unique_issues)}."
|
|
)
|
|
|
|
if mode == "classify":
|
|
categories = [r.get("top_category", "unknown") for r in frame_results]
|
|
if n == 1:
|
|
return f"Scene classified as: {categories[0]}"
|
|
return (
|
|
f"Analyzed {n} frames. Scene categories: "
|
|
+ ", ".join(categories)
|
|
)
|
|
|
|
return f"Analyzed {n} frame(s)."
|