528 lines
18 KiB
Python
528 lines
18 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
光湖视频AI系统 · OpenCV 平面追踪引擎
|
|||
|
|
D140 · 铸渊 ICE-GL-ZY001 · 2026-06-23
|
|||
|
|
|
|||
|
|
用途:
|
|||
|
|
1. 特征点追踪 — 追踪画面中物体运动轨迹
|
|||
|
|
2. 画面稳定化 — 去除抖动
|
|||
|
|
3. 文字/图形贴合 — 把文字/水印/PIN到追踪的平面上
|
|||
|
|
4. 运动数据导出 — 供 video-editor.js 的 keyframes 使用
|
|||
|
|
|
|||
|
|
调用方式:
|
|||
|
|
python3 planar-tracker.py <mode> <video> [options]
|
|||
|
|
|
|||
|
|
mode:
|
|||
|
|
track — 追踪特征点,输出运动轨迹JSON
|
|||
|
|
stabilize — 画面稳定化,输出稳定后视频
|
|||
|
|
pin_text — 追踪+贴合文字到指定区域
|
|||
|
|
motion_data — 导出运动数据供FFmpeg使用
|
|||
|
|
|
|||
|
|
输出:
|
|||
|
|
track → JSON文件 (轨迹数据)
|
|||
|
|
stabilize → MP4文件 (稳定后视频)
|
|||
|
|
pin_text → MP4文件 (带贴合文字的视频)
|
|||
|
|
motion_data → JSON文件 (zoom/pan关键帧数据)
|
|||
|
|
|
|||
|
|
铸渊 ICE-GL-ZY001 · D140
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import cv2
|
|||
|
|
import numpy as np
|
|||
|
|
import json
|
|||
|
|
import sys
|
|||
|
|
import os
|
|||
|
|
import argparse
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
# ==================== 特征点追踪 ====================
|
|||
|
|
|
|||
|
|
def track_features(video_path, output_json, max_points=200, min_distance=15, quality=0.01):
|
|||
|
|
"""
|
|||
|
|
Shi-Tomasi角点检测 + Lucas-Kanade光流追踪
|
|||
|
|
|
|||
|
|
输出JSON格式:
|
|||
|
|
{
|
|||
|
|
"fps": 24,
|
|||
|
|
"frame_count": 120,
|
|||
|
|
"resolution": [1920, 1080],
|
|||
|
|
"tracks": [
|
|||
|
|
{
|
|||
|
|
"point_id": 0,
|
|||
|
|
"points": [{"frame": 0, "x": 100, "y": 200}, ...],
|
|||
|
|
"start": [100, 200],
|
|||
|
|
"end": [105, 198],
|
|||
|
|
"displacement": [5, -2],
|
|||
|
|
"avg_velocity": [0.04, -0.017]
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
cap = cv2.VideoCapture(video_path)
|
|||
|
|
if not cap.isOpened():
|
|||
|
|
raise RuntimeError(f"无法打开视频: {video_path}")
|
|||
|
|
|
|||
|
|
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))
|
|||
|
|
|
|||
|
|
print(f"[Tracker] 视频: {Path(video_path).name} {width}x{height} @ {fps:.1f}fps {frame_count}帧")
|
|||
|
|
|
|||
|
|
# 读取第一帧
|
|||
|
|
ret, prev_frame = cap.read()
|
|||
|
|
if not ret:
|
|||
|
|
raise RuntimeError("无法读取视频帧")
|
|||
|
|
|
|||
|
|
prev_gray = cv2.cvtColor(prev_frame, cv2.COLOR_BGR2GRAY)
|
|||
|
|
|
|||
|
|
# Shi-Tomasi角点检测
|
|||
|
|
prev_points = cv2.goodFeaturesToTrack(
|
|||
|
|
prev_gray, maxCorners=max_points, qualityLevel=quality,
|
|||
|
|
minDistance=min_distance, blockSize=7
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if prev_points is None:
|
|||
|
|
raise RuntimeError("第一帧未检测到特征点")
|
|||
|
|
|
|||
|
|
print(f"[Tracker] 检测到 {len(prev_points)} 个特征点")
|
|||
|
|
|
|||
|
|
# 初始化轨迹
|
|||
|
|
tracks = []
|
|||
|
|
for i, pt in enumerate(prev_points):
|
|||
|
|
tracks.append({
|
|||
|
|
"point_id": i,
|
|||
|
|
"points": [{"frame": 0, "x": float(pt[0][0]), "y": float(pt[0][1])}],
|
|||
|
|
"start": [float(pt[0][0]), float(pt[0][1])],
|
|||
|
|
"active": True
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# Lucas-Kanade参数
|
|||
|
|
lk_params = dict(
|
|||
|
|
winSize=(15, 15),
|
|||
|
|
maxLevel=2,
|
|||
|
|
criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0.03)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
frame_idx = 1
|
|||
|
|
while True:
|
|||
|
|
ret, frame = cap.read()
|
|||
|
|
if not ret:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
frame_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|||
|
|
|
|||
|
|
# 计算光流
|
|||
|
|
next_points, status, error = cv2.calcOpticalFlowPyrLK(
|
|||
|
|
prev_gray, frame_gray, prev_points, None, **lk_params
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 更新轨迹
|
|||
|
|
active_count = 0
|
|||
|
|
for i, (track, st) in enumerate(zip(tracks, status)):
|
|||
|
|
if not track["active"]:
|
|||
|
|
continue
|
|||
|
|
if st == 1 and next_points is not None:
|
|||
|
|
x, y = float(next_points[i][0]), float(next_points[i][1])
|
|||
|
|
track["points"].append({"frame": frame_idx, "x": x, "y": y})
|
|||
|
|
track["active"] = True
|
|||
|
|
active_count += 1
|
|||
|
|
else:
|
|||
|
|
track["active"] = False
|
|||
|
|
|
|||
|
|
# 只保留活跃点供下一帧追踪
|
|||
|
|
if active_count > 0:
|
|||
|
|
mask = status.flatten() == 1
|
|||
|
|
prev_points = next_points[mask].reshape(-1, 1, 2)
|
|||
|
|
# 对应更新tracks索引
|
|||
|
|
new_tracks = [t for t, m in zip(tracks, mask) if m]
|
|||
|
|
# 但要保留丢失的轨迹(标记为非活跃)
|
|||
|
|
lost_tracks = [t for t, m in zip(tracks, mask) if not m]
|
|||
|
|
tracks = new_tracks + lost_tracks
|
|||
|
|
# 重新编号并调整
|
|||
|
|
# 注意:prev_points和新tracks顺序需要对齐
|
|||
|
|
# 上面的操作保证了new_tracks和prev_points对齐
|
|||
|
|
|
|||
|
|
prev_gray = frame_gray
|
|||
|
|
frame_idx += 1
|
|||
|
|
|
|||
|
|
if frame_idx % 30 == 0:
|
|||
|
|
print(f" [Tracker] 帧 {frame_idx}/{frame_count} 活跃点: {active_count}")
|
|||
|
|
|
|||
|
|
cap.release()
|
|||
|
|
|
|||
|
|
# 计算位移和速度
|
|||
|
|
for track in tracks:
|
|||
|
|
if len(track["points"]) >= 2:
|
|||
|
|
start = track["start"]
|
|||
|
|
end = track["points"][-1]
|
|||
|
|
track["end"] = [end["x"], end["y"]]
|
|||
|
|
track["displacement"] = [end["x"] - start[0], end["y"] - start[1]]
|
|||
|
|
frames = len(track["points"])
|
|||
|
|
track["avg_velocity"] = [
|
|||
|
|
track["displacement"][0] / frames,
|
|||
|
|
track["displacement"][1] / frames
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# 只保留有足够数据的轨迹
|
|||
|
|
valid_tracks = [t for t in tracks if len(t["points"]) >= frame_count * 0.3]
|
|||
|
|
print(f"[Tracker] 有效轨迹: {len(valid_tracks)}/{len(tracks)}")
|
|||
|
|
|
|||
|
|
result = {
|
|||
|
|
"fps": fps,
|
|||
|
|
"frame_count": frame_idx - 1,
|
|||
|
|
"resolution": [width, height],
|
|||
|
|
"tracks": valid_tracks
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
with open(output_json, 'w', encoding='utf-8') as f:
|
|||
|
|
json.dump(result, f, ensure_ascii=False, indent=2)
|
|||
|
|
|
|||
|
|
print(f"[Tracker] ✅ 轨迹数据已保存: {output_json}")
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================== 画面稳定化 ====================
|
|||
|
|
|
|||
|
|
def stabilize_video(video_path, output_path, smoothing_radius=25, crop_percent=0.08):
|
|||
|
|
"""
|
|||
|
|
基于特征点轨迹的画面稳定化
|
|||
|
|
|
|||
|
|
原理:
|
|||
|
|
1. 追踪相邻帧的特征点
|
|||
|
|
2. 计算相邻帧之间的仿射变换矩阵
|
|||
|
|
3. 累积变换轨迹
|
|||
|
|
4. 平滑轨迹(滑动平均)
|
|||
|
|
5. 应用平滑后的变换
|
|||
|
|
|
|||
|
|
smoothing_radius: 平滑窗口大小(帧数),越大越平滑但延迟越大
|
|||
|
|
crop_percent: 边缘裁剪比例,补偿稳定后的黑边
|
|||
|
|
"""
|
|||
|
|
cap = cv2.VideoCapture(video_path)
|
|||
|
|
if not cap.isOpened():
|
|||
|
|
raise RuntimeError(f"无法打开视频: {video_path}")
|
|||
|
|
|
|||
|
|
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))
|
|||
|
|
|
|||
|
|
print(f"[Stabilize] {Path(video_path).name} {width}x{height} @ {fps:.1f}fps")
|
|||
|
|
|
|||
|
|
# 读取所有帧
|
|||
|
|
frames = []
|
|||
|
|
while True:
|
|||
|
|
ret, frame = cap.read()
|
|||
|
|
if not ret:
|
|||
|
|
break
|
|||
|
|
frames.append(frame)
|
|||
|
|
cap.release()
|
|||
|
|
|
|||
|
|
if len(frames) < 2:
|
|||
|
|
raise RuntimeError("视频帧数不足")
|
|||
|
|
|
|||
|
|
# 追踪特征点
|
|||
|
|
prev_gray = cv2.cvtColor(frames[0], cv2.COLOR_BGR2GRAY)
|
|||
|
|
transforms = [] # 每帧的变换 [dx, dy, da(角度)]
|
|||
|
|
|
|||
|
|
for i in range(1, len(frames)):
|
|||
|
|
curr_gray = cv2.cvtColor(frames[i], cv2.COLOR_BGR2GRAY)
|
|||
|
|
|
|||
|
|
prev_pts = cv2.goodFeaturesToTrack(prev_gray, maxCorners=200, qualityLevel=0.01,
|
|||
|
|
minDistance=15, blockSize=7)
|
|||
|
|
if prev_pts is None or len(prev_pts) < 10:
|
|||
|
|
transforms.append([0, 0, 0])
|
|||
|
|
prev_gray = curr_gray
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
curr_pts, status, _ = cv2.calcOpticalFlowPyrLK(prev_gray, curr_gray, prev_pts, None)
|
|||
|
|
|
|||
|
|
# 筛选有效点
|
|||
|
|
valid_prev = prev_pts[status == 1]
|
|||
|
|
valid_curr = curr_pts[status == 1]
|
|||
|
|
|
|||
|
|
if len(valid_prev) < 10:
|
|||
|
|
transforms.append([0, 0, 0])
|
|||
|
|
prev_gray = curr_gray
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 计算仿射变换 (只取平移和旋转)
|
|||
|
|
m = cv2.estimateAffinePartial2D(valid_prev, valid_curr)[0]
|
|||
|
|
if m is None:
|
|||
|
|
transforms.append([0, 0, 0])
|
|||
|
|
prev_gray = curr_gray
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
dx = m[0, 2]
|
|||
|
|
dy = m[1, 2]
|
|||
|
|
da = np.arctan2(m[1, 0], m[0, 0])
|
|||
|
|
transforms.append([dx, dy, da])
|
|||
|
|
prev_gray = curr_gray
|
|||
|
|
|
|||
|
|
if i % 30 == 0:
|
|||
|
|
print(f" [Stabilize] 帧 {i}/{len(frames)}")
|
|||
|
|
|
|||
|
|
# 累积轨迹
|
|||
|
|
trajectory = np.zeros((len(transforms) + 1, 3))
|
|||
|
|
for i, t in enumerate(transforms):
|
|||
|
|
trajectory[i + 1] = trajectory[i] + np.array(t)
|
|||
|
|
|
|||
|
|
# 平滑轨迹(滑动平均)
|
|||
|
|
smoothed = np.zeros_like(trajectory)
|
|||
|
|
for i in range(len(trajectory)):
|
|||
|
|
start = max(0, i - smoothing_radius)
|
|||
|
|
end = min(len(trajectory), i + smoothing_radius + 1)
|
|||
|
|
smoothed[i] = np.mean(trajectory[start:end], axis=0)
|
|||
|
|
|
|||
|
|
# 计算平滑后的变换
|
|||
|
|
smooth_diff = smoothed - trajectory
|
|||
|
|
|
|||
|
|
# 写出稳定后的视频
|
|||
|
|
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
|||
|
|
crop_x = int(width * crop_percent)
|
|||
|
|
crop_y = int(height * crop_percent)
|
|||
|
|
out_w = width - 2 * crop_x
|
|||
|
|
out_h = height - 2 * crop_y
|
|||
|
|
|
|||
|
|
writer = cv2.VideoWriter(output_path, fourcc, fps, (out_w, out_h))
|
|||
|
|
|
|||
|
|
for i, frame in enumerate(frames):
|
|||
|
|
dx = smooth_diff[i, 0]
|
|||
|
|
dy = smooth_diff[i, 1]
|
|||
|
|
da = smooth_diff[i, 2]
|
|||
|
|
|
|||
|
|
# 构造仿射变换矩阵
|
|||
|
|
m = np.array([
|
|||
|
|
[np.cos(da), -np.sin(da), dx],
|
|||
|
|
[np.sin(da), np.cos(da), dy]
|
|||
|
|
], dtype=np.float32)
|
|||
|
|
|
|||
|
|
stabilized = cv2.warpAffine(frame, m, (width, height))
|
|||
|
|
# 裁剪黑边
|
|||
|
|
cropped = stabilized[crop_y:crop_y + out_h, crop_x:crop_x + out_w]
|
|||
|
|
writer.write(cropped)
|
|||
|
|
|
|||
|
|
writer.release()
|
|||
|
|
print(f"[Stabilize] ✅ 稳定后视频: {output_path}")
|
|||
|
|
return output_path
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================== 文字贴合 ====================
|
|||
|
|
|
|||
|
|
def pin_text_to_video(video_path, output_path, text,
|
|||
|
|
start_frame=0, end_frame=None,
|
|||
|
|
init_x=None, init_y=None,
|
|||
|
|
font_scale=1.0, color=(255, 255, 255),
|
|||
|
|
thickness=2, font=cv2.FONT_HERSHEY_SIMPLEX,
|
|||
|
|
track_mode='feature'):
|
|||
|
|
"""
|
|||
|
|
追踪画面特征点 + 将文字PIN到追踪位置
|
|||
|
|
|
|||
|
|
track_mode:
|
|||
|
|
'feature' — 特征点追踪(自动选择画面中心区域的强特征点)
|
|||
|
|
'fixed' — 固定位置(不追踪)
|
|||
|
|
|
|||
|
|
文字会跟随画面运动,保持相对位置不变
|
|||
|
|
"""
|
|||
|
|
cap = cv2.VideoCapture(video_path)
|
|||
|
|
if not cap.isOpened():
|
|||
|
|
raise RuntimeError(f"无法打开视频: {video_path}")
|
|||
|
|
|
|||
|
|
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))
|
|||
|
|
|
|||
|
|
if end_frame is None:
|
|||
|
|
end_frame = frame_count
|
|||
|
|
|
|||
|
|
if init_x is None:
|
|||
|
|
init_x = width // 2
|
|||
|
|
if init_y is None:
|
|||
|
|
init_y = height // 2
|
|||
|
|
|
|||
|
|
print(f"[PinText] 文字: '{text}' 位置: ({init_x},{init_y}) 帧: {start_frame}-{end_frame}")
|
|||
|
|
|
|||
|
|
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
|||
|
|
writer = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
|
|||
|
|
|
|||
|
|
# 跳到起始帧
|
|||
|
|
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
|||
|
|
|
|||
|
|
prev_gray = None
|
|||
|
|
prev_point = np.array([[init_x, init_y]], dtype=np.float32).reshape(-1, 1, 2)
|
|||
|
|
current_offset = np.array([0.0, 0.0])
|
|||
|
|
|
|||
|
|
for frame_idx in range(start_frame, min(end_frame, frame_count)):
|
|||
|
|
ret, frame = cap.read()
|
|||
|
|
if not ret:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
curr_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|||
|
|
|
|||
|
|
if track_mode == 'feature' and prev_gray is not None:
|
|||
|
|
# 追踪文字所在位置附近的特征点
|
|||
|
|
next_point, status, _ = cv2.calcOpticalFlowPyrLK(
|
|||
|
|
prev_gray, curr_gray, prev_point, None,
|
|||
|
|
winSize=(31, 31), maxLevel=2
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if status is not None and status[0] == 1 and next_point is not None:
|
|||
|
|
# 计算位移
|
|||
|
|
dx = next_point[0][0] - prev_point[0][0]
|
|||
|
|
dy = next_point[0][1] - prev_point[0][1]
|
|||
|
|
current_offset += np.array([dx, dy])
|
|||
|
|
prev_point = next_point
|
|||
|
|
|
|||
|
|
# 计算文字绘制位置
|
|||
|
|
text_x = int(init_x + current_offset[0])
|
|||
|
|
text_y = int(init_y + current_offset[1])
|
|||
|
|
|
|||
|
|
# 绘制文字(带描边)
|
|||
|
|
# 描边
|
|||
|
|
cv2.putText(frame, text, (text_x + 2, text_y + 2), font, font_scale,
|
|||
|
|
(0, 0, 0), thickness + 2, cv2.LINE_AA)
|
|||
|
|
# 主文字
|
|||
|
|
cv2.putText(frame, text, (text_x, text_y), font, font_scale,
|
|||
|
|
color, thickness, cv2.LINE_AA)
|
|||
|
|
|
|||
|
|
writer.write(frame)
|
|||
|
|
prev_gray = curr_gray
|
|||
|
|
|
|||
|
|
if (frame_idx - start_frame) % 30 == 0:
|
|||
|
|
print(f" [PinText] 帧 {frame_idx}/{end_frame} 偏移: ({current_offset[0]:.1f}, {current_offset[1]:.1f})")
|
|||
|
|
|
|||
|
|
cap.release()
|
|||
|
|
writer.release()
|
|||
|
|
print(f"[PinText] ✅ 文字贴合视频: {output_path}")
|
|||
|
|
return output_path
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================== 运动数据导出 ====================
|
|||
|
|
|
|||
|
|
def export_motion_data(track_json, output_json, mode='zoom_pan'):
|
|||
|
|
"""
|
|||
|
|
从追踪数据中提取运动趋势,导出为 video-editor.js 可用的 keyframes 格式
|
|||
|
|
|
|||
|
|
mode='zoom_pan':
|
|||
|
|
分析整体运动趋势 → 输出 zoom(缩放) + pan(平移) 关键帧
|
|||
|
|
|
|||
|
|
输出格式:
|
|||
|
|
{
|
|||
|
|
"keyframes": {
|
|||
|
|
"zoom": { "from": 1.0, "to": 1.05 },
|
|||
|
|
"pan": { "from": [0, 0], "to": [12, -8] }
|
|||
|
|
},
|
|||
|
|
"stability": 0.85, // 稳定性评分 0-1
|
|||
|
|
"motion_intensity": "low" | "medium" | "high"
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
with open(track_json, 'r') as f:
|
|||
|
|
track_data = json.load(f)
|
|||
|
|
|
|||
|
|
tracks = track_data.get('tracks', [])
|
|||
|
|
if not tracks:
|
|||
|
|
return {"keyframes": {}, "stability": 0, "motion_intensity": "unknown"}
|
|||
|
|
|
|||
|
|
# 分析所有轨迹的位移
|
|||
|
|
displacements = []
|
|||
|
|
for t in tracks:
|
|||
|
|
if 'displacement' in t:
|
|||
|
|
displacements.append(t['displacement'])
|
|||
|
|
|
|||
|
|
if not displacements:
|
|||
|
|
return {"keyframes": {}, "stability": 0, "motion_intensity": "unknown"}
|
|||
|
|
|
|||
|
|
disp_arr = np.array(displacements)
|
|||
|
|
|
|||
|
|
# 计算平均位移
|
|||
|
|
mean_dx = float(np.mean(disp_arr[:, 0]))
|
|||
|
|
mean_dy = float(np.mean(disp_arr[:, 1]))
|
|||
|
|
|
|||
|
|
# 计算位移标准差(稳定性指标)
|
|||
|
|
std_dx = float(np.std(disp_arr[:, 0]))
|
|||
|
|
std_dy = float(np.std(disp_arr[:, 1]))
|
|||
|
|
stability = 1.0 / (1.0 + (std_dx + std_dy) / 100.0)
|
|||
|
|
stability = max(0.0, min(1.0, stability))
|
|||
|
|
|
|||
|
|
# 运动强度
|
|||
|
|
total_motion = abs(mean_dx) + abs(mean_dy) + (std_dx + std_dy) * 0.5
|
|||
|
|
if total_motion < 20:
|
|||
|
|
motion_intensity = 'low'
|
|||
|
|
elif total_motion < 80:
|
|||
|
|
motion_intensity = 'medium'
|
|||
|
|
else:
|
|||
|
|
motion_intensity = 'high'
|
|||
|
|
|
|||
|
|
# 缩放趋势(通过分析点之间距离的变化)
|
|||
|
|
# 简化版:用位移方差近似
|
|||
|
|
zoom_trend = 1.0 + min(0.2, max(-0.1, (std_dx + std_dy) / 5000.0))
|
|||
|
|
|
|||
|
|
result = {
|
|||
|
|
"keyframes": {
|
|||
|
|
"zoom": {
|
|||
|
|
"from": 1.0,
|
|||
|
|
"to": round(zoom_trend, 4)
|
|||
|
|
},
|
|||
|
|
"pan": {
|
|||
|
|
"from": [0, 0],
|
|||
|
|
"to": [round(mean_dx, 2), round(mean_dy, 2)]
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
"stability": round(stability, 4),
|
|||
|
|
"motion_intensity": motion_intensity,
|
|||
|
|
"avg_displacement": [round(mean_dx, 2), round(mean_dy, 2)],
|
|||
|
|
"track_count": len(tracks)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
with open(output_json, 'w', encoding='utf-8') as f:
|
|||
|
|
json.dump(result, f, ensure_ascii=False, indent=2)
|
|||
|
|
|
|||
|
|
print(f"[MotionData] ✅ 运动数据: {output_json}")
|
|||
|
|
print(f" 稳定性: {result['stability']} 强度: {motion_intensity}")
|
|||
|
|
print(f" 平均位移: ({mean_dx:.1f}, {mean_dy:.1f})")
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================== CLI ====================
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
parser = argparse.ArgumentParser(description='光湖OpenCV平面追踪引擎')
|
|||
|
|
parser.add_argument('mode', choices=['track', 'stabilize', 'pin_text', 'motion_data'],
|
|||
|
|
help='运行模式')
|
|||
|
|
parser.add_argument('video', help='输入视频路径')
|
|||
|
|
parser.add_argument('-o', '--output', help='输出路径')
|
|||
|
|
parser.add_argument('--text', help='pin_text模式的文字内容')
|
|||
|
|
parser.add_argument('--x', type=int, help='文字初始X坐标')
|
|||
|
|
parser.add_argument('--y', type=int, help='文字初始Y坐标')
|
|||
|
|
parser.add_argument('--track-json', help='motion_data模式的追踪数据JSON')
|
|||
|
|
parser.add_argument('--smoothing', type=int, default=25, help='稳定化平滑窗口')
|
|||
|
|
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
|
|||
|
|
if args.mode == 'track':
|
|||
|
|
out = args.output or args.video.rsplit('.', 1)[0] + '_tracks.json'
|
|||
|
|
track_features(args.video, out)
|
|||
|
|
|
|||
|
|
elif args.mode == 'stabilize':
|
|||
|
|
out = args.output or args.video.rsplit('.', 1)[0] + '_stabilized.mp4'
|
|||
|
|
stabilize_video(args.video, out, smoothing_radius=args.smoothing)
|
|||
|
|
|
|||
|
|
elif args.mode == 'pin_text':
|
|||
|
|
if not args.text:
|
|||
|
|
print("错误: pin_text模式需要 --text 参数")
|
|||
|
|
sys.exit(1)
|
|||
|
|
out = args.output or args.video.rsplit('.', 1)[0] + '_pinned.mp4'
|
|||
|
|
pin_text_to_video(args.video, out, args.text, init_x=args.x, init_y=args.y)
|
|||
|
|
|
|||
|
|
elif args.mode == 'motion_data':
|
|||
|
|
if not args.track_json:
|
|||
|
|
print("错误: motion_data模式需要 --track-json 参数")
|
|||
|
|
sys.exit(1)
|
|||
|
|
out = args.output or args.video.rsplit('.', 1)[0] + '_motion.json'
|
|||
|
|
export_motion_data(args.track_json, out)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == '__main__':
|
|||
|
|
main()
|