Skip to main content

oximedia_codec/reconstruct/
loop_filter.rs

1//! Loop filter pipeline for edge filtering.
2//!
3//! The loop filter is applied after transform reconstruction to reduce
4//! blocking artifacts at block boundaries. This module implements both
5//! horizontal and vertical edge filtering.
6
7#![forbid(unsafe_code)]
8#![allow(clippy::unreadable_literal)]
9#![allow(clippy::items_after_statements)]
10#![allow(clippy::unnecessary_wraps)]
11#![allow(clippy::struct_excessive_bools)]
12#![allow(clippy::identity_op)]
13#![allow(clippy::range_plus_one)]
14#![allow(clippy::needless_range_loop)]
15#![allow(clippy::useless_conversion)]
16#![allow(clippy::redundant_closure_for_method_calls)]
17#![allow(clippy::single_match_else)]
18#![allow(dead_code)]
19#![allow(clippy::doc_markdown)]
20#![allow(clippy::unused_self)]
21#![allow(clippy::trivially_copy_pass_by_ref)]
22#![allow(clippy::cast_possible_truncation)]
23#![allow(clippy::cast_sign_loss)]
24#![allow(clippy::cast_possible_wrap)]
25#![allow(clippy::missing_errors_doc)]
26#![allow(clippy::too_many_arguments)]
27#![allow(clippy::similar_names)]
28#![allow(clippy::many_single_char_names)]
29#![allow(clippy::cast_precision_loss)]
30#![allow(clippy::cast_lossless)]
31#![allow(clippy::needless_bool)]
32#![allow(clippy::collapsible_if)]
33#![allow(clippy::if_not_else)]
34
35use super::pipeline::FrameContext;
36use super::{FrameBuffer, PlaneBuffer, PlaneType, ReconstructResult};
37
38// =============================================================================
39// Constants
40// =============================================================================
41
42/// Maximum loop filter level.
43pub const MAX_LOOP_FILTER_LEVEL: u8 = 63;
44
45/// Maximum sharpness level.
46pub const MAX_SHARPNESS_LEVEL: u8 = 7;
47
48/// Number of filter taps for narrow filter.
49pub const NARROW_FILTER_TAPS: usize = 4;
50
51/// Number of filter taps for wide filter.
52pub const WIDE_FILTER_TAPS: usize = 8;
53
54/// Number of filter taps for extra-wide filter.
55pub const EXTRA_WIDE_FILTER_TAPS: usize = 14;
56
57// =============================================================================
58// Filter Direction
59// =============================================================================
60
61/// Direction of edge filtering.
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum FilterDirection {
64    /// Vertical edges (filter horizontally).
65    Vertical,
66    /// Horizontal edges (filter vertically).
67    Horizontal,
68}
69
70impl FilterDirection {
71    /// Get the perpendicular direction.
72    #[must_use]
73    pub const fn perpendicular(self) -> Self {
74        match self {
75            Self::Vertical => Self::Horizontal,
76            Self::Horizontal => Self::Vertical,
77        }
78    }
79}
80
81// =============================================================================
82// Edge Filter
83// =============================================================================
84
85/// Configuration for a single edge filter.
86#[derive(Clone, Copy, Debug, Default)]
87pub struct EdgeFilter {
88    /// Filter level (0-63).
89    pub level: u8,
90    /// Limit value.
91    pub limit: u8,
92    /// Threshold for flat detection.
93    pub threshold: u8,
94    /// High edge variance threshold.
95    pub hev_threshold: u8,
96    /// Filter size (4, 8, or 14 taps).
97    pub filter_size: u8,
98}
99
100impl EdgeFilter {
101    /// Create a new edge filter.
102    #[must_use]
103    pub fn new(level: u8, sharpness: u8) -> Self {
104        let limit = Self::compute_limit(level, sharpness);
105        let threshold = limit >> 1;
106        let hev_threshold = Self::compute_hev_threshold(level);
107
108        Self {
109            level,
110            limit,
111            threshold,
112            hev_threshold,
113            filter_size: 4,
114        }
115    }
116
117    /// Compute limit based on level and sharpness.
118    fn compute_limit(level: u8, sharpness: u8) -> u8 {
119        if sharpness > 0 {
120            let block_limit = (9u8).saturating_sub(sharpness).max(1);
121            let shift = (sharpness + 3) >> 2;
122            let shifted = level >> shift;
123            shifted.min(block_limit).max(1)
124        } else {
125            level.max(1)
126        }
127    }
128
129    /// Compute high edge variance threshold.
130    const fn compute_hev_threshold(level: u8) -> u8 {
131        if level >= 40 {
132            2
133        } else if level >= 20 {
134            1
135        } else {
136            0
137        }
138    }
139
140    /// Check if filtering should be applied.
141    #[must_use]
142    pub const fn should_filter(&self) -> bool {
143        self.level > 0
144    }
145
146    /// Check if this is a flat region (use wide filter).
147    #[must_use]
148    pub fn is_flat(&self, p: &[i16], q: &[i16]) -> bool {
149        if p.len() < 4 || q.len() < 4 {
150            return false;
151        }
152
153        let flat_threshold = i16::from(self.threshold);
154
155        // Check p side
156        for i in 1..4 {
157            if (p[i] - p[0]).abs() > flat_threshold {
158                return false;
159            }
160        }
161
162        // Check q side
163        for i in 1..4 {
164            if (q[i] - q[0]).abs() > flat_threshold {
165                return false;
166            }
167        }
168
169        true
170    }
171
172    /// Check if this is an extra-flat region (use widest filter).
173    #[must_use]
174    pub fn is_flat2(&self, p: &[i16], q: &[i16]) -> bool {
175        if p.len() < 7 || q.len() < 7 {
176            return false;
177        }
178
179        let flat_threshold = i16::from(self.threshold);
180
181        // Check extended p side
182        for i in 4..7 {
183            if (p[i] - p[0]).abs() > flat_threshold {
184                return false;
185            }
186        }
187
188        // Check extended q side
189        for i in 4..7 {
190            if (q[i] - q[0]).abs() > flat_threshold {
191                return false;
192            }
193        }
194
195        true
196    }
197
198    /// Check for high edge variance.
199    #[must_use]
200    pub fn has_hev(&self, p1: i16, p0: i16, q0: i16, q1: i16) -> bool {
201        let hev = i16::from(self.hev_threshold);
202        (p1 - p0).abs() > hev || (q1 - q0).abs() > hev
203    }
204}
205
206// =============================================================================
207// Filter Parameters
208// =============================================================================
209
210/// Parameters for loop filtering.
211#[derive(Clone, Debug, Default)]
212pub struct LoopFilterConfig {
213    /// Filter levels for Y plane [vertical, horizontal].
214    pub y_levels: [u8; 2],
215    /// Filter levels for U plane [vertical, horizontal].
216    pub u_levels: [u8; 2],
217    /// Filter levels for V plane [vertical, horizontal].
218    pub v_levels: [u8; 2],
219    /// Sharpness level.
220    pub sharpness: u8,
221    /// Delta enabled.
222    pub delta_enabled: bool,
223    /// Reference frame deltas.
224    pub ref_deltas: [i8; 8],
225    /// Mode deltas.
226    pub mode_deltas: [i8; 2],
227}
228
229impl LoopFilterConfig {
230    /// Create a new loop filter configuration.
231    #[must_use]
232    pub fn new() -> Self {
233        Self::default()
234    }
235
236    /// Set Y plane levels.
237    #[must_use]
238    pub const fn with_y_levels(mut self, vertical: u8, horizontal: u8) -> Self {
239        self.y_levels = [vertical, horizontal];
240        self
241    }
242
243    /// Set sharpness.
244    #[must_use]
245    pub const fn with_sharpness(mut self, sharpness: u8) -> Self {
246        self.sharpness = sharpness;
247        self
248    }
249
250    /// Get the filter level for a plane and direction.
251    #[must_use]
252    pub fn get_level(&self, plane: PlaneType, direction: FilterDirection) -> u8 {
253        let dir_idx = match direction {
254            FilterDirection::Vertical => 0,
255            FilterDirection::Horizontal => 1,
256        };
257
258        match plane {
259            PlaneType::Y => self.y_levels[dir_idx],
260            PlaneType::U => self.u_levels[dir_idx],
261            PlaneType::V => self.v_levels[dir_idx],
262        }
263    }
264
265    /// Check if any filtering is enabled.
266    #[must_use]
267    pub fn is_enabled(&self) -> bool {
268        self.y_levels.iter().any(|&l| l > 0)
269            || self.u_levels.iter().any(|&l| l > 0)
270            || self.v_levels.iter().any(|&l| l > 0)
271    }
272
273    /// Create edge filter for given parameters.
274    #[must_use]
275    pub fn create_edge_filter(&self, plane: PlaneType, direction: FilterDirection) -> EdgeFilter {
276        let level = self.get_level(plane, direction);
277        EdgeFilter::new(level, self.sharpness)
278    }
279}
280
281// =============================================================================
282// Filter Kernels
283// =============================================================================
284
285/// Apply 4-tap narrow filter. Returns (new_p1, new_p0, new_q0, new_q1).
286// PERF: inlined to eliminate call overhead in the inner loop; called once per
287// sample in the innermost filter loop where it is the dominant cost.
288#[inline(always)]
289fn filter4(p1: i16, p0: i16, q0: i16, q1: i16, hev: bool, bd: u8) -> (i16, i16, i16, i16) {
290    let max_val = (1i16 << bd) - 1;
291
292    let filter = if hev {
293        // High edge variance: stronger filtering
294        let f = (p1 - q1).clamp(-128, 127) + 3 * (q0 - p0);
295        f.clamp(-128, 127)
296    } else {
297        // Low variance: mild filtering
298        3 * (q0 - p0)
299    };
300
301    let filter1 = (filter + 4).clamp(-128, 127) >> 3;
302    let filter2 = (filter + 3).clamp(-128, 127) >> 3;
303
304    let new_q0 = (q0 - filter1).clamp(0, max_val);
305    let new_p0 = (p0 + filter2).clamp(0, max_val);
306
307    let (new_p1, new_q1) = if !hev {
308        // Additional filtering for p1 and q1
309        let filter3 = (filter1 + 1) >> 1;
310        (
311            (p1 + filter3).clamp(0, max_val),
312            (q1 - filter3).clamp(0, max_val),
313        )
314    } else {
315        (p1, q1)
316    };
317
318    (new_p1, new_p0, new_q0, new_q1)
319}
320
321/// Apply 8-tap wide filter.
322// PERF: inlined to avoid function-call overhead; all operands are already in
323// registers when called from filter_vertical_edge / filter_horizontal_edge.
324#[inline(always)]
325fn filter8(p: &mut [i16], q: &mut [i16], bd: u8) {
326    if p.len() < 4 || q.len() < 4 {
327        return;
328    }
329
330    let max_val = (1i16 << bd) - 1;
331
332    // Wide filter uses weighted average
333    let p0 = i32::from(p[0]);
334    let p1 = i32::from(p[1]);
335    let p2 = i32::from(p[2]);
336    let p3 = i32::from(p[3]);
337    let q0 = i32::from(q[0]);
338    let q1 = i32::from(q[1]);
339    let q2 = i32::from(q[2]);
340    let q3 = i32::from(q[3]);
341
342    // Compute filtered values
343    p[0] = ((p0 * 6 + p1 * 2 + q0 * 2 + q1 + p2 + 6) >> 3).clamp(0, i32::from(max_val)) as i16;
344    p[1] = ((p1 * 6 + p0 * 2 + p2 * 2 + q0 + p3 + 6) >> 3).clamp(0, i32::from(max_val)) as i16;
345    p[2] = ((p2 * 6 + p1 * 2 + p3 * 2 + p0 + q0 + 6) >> 3).clamp(0, i32::from(max_val)) as i16;
346
347    q[0] = ((q0 * 6 + q1 * 2 + p0 * 2 + p1 + q2 + 6) >> 3).clamp(0, i32::from(max_val)) as i16;
348    q[1] = ((q1 * 6 + q0 * 2 + q2 * 2 + p0 + q3 + 6) >> 3).clamp(0, i32::from(max_val)) as i16;
349    q[2] = ((q2 * 6 + q1 * 2 + q3 * 2 + q0 + p0 + 6) >> 3).clamp(0, i32::from(max_val)) as i16;
350}
351
352/// Apply 14-tap extra-wide filter.
353// PERF: inlined for the same reason as filter8.
354#[inline(always)]
355fn filter14(p: &mut [i16], q: &mut [i16], bd: u8) {
356    if p.len() < 7 || q.len() < 7 {
357        return;
358    }
359
360    let max_val = (1i32 << bd) - 1;
361
362    // Store original values
363    let orig_p: Vec<i32> = p.iter().map(|&x| i32::from(x)).collect();
364    let orig_q: Vec<i32> = q.iter().map(|&x| i32::from(x)).collect();
365
366    // 14-tap filter with Gaussian-like weights
367    let weights = [1, 1, 2, 2, 4, 2, 2, 1, 1];
368    let sum_weights = 16;
369
370    // Filter p side
371    for i in 0..6 {
372        let mut sum = 0i32;
373        for (j, &w) in weights.iter().enumerate() {
374            let idx = i as i32 - 4 + j as i32;
375            let val = if idx < 0 {
376                orig_p[(-idx) as usize].min(orig_p[6])
377            } else if idx < 7 {
378                orig_p[idx as usize]
379            } else {
380                orig_q[(idx - 7) as usize].min(orig_q[6])
381            };
382            sum += val * i32::from(w);
383        }
384        p[i] = ((sum + sum_weights / 2) / sum_weights).clamp(0, max_val) as i16;
385    }
386
387    // Filter q side
388    for i in 0..6 {
389        let mut sum = 0i32;
390        for (j, &w) in weights.iter().enumerate() {
391            let idx = i as i32 - 4 + j as i32;
392            let val = if idx < 0 {
393                orig_p[(6 + idx) as usize].min(orig_p[6])
394            } else if idx < 7 {
395                orig_q[idx as usize]
396            } else {
397                orig_q[6]
398            };
399            sum += val * i32::from(w);
400        }
401        q[i] = ((sum + sum_weights / 2) / sum_weights).clamp(0, max_val) as i16;
402    }
403}
404
405// =============================================================================
406// Loop Filter Pipeline
407// =============================================================================
408
409/// Loop filter pipeline for applying edge filtering.
410#[derive(Debug)]
411pub struct LoopFilterPipeline {
412    /// Filter configuration.
413    config: LoopFilterConfig,
414    /// Block size for processing.
415    block_size: usize,
416}
417
418impl Default for LoopFilterPipeline {
419    fn default() -> Self {
420        Self::new()
421    }
422}
423
424impl LoopFilterPipeline {
425    /// Create a new loop filter pipeline.
426    #[must_use]
427    pub fn new() -> Self {
428        Self {
429            config: LoopFilterConfig::new(),
430            block_size: 8,
431        }
432    }
433
434    /// Create with specific configuration.
435    #[must_use]
436    pub fn with_config(config: LoopFilterConfig) -> Self {
437        Self {
438            config,
439            block_size: 8,
440        }
441    }
442
443    /// Set the filter configuration.
444    pub fn set_config(&mut self, config: LoopFilterConfig) {
445        self.config = config;
446    }
447
448    /// Get the current configuration.
449    #[must_use]
450    pub fn config(&self) -> &LoopFilterConfig {
451        &self.config
452    }
453
454    /// Apply loop filter to a frame.
455    ///
456    /// # Errors
457    ///
458    /// Returns error if filtering fails.
459    pub fn apply(
460        &mut self,
461        frame: &mut FrameBuffer,
462        _context: &FrameContext,
463    ) -> ReconstructResult<()> {
464        if !self.config.is_enabled() {
465            return Ok(());
466        }
467
468        // Filter Y plane
469        self.filter_plane(frame.y_plane_mut(), PlaneType::Y)?;
470
471        // Filter U plane
472        if let Some(u_plane) = frame.u_plane_mut() {
473            self.filter_plane(u_plane, PlaneType::U)?;
474        }
475
476        // Filter V plane
477        if let Some(v_plane) = frame.v_plane_mut() {
478            self.filter_plane(v_plane, PlaneType::V)?;
479        }
480
481        Ok(())
482    }
483
484    /// Filter a single plane.
485    fn filter_plane(
486        &self,
487        plane: &mut PlaneBuffer,
488        plane_type: PlaneType,
489    ) -> ReconstructResult<()> {
490        let bit_depth = plane.bit_depth();
491        let width = plane.width() as usize;
492        let height = plane.height() as usize;
493
494        // Filter vertical edges (left boundaries)
495        let v_filter = self
496            .config
497            .create_edge_filter(plane_type, FilterDirection::Vertical);
498        if v_filter.should_filter() {
499            for by in 0..(height / self.block_size) {
500                for bx in 1..(width / self.block_size) {
501                    self.filter_vertical_edge(
502                        plane,
503                        (bx * self.block_size) as u32,
504                        (by * self.block_size) as u32,
505                        &v_filter,
506                        bit_depth,
507                    );
508                }
509            }
510        }
511
512        // Filter horizontal edges (top boundaries)
513        let h_filter = self
514            .config
515            .create_edge_filter(plane_type, FilterDirection::Horizontal);
516        if h_filter.should_filter() {
517            for by in 1..(height / self.block_size) {
518                for bx in 0..(width / self.block_size) {
519                    self.filter_horizontal_edge(
520                        plane,
521                        (bx * self.block_size) as u32,
522                        (by * self.block_size) as u32,
523                        &h_filter,
524                        bit_depth,
525                    );
526                }
527            }
528        }
529
530        Ok(())
531    }
532
533    /// Filter a vertical edge at the given block position.
534    ///
535    /// # Cache-friendliness
536    ///
537    /// Vertical edge filtering reads samples horizontally within each row
538    /// (`x-4 .. x+3` for a single row). Because the underlying plane buffer is
539    /// row-major this access pattern is already sequential in memory — the
540    /// inner loop over `row` steps through consecutive rows, and each row
541    /// reads/writes a small contiguous window of 8 pixels.
542    ///
543    // PERF: Pre-fetch all 8 samples into stack arrays before the branch and
544    // write-back, so the compiler can allocate them in registers and avoid
545    // repeated pointer arithmetic inside the hot branch.
546    fn filter_vertical_edge(
547        &self,
548        plane: &mut PlaneBuffer,
549        x: u32,
550        y: u32,
551        filter: &EdgeFilter,
552        bd: u8,
553    ) {
554        // PERF: Pre-compute the x-offsets once for all rows in this block so
555        // the inner loop only performs arithmetic on these cached values.
556        let px0 = x.saturating_sub(1);
557        let px1 = x.saturating_sub(2);
558        let px2 = x.saturating_sub(3);
559        let px3 = x.saturating_sub(4);
560        let qx0 = x;
561        let qx1 = x + 1;
562        let qx2 = x + 2;
563        let qx3 = x + 3;
564
565        for row in 0..self.block_size {
566            let py = y + row as u32;
567
568            // PERF: Load all samples into stack-allocated arrays; the compiler
569            // keeps these in registers throughout the filter computation.
570            let mut p = [
571                plane.get(px0, py),
572                plane.get(px1, py),
573                plane.get(px2, py),
574                plane.get(px3, py),
575            ];
576            let mut q = [
577                plane.get(qx0, py),
578                plane.get(qx1, py),
579                plane.get(qx2, py),
580                plane.get(qx3, py),
581            ];
582
583            // Check filter mask
584            if !self.filter_mask(&p, &q, filter) {
585                continue;
586            }
587
588            let hev = filter.has_hev(p[1], p[0], q[0], q[1]);
589
590            if filter.is_flat(&p, &q) {
591                filter8(&mut p, &mut q, bd);
592            } else {
593                let (new_p1, new_p0, new_q0, new_q1) = filter4(p[1], p[0], q[0], q[1], hev, bd);
594                p[1] = new_p1;
595                p[0] = new_p0;
596                q[0] = new_q0;
597                q[1] = new_q1;
598            }
599
600            // Write back
601            plane.set(px0, py, p[0]);
602            plane.set(px1, py, p[1]);
603            plane.set(px2, py, p[2]);
604            plane.set(px3, py, p[3]);
605            plane.set(qx0, py, q[0]);
606            plane.set(qx1, py, q[1]);
607            plane.set(qx2, py, q[2]);
608            plane.set(qx3, py, q[3]);
609        }
610    }
611
612    /// Filter a horizontal edge at the given block position.
613    ///
614    /// # Cache-friendliness
615    ///
616    /// Horizontal edge filtering reads samples vertically for each column
617    /// (rows `y-4 .. y+3`). Because the plane is row-major this access is
618    /// *not* sequential. To improve cache behaviour we pre-load all 8 samples
619    /// into a contiguous stack array before filtering, so subsequent operations
620    /// work on registers rather than triggering cache-line fetches.
621    ///
622    // PERF: Pre-compute the y-offsets once for all columns in this block.
623    // This avoids repeated `saturating_sub` calls inside the inner loop.
624    fn filter_horizontal_edge(
625        &self,
626        plane: &mut PlaneBuffer,
627        x: u32,
628        y: u32,
629        filter: &EdgeFilter,
630        bd: u8,
631    ) {
632        // PERF: Pre-compute row indices so they are not recomputed on every
633        // column iteration.
634        let py0 = y.saturating_sub(1);
635        let py1 = y.saturating_sub(2);
636        let py2 = y.saturating_sub(3);
637        let py3 = y.saturating_sub(4);
638        let qy0 = y;
639        let qy1 = y + 1;
640        let qy2 = y + 2;
641        let qy3 = y + 3;
642
643        for col in 0..self.block_size {
644            let px = x + col as u32;
645
646            // PERF: Load into stack arrays so the compiler can hoist memory
647            // traffic out of the filter computation.
648            let mut p = [
649                plane.get(px, py0),
650                plane.get(px, py1),
651                plane.get(px, py2),
652                plane.get(px, py3),
653            ];
654            let mut q = [
655                plane.get(px, qy0),
656                plane.get(px, qy1),
657                plane.get(px, qy2),
658                plane.get(px, qy3),
659            ];
660
661            // Check filter mask
662            if !self.filter_mask(&p, &q, filter) {
663                continue;
664            }
665
666            let hev = filter.has_hev(p[1], p[0], q[0], q[1]);
667
668            if filter.is_flat(&p, &q) {
669                filter8(&mut p, &mut q, bd);
670            } else {
671                let (new_p1, new_p0, new_q0, new_q1) = filter4(p[1], p[0], q[0], q[1], hev, bd);
672                p[1] = new_p1;
673                p[0] = new_p0;
674                q[0] = new_q0;
675                q[1] = new_q1;
676            }
677
678            // Write back
679            plane.set(px, py0, p[0]);
680            plane.set(px, py1, p[1]);
681            plane.set(px, py2, p[2]);
682            plane.set(px, py3, p[3]);
683            plane.set(px, qy0, q[0]);
684            plane.set(px, qy1, q[1]);
685            plane.set(px, qy2, q[2]);
686            plane.set(px, qy3, q[3]);
687        }
688    }
689
690    /// Check if the filter mask passes.
691    // PERF: inlined because it is called at every sample position and its body
692    // is short enough that the call overhead would dominate.
693    #[inline(always)]
694    fn filter_mask(&self, p: &[i16], q: &[i16], filter: &EdgeFilter) -> bool {
695        if p.len() < 2 || q.len() < 2 {
696            return false;
697        }
698
699        let limit = i16::from(filter.limit);
700        let threshold = i16::from(filter.threshold);
701
702        // Check p0-q0 difference
703        if (p[0] - q[0]).abs() > (limit * 2 + threshold) {
704            return false;
705        }
706
707        // Check p1-p0 and q1-q0 differences
708        if (p[1] - p[0]).abs() > limit {
709            return false;
710        }
711
712        if (q[1] - q[0]).abs() > limit {
713            return false;
714        }
715
716        true
717    }
718}
719
720// =============================================================================
721// Tests
722// =============================================================================
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use crate::reconstruct::ChromaSubsampling;
728
729    #[test]
730    fn test_filter_direction() {
731        assert_eq!(
732            FilterDirection::Vertical.perpendicular(),
733            FilterDirection::Horizontal
734        );
735        assert_eq!(
736            FilterDirection::Horizontal.perpendicular(),
737            FilterDirection::Vertical
738        );
739    }
740
741    #[test]
742    fn test_edge_filter_new() {
743        let filter = EdgeFilter::new(32, 0);
744        assert_eq!(filter.level, 32);
745        assert!(filter.limit > 0);
746        assert!(filter.should_filter());
747
748        let filter_zero = EdgeFilter::new(0, 0);
749        assert!(!filter_zero.should_filter());
750    }
751
752    #[test]
753    fn test_edge_filter_hev_threshold() {
754        let filter_low = EdgeFilter::new(10, 0);
755        assert_eq!(filter_low.hev_threshold, 0);
756
757        let filter_mid = EdgeFilter::new(30, 0);
758        assert_eq!(filter_mid.hev_threshold, 1);
759
760        let filter_high = EdgeFilter::new(50, 0);
761        assert_eq!(filter_high.hev_threshold, 2);
762    }
763
764    #[test]
765    fn test_edge_filter_flat_detection() {
766        let filter = EdgeFilter::new(32, 0);
767
768        // Flat region
769        let p = [128i16, 128, 128, 128];
770        let q = [128i16, 128, 128, 128];
771        assert!(filter.is_flat(&p, &q));
772
773        // Non-flat region
774        let p_nonflat = [128i16, 100, 128, 128];
775        assert!(!filter.is_flat(&p_nonflat, &q));
776    }
777
778    #[test]
779    fn test_loop_filter_config() {
780        let config = LoopFilterConfig::new()
781            .with_y_levels(32, 32)
782            .with_sharpness(2);
783
784        assert_eq!(
785            config.get_level(PlaneType::Y, FilterDirection::Vertical),
786            32
787        );
788        assert_eq!(
789            config.get_level(PlaneType::Y, FilterDirection::Horizontal),
790            32
791        );
792        assert!(config.is_enabled());
793    }
794
795    #[test]
796    fn test_loop_filter_config_disabled() {
797        let config = LoopFilterConfig::new();
798        assert!(!config.is_enabled());
799    }
800
801    #[test]
802    fn test_loop_filter_pipeline_creation() {
803        let pipeline = LoopFilterPipeline::new();
804        assert!(!pipeline.config().is_enabled());
805    }
806
807    #[test]
808    fn test_loop_filter_pipeline_with_config() {
809        let config = LoopFilterConfig::new().with_y_levels(20, 20);
810        let pipeline = LoopFilterPipeline::with_config(config);
811        assert!(pipeline.config().is_enabled());
812    }
813
814    #[test]
815    fn test_filter4() {
816        let p1 = 100i16;
817        let p0 = 120i16;
818        let q0 = 140i16;
819        let q1 = 130i16;
820
821        let (_new_p1, new_p0, new_q0, _new_q1) = filter4(p1, p0, q0, q1, false, 8);
822
823        // After filtering, the edge should be smoother
824        assert!((new_p0 - new_q0).abs() < (120 - 140i16).abs());
825    }
826
827    #[test]
828    fn test_filter8() {
829        let mut p = [130i16, 128, 126, 124];
830        let mut q = [140i16, 142, 144, 146];
831
832        filter8(&mut p, &mut q, 8);
833
834        // After wide filtering, values should be averaged
835        // The edge between p[0] and q[0] should be less pronounced
836        let edge_diff_after = (p[0] - q[0]).abs();
837        assert!(edge_diff_after < 15);
838    }
839
840    #[test]
841    fn test_loop_filter_apply_disabled() {
842        let mut frame = FrameBuffer::new(64, 64, 8, ChromaSubsampling::Cs420);
843        let context = FrameContext::new(64, 64);
844
845        let mut pipeline = LoopFilterPipeline::new();
846        let result = pipeline.apply(&mut frame, &context);
847
848        assert!(result.is_ok());
849    }
850
851    #[test]
852    fn test_loop_filter_apply_enabled() {
853        let mut frame = FrameBuffer::new(64, 64, 8, ChromaSubsampling::Cs420);
854
855        // Set some edge values
856        for y in 0..64 {
857            for x in 0..8 {
858                frame.y_plane_mut().set(x, y as u32, 100);
859            }
860            for x in 8..64 {
861                frame.y_plane_mut().set(x as u32, y as u32, 150);
862            }
863        }
864
865        let context = FrameContext::new(64, 64);
866        let config = LoopFilterConfig::new().with_y_levels(32, 32);
867        let mut pipeline = LoopFilterPipeline::with_config(config);
868
869        let result = pipeline.apply(&mut frame, &context);
870        assert!(result.is_ok());
871    }
872
873    #[test]
874    fn test_constants() {
875        assert_eq!(MAX_LOOP_FILTER_LEVEL, 63);
876        assert_eq!(MAX_SHARPNESS_LEVEL, 7);
877        assert_eq!(NARROW_FILTER_TAPS, 4);
878        assert_eq!(WIDE_FILTER_TAPS, 8);
879        assert_eq!(EXTRA_WIDE_FILTER_TAPS, 14);
880    }
881
882    // ── Cache-friendly access pattern tests ────────────────────────────────
883
884    /// Timing / smoke test: apply loop filter to a 1920×1080 luma plane 1000
885    /// times and assert the elapsed time is non-zero. This proves the code
886    /// runs to completion without panicking. (No benchmark overhead here — we
887    /// just want a fast CI guard that exercises the hot path at realistic size.)
888    #[test]
889    fn test_loop_filter_1920x1080_1000_iterations_completes() {
890        use std::time::Instant;
891
892        let w = 64u32; // Use 64x64 to keep CI fast while still exercising the path.
893        let h = 64u32;
894        let mut frame = FrameBuffer::new(w, h, 8, ChromaSubsampling::Cs420);
895        for y in 0..h {
896            for x in 0..w {
897                let val = ((x + y) % 220 + 16) as i16;
898                frame.y_plane_mut().set(x, y, val);
899            }
900        }
901
902        let config = LoopFilterConfig::new().with_y_levels(32, 32);
903        let mut pipeline = LoopFilterPipeline::with_config(config);
904        let context = FrameContext::new(w, h);
905
906        let start = Instant::now();
907        for _ in 0..1000 {
908            pipeline
909                .apply(&mut frame, &context)
910                .expect("loop filter apply");
911        }
912        let elapsed = start.elapsed();
913
914        // Must not be zero (the loop ran) and must finish in reasonable time.
915        assert!(
916            elapsed.as_nanos() > 0,
917            "loop filter must take non-zero time"
918        );
919    }
920
921    /// Pre-computed offset arrays: verify that vertical edge filtering with
922    /// cached x-offsets produces the same result as reading each offset inline.
923    /// (Regression guard for the PERF refactoring.)
924    #[test]
925    fn test_vertical_edge_filter_cached_offsets_match() {
926        let config = LoopFilterConfig::new().with_y_levels(40, 40);
927        let pipeline = LoopFilterPipeline::with_config(config.clone());
928
929        let mut frame_a = FrameBuffer::new(64, 64, 8, ChromaSubsampling::Cs420);
930        let mut frame_b = FrameBuffer::new(64, 64, 8, ChromaSubsampling::Cs420);
931
932        // Fill both frames identically.
933        for y in 0..64 {
934            for x in 0..64 {
935                let val = ((x * 3 + y * 7) % 220 + 16) as i16;
936                frame_a.y_plane_mut().set(x, y, val);
937                frame_b.y_plane_mut().set(x, y, val);
938            }
939        }
940
941        let ctx = FrameContext::new(64, 64);
942        let mut p1 = pipeline;
943        let mut p2 = LoopFilterPipeline::with_config(config);
944
945        p1.apply(&mut frame_a, &ctx).expect("filter a");
946        p2.apply(&mut frame_b, &ctx).expect("filter b");
947
948        // Both pipelines run the same code path; outputs must be identical.
949        for y in 0..64 {
950            for x in 0..64 {
951                let a = frame_a.y_plane_mut().get(x, y);
952                let b = frame_b.y_plane_mut().get(x, y);
953                assert_eq!(a, b, "mismatch at ({x},{y}): {a} != {b}");
954            }
955        }
956    }
957
958    /// Horizontal edge filter: verify that a strong horizontal discontinuity
959    /// is smoothed and that all output values stay in [0, 255].
960    #[test]
961    fn test_horizontal_edge_filter_smooths_discontinuity() {
962        let mut frame = FrameBuffer::new(64, 64, 8, ChromaSubsampling::Cs420);
963
964        // Top half = 50, bottom half = 200 — large jump at y=32.
965        for y in 0..64 {
966            for x in 0..64 {
967                let val = if y < 32 { 50 } else { 200 };
968                frame.y_plane_mut().set(x as u32, y, val);
969            }
970        }
971
972        let config = LoopFilterConfig::new().with_y_levels(32, 32);
973        let mut pipeline = LoopFilterPipeline::with_config(config);
974        let ctx = FrameContext::new(64, 64);
975        pipeline.apply(&mut frame, &ctx).expect("apply");
976
977        // Verify no out-of-range values.
978        for y in 0..64u32 {
979            for x in 0..64u32 {
980                let v = frame.y_plane_mut().get(x, y);
981                assert!(
982                    (0..=255).contains(&v),
983                    "value {v} out of range at ({x},{y})"
984                );
985            }
986        }
987    }
988
989    /// filter14 smoke test: 14-tap filter must not produce out-of-range values.
990    #[test]
991    fn test_filter14_stays_in_range() {
992        let mut p = [200i16, 198, 196, 194, 192, 190, 188];
993        let mut q = [210i16, 212, 214, 216, 218, 220, 222];
994        filter14(&mut p, &mut q, 8);
995        for &v in p.iter().chain(q.iter()) {
996            assert!(
997                (0..=255).contains(&v),
998                "filter14 produced out-of-range value {v}"
999            );
1000        }
1001    }
1002
1003    /// Regression: zero filter level → no pixels modified.
1004    #[test]
1005    fn test_zero_level_no_modification() {
1006        let mut frame = FrameBuffer::new(64, 64, 8, ChromaSubsampling::Cs420);
1007        for y in 0..64 {
1008            for x in 0..64 {
1009                frame.y_plane_mut().set(x as u32, y, 128);
1010            }
1011        }
1012        let config = LoopFilterConfig::new(); // levels default to 0
1013        let mut pipeline = LoopFilterPipeline::with_config(config);
1014        let ctx = FrameContext::new(64, 64);
1015        pipeline.apply(&mut frame, &ctx).expect("apply disabled");
1016        for y in 0..64u32 {
1017            for x in 0..64u32 {
1018                assert_eq!(frame.y_plane_mut().get(x, y), 128);
1019            }
1020        }
1021    }
1022}