"""Unit tests for Reframer utility methods in reframing.py.""" import numpy as np import pytest from fansifter_clipper.models import CropKeyframe, Detection from fansifter_clipper.reframing import Reframer def make_kf(time, x, y, scene_change=False, width=720, height=1280, confidence=0.8): return CropKeyframe(time=time, x=x, y=y, width=width, height=height, scene_change=scene_change, confidence=confidence) def make_detection(time, cx, cy, confidence=0.9, method="mediapipe_face"): return Detection(time=time, center_x=cx, center_y=cy, confidence=confidence, detection_method=method) class TestWeightedPercentile: def setup_method(self): self.reframer = Reframer() def test_uniform_weights_median(self): data = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) weights = np.ones(5) result = self.reframer._weighted_percentile(data, weights, 50) assert result == pytest.approx(3.0, abs=0.5) def test_single_element(self): data = np.array([42.0]) weights = np.array([1.0]) result = self.reframer._weighted_percentile(data, weights, 50) assert result == 42.0 def test_skewed_weights_pull_toward_heavy_side(self): data = np.array([0.0, 100.0]) # Heavily weight the 100 side weights = np.array([0.1, 0.9]) result = self.reframer._weighted_percentile(data, weights, 50) assert result > 50.0 # Should be closer to 100 than to 0 def test_percentile_0_returns_min(self): data = np.array([10.0, 20.0, 30.0]) weights = np.ones(3) result = self.reframer._weighted_percentile(data, weights, 0) assert result == pytest.approx(10.0, abs=1.0) def test_percentile_100_returns_max(self): data = np.array([10.0, 20.0, 30.0]) weights = np.ones(3) result = self.reframer._weighted_percentile(data, weights, 100) assert result == pytest.approx(30.0, abs=1.0) class TestComputeEquilibriumFromSamples: def setup_method(self): self.reframer = Reframer() def test_fallback_on_empty_positions(self): eq_x, eq_y, stats = self.reframer._compute_equilibrium_from_samples( [], [], [], crop_width=720, crop_height=1280, frame_width=1920, frame_height=1080 ) assert stats["method"] == "fallback_center" # Should be centered assert eq_x == (1920 - 720) // 2 assert eq_y == (1080 - 1280) // 2 # Negative, but max(0,...) will clamp def test_centered_subject(self): # Subject always at frame center (960, 540) positions_x = [960] * 5 positions_y = [540] * 5 confidences = [0.9] * 5 eq_x, eq_y, stats = self.reframer._compute_equilibrium_from_samples( positions_x, positions_y, confidences, crop_width=720, crop_height=1280, frame_width=1920, frame_height=1080 ) assert stats["method"] == "equilibrium" # Equilibrium crop center should align with subject center crop_center_x = eq_x + 720 // 2 assert crop_center_x == pytest.approx(960, abs=10) def test_statistics_included(self): positions_x = [100, 200, 300] positions_y = [200, 300, 400] confidences = [0.8, 0.9, 0.7] _, _, stats = self.reframer._compute_equilibrium_from_samples( positions_x, positions_y, confidences, crop_width=720, crop_height=1280, frame_width=1920, frame_height=1080 ) assert "num_samples" in stats assert stats["num_samples"] == 3 assert "avg_confidence" in stats def test_result_clamped_to_frame(self): # Subject far to the right (near frame edge) positions_x = [1900] * 3 positions_y = [100] * 3 confidences = [0.9] * 3 eq_x, eq_y, _ = self.reframer._compute_equilibrium_from_samples( positions_x, positions_y, confidences, crop_width=720, crop_height=1280, frame_width=1920, frame_height=1080 ) assert eq_x >= 0 assert eq_x <= 1920 - 720 assert eq_y >= 0 class TestComputeEquilibriumFromDetections: def setup_method(self): self.reframer = Reframer() def test_empty_detections(self): eq_x, eq_y, stats = self.reframer._compute_equilibrium_from_detections( [], sample_interval=0.5, crop_width=720, crop_height=1280, frame_width=1920, frame_height=1080 ) assert stats["method"] == "fallback_center" def test_detections_sampled_at_interval(self): # Create detections every 0.1s detections = [ make_detection(t * 0.1, 960, 540) for t in range(20) # 0.0s to 1.9s ] eq_x, eq_y, stats = self.reframer._compute_equilibrium_from_detections( detections, sample_interval=0.5, crop_width=720, crop_height=1280, frame_width=1920, frame_height=1080 ) # Should successfully compute equilibrium assert stats["method"] == "equilibrium" assert stats["num_samples"] >= 1 def test_none_positions_skipped(self): detections = [ Detection(time=0.0, center_x=None, center_y=None, confidence=0.0, detection_method="none"), make_detection(0.5, 960, 540), Detection(time=1.0, center_x=None, center_y=None, confidence=0.0, detection_method="none"), ] eq_x, eq_y, stats = self.reframer._compute_equilibrium_from_detections( detections, sample_interval=0.5, crop_width=720, crop_height=1280, frame_width=1920, frame_height=1080 ) # Only one valid detection but should still work assert eq_x >= 0 assert eq_y >= 0 class TestGenerateKeyframesFromDetections: def setup_method(self): self.reframer = Reframer() def test_empty_detections_returns_empty(self): result = self.reframer._generate_keyframes_from_detections( detections=[], equilibrium_x=600, equilibrium_y=200, crop_width=720, crop_height=1280, frame_width=1920, frame_height=1080, keyframe_interval=1.0, ) assert result == [] def test_keyframes_generated_at_intervals(self): # Detections every 0.5s, interval = 1.0s -> should generate fewer keyframes detections = [make_detection(t * 0.5, 960, 540) for t in range(10)] result = self.reframer._generate_keyframes_from_detections( detections=detections, equilibrium_x=600, equilibrium_y=200, crop_width=720, crop_height=1280, frame_width=1920, frame_height=1080, keyframe_interval=1.0, ) # Should generate 1 keyframe per 1.0s interval (up to ~5 from 0-4.5s) assert 1 <= len(result) <= len(detections) def test_subject_in_safe_zone_stays_at_equilibrium(self): # Subject at frame center — should stay at equilibrium # Equilibrium at center: eq_x = (1920-720)//2 = 600, eq_y = (1080-1280)//2 -> clamped 0 # But let's use a frame that fits: 1920x2560 so crop fits frame_w, frame_h = 1920, 2560 crop_w, crop_h = 720, 1280 eq_x = (frame_w - crop_w) // 2 # 600 eq_y = (frame_h - crop_h) // 2 # 640 # Subject exactly at crop center = should be in safe zone subject_x = eq_x + crop_w // 2 subject_y = eq_y + crop_h // 2 detections = [make_detection(0.0, subject_x, subject_y)] result = self.reframer._generate_keyframes_from_detections( detections=detections, equilibrium_x=eq_x, equilibrium_y=eq_y, crop_width=crop_w, crop_height=crop_h, frame_width=frame_w, frame_height=frame_h, keyframe_interval=0.5, ) assert len(result) == 1 assert result[0].x == eq_x # Should stay at equilibrium assert result[0].y == eq_y def test_keyframes_have_correct_width_height(self): detections = [make_detection(0.0, 960, 540)] result = self.reframer._generate_keyframes_from_detections( detections=detections, equilibrium_x=600, equilibrium_y=200, crop_width=720, crop_height=1280, frame_width=1920, frame_height=1080, keyframe_interval=0.5, ) for kf in result: assert kf.width == 720 assert kf.height == 1280 def test_keyframe_positions_clamped_to_frame(self): # Subject way off to the right side detections = [make_detection(0.0, 1900, 540)] result = self.reframer._generate_keyframes_from_detections( detections=detections, equilibrium_x=600, equilibrium_y=200, crop_width=720, crop_height=1280, frame_width=1920, frame_height=1080, keyframe_interval=0.5, ) for kf in result: assert kf.x >= 0 assert kf.x <= 1920 - 720 class TestSmoothSegment: def setup_method(self): self.reframer = Reframer() def test_single_keyframe_returned_as_is(self): kfs = [make_kf(0.0, 100, 50)] result = self.reframer._smooth_segment(kfs) assert len(result) == 1 assert result[0].x == 100 def test_two_keyframes_smoothed(self): kfs = [make_kf(0.0, 100, 50), make_kf(1.0, 200, 100)] result = self.reframer._smooth_segment(kfs) assert len(result) >= 2 # All positions should be between 100 and 200 for kf in result: assert 90 <= kf.x <= 210 def test_output_has_correct_width_height(self): kfs = [make_kf(t, 100 + t * 10, 50, width=720, height=1280) for t in range(5)] result = self.reframer._smooth_segment(kfs) for kf in result: assert kf.width == 720 assert kf.height == 1280 def test_first_keyframe_preserves_scene_change_flag(self): kfs = [ make_kf(0.0, 800, 600, scene_change=True), make_kf(1.0, 810, 610), make_kf(2.0, 820, 620), make_kf(3.0, 830, 630), ] result = self.reframer._smooth_segment(kfs) assert result[0].scene_change is True def test_cubic_spline_for_4plus_keyframes(self): # With >=4 keyframes, cubic spline path is taken — output may be denser kfs = [make_kf(float(t), 100 + t * 20, 50 + t * 10) for t in range(5)] result = self.reframer._smooth_segment(kfs) # Should have up to max_keyframes (8) output keyframes assert len(result) <= 8 assert len(result) >= 2 def test_smoothing_reduces_noise(self): # Noisy keyframes kfs = [ make_kf(0.0, 100, 50), make_kf(1.0, 300, 50), # spike make_kf(2.0, 100, 50), make_kf(3.0, 100, 50), ] raw_spread = max(kf.x for kf in kfs) - min(kf.x for kf in kfs) result = self.reframer._smooth_segment(kfs) smooth_spread = max(kf.x for kf in result) - min(kf.x for kf in result) # Smoothing should reduce the x-range assert smooth_spread <= raw_spread class TestDetectEdgesSaliency: def setup_method(self): self.reframer = Reframer() def test_returns_position_for_high_edge_frame(self): # Frame with strong central edges should detect something frame = np.zeros((360, 640, 3), dtype=np.uint8) # Draw a bright rectangle in the center frame[140:220, 270:370] = 255 cx, cy = self.reframer._detect_edges_saliency(frame) assert cx is not None assert cy is not None def test_returns_none_for_blank_frame(self): # Completely uniform frame has no edges frame = np.ones((360, 640, 3), dtype=np.uint8) * 128 cx, cy = self.reframer._detect_edges_saliency(frame) # Very low or no edges — may return None # A uniform gray frame has no edges, so Canny produces no output assert cx is None assert cy is None def test_position_within_frame_bounds(self): # Frame with strong edges at a specific location frame = np.zeros((360, 640, 3), dtype=np.uint8) frame[150:210, 300:340] = 255 # Bright patch cx, cy = self.reframer._detect_edges_saliency(frame) if cx is not None: assert 0 <= cx < 640 assert 0 <= cy < 360 def test_center_biased_weighting(self): # Two equal bright patches — one central, one in corner frame = np.zeros((360, 640, 3), dtype=np.uint8) # Central patch frame[150:210, 290:350] = 200 # Corner patch (should have lower spatial weight) frame[0:20, 0:20] = 200 cx, cy = self.reframer._detect_edges_saliency(frame) if cx is not None: # Result should be closer to center than to corner assert cx > 20 or cy > 20