feat(wan): Add diagnostic scripts and porting guide
This commit is contained in:
306
scripts/video/compare_videos.py
Normal file
306
scripts/video/compare_videos.py
Normal file
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare two videos frame-by-frame with quality metrics.
|
||||
|
||||
Useful for validating MLX ports against reference PyTorch implementations.
|
||||
Reports PSNR, SSIM, per-frame differences, temporal coherence, and color
|
||||
fidelity. Optionally saves a side-by-side diff video.
|
||||
|
||||
Usage:
|
||||
# Basic comparison
|
||||
python scripts/video/compare_videos.py reference.mp4 test.mp4
|
||||
|
||||
# Save side-by-side diff visualization
|
||||
python scripts/video/compare_videos.py ref.mp4 test.mp4 --diff-video diff.mp4
|
||||
|
||||
# Compare only first 64 frames
|
||||
python scripts/video/compare_videos.py ref.mp4 test.mp4 --max-frames 64
|
||||
|
||||
# Adjust SSIM window size (default: 7)
|
||||
python scripts/video/compare_videos.py ref.mp4 test.mp4 --ssim-win 11
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def load_video(path, max_frames=None):
|
||||
"""Load video frames as float32 numpy arrays (0-255 range)."""
|
||||
cap = cv2.VideoCapture(path)
|
||||
if not cap.isOpened():
|
||||
print(f"Error: cannot open {path}")
|
||||
sys.exit(1)
|
||||
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
frames = []
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
frames.append(frame.astype(np.float32))
|
||||
if max_frames and len(frames) >= max_frames:
|
||||
break
|
||||
cap.release()
|
||||
return frames, fps
|
||||
|
||||
|
||||
def compute_psnr(a, b):
|
||||
"""Peak Signal-to-Noise Ratio between two frames."""
|
||||
mse = np.mean((a - b) ** 2)
|
||||
if mse == 0:
|
||||
return float("inf")
|
||||
return 10 * np.log10(255.0**2 / mse)
|
||||
|
||||
|
||||
def compute_ssim(a, b, win_size=7):
|
||||
"""Structural Similarity Index (per-channel, averaged).
|
||||
|
||||
Uses the standard SSIM formula with default constants.
|
||||
"""
|
||||
C1 = (0.01 * 255) ** 2
|
||||
C2 = (0.03 * 255) ** 2
|
||||
|
||||
kernel = cv2.getGaussianKernel(win_size, 1.5)
|
||||
window = kernel @ kernel.T
|
||||
|
||||
ssim_channels = []
|
||||
for c in range(a.shape[2]):
|
||||
ac, bc = a[:, :, c], b[:, :, c]
|
||||
mu_a = cv2.filter2D(ac, -1, window)
|
||||
mu_b = cv2.filter2D(bc, -1, window)
|
||||
|
||||
mu_a_sq = mu_a**2
|
||||
mu_b_sq = mu_b**2
|
||||
mu_ab = mu_a * mu_b
|
||||
|
||||
sigma_a_sq = cv2.filter2D(ac**2, -1, window) - mu_a_sq
|
||||
sigma_b_sq = cv2.filter2D(bc**2, -1, window) - mu_b_sq
|
||||
sigma_ab = cv2.filter2D(ac * bc, -1, window) - mu_ab
|
||||
|
||||
num = (2 * mu_ab + C1) * (2 * sigma_ab + C2)
|
||||
den = (mu_a_sq + mu_b_sq + C1) * (sigma_a_sq + sigma_b_sq + C2)
|
||||
ssim_map = num / den
|
||||
ssim_channels.append(np.mean(ssim_map))
|
||||
|
||||
return np.mean(ssim_channels)
|
||||
|
||||
|
||||
def temporal_coherence(frames):
|
||||
"""Mean frame-to-frame difference (lower = smoother)."""
|
||||
if len(frames) < 2:
|
||||
return 0.0
|
||||
diffs = []
|
||||
for i in range(1, len(frames)):
|
||||
diffs.append(np.mean(np.abs(frames[i] - frames[i - 1])))
|
||||
return np.mean(diffs)
|
||||
|
||||
|
||||
def color_histogram_distance(a, b, bins=64):
|
||||
"""Chi-squared distance between color histograms."""
|
||||
dist = 0.0
|
||||
for c in range(3):
|
||||
ha, _ = np.histogram(a[:, :, c], bins=bins, range=(0, 256))
|
||||
hb, _ = np.histogram(b[:, :, c], bins=bins, range=(0, 256))
|
||||
ha = ha.astype(np.float64) / (ha.sum() + 1e-10)
|
||||
hb = hb.astype(np.float64) / (hb.sum() + 1e-10)
|
||||
dist += np.sum((ha - hb) ** 2 / (ha + hb + 1e-10)) / 2
|
||||
return dist / 3
|
||||
|
||||
|
||||
def make_diff_frame(a, b, scale=5.0):
|
||||
"""Create a heatmap visualization of the absolute difference."""
|
||||
diff = np.mean(np.abs(a - b), axis=2)
|
||||
diff_scaled = np.clip(diff * scale, 0, 255).astype(np.uint8)
|
||||
heatmap = cv2.applyColorMap(diff_scaled, cv2.COLORMAP_JET)
|
||||
return heatmap
|
||||
|
||||
|
||||
def analyze(ref_frames, test_frames, ssim_win=7):
|
||||
"""Compute per-frame and aggregate metrics."""
|
||||
n = min(len(ref_frames), len(test_frames))
|
||||
|
||||
psnrs = []
|
||||
ssims = []
|
||||
mean_diffs = []
|
||||
max_diffs = []
|
||||
color_dists = []
|
||||
|
||||
for i in range(n):
|
||||
r, t = ref_frames[i], test_frames[i]
|
||||
psnrs.append(compute_psnr(r, t))
|
||||
ssims.append(compute_ssim(r, t, ssim_win))
|
||||
absdiff = np.abs(r - t)
|
||||
mean_diffs.append(np.mean(absdiff))
|
||||
max_diffs.append(np.max(absdiff))
|
||||
color_dists.append(color_histogram_distance(r, t))
|
||||
|
||||
ref_tc = temporal_coherence(ref_frames[:n])
|
||||
test_tc = temporal_coherence(test_frames[:n])
|
||||
|
||||
return {
|
||||
"num_frames": n,
|
||||
"psnr": np.array(psnrs),
|
||||
"ssim": np.array(ssims),
|
||||
"mean_diff": np.array(mean_diffs),
|
||||
"max_diff": np.array(max_diffs),
|
||||
"color_dist": np.array(color_dists),
|
||||
"ref_temporal_coherence": ref_tc,
|
||||
"test_temporal_coherence": test_tc,
|
||||
}
|
||||
|
||||
|
||||
def print_report(results, ref_path, test_path):
|
||||
"""Print a formatted comparison report."""
|
||||
n = results["num_frames"]
|
||||
psnr = results["psnr"]
|
||||
ssim = results["ssim"]
|
||||
md = results["mean_diff"]
|
||||
mx = results["max_diff"]
|
||||
cd = results["color_dist"]
|
||||
|
||||
print("=" * 72)
|
||||
print("VIDEO COMPARISON REPORT")
|
||||
print("=" * 72)
|
||||
print(f" Reference: {ref_path}")
|
||||
print(f" Test: {test_path}")
|
||||
print(f" Frames compared: {n}")
|
||||
print()
|
||||
|
||||
print("AGGREGATE METRICS")
|
||||
print("-" * 40)
|
||||
print(f" PSNR (dB): mean={np.mean(psnr):6.2f} min={np.min(psnr):6.2f} max={np.max(psnr):6.2f}")
|
||||
print(f" SSIM: mean={np.mean(ssim):.4f} min={np.min(ssim):.4f} max={np.max(ssim):.4f}")
|
||||
print(f" Mean diff: mean={np.mean(md):6.2f} min={np.min(md):6.2f} max={np.max(md):6.2f}")
|
||||
print(f" Max diff: mean={np.mean(mx):6.1f} min={np.min(mx):6.1f} max={np.max(mx):6.1f}")
|
||||
print(f" Color dist: mean={np.mean(cd):.4f} min={np.min(cd):.4f} max={np.max(cd):.4f}")
|
||||
print()
|
||||
|
||||
print("TEMPORAL COHERENCE (mean frame-to-frame diff, lower = smoother)")
|
||||
print("-" * 40)
|
||||
print(f" Reference: {results['ref_temporal_coherence']:.2f}")
|
||||
print(f" Test: {results['test_temporal_coherence']:.2f}")
|
||||
ratio = results["test_temporal_coherence"] / (results["ref_temporal_coherence"] + 1e-10)
|
||||
print(f" Ratio: {ratio:.2f}x {'(test is smoother)' if ratio < 1 else '(test is jerkier)' if ratio > 1.05 else '(similar)'}")
|
||||
print()
|
||||
|
||||
# Identify worst frames
|
||||
print("WORST FRAMES (by PSNR)")
|
||||
print("-" * 40)
|
||||
worst_idx = np.argsort(psnr)[:5]
|
||||
for i in worst_idx:
|
||||
print(f" Frame {i:4d}: PSNR={psnr[i]:6.2f} dB SSIM={ssim[i]:.4f} mean_diff={md[i]:.2f}")
|
||||
print()
|
||||
|
||||
# Quality assessment
|
||||
mean_psnr = np.mean(psnr)
|
||||
mean_ssim = np.mean(ssim)
|
||||
print("QUALITY ASSESSMENT")
|
||||
print("-" * 40)
|
||||
if mean_psnr > 40:
|
||||
grade = "Excellent"
|
||||
elif mean_psnr > 35:
|
||||
grade = "Good"
|
||||
elif mean_psnr > 30:
|
||||
grade = "Fair"
|
||||
elif mean_psnr > 25:
|
||||
grade = "Poor"
|
||||
else:
|
||||
grade = "Very different"
|
||||
print(f" Overall: {grade} (PSNR={mean_psnr:.1f} dB, SSIM={mean_ssim:.4f})")
|
||||
if mean_psnr < 30:
|
||||
print(" ⚠ Videos differ significantly — likely a bug or different generation seed")
|
||||
print("=" * 72)
|
||||
|
||||
|
||||
def save_diff_video(ref_frames, test_frames, output_path, fps, scale=5.0):
|
||||
"""Save a side-by-side video: reference | test | diff heatmap."""
|
||||
n = min(len(ref_frames), len(test_frames))
|
||||
h, w = ref_frames[0].shape[:2]
|
||||
|
||||
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||
out = cv2.VideoWriter(output_path, fourcc, fps, (w * 3, h))
|
||||
|
||||
for i in range(n):
|
||||
r = ref_frames[i].astype(np.uint8)
|
||||
t = test_frames[i].astype(np.uint8)
|
||||
d = make_diff_frame(ref_frames[i], test_frames[i], scale)
|
||||
combined = np.hstack([r, t, d])
|
||||
out.write(combined)
|
||||
|
||||
out.release()
|
||||
print(f"Diff video saved to {output_path}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compare two videos frame-by-frame with quality metrics"
|
||||
)
|
||||
parser.add_argument("reference", help="Path to reference video")
|
||||
parser.add_argument("test", help="Path to test video")
|
||||
parser.add_argument(
|
||||
"--diff-video", help="Save side-by-side diff visualization to this path"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-frames", type=int, help="Compare only first N frames"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ssim-win", type=int, default=7, help="SSIM window size (default: 7)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diff-scale",
|
||||
type=float,
|
||||
default=5.0,
|
||||
help="Diff heatmap amplification (default: 5.0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--csv", help="Export per-frame metrics to CSV file"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Loading reference: {args.reference}")
|
||||
ref_frames, ref_fps = load_video(args.reference, args.max_frames)
|
||||
print(f" → {len(ref_frames)} frames, {ref_fps:.1f} fps, {ref_frames[0].shape[1]}x{ref_frames[0].shape[0]}")
|
||||
|
||||
print(f"Loading test: {args.test}")
|
||||
test_frames, test_fps = load_video(args.test, args.max_frames)
|
||||
print(f" → {len(test_frames)} frames, {test_fps:.1f} fps, {test_frames[0].shape[1]}x{test_frames[0].shape[0]}")
|
||||
|
||||
if ref_frames[0].shape != test_frames[0].shape:
|
||||
print(f"Warning: resolution mismatch {ref_frames[0].shape} vs {test_frames[0].shape}")
|
||||
print("Resizing test frames to match reference...")
|
||||
h, w = ref_frames[0].shape[:2]
|
||||
test_frames = [
|
||||
cv2.resize(f, (w, h), interpolation=cv2.INTER_LANCZOS4)
|
||||
for f in test_frames
|
||||
]
|
||||
|
||||
print("Computing metrics...")
|
||||
results = analyze(ref_frames, test_frames, args.ssim_win)
|
||||
print()
|
||||
print_report(results, args.reference, args.test)
|
||||
|
||||
if args.diff_video:
|
||||
save_diff_video(ref_frames, test_frames, args.diff_video, ref_fps, args.diff_scale)
|
||||
|
||||
if args.csv:
|
||||
import csv
|
||||
|
||||
with open(args.csv, "w", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(["frame", "psnr", "ssim", "mean_diff", "max_diff", "color_dist"])
|
||||
for i in range(results["num_frames"]):
|
||||
writer.writerow([
|
||||
i,
|
||||
f"{results['psnr'][i]:.4f}",
|
||||
f"{results['ssim'][i]:.6f}",
|
||||
f"{results['mean_diff'][i]:.4f}",
|
||||
f"{results['max_diff'][i]:.1f}",
|
||||
f"{results['color_dist'][i]:.6f}",
|
||||
])
|
||||
print(f"Per-frame metrics saved to {args.csv}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
348
scripts/video/video_quality.py
Normal file
348
scripts/video/video_quality.py
Normal file
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyze quality of a single generated video.
|
||||
|
||||
Reports sharpness, temporal stability, color distribution, motion smoothness,
|
||||
chunk boundary artifacts, and common generation defects. Useful for quick
|
||||
quality checks during model porting and debugging.
|
||||
|
||||
Usage:
|
||||
# Basic analysis
|
||||
python scripts/video/video_quality.py output.mp4
|
||||
|
||||
# With chunk boundary analysis (e.g., 32 frames/chunk)
|
||||
python scripts/video/video_quality.py output.mp4 --chunk-size 32
|
||||
|
||||
# Detailed per-frame CSV export
|
||||
python scripts/video/video_quality.py output.mp4 --csv metrics.csv
|
||||
|
||||
# Analyze specific frame range
|
||||
python scripts/video/video_quality.py output.mp4 --start 0 --end 64
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def load_video(path, start=0, end=None):
|
||||
"""Load video frames as float32 numpy arrays (0-255 range)."""
|
||||
cap = cv2.VideoCapture(path)
|
||||
if not cap.isOpened():
|
||||
print(f"Error: cannot open {path}")
|
||||
sys.exit(1)
|
||||
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
|
||||
if start > 0:
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, start)
|
||||
|
||||
frames = []
|
||||
idx = start
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
frames.append(frame.astype(np.float32))
|
||||
idx += 1
|
||||
if end and idx >= end:
|
||||
break
|
||||
cap.release()
|
||||
return frames, fps, total
|
||||
|
||||
|
||||
def sharpness_laplacian(frame):
|
||||
"""Laplacian variance — higher means sharper."""
|
||||
gray = cv2.cvtColor(frame.astype(np.uint8), cv2.COLOR_BGR2GRAY)
|
||||
return cv2.Laplacian(gray, cv2.CV_64F).var()
|
||||
|
||||
|
||||
def sharpness_gradient(frame):
|
||||
"""Mean gradient magnitude — higher means more edges/detail."""
|
||||
gray = cv2.cvtColor(frame.astype(np.uint8), cv2.COLOR_BGR2GRAY).astype(np.float32)
|
||||
gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
|
||||
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
|
||||
return np.mean(np.sqrt(gx**2 + gy**2))
|
||||
|
||||
|
||||
def color_stats(frame):
|
||||
"""Per-channel mean and std in BGR order."""
|
||||
means = [np.mean(frame[:, :, c]) for c in range(3)]
|
||||
stds = [np.std(frame[:, :, c]) for c in range(3)]
|
||||
return means, stds
|
||||
|
||||
|
||||
def detect_uniform_color(frame, std_threshold=15.0):
|
||||
"""Detect if frame is near-uniform (common failure mode)."""
|
||||
return np.std(frame) < std_threshold
|
||||
|
||||
|
||||
def detect_noise(frame, threshold=200.0):
|
||||
"""High Laplacian variance with low gradient can indicate noise."""
|
||||
lap = sharpness_laplacian(frame)
|
||||
grad = sharpness_gradient(frame)
|
||||
# Noise has high variance but less coherent edges
|
||||
return lap > threshold and grad < 5.0
|
||||
|
||||
|
||||
def frame_difference(a, b):
|
||||
"""Mean absolute pixel difference between frames."""
|
||||
return np.mean(np.abs(a - b))
|
||||
|
||||
|
||||
def optical_flow_magnitude(prev, curr):
|
||||
"""Mean optical flow magnitude (Farneback method)."""
|
||||
prev_gray = cv2.cvtColor(prev.astype(np.uint8), cv2.COLOR_BGR2GRAY)
|
||||
curr_gray = cv2.cvtColor(curr.astype(np.uint8), cv2.COLOR_BGR2GRAY)
|
||||
flow = cv2.calcOpticalFlowFarneback(
|
||||
prev_gray, curr_gray, None, 0.5, 3, 15, 3, 5, 1.2, 0
|
||||
)
|
||||
mag = np.sqrt(flow[..., 0] ** 2 + flow[..., 1] ** 2)
|
||||
return np.mean(mag), np.max(mag)
|
||||
|
||||
|
||||
def analyze_video(frames, chunk_size=None, compute_flow=False):
|
||||
"""Compute per-frame and aggregate quality metrics."""
|
||||
n = len(frames)
|
||||
|
||||
metrics = {
|
||||
"sharpness_lap": [],
|
||||
"sharpness_grad": [],
|
||||
"brightness": [],
|
||||
"contrast": [],
|
||||
"color_mean_b": [],
|
||||
"color_mean_g": [],
|
||||
"color_mean_r": [],
|
||||
"frame_diff": [],
|
||||
"is_uniform": [],
|
||||
"is_noisy": [],
|
||||
}
|
||||
if compute_flow:
|
||||
metrics["flow_mean"] = []
|
||||
metrics["flow_max"] = []
|
||||
|
||||
for i in range(n):
|
||||
f = frames[i]
|
||||
metrics["sharpness_lap"].append(sharpness_laplacian(f))
|
||||
metrics["sharpness_grad"].append(sharpness_gradient(f))
|
||||
metrics["brightness"].append(np.mean(f))
|
||||
metrics["contrast"].append(np.std(f))
|
||||
means, _ = color_stats(f)
|
||||
metrics["color_mean_b"].append(means[0])
|
||||
metrics["color_mean_g"].append(means[1])
|
||||
metrics["color_mean_r"].append(means[2])
|
||||
metrics["is_uniform"].append(detect_uniform_color(f))
|
||||
metrics["is_noisy"].append(detect_noise(f))
|
||||
|
||||
if i > 0:
|
||||
metrics["frame_diff"].append(frame_difference(frames[i - 1], f))
|
||||
if compute_flow:
|
||||
fm, fmx = optical_flow_magnitude(frames[i - 1], f)
|
||||
metrics["flow_mean"].append(fm)
|
||||
metrics["flow_max"].append(fmx)
|
||||
else:
|
||||
metrics["frame_diff"].append(0.0)
|
||||
if compute_flow:
|
||||
metrics["flow_mean"].append(0.0)
|
||||
metrics["flow_max"].append(0.0)
|
||||
|
||||
# Convert to arrays
|
||||
for k in metrics:
|
||||
metrics[k] = np.array(metrics[k])
|
||||
|
||||
# Chunk boundary analysis
|
||||
if chunk_size and n > chunk_size:
|
||||
boundaries = list(range(chunk_size, n, chunk_size))
|
||||
boundary_metrics = []
|
||||
for b in boundaries:
|
||||
if b < n and b > 0:
|
||||
pre = metrics["frame_diff"][b - 1] if b > 1 else metrics["frame_diff"][1]
|
||||
at = metrics["frame_diff"][b]
|
||||
ratio = at / (pre + 1e-10)
|
||||
brightness_jump = metrics["brightness"][b] - metrics["brightness"][b - 1]
|
||||
contrast_jump = (
|
||||
(metrics["contrast"][b] - metrics["contrast"][b - 1])
|
||||
/ (metrics["contrast"][b - 1] + 1e-10)
|
||||
* 100
|
||||
)
|
||||
sharpness_jump = (
|
||||
(metrics["sharpness_lap"][b] - metrics["sharpness_lap"][b - 1])
|
||||
/ (metrics["sharpness_lap"][b - 1] + 1e-10)
|
||||
* 100
|
||||
)
|
||||
boundary_metrics.append(
|
||||
{
|
||||
"frame": b,
|
||||
"diff_ratio": ratio,
|
||||
"brightness_jump": brightness_jump,
|
||||
"contrast_jump_pct": contrast_jump,
|
||||
"sharpness_jump_pct": sharpness_jump,
|
||||
}
|
||||
)
|
||||
metrics["boundaries"] = boundary_metrics
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def print_report(metrics, path, fps, total_frames, frames_analyzed):
|
||||
"""Print a formatted quality report."""
|
||||
sl = metrics["sharpness_lap"]
|
||||
sg = metrics["sharpness_grad"]
|
||||
br = metrics["brightness"]
|
||||
ct = metrics["contrast"]
|
||||
fd = metrics["frame_diff"]
|
||||
|
||||
print("=" * 72)
|
||||
print("VIDEO QUALITY REPORT")
|
||||
print("=" * 72)
|
||||
print(f" File: {path}")
|
||||
print(f" Total frames: {total_frames} Analyzed: {frames_analyzed} FPS: {fps:.1f}")
|
||||
duration = total_frames / fps if fps > 0 else 0
|
||||
print(f" Duration: {duration:.1f}s")
|
||||
print()
|
||||
|
||||
# Defect detection
|
||||
n_uniform = int(np.sum(metrics["is_uniform"]))
|
||||
n_noisy = int(np.sum(metrics["is_noisy"]))
|
||||
if n_uniform > 0 or n_noisy > 0:
|
||||
print("⚠ DEFECTS DETECTED")
|
||||
print("-" * 40)
|
||||
if n_uniform:
|
||||
frames_list = np.where(metrics["is_uniform"])[0][:10]
|
||||
print(f" Uniform/blank frames: {n_uniform} — frames {list(frames_list)}{'...' if n_uniform > 10 else ''}")
|
||||
if n_noisy:
|
||||
frames_list = np.where(metrics["is_noisy"])[0][:10]
|
||||
print(f" Noisy frames: {n_noisy} — frames {list(frames_list)}{'...' if n_noisy > 10 else ''}")
|
||||
print()
|
||||
|
||||
print("SHARPNESS")
|
||||
print("-" * 40)
|
||||
print(f" Laplacian var: mean={np.mean(sl):8.1f} min={np.min(sl):8.1f} max={np.max(sl):8.1f} std={np.std(sl):.1f}")
|
||||
print(f" Gradient mag: mean={np.mean(sg):8.2f} min={np.min(sg):8.2f} max={np.max(sg):8.2f} std={np.std(sg):.2f}")
|
||||
if np.std(sl) / (np.mean(sl) + 1e-10) > 0.3:
|
||||
print(" ⚠ High sharpness variation — possible blur artifacts")
|
||||
print()
|
||||
|
||||
print("BRIGHTNESS & CONTRAST")
|
||||
print("-" * 40)
|
||||
print(f" Brightness: mean={np.mean(br):6.1f} min={np.min(br):6.1f} max={np.max(br):6.1f} std={np.std(br):.2f}")
|
||||
print(f" Contrast (std): mean={np.mean(ct):6.1f} min={np.min(ct):6.1f} max={np.max(ct):6.1f} std={np.std(ct):.2f}")
|
||||
if np.std(br) > 3.0:
|
||||
print(" ⚠ Brightness instability — may indicate chunk boundary artifacts")
|
||||
print()
|
||||
|
||||
print("COLOR DISTRIBUTION (BGR)")
|
||||
print("-" * 40)
|
||||
print(f" Blue: mean={np.mean(metrics['color_mean_b']):6.1f} std={np.std(metrics['color_mean_b']):.2f}")
|
||||
print(f" Green: mean={np.mean(metrics['color_mean_g']):6.1f} std={np.std(metrics['color_mean_g']):.2f}")
|
||||
print(f" Red: mean={np.mean(metrics['color_mean_r']):6.1f} std={np.std(metrics['color_mean_r']):.2f}")
|
||||
print()
|
||||
|
||||
print("TEMPORAL STABILITY")
|
||||
print("-" * 40)
|
||||
fd_nz = fd[1:] # skip first frame (always 0)
|
||||
if len(fd_nz) > 0:
|
||||
print(f" Frame diff: mean={np.mean(fd_nz):6.2f} min={np.min(fd_nz):6.2f} max={np.max(fd_nz):6.2f} std={np.std(fd_nz):.2f}")
|
||||
if np.std(fd_nz) / (np.mean(fd_nz) + 1e-10) > 0.5:
|
||||
print(" ⚠ High diff variance — jitter or discontinuities")
|
||||
if "flow_mean" in metrics:
|
||||
fm = metrics["flow_mean"][1:]
|
||||
print(f" Optical flow: mean={np.mean(fm):6.2f} max_frame={np.max(metrics['flow_max'][1:]):.1f}")
|
||||
print()
|
||||
|
||||
# Chunk boundaries
|
||||
if "boundaries" in metrics and metrics["boundaries"]:
|
||||
print("CHUNK BOUNDARIES")
|
||||
print("-" * 40)
|
||||
print(f" {'Frame':>6} {'Diff ratio':>10} {'Brightness':>10} {'Contrast %':>10} {'Sharpness %':>11}")
|
||||
for bm in metrics["boundaries"]:
|
||||
print(
|
||||
f" {bm['frame']:6d}"
|
||||
f" {bm['diff_ratio']:10.2f}x"
|
||||
f" {bm['brightness_jump']:+10.1f}"
|
||||
f" {bm['contrast_jump_pct']:+10.1f}%"
|
||||
f" {bm['sharpness_jump_pct']:+11.1f}%"
|
||||
)
|
||||
avg_ratio = np.mean([b["diff_ratio"] for b in metrics["boundaries"]])
|
||||
if avg_ratio > 2.0:
|
||||
print(f" ⚠ Boundary diff ratio {avg_ratio:.1f}x — visible chunk transitions")
|
||||
print()
|
||||
|
||||
# Overall grade
|
||||
print("OVERALL ASSESSMENT")
|
||||
print("-" * 40)
|
||||
issues = []
|
||||
if n_uniform > 0:
|
||||
issues.append("uniform/blank frames")
|
||||
if n_noisy > 0:
|
||||
issues.append("noisy frames")
|
||||
if np.std(br) > 3.0:
|
||||
issues.append("brightness flicker")
|
||||
if np.std(sl) / (np.mean(sl) + 1e-10) > 0.3:
|
||||
issues.append("sharpness variation")
|
||||
if "boundaries" in metrics and metrics["boundaries"]:
|
||||
avg_ratio = np.mean([b["diff_ratio"] for b in metrics["boundaries"]])
|
||||
if avg_ratio > 2.0:
|
||||
issues.append("chunk boundary artifacts")
|
||||
if issues:
|
||||
print(f" Issues found: {', '.join(issues)}")
|
||||
else:
|
||||
print(" ✓ No significant quality issues detected")
|
||||
print("=" * 72)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Analyze quality of a single generated video"
|
||||
)
|
||||
parser.add_argument("video", help="Path to video file")
|
||||
parser.add_argument(
|
||||
"--chunk-size",
|
||||
type=int,
|
||||
help="Frames per chunk for boundary analysis (e.g., 32)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--start", type=int, default=0, help="Start frame (default: 0)"
|
||||
)
|
||||
parser.add_argument("--end", type=int, help="End frame (default: all)")
|
||||
parser.add_argument(
|
||||
"--flow",
|
||||
action="store_true",
|
||||
help="Compute optical flow (slower but more detailed)",
|
||||
)
|
||||
parser.add_argument("--csv", help="Export per-frame metrics to CSV")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Loading: {args.video}")
|
||||
frames, fps, total = load_video(args.video, args.start, args.end)
|
||||
h, w = frames[0].shape[:2]
|
||||
print(f" → {len(frames)} frames, {fps:.1f} fps, {w}x{h}")
|
||||
|
||||
print("Analyzing...")
|
||||
metrics = analyze_video(frames, args.chunk_size, args.flow)
|
||||
print()
|
||||
print_report(metrics, args.video, fps, total, len(frames))
|
||||
|
||||
if args.csv:
|
||||
import csv
|
||||
|
||||
keys = [
|
||||
"sharpness_lap", "sharpness_grad", "brightness", "contrast",
|
||||
"color_mean_b", "color_mean_g", "color_mean_r", "frame_diff",
|
||||
]
|
||||
if args.flow:
|
||||
keys += ["flow_mean", "flow_max"]
|
||||
|
||||
with open(args.csv, "w", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(["frame"] + keys)
|
||||
for i in range(len(frames)):
|
||||
row = [i] + [f"{metrics[k][i]:.4f}" for k in keys]
|
||||
writer.writerow(row)
|
||||
print(f"Per-frame metrics saved to {args.csv}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user