Files
momentry_core/scripts/interval_vlm_caption.py
T
Accusys 39a2cbc65b fix: face group name read consistency, sync_file_status fix, cleanup ghost records, identity_agent replaced with face_dedup
- get_face_groups_handler: COALESCE(tp.name, tn.label) for name consistency
- sync_file_status: compare JSON vs pre_chunks (not chunk table)
- face consistency: compare frames.len() not total_faces
- cleanup 2 ghost records with NULL file_name/file_path
- replace identity_agent with face_dedup in pipeline stages
- remove identity_agent_api.rs and all references
- update required_processors to match actual processors
- update AGENTS.md with team responsibilities
- add Studio pipeline changes documentation
2026-07-27 02:15:51 +08:00

303 lines
9.6 KiB
Python

#!/opt/homebrew/bin/python3.11
"""
Interval VLM Caption - Analyze video at regular intervals (10s default)
Extracts frames on-the-fly with ffmpeg (no pre-storage) and analyzes with VLM.
Runs in background, non-blocking.
Usage:
python interval_vlm_caption.py --file-uuid abc123 --video /path/to/video.mp4
python interval_vlm_caption.py --file-uuid abc123 --video /path/to/video.mp4 --interval 10
Output:
{output_dir}/{uuid}_interval_profile.json
"""
import argparse
import base64
import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
try:
import requests
except ImportError:
print("requests not installed: pip install requests", file=sys.stderr)
sys.exit(1)
def get_video_duration(video_path: str) -> float:
"""Get video duration in seconds using ffprobe."""
cmd = [
"ffprobe", "-v", "quiet",
"-show_entries", "format=duration",
"-of", "json",
video_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
return 0.0
data = json.loads(result.stdout)
return float(data["format"]["duration"])
def extract_frame_at_time(video_path: str, timestamp_sec: float, output_path: str) -> bool:
"""Extract a single frame at specific timestamp."""
cmd = [
"ffmpeg", "-y", "-v", "quiet",
"-ss", str(timestamp_sec),
"-i", video_path,
"-vframes", "1",
"-q:v", "2",
output_path
]
result = subprocess.run(cmd, capture_output=True)
return result.returncode == 0
def call_vlm(image_path: str, prompt: str, model: str = "llava:7b", ollama_url: str = "http://localhost:11434") -> str:
"""Call Ollama VLM API."""
with open(image_path, "rb") as f:
image_b64 = base64.b64encode(f.read()).decode("utf-8")
payload = {
"model": model,
"prompt": prompt,
"images": [image_b64],
"stream": False,
"options": {"num_predict": 100}
}
try:
resp = requests.post(f"{ollama_url}/api/generate", json=payload, timeout=30)
resp.raise_for_status()
data = resp.json()
return data.get("response", "").strip()
except Exception as e:
print(f"[vlm] API error: {e}", file=sys.stderr)
return ""
def get_embedding(text: str, model: str = "nomic-embed-text-v2-moe", ollama_url: str = "http://localhost:11434") -> list:
"""Get embedding from Ollama."""
try:
resp = requests.post(
f"{ollama_url}/api/embed",
json={"model": model, "input": text},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
return data.get("embeddings", [[]])[0]
except Exception as e:
print(f"[vlm] Embedding error: {e}", file=sys.stderr)
return []
def store_to_qdrant(analysis: dict, file_uuid: str, interval_index: int, timestamp_sec: float, qdrant_url: str = "http://localhost:6333", qdrant_api_key: str = None) -> bool:
"""Store VLM results to Qdrant _vlm collection."""
description = analysis.get("vlm_description", "")
if not description:
return False
# Get embedding
embedding = get_embedding(description)
if not embedding:
print(f"[vlm] Failed to get embedding for interval_{interval_index}", file=sys.stderr)
return False
# Generate point ID
import hashlib
point_id = int(hashlib.md5(f"{file_uuid}_interval_{interval_index}".encode()).hexdigest()[:16], 16)
# Build payload
payload = {
"type": "interval",
"file_uuid": file_uuid,
"interval_index": interval_index,
"timestamp_sec": timestamp_sec,
**analysis,
}
# Upsert to Qdrant
try:
headers = {}
if qdrant_api_key:
headers["api-key"] = qdrant_api_key
resp = requests.put(
f"{qdrant_url}/collections/_vlm/points?wait=true",
json={
"points": [{
"id": point_id,
"vector": embedding,
"payload": payload,
}]
},
headers=headers,
timeout=30,
)
resp.raise_for_status()
print(f"[vlm] Stored to Qdrant: interval_{interval_index}")
return True
except Exception as e:
print(f"[vlm] Qdrant error: {e}", file=sys.stderr)
return False
def analyze_frame(image_path: str, model: str = "llava:7b") -> dict:
"""Analyze a single frame with VLM - scene/background focus."""
# Prompt 1: Scene description (concise)
desc_prompt = "Describe this scene in one sentence. Focus on: location, main activity, visible objects. If unclear, say 'unclear'. Do not guess."
description = call_vlm(image_path, desc_prompt, model)
# Prompt 2: Classification (JSON)
class_prompt = "Classify the scene. Answer in JSON: {\"location\": \"indoor/outdoor/unknown\", \"setting\": \"office/street/home/nature/studio/unknown\", \"lighting\": \"day/night/indoor-light/mixed/unknown\"}. Use 'unknown' if uncertain."
class_raw = call_vlm(image_path, class_prompt, model)
class_data = {}
try:
class_clean = class_raw.replace("```json", "").replace("```", "").strip()
class_data = json.loads(class_clean)
except:
class_data = {}
# Prompt 3: People count
people_prompt = "How many people? Answer a number or 'unclear'."
people_count = call_vlm(image_path, people_prompt, model).strip()
# Prompt 4: Tags
tags_prompt = "List 3 tags for this scene, comma-separated. Examples: office, street, crowd, nature."
tags_raw = call_vlm(image_path, tags_prompt, model)
tags = [t.strip() for t in tags_raw.replace(",", " ").split() if t.strip()][:3]
return {
"vlm_description": description,
"vlm_location": class_data.get("location", "unknown"),
"vlm_setting": class_data.get("setting", "unknown"),
"vlm_lighting": class_data.get("lighting", "unknown"),
"vlm_people_count": people_count,
"vlm_tags": tags,
}
def analyze_intervals(
file_uuid: str,
video_path: str,
interval_sec: float = 10.0,
output_dir: str = "/Users/accusys/momentry/output",
model: str = "llava:7b",
store_qdrant: bool = True,
) -> dict:
"""
Analyze video at regular intervals.
Returns:
Summary dict with all interval analyses
"""
# Get video duration
duration = get_video_duration(video_path)
if duration <= 0:
print(f"[vlm] Cannot get video duration: {video_path}", file=sys.stderr)
return {"error": "Cannot get duration"}
print(f"[vlm] Video: {duration:.1f}s, interval: {interval_sec}s")
# Calculate timestamps
timestamps = []
t = 0.0
while t < duration:
timestamps.append(t)
t += interval_sec
print(f"[vlm] Total frames to analyze: {len(timestamps)}")
results = []
qdrant_api_key = os.environ.get("QDRANT_API_KEY")
# Create temp directory for frames
with tempfile.TemporaryDirectory() as tmpdir:
for i, ts in enumerate(timestamps):
frame_path = f"{tmpdir}/frame_{i:04d}.jpg"
# Extract frame
success = extract_frame_at_time(video_path, ts, frame_path)
if not success:
print(f"[vlm] Failed to extract frame at {ts:.1f}s", file=sys.stderr)
continue
# Analyze
print(f"[vlm] [{i+1}/{len(timestamps)}] {ts:.1f}s...", end=" ", flush=True)
start_time = time.time()
analysis = analyze_frame(frame_path, model)
# Store to Qdrant
if store_qdrant:
store_to_qdrant(analysis, file_uuid, i, ts, qdrant_api_key=qdrant_api_key)
elapsed = time.time() - start_time
print(f"done ({elapsed:.1f}s)")
results.append({
"interval_index": i,
"timestamp_sec": round(ts, 1),
**analysis,
})
# Save profile
profile = {
"file_uuid": file_uuid,
"video_duration_sec": round(duration, 1),
"interval_sec": interval_sec,
"total_intervals": len(timestamps),
"analyzed": len(results),
"model": model,
"intervals": results,
}
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
profile_path = output_path / f"{file_uuid}_interval_profile.json"
with open(profile_path, "w") as f:
json.dump(profile, f, indent=2)
print(f"[vlm] Saved: {profile_path}")
return profile
def main():
parser = argparse.ArgumentParser(description="VLM analysis at regular intervals")
parser.add_argument("--file-uuid", "-u", required=True, help="File UUID")
parser.add_argument("--video", "-v", required=True, help="Video file path")
parser.add_argument("--interval", "-i", type=float, default=10.0, help="Interval in seconds (default: 10)")
parser.add_argument("--output-dir", "-o", default="/Users/accusys/momentry/output", help="Output directory")
parser.add_argument("--model", "-m", default="llava:7b", help="VLM model name")
parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
args = parser.parse_args()
result = analyze_intervals(
args.file_uuid,
args.video,
args.interval,
args.output_dir,
args.model,
)
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"Analyzed {result.get('analyzed', 0)} intervals")
if __name__ == "__main__":
main()