Skip to main content

oximedia_optimize/
roi_encode.rs

1#![allow(dead_code)]
2//! Region of Interest (ROI) encoding optimization.
3//!
4//! This module provides tools for defining regions of interest within video frames
5//! and adjusting encoding parameters (QP offsets, bitrate allocation) to prioritize
6//! visual quality in those regions. Common use cases include face-aware encoding,
7//! text region preservation, and broadcast graphics protection.
8//!
9//! The ROI encoder integrates with the main [`Optimizer`](crate::Optimizer) pipeline
10//! via [`RoiOptimizeResult`], which provides per-CTU QP adjustments that the
11//! optimizer applies on top of its base AQ decisions.
12
13/// Coordinate type for ROI regions.
14#[allow(clippy::cast_precision_loss)]
15type Coord = i32;
16
17/// A rectangular region of interest within a frame.
18#[derive(Debug, Clone, PartialEq)]
19pub struct RoiRegion {
20    /// Left edge in pixels.
21    pub x: Coord,
22    /// Top edge in pixels.
23    pub y: Coord,
24    /// Width in pixels.
25    pub width: u32,
26    /// Height in pixels.
27    pub height: u32,
28    /// Priority weight (0.0 = ignore, 1.0 = normal, >1.0 = boosted).
29    pub priority: f64,
30    /// Optional label for the region.
31    pub label: String,
32}
33
34impl RoiRegion {
35    /// Creates a new ROI region with default priority.
36    pub fn new(x: Coord, y: Coord, width: u32, height: u32) -> Self {
37        Self {
38            x,
39            y,
40            width,
41            height,
42            priority: 1.0,
43            label: String::new(),
44        }
45    }
46
47    /// Creates a new ROI region with a given priority weight.
48    pub fn with_priority(x: Coord, y: Coord, width: u32, height: u32, priority: f64) -> Self {
49        Self {
50            x,
51            y,
52            width,
53            height,
54            priority,
55            label: String::new(),
56        }
57    }
58
59    /// Sets the label for this region.
60    pub fn set_label(&mut self, label: &str) {
61        self.label = label.to_string();
62    }
63
64    /// Returns the area of the region in pixels.
65    #[allow(clippy::cast_precision_loss)]
66    pub fn area(&self) -> u64 {
67        u64::from(self.width) * u64::from(self.height)
68    }
69
70    /// Returns the right edge coordinate.
71    pub fn right(&self) -> Coord {
72        self.x + self.width as Coord
73    }
74
75    /// Returns the bottom edge coordinate.
76    pub fn bottom(&self) -> Coord {
77        self.y + self.height as Coord
78    }
79
80    /// Checks whether a pixel coordinate falls inside this region.
81    pub fn contains(&self, px: Coord, py: Coord) -> bool {
82        px >= self.x && px < self.right() && py >= self.y && py < self.bottom()
83    }
84
85    /// Checks whether two regions overlap.
86    pub fn overlaps(&self, other: &Self) -> bool {
87        self.x < other.right()
88            && self.right() > other.x
89            && self.y < other.bottom()
90            && self.bottom() > other.y
91    }
92
93    /// Returns the intersection area with another region, or 0 if they don't overlap.
94    pub fn intersection_area(&self, other: &Self) -> u64 {
95        let left = self.x.max(other.x);
96        let top = self.y.max(other.y);
97        let right = self.right().min(other.right());
98        let bottom = self.bottom().min(other.bottom());
99        if right > left && bottom > top {
100            (right - left) as u64 * (bottom - top) as u64
101        } else {
102            0
103        }
104    }
105}
106
107/// QP adjustment mode for ROI regions.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum QpAdjustMode {
110    /// Apply an absolute QP offset (delta).
111    AbsoluteOffset,
112    /// Scale the base QP by a factor derived from priority.
113    PriorityScale,
114    /// Use a fixed QP value for the region regardless of base QP.
115    FixedQp,
116}
117
118/// Configuration for the ROI encoder optimizer.
119#[derive(Debug, Clone)]
120pub struct RoiEncoderConfig {
121    /// Frame width in pixels.
122    pub frame_width: u32,
123    /// Frame height in pixels.
124    pub frame_height: u32,
125    /// CTU (Coding Tree Unit) size for block-level QP mapping.
126    pub ctu_size: u32,
127    /// Base QP for the frame.
128    pub base_qp: u8,
129    /// Maximum negative QP offset allowed for boosted regions.
130    pub max_qp_reduction: u8,
131    /// Maximum positive QP offset allowed for background regions.
132    pub max_qp_increase: u8,
133    /// QP adjustment mode.
134    pub adjust_mode: QpAdjustMode,
135}
136
137impl Default for RoiEncoderConfig {
138    fn default() -> Self {
139        Self {
140            frame_width: 1920,
141            frame_height: 1080,
142            ctu_size: 64,
143            base_qp: 28,
144            max_qp_reduction: 10,
145            max_qp_increase: 6,
146            adjust_mode: QpAdjustMode::PriorityScale,
147        }
148    }
149}
150
151/// A QP delta map for a single frame, organized by CTU blocks.
152#[derive(Debug, Clone)]
153pub struct QpDeltaMap {
154    /// Number of CTU columns.
155    pub cols: usize,
156    /// Number of CTU rows.
157    pub rows: usize,
158    /// QP deltas, stored row-major.
159    pub deltas: Vec<i8>,
160}
161
162impl QpDeltaMap {
163    /// Creates a new zero-filled QP delta map.
164    pub fn new(cols: usize, rows: usize) -> Self {
165        Self {
166            cols,
167            rows,
168            deltas: vec![0; cols * rows],
169        }
170    }
171
172    /// Gets the delta for a specific CTU.
173    pub fn get(&self, col: usize, row: usize) -> i8 {
174        if col < self.cols && row < self.rows {
175            self.deltas[row * self.cols + col]
176        } else {
177            0
178        }
179    }
180
181    /// Sets the delta for a specific CTU.
182    pub fn set(&mut self, col: usize, row: usize, delta: i8) {
183        if col < self.cols && row < self.rows {
184            self.deltas[row * self.cols + col] = delta;
185        }
186    }
187
188    /// Returns the average delta across the map.
189    #[allow(clippy::cast_precision_loss)]
190    pub fn average_delta(&self) -> f64 {
191        if self.deltas.is_empty() {
192            return 0.0;
193        }
194        let sum: i64 = self.deltas.iter().map(|&d| i64::from(d)).sum();
195        sum as f64 / self.deltas.len() as f64
196    }
197
198    /// Returns the number of CTUs with non-zero deltas.
199    pub fn active_ctu_count(&self) -> usize {
200        self.deltas.iter().filter(|&&d| d != 0).count()
201    }
202
203    /// Merges another QP delta map, adding deltas element-wise with clamping.
204    pub fn merge_additive(&mut self, other: &Self, max_magnitude: i8) {
205        if self.cols != other.cols || self.rows != other.rows {
206            return;
207        }
208        for i in 0..self.deltas.len() {
209            let sum = i16::from(self.deltas[i]) + i16::from(other.deltas[i]);
210            self.deltas[i] = sum.clamp(i16::from(-max_magnitude), i16::from(max_magnitude)) as i8;
211        }
212    }
213}
214
215/// The ROI encoder optimizer generates per-CTU QP delta maps from ROI regions.
216#[derive(Debug)]
217pub struct RoiEncoder {
218    /// Encoder configuration.
219    config: RoiEncoderConfig,
220    /// Current set of ROI regions.
221    regions: Vec<RoiRegion>,
222}
223
224impl RoiEncoder {
225    /// Creates a new ROI encoder with the given configuration.
226    pub fn new(config: RoiEncoderConfig) -> Self {
227        Self {
228            config,
229            regions: Vec::new(),
230        }
231    }
232
233    /// Adds a region of interest.
234    pub fn add_region(&mut self, region: RoiRegion) {
235        self.regions.push(region);
236    }
237
238    /// Clears all ROI regions.
239    pub fn clear_regions(&mut self) {
240        self.regions.clear();
241    }
242
243    /// Returns the number of configured regions.
244    pub fn region_count(&self) -> usize {
245        self.regions.len()
246    }
247
248    /// Returns the current regions.
249    pub fn regions(&self) -> &[RoiRegion] {
250        &self.regions
251    }
252
253    /// Returns the encoder configuration.
254    pub fn config(&self) -> &RoiEncoderConfig {
255        &self.config
256    }
257
258    /// Generates a QP delta map for the current set of regions.
259    #[allow(clippy::cast_precision_loss)]
260    pub fn generate_qp_map(&self) -> QpDeltaMap {
261        let cols =
262            ((self.config.frame_width + self.config.ctu_size - 1) / self.config.ctu_size) as usize;
263        let rows =
264            ((self.config.frame_height + self.config.ctu_size - 1) / self.config.ctu_size) as usize;
265        let mut map = QpDeltaMap::new(cols, rows);
266
267        if self.regions.is_empty() {
268            return map;
269        }
270
271        for row in 0..rows {
272            for col in 0..cols {
273                let ctu_x = (col as u32 * self.config.ctu_size) as Coord;
274                let ctu_y = (row as u32 * self.config.ctu_size) as Coord;
275                let ctu_region =
276                    RoiRegion::new(ctu_x, ctu_y, self.config.ctu_size, self.config.ctu_size);
277
278                let mut max_priority: f64 = 0.0;
279                for region in &self.regions {
280                    if region.overlaps(&ctu_region) {
281                        let overlap = region.intersection_area(&ctu_region);
282                        let ctu_area = ctu_region.area();
283                        let coverage = if ctu_area > 0 {
284                            overlap as f64 / ctu_area as f64
285                        } else {
286                            0.0
287                        };
288                        let effective = region.priority * coverage;
289                        if effective > max_priority {
290                            max_priority = effective;
291                        }
292                    }
293                }
294
295                let delta = self.compute_delta(max_priority);
296                map.set(col, row, delta);
297            }
298        }
299
300        map
301    }
302
303    /// Generates an [`RoiOptimizeResult`] for pipeline integration with the main Optimizer.
304    ///
305    /// This produces a result that includes both the QP delta map and per-block
306    /// quality weights that the Optimizer uses to adjust AQ decisions.
307    #[allow(clippy::cast_precision_loss)]
308    pub fn optimize_frame(&self) -> RoiOptimizeResult {
309        let map = self.generate_qp_map();
310        let analysis = analyze_qp_map(&map);
311
312        // Compute per-CTU quality weights (higher priority = lower weight = more bits)
313        let quality_weights: Vec<f64> = map
314            .deltas
315            .iter()
316            .map(|&d| {
317                // Convert delta to weight: negative delta (boost) -> higher weight
318                let w = 1.0 - f64::from(d) / f64::from(self.config.max_qp_reduction as i8);
319                w.clamp(0.5, 2.0)
320            })
321            .collect();
322
323        let bitrate_impact = self.estimate_bitrate_impact();
324
325        RoiOptimizeResult {
326            qp_map: map,
327            quality_weights,
328            analysis,
329            estimated_bitrate_change: bitrate_impact,
330            has_active_regions: !self.regions.is_empty(),
331        }
332    }
333
334    /// Computes a QP delta from a priority value.
335    #[allow(clippy::cast_precision_loss)]
336    #[allow(clippy::cast_possible_truncation)]
337    fn compute_delta(&self, priority: f64) -> i8 {
338        if priority <= 0.0 {
339            return 0;
340        }
341        match self.config.adjust_mode {
342            QpAdjustMode::AbsoluteOffset => {
343                let offset = -(priority * f64::from(self.config.max_qp_reduction));
344                offset.round().max(-(i8::MAX as f64)).min(0.0) as i8
345            }
346            QpAdjustMode::PriorityScale => {
347                if priority > 1.0 {
348                    let reduction =
349                        (priority - 1.0).min(1.0) * f64::from(self.config.max_qp_reduction);
350                    -(reduction
351                        .round()
352                        .min(f64::from(self.config.max_qp_reduction)) as i8)
353                } else if priority < 1.0 {
354                    let increase = (1.0 - priority) * f64::from(self.config.max_qp_increase);
355                    increase.round().min(f64::from(self.config.max_qp_increase)) as i8
356                } else {
357                    0
358                }
359            }
360            QpAdjustMode::FixedQp => {
361                let target_qp = (f64::from(self.config.base_qp) / priority).round() as i16;
362                let delta = target_qp - i16::from(self.config.base_qp);
363                delta
364                    .max(-i16::from(self.config.max_qp_reduction))
365                    .min(i16::from(self.config.max_qp_increase)) as i8
366            }
367        }
368    }
369
370    /// Estimates the bitrate savings/cost relative to uniform encoding.
371    #[allow(clippy::cast_precision_loss)]
372    pub fn estimate_bitrate_impact(&self) -> f64 {
373        let map = self.generate_qp_map();
374        let avg_delta = map.average_delta();
375        // Rough model: each QP unit ~= 12% bitrate change
376        let factor = 2.0_f64.powf(-avg_delta / 6.0);
377        factor - 1.0
378    }
379}
380
381/// Result of ROI optimization for integration with the main Optimizer pipeline.
382#[derive(Debug, Clone)]
383pub struct RoiOptimizeResult {
384    /// Per-CTU QP delta map.
385    pub qp_map: QpDeltaMap,
386    /// Per-CTU quality weights (1.0 = normal, >1.0 = boosted region).
387    pub quality_weights: Vec<f64>,
388    /// Summary analysis of the QP map.
389    pub analysis: RoiAnalysisSummary,
390    /// Estimated bitrate change factor (0.0 = no change, positive = more bits).
391    pub estimated_bitrate_change: f64,
392    /// Whether any active ROI regions exist.
393    pub has_active_regions: bool,
394}
395
396impl RoiOptimizeResult {
397    /// Creates an empty result with no active regions.
398    pub fn empty(cols: usize, rows: usize) -> Self {
399        Self {
400            qp_map: QpDeltaMap::new(cols, rows),
401            quality_weights: vec![1.0; cols * rows],
402            analysis: RoiAnalysisSummary {
403                total_ctus: cols * rows,
404                roi_ctus: 0,
405                avg_delta: 0.0,
406                min_delta: 0,
407                max_delta: 0,
408            },
409            estimated_bitrate_change: 0.0,
410            has_active_regions: false,
411        }
412    }
413
414    /// Returns the QP delta for a specific CTU position.
415    pub fn qp_delta_at(&self, col: usize, row: usize) -> i8 {
416        self.qp_map.get(col, row)
417    }
418
419    /// Returns the quality weight for a specific CTU position.
420    #[allow(clippy::cast_precision_loss)]
421    pub fn quality_weight_at(&self, col: usize, row: usize) -> f64 {
422        if col < self.qp_map.cols && row < self.qp_map.rows {
423            let idx = row * self.qp_map.cols + col;
424            if idx < self.quality_weights.len() {
425                return self.quality_weights[idx];
426            }
427        }
428        1.0
429    }
430}
431
432/// Summary statistics for ROI encoding analysis.
433#[derive(Debug, Clone)]
434pub struct RoiAnalysisSummary {
435    /// Total number of CTUs in the frame.
436    pub total_ctus: usize,
437    /// Number of CTUs touched by at least one ROI.
438    pub roi_ctus: usize,
439    /// Average QP delta across the frame.
440    pub avg_delta: f64,
441    /// Minimum delta (most boosted).
442    pub min_delta: i8,
443    /// Maximum delta (most reduced quality).
444    pub max_delta: i8,
445}
446
447/// Analyzes a QP delta map and returns summary statistics.
448pub fn analyze_qp_map(map: &QpDeltaMap) -> RoiAnalysisSummary {
449    let total_ctus = map.deltas.len();
450    let roi_ctus = map.active_ctu_count();
451    let avg_delta = map.average_delta();
452    let min_delta = map.deltas.iter().copied().min().unwrap_or(0);
453    let max_delta = map.deltas.iter().copied().max().unwrap_or(0);
454    RoiAnalysisSummary {
455        total_ctus,
456        roi_ctus,
457        avg_delta,
458        min_delta,
459        max_delta,
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn test_roi_region_new() {
469        let r = RoiRegion::new(10, 20, 100, 200);
470        assert_eq!(r.x, 10);
471        assert_eq!(r.y, 20);
472        assert_eq!(r.width, 100);
473        assert_eq!(r.height, 200);
474        assert!((r.priority - 1.0).abs() < f64::EPSILON);
475    }
476
477    #[test]
478    fn test_roi_region_with_priority() {
479        let r = RoiRegion::with_priority(0, 0, 50, 50, 2.5);
480        assert!((r.priority - 2.5).abs() < f64::EPSILON);
481    }
482
483    #[test]
484    fn test_roi_region_area() {
485        let r = RoiRegion::new(0, 0, 100, 200);
486        assert_eq!(r.area(), 20_000);
487    }
488
489    #[test]
490    fn test_roi_region_contains() {
491        let r = RoiRegion::new(10, 10, 50, 50);
492        assert!(r.contains(10, 10));
493        assert!(r.contains(30, 30));
494        assert!(!r.contains(60, 60));
495        assert!(!r.contains(9, 10));
496    }
497
498    #[test]
499    fn test_roi_region_overlaps() {
500        let a = RoiRegion::new(0, 0, 100, 100);
501        let b = RoiRegion::new(50, 50, 100, 100);
502        assert!(a.overlaps(&b));
503
504        let c = RoiRegion::new(200, 200, 10, 10);
505        assert!(!a.overlaps(&c));
506    }
507
508    #[test]
509    fn test_roi_region_intersection_area() {
510        let a = RoiRegion::new(0, 0, 100, 100);
511        let b = RoiRegion::new(50, 50, 100, 100);
512        assert_eq!(a.intersection_area(&b), 50 * 50);
513
514        let c = RoiRegion::new(200, 200, 10, 10);
515        assert_eq!(a.intersection_area(&c), 0);
516    }
517
518    #[test]
519    fn test_roi_region_label() {
520        let mut r = RoiRegion::new(0, 0, 10, 10);
521        r.set_label("face");
522        assert_eq!(r.label, "face");
523    }
524
525    #[test]
526    fn test_qp_delta_map_new() {
527        let map = QpDeltaMap::new(3, 2);
528        assert_eq!(map.cols, 3);
529        assert_eq!(map.rows, 2);
530        assert_eq!(map.deltas.len(), 6);
531    }
532
533    #[test]
534    fn test_qp_delta_map_get_set() {
535        let mut map = QpDeltaMap::new(4, 4);
536        map.set(1, 2, -5);
537        assert_eq!(map.get(1, 2), -5);
538        assert_eq!(map.get(0, 0), 0);
539        // Out of bounds returns 0
540        assert_eq!(map.get(10, 10), 0);
541    }
542
543    #[test]
544    fn test_qp_delta_map_average() {
545        let mut map = QpDeltaMap::new(2, 2);
546        map.set(0, 0, -4);
547        map.set(1, 0, -4);
548        map.set(0, 1, 0);
549        map.set(1, 1, 0);
550        assert!((map.average_delta() - (-2.0)).abs() < f64::EPSILON);
551    }
552
553    #[test]
554    fn test_qp_delta_map_active_count() {
555        let mut map = QpDeltaMap::new(3, 3);
556        map.set(0, 0, -2);
557        map.set(2, 2, 3);
558        assert_eq!(map.active_ctu_count(), 2);
559    }
560
561    #[test]
562    fn test_qp_delta_map_merge_additive() {
563        let mut map1 = QpDeltaMap::new(2, 2);
564        map1.set(0, 0, -3);
565        map1.set(1, 1, 2);
566
567        let mut map2 = QpDeltaMap::new(2, 2);
568        map2.set(0, 0, -4);
569        map2.set(1, 1, 3);
570
571        map1.merge_additive(&map2, 6);
572        assert_eq!(map1.get(0, 0), -6); // clamped to -6
573        assert_eq!(map1.get(1, 1), 5);
574    }
575
576    #[test]
577    fn test_roi_encoder_empty_regions() {
578        let config = RoiEncoderConfig {
579            frame_width: 128,
580            frame_height: 128,
581            ctu_size: 64,
582            ..Default::default()
583        };
584        let enc = RoiEncoder::new(config);
585        let map = enc.generate_qp_map();
586        assert_eq!(map.cols, 2);
587        assert_eq!(map.rows, 2);
588        assert_eq!(map.active_ctu_count(), 0);
589    }
590
591    #[test]
592    fn test_roi_encoder_with_region() {
593        let config = RoiEncoderConfig {
594            frame_width: 256,
595            frame_height: 256,
596            ctu_size: 64,
597            adjust_mode: QpAdjustMode::AbsoluteOffset,
598            max_qp_reduction: 6,
599            ..Default::default()
600        };
601        let mut enc = RoiEncoder::new(config);
602        enc.add_region(RoiRegion::with_priority(0, 0, 64, 64, 1.0));
603        let map = enc.generate_qp_map();
604        // The CTU at (0,0) should have a negative delta
605        assert!(map.get(0, 0) < 0);
606    }
607
608    #[test]
609    fn test_analyze_qp_map() {
610        let mut map = QpDeltaMap::new(4, 4);
611        map.set(0, 0, -3);
612        map.set(3, 3, 2);
613        let summary = analyze_qp_map(&map);
614        assert_eq!(summary.total_ctus, 16);
615        assert_eq!(summary.roi_ctus, 2);
616        assert_eq!(summary.min_delta, -3);
617        assert_eq!(summary.max_delta, 2);
618    }
619
620    #[test]
621    fn test_roi_encoder_bitrate_impact_no_regions() {
622        let config = RoiEncoderConfig {
623            frame_width: 128,
624            frame_height: 128,
625            ctu_size: 64,
626            ..Default::default()
627        };
628        let enc = RoiEncoder::new(config);
629        let impact = enc.estimate_bitrate_impact();
630        assert!(impact.abs() < f64::EPSILON);
631    }
632
633    #[test]
634    fn test_roi_encoder_region_count() {
635        let config = RoiEncoderConfig::default();
636        let mut enc = RoiEncoder::new(config);
637        assert_eq!(enc.region_count(), 0);
638        enc.add_region(RoiRegion::new(0, 0, 100, 100));
639        enc.add_region(RoiRegion::new(200, 200, 50, 50));
640        assert_eq!(enc.region_count(), 2);
641        enc.clear_regions();
642        assert_eq!(enc.region_count(), 0);
643    }
644
645    // --- New tests for ROI pipeline integration ---
646
647    #[test]
648    fn test_roi_optimize_frame_no_regions() {
649        let config = RoiEncoderConfig {
650            frame_width: 128,
651            frame_height: 128,
652            ctu_size: 64,
653            ..Default::default()
654        };
655        let enc = RoiEncoder::new(config);
656        let result = enc.optimize_frame();
657        assert!(!result.has_active_regions);
658        assert_eq!(result.analysis.roi_ctus, 0);
659        assert!((result.estimated_bitrate_change - 0.0).abs() < f64::EPSILON);
660    }
661
662    #[test]
663    fn test_roi_optimize_frame_with_regions() {
664        let config = RoiEncoderConfig {
665            frame_width: 256,
666            frame_height: 256,
667            ctu_size: 64,
668            adjust_mode: QpAdjustMode::AbsoluteOffset,
669            max_qp_reduction: 8,
670            ..Default::default()
671        };
672        let mut enc = RoiEncoder::new(config);
673        enc.add_region(RoiRegion::with_priority(0, 0, 128, 128, 1.5));
674        let result = enc.optimize_frame();
675        assert!(result.has_active_regions);
676        assert!(result.analysis.roi_ctus > 0);
677        // Boosted region should have quality weight > 1.0
678        let w = result.quality_weight_at(0, 0);
679        assert!(w > 1.0, "Quality weight in ROI should be > 1.0: {}", w);
680    }
681
682    #[test]
683    fn test_roi_optimize_result_empty() {
684        let result = RoiOptimizeResult::empty(4, 4);
685        assert!(!result.has_active_regions);
686        assert_eq!(result.quality_weights.len(), 16);
687        assert!((result.quality_weight_at(0, 0) - 1.0).abs() < f64::EPSILON);
688        assert_eq!(result.qp_delta_at(0, 0), 0);
689    }
690
691    #[test]
692    fn test_roi_optimize_result_accessors() {
693        let config = RoiEncoderConfig {
694            frame_width: 128,
695            frame_height: 128,
696            ctu_size: 64,
697            adjust_mode: QpAdjustMode::AbsoluteOffset,
698            max_qp_reduction: 6,
699            ..Default::default()
700        };
701        let mut enc = RoiEncoder::new(config);
702        enc.add_region(RoiRegion::with_priority(0, 0, 64, 64, 1.0));
703        let result = enc.optimize_frame();
704        // CTU (0,0) should have negative delta
705        assert!(result.qp_delta_at(0, 0) < 0);
706        // Out of bounds should return defaults
707        assert_eq!(result.qp_delta_at(100, 100), 0);
708        assert!((result.quality_weight_at(100, 100) - 1.0).abs() < f64::EPSILON);
709    }
710
711    #[test]
712    fn test_roi_high_priority_gets_more_bits() {
713        let config = RoiEncoderConfig {
714            frame_width: 256,
715            frame_height: 256,
716            ctu_size: 64,
717            adjust_mode: QpAdjustMode::AbsoluteOffset,
718            max_qp_reduction: 10,
719            ..Default::default()
720        };
721        let mut enc = RoiEncoder::new(config);
722        // High priority face region
723        enc.add_region(RoiRegion::with_priority(0, 0, 64, 64, 2.0));
724        let result = enc.optimize_frame();
725        let delta_roi = result.qp_delta_at(0, 0);
726        let delta_bg = result.qp_delta_at(3, 3);
727        // ROI should have more negative delta (lower QP = more bits)
728        assert!(
729            delta_roi < delta_bg,
730            "ROI delta ({}) should be less than background ({})",
731            delta_roi,
732            delta_bg
733        );
734    }
735
736    #[test]
737    fn test_roi_encoder_regions_accessor() {
738        let config = RoiEncoderConfig::default();
739        let mut enc = RoiEncoder::new(config);
740        enc.add_region(RoiRegion::new(0, 0, 100, 100));
741        enc.add_region(RoiRegion::with_priority(200, 200, 50, 50, 2.0));
742        assert_eq!(enc.regions().len(), 2);
743        assert_eq!(enc.regions()[0].width, 100);
744        assert!((enc.regions()[1].priority - 2.0).abs() < f64::EPSILON);
745    }
746
747    #[test]
748    fn test_merge_additive_different_sizes_noop() {
749        let mut map1 = QpDeltaMap::new(2, 2);
750        map1.set(0, 0, -3);
751        let map2 = QpDeltaMap::new(3, 3);
752        map1.merge_additive(&map2, 10);
753        // Should not change since sizes differ
754        assert_eq!(map1.get(0, 0), -3);
755    }
756}