neotwin-api / pipeline /capture_utils.py
1qwsd's picture
deploy: NeoTwin backend v1.0 - FastAPI + Gemini AI
488880d
Raw
History Blame Contribute Delete
12 kB
"""
Capture Utilities - Video Processing & Frame Extraction
Handles video upload, frame extraction, quality filtering
"""
import subprocess
import os
import cv2
import numpy as np
from pathlib import Path
from typing import Tuple, List, Dict
class VideoProcessor:
"""Process video uploads for 3D reconstruction"""
def __init__(self):
self.supported_formats = ['.mp4', '.mov', '.avi', '.mkv']
self.max_file_size_mb = 500
self.min_duration_sec = 30
self.max_duration_sec = 600
self.target_fps = 2
self.max_resolution = 1920
self.blur_threshold = 15.0
self.min_frames = 50
self.max_frames = 500
def validate_video(self, video_path: str) -> Dict:
"""Validate video file before processing"""
result = {
"valid": True,
"warnings": [],
"errors": [],
"info": {}
}
# Check file exists
if not os.path.exists(video_path):
result["valid"] = False
result["errors"].append("Video file not found")
return result
# Check file size
file_size_mb = os.path.getsize(video_path) / (1024 * 1024)
if file_size_mb > self.max_file_size_mb:
result["valid"] = False
result["errors"].append(f"File too large: {file_size_mb:.1f}MB (max {self.max_file_size_mb}MB)")
return result
# Get video info
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
result["valid"] = False
result["errors"].append("Cannot open video file")
return result
fps = cap.get(cv2.CAP_PROP_FPS)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
duration = frame_count / fps if fps > 0 else 0
result["info"] = {
"fps": fps,
"frame_count": frame_count,
"width": width,
"height": height,
"duration": duration,
"file_size_mb": round(file_size_mb, 2)
}
# Validate duration
if duration < self.min_duration_sec:
result["valid"] = False
result["errors"].append(
f"Video too short: {duration:.0f}s (minimum {self.min_duration_sec}s). "
f"Walk slowly around the space for better reconstruction."
)
if duration > self.max_duration_sec:
result["warnings"].append(
f"Video very long: {duration:.0f}s. Processing may take longer. "
f"Consider trimming to 1-5 minutes."
)
# Check resolution
if width < 1280 or height < 720:
result["warnings"].append(
f"Low resolution: {width}x{height}. "
f"Recommend 1080p or 4K for best results."
)
# Check FPS
if fps < 24:
result["warnings"].append(
f"Low frame rate: {fps:.0f}fps. "
f"Recommend 30fps or 60fps for smooth capture."
)
# Sample frames for quality check
quality_score = self._assess_video_quality(cap, frame_count)
result["info"]["quality_score"] = quality_score
if quality_score < 0.5:
result["warnings"].append(
f"Low video quality detected (score: {quality_score:.2f}). "
f"Ensure good lighting and steady camera movement."
)
cap.release()
return result
def _assess_video_quality(self, cap: cv2.VideoCapture, frame_count: int) -> float:
"""Assess overall video quality by sampling frames"""
sample_count = min(20, frame_count)
step = max(1, frame_count // sample_count)
quality_scores = []
for i in range(0, frame_count, step):
cap.set(cv2.CAP_PROP_POS_FRAMES, i)
ret, frame = cap.read()
if not ret:
continue
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
blur_score = cv2.Laplacian(gray, cv2.CV_64F).var()
# Normalize to 0-1
normalized = min(1.0, blur_score / 500.0)
quality_scores.append(normalized)
return np.mean(quality_scores) if quality_scores else 0.0
def extract_frames(
self,
video_path: str,
output_dir: str,
fps: int = None,
max_resolution: int = None
) -> Tuple[str, int]:
"""
Extract frames from video using ffmpeg
Returns:
Tuple of (output_dir, frame_count)
"""
if fps is None:
fps = self.target_fps
if max_resolution is None:
max_resolution = self.max_resolution
os.makedirs(output_dir, exist_ok=True)
# Calculate optimal FPS based on video duration
cap = cv2.VideoCapture(video_path)
duration = cap.get(cv2.CAP_PROP_FRAME_COUNT) / cap.get(cv2.CAP_PROP_FPS)
cap.release()
# Adjust FPS to target 100-300 frames
optimal_fps = max(1, min(4, int(200 / duration)))
fps = min(fps, optimal_fps)
# Extract frames with ffmpeg
output_pattern = os.path.join(output_dir, "frame_%04d.jpg")
cmd = [
"ffmpeg",
"-i", video_path,
"-vf", f"fps={fps},scale={max_resolution}:-1",
"-q:v", "2",
"-y",
output_pattern
]
try:
subprocess.run(cmd, check=True, capture_output=True)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"ffmpeg failed: {e.stderr.decode()}")
# Filter blurry frames
filtered_count = self.filter_blurry_frames(output_dir)
# Limit frame count
self.limit_frames(output_dir, self.max_frames)
final_count = len([f for f in os.listdir(output_dir) if f.endswith('.jpg')])
if final_count < self.min_frames:
raise ValueError(
f"Too few frames extracted: {final_count} (minimum {self.min_frames}). "
f"Please record a longer video or walk more slowly."
)
return output_dir, final_count
def filter_blurry_frames(self, frames_dir: str, threshold: float = None) -> int:
"""Remove blurry frames using Laplacian variance, ensuring we keep a minimum count of clear frames"""
if threshold is None:
threshold = self.blur_threshold
frames = sorted([f for f in os.listdir(frames_dir) if f.endswith('.jpg')])
# Calculate variance for each frame
frame_variances = []
for frame in frames:
path = os.path.join(frames_dir, frame)
img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
if img is None:
continue
variance = cv2.Laplacian(img, cv2.CV_64F).var()
frame_variances.append((frame, variance))
# Sort frames by variance (clearest first)
frame_variances.sort(key=lambda x: x[1], reverse=True)
# Determine which frames to keep
kept_frames = set()
for i, (frame, variance) in enumerate(frame_variances):
# Always keep the frame if it's above the blur threshold,
# OR if we need to satisfy the minimum frames requirement (min_frames)
if variance >= threshold or len(kept_frames) < self.min_frames:
kept_frames.add(frame)
# Delete the ones we didn't keep
removed = 0
for frame in frames:
if frame not in kept_frames:
try:
os.remove(os.path.join(frames_dir, frame))
removed += 1
except Exception:
pass
print(f"Removed {removed} blurry frames, kept {len(frames) - removed}")
return len(frames) - removed
def limit_frames(self, frames_dir: str, max_frames: int):
"""Limit frame count by sampling evenly"""
frames = sorted([f for f in os.listdir(frames_dir) if f.endswith('.jpg')])
if len(frames) <= max_frames:
return
# Keep evenly spaced frames
step = len(frames) / max_frames
keep_indices = [int(i * step) for i in range(max_frames)]
for i, frame in enumerate(frames):
if i not in keep_indices:
os.remove(os.path.join(frames_dir, frame))
def generate_preview(self, video_path: str, output_dir: str, num_previews: int = 10) -> List[str]:
"""Generate preview images from video"""
os.makedirs(output_dir, exist_ok=True)
cap = cv2.VideoCapture(video_path)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
step = frame_count // num_previews
preview_paths = []
for i in range(0, frame_count, step):
cap.set(cv2.CAP_PROP_POS_FRAMES, i)
ret, frame = cap.read()
if not ret:
break
preview_path = os.path.join(output_dir, f"preview_{i:04d}.jpg")
cv2.imwrite(preview_path, frame)
preview_paths.append(preview_path)
cap.release()
return preview_paths
def get_capture_instructions(self) -> Dict:
"""Return video capture instructions for users"""
return {
"title": "How to Capture Your Space",
"duration": "2-5 minutes per room",
"steps": [
{
"step": 1,
"title": "Start at One Corner",
"description": "Begin recording at one corner of the room",
"tip": "Hold phone steady at eye level"
},
{
"step": 2,
"title": "Walk Slowly in a Circle",
"description": "Walk around the perimeter of the room slowly",
"tip": "Maintain 70% overlap between views"
},
{
"step": 3,
"title": "Tilt Up and Down",
"description": "Capture ceiling, walls, and floor",
"tip": "Don't just shoot at eye level"
},
{
"step": 4,
"title": "Get Close to Objects",
"description": "Move closer to furniture and details",
"tip": "Capture from multiple angles"
},
{
"step": 5,
"title": "Complete the Circle",
"description": "Return to starting position",
"tip": "Ensure full 360° coverage"
}
],
"do": [
"Walk slowly and steadily",
"Capture from multiple heights",
"Get close to important objects",
"Shoot in good lighting",
"Complete full circles around spaces"
],
"dont": [
"Run or move too fast",
"Stand still and rotate",
"Point only at blank walls",
"Use digital zoom",
"Shoot in very dark areas"
],
"ideal_specs": {
"resolution": "1080p or 4K",
"frame_rate": "30fps or 60fps",
"duration": "2-5 minutes per room",
"format": "MP4 or MOV",
"max_file_size": "500MB"
}
}
# Singleton instance
video_processor = VideoProcessor()