diff --git a/dolby_vision/src/rpu/extension_metadata/blocks/level5.rs b/dolby_vision/src/rpu/extension_metadata/blocks/level5.rs index 7d4b850..a2f2f89 100644 --- a/dolby_vision/src/rpu/extension_metadata/blocks/level5.rs +++ b/dolby_vision/src/rpu/extension_metadata/blocks/level5.rs @@ -12,7 +12,7 @@ const MAX_RESOLUTION_13_BITS: u16 = 8191; /// Active area of the picture (letterbox, aspect ratio) #[repr(C)] -#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(Deserialize, Serialize))] pub struct ExtMetadataBlockLevel5 { pub active_area_left_offset: u16, diff --git a/dolby_vision/src/rpu/extension_metadata/blocks/level8.rs b/dolby_vision/src/rpu/extension_metadata/blocks/level8.rs index 3182ffd..0f977d5 100644 --- a/dolby_vision/src/rpu/extension_metadata/blocks/level8.rs +++ b/dolby_vision/src/rpu/extension_metadata/blocks/level8.rs @@ -148,6 +148,18 @@ impl ExtMetadataBlockLevel8 { Ok(()) } + + pub fn trim_target_nits(&self) -> u16 { + match self.target_display_index { + 16 | 18 | 21 => 48, + 42 => 108, + 24 | 25 => 300, + 27 | 28 => 600, + 48 | 49 => 1000, + 37 | 38 => 2000, + _ => 100, + } + } } impl ExtMetadataBlockInfo for ExtMetadataBlockLevel8 { diff --git a/dolby_vision/src/rpu/extension_metadata/primaries.rs b/dolby_vision/src/rpu/extension_metadata/primaries.rs index bb6d4ab..b75f382 100644 --- a/dolby_vision/src/rpu/extension_metadata/primaries.rs +++ b/dolby_vision/src/rpu/extension_metadata/primaries.rs @@ -50,6 +50,40 @@ pub enum MasteringDisplayPrimaries { SGamut3Cine, } +impl From for MasteringDisplayPrimaries { + fn from(value: u8) -> Self { + match value { + 0 => Self::DCIP3D65, + 1 => Self::BT709, + 2 => Self::BT2020, + 3 => Self::SMPTEC, + 4 => Self::BT601, + 5 => Self::DCIP3, + 6 => Self::ACES, + 7 => Self::SGamut, + 8 => Self::SGamut3Cine, + _ => Self::DCIP3D65, + } + } +} + +impl std::fmt::Display for MasteringDisplayPrimaries { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let alias = match self { + Self::DCIP3D65 => "DCI-P3 D65", + Self::BT709 => "BT.709", + Self::BT2020 => "BT.2020", + Self::SMPTEC => "SMPTE-C", + Self::BT601 => "BT.601", + Self::DCIP3 => "DCI-P3", + Self::ACES => "ACES", + Self::SGamut => "S-Gamut", + Self::SGamut3Cine => "S-Gamut-3.Cine", + }; + write!(f, "{}", alias) + } +} + impl ColorPrimaries { pub fn from_array_int(primaries: &[u16; 8]) -> ColorPrimaries { Self { diff --git a/dolby_vision/src/utils.rs b/dolby_vision/src/utils.rs index e86bb27..8ecf39e 100644 --- a/dolby_vision/src/utils.rs +++ b/dolby_vision/src/utils.rs @@ -33,6 +33,12 @@ pub fn nits_to_pq(nits: f64) -> f64 { .powf(ST2084_M2) } +/// Helper function to calculate PQ codes from nits (cd/m2) values +#[inline(always)] +pub fn nits_to_pq_12_bit(nits: f64) -> u16 { + (nits_to_pq(nits) * 4095.0).round() as u16 +} + /// Copied from hevc_parser for convenience, and to avoid a dependency /// Unescapes a byte slice from annexb. /// Allocates a new Vec. diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c058603..26484a1 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -57,7 +57,7 @@ pub enum Commands { #[command(about = "Interleaves the enhancement layer into a base layer HEVC bitstream")] Mux(MuxArgs), - #[command(about = "Plot the L1 dynamic brightness metadata")] + #[command(about = "Plot the L1/L2/L8 metadata")] Plot(PlotArgs), #[command(about = "Removes the enhancement layer and RPU data from the video")] diff --git a/src/commands/plot.rs b/src/commands/plot.rs index 48b6793..3649385 100644 --- a/src/commands/plot.rs +++ b/src/commands/plot.rs @@ -1,3 +1,4 @@ +use crate::dovi::plotter::{PlotType, TrimsFilter}; use clap::{Args, ValueHint}; use std::path::PathBuf; @@ -39,4 +40,29 @@ pub struct PlotArgs { #[arg(long, short = 'e', help = "Set frame range end (inclusive)")] pub end: Option, + + #[arg( + long, + short = 'p', + help = "Sets the DV metadata level to plot", + value_enum, + default_value = "l1" + )] + pub plot_type: PlotType, + + #[arg( + long = "target-nits", + help = "Target brightness in nits for L2/L8 plots", + default_value = "100", + value_parser = ["100", "300", "600", "1000"] + )] + pub target_nits_str: String, + + #[arg( + long, + help = "Parameters to exclude from L2/L8 trims plots\nSupports multiple occurrences prefixed by --trims-filter or delimited by ','", + value_enum, + value_delimiter = ',' + )] + pub trims_filter: Vec, } diff --git a/src/dovi/plotter.rs b/src/dovi/plotter.rs index 004fe15..d47c7b9 100644 --- a/src/dovi/plotter.rs +++ b/src/dovi/plotter.rs @@ -2,25 +2,24 @@ use std::fmt::Write; use std::ops::Range; use std::path::PathBuf; -#[cfg(not(feature = "system-font"))] -use anyhow::bail; - -use anyhow::Result; +use super::input_from_either; +use super::rpu_info::RpusListSummary; +use crate::commands::PlotArgs; +use anyhow::{Result, bail}; +use dolby_vision::rpu::dovi_rpu::DoviRpu; +use dolby_vision::rpu::extension_metadata::blocks::{ + ExtMetadataBlockLevel2, ExtMetadataBlockLevel8, +}; +use dolby_vision::rpu::utils::parse_rpu_file; +use dolby_vision::utils::{nits_to_pq, pq_to_nits}; use plotters::coord::ranged1d::{KeyPointHint, NoDefaultFormatting, Ranged, ValueFormatter}; use plotters::coord::types::RangedCoordusize; use plotters::prelude::{ AreaSeries, BitMapBackend, Cartesian2d, ChartBuilder, ChartContext, IntoDrawingArea, - PathElement, SeriesLabelPosition, WHITE, + LineSeries, PathElement, SeriesLabelPosition, WHITE, }; use plotters::style::{BLACK, Color, IntoTextStyle, RGBColor, ShapeStyle}; -use dolby_vision::rpu::utils::parse_rpu_file; -use dolby_vision::utils::{nits_to_pq, pq_to_nits}; - -use super::input_from_either; -use super::rpu_info::RpusListSummary; -use crate::commands::PlotArgs; - #[cfg(not(feature = "system-font"))] const NOTO_SANS_REGULAR: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -29,6 +28,109 @@ const NOTO_SANS_REGULAR: &[u8] = include_bytes!(concat!( const MAX_COLOR: RGBColor = RGBColor(65, 105, 225); const AVERAGE_COLOR: RGBColor = RGBColor(75, 0, 130); +const COLORS: [RGBColor; 8] = [ + RGBColor(220, 38, 38), // red + RGBColor(234, 179, 8), // yellow + RGBColor(34, 197, 94), // green + RGBColor(34, 211, 238), // cyan + RGBColor(59, 130, 246), // blue + RGBColor(236, 72, 153), // magenta + RGBColor(249, 115, 22), // orange + RGBColor(139, 92, 246), // purple +]; + +type Series = (&'static str, bool, fn(&T) -> f64, (f64, f64, f64)); + +#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrimsFilter { + Slope, + Offset, + Power, + Chroma, + Saturation, + MS, + Mid, + Clip, +} + +#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlotType { + /// L1 Dynamic Brightness + L1, + /// L2 Trims + L2, + /// L8 Trims (CM v4.0 RPU required) + L8T, + /// L8 Saturation Vectors (CM v4.0 RPU required) + L8S, + /// L8 Hue Vectors (CM v4.0 RPU required) + L8H, +} + +impl PlotType { + pub fn name(&self) -> &str { + match self { + PlotType::L1 => "L1 Dynamic Brightness", + PlotType::L2 => "L2 Trims", + PlotType::L8T => "L8 Trims", + PlotType::L8S => "L8 Saturation Vectors", + PlotType::L8H => "L8 Hue Vectors", + } + } + + pub fn default_title(&self, target_nits: u16) -> String { + match self { + PlotType::L1 => format!("Dolby Vision {}", self.name()), + _ => format!("Dolby Vision {} ({} nits)", self.name(), target_nits), + } + } + + pub fn default_output(&self, target_nits: u16) -> String { + match self { + PlotType::L1 => "L1_plot.png".to_string(), + PlotType::L2 => format!("L2_plot-{}.png", target_nits), + PlotType::L8T => format!("L8-trims_plot-{}.png", target_nits), + PlotType::L8S => format!("L8-saturation_plot-{}.png", target_nits), + PlotType::L8H => format!("L8-hue_plot-{}.png", target_nits), + } + } + + pub fn y_desc(&self) -> &str { + match self { + PlotType::L1 => "nits (cd/m²)", + _ => "", + } + } + + pub fn requires_dmv2(&self) -> bool { + !matches!(self, PlotType::L1 | PlotType::L2) + } + + pub fn summary(&self, rpus: &[DoviRpu], target_nits: u16) -> Result { + match self { + PlotType::L1 => RpusListSummary::new(rpus), + PlotType::L2 => RpusListSummary::with_l2_data(rpus, target_nits), + PlotType::L8T => RpusListSummary::with_l8_trims_data(rpus, target_nits), + PlotType::L8S => RpusListSummary::with_l8_saturation_data(rpus, target_nits), + PlotType::L8H => RpusListSummary::with_l8_hue_data(rpus, target_nits), + } + } + + pub fn draw_series( + &self, + chart: &mut ChartContext>, + summary: &RpusListSummary, + trims_filter: &[TrimsFilter], + ) -> Result<()> { + match self { + PlotType::L1 => Plotter::draw_l1_series(chart, summary), + PlotType::L2 => Plotter::draw_l2_series(chart, summary, trims_filter), + PlotType::L8T => Plotter::draw_l8_trims_series(chart, summary, trims_filter), + PlotType::L8S => Plotter::draw_l8_saturation_series(chart, summary), + PlotType::L8H => Plotter::draw_l8_hue_series(chart, summary), + } + } +} pub struct Plotter { input: PathBuf, @@ -56,10 +158,15 @@ impl Plotter { title, start: start_arg, end: end_arg, + plot_type, + target_nits_str, + trims_filter, } = args; - let output = output.unwrap_or(PathBuf::from("L1_plot.png")); - let title = title.unwrap_or(String::from("Dolby Vision L1 plot")); + let target_nits = target_nits_str.parse::()?; + + let output = output.unwrap_or(PathBuf::from(plot_type.default_output(target_nits))); + let title = title.unwrap_or(plot_type.default_title(target_nits).to_string()); let input = input_from_either("info", input, input_pos)?; let plotter = Plotter { input }; @@ -72,6 +179,16 @@ impl Plotter { let end = end_arg.unwrap_or(orig_rpus.len() - 1); let rpus = &orig_rpus[start..=end]; + println!("Plotting..."); + let summary = plot_type.summary(rpus, target_nits)?; + + if plot_type.requires_dmv2() && !summary.dmv2 { + bail!( + "Cannot generate {}: CM v4.0 RPU is required", + plot_type.name() + ); + } + let x_spec = 0..rpus.len(); let root = BitMapBackend::new(&output, (3000, 1200)).into_drawing_area(); @@ -80,14 +197,11 @@ impl Plotter { .margin(30, 30, 60, 60) .titled(&title, ("sans-serif", 40))?; - println!("Plotting..."); - let summary = RpusListSummary::new(rpus)?; - let mut chart = ChartBuilder::on(&root) .x_label_area_size(60) .y_label_area_size(60) .margin_top(90) - .build_cartesian_2d(x_spec, PqCoord {})?; + .build_cartesian_2d(x_spec, PqCoord::from(plot_type))?; chart .configure_mesh() @@ -98,10 +212,11 @@ impl Plotter { .x_desc("frames") .x_max_light_lines(1) .x_labels(24) - .y_desc("nits (cd/m²)") + .y_desc(plot_type.y_desc()) .draw()?; - Self::draw_l1_series(&mut chart, &summary)?; + plot_type.draw_series(&mut chart, &summary, &trims_filter)?; + chart .configure_series_labels() .border_style(BLACK) @@ -136,23 +251,40 @@ impl Plotter { )?; } + let caption_md = if let Some(l9_mdp) = &summary.l9_mdp { + format!("{} - {}", summary.rpu_mastering_meta_str, l9_mdp.join(", ")) + } else { + summary.rpu_mastering_meta_str + }; + let caption_style = ("sans-serif", 24).into_text_style(&root); root.draw_text(&chart_caption, &caption_style, (60, 10))?; - root.draw_text(&summary.rpu_mastering_meta_str, &caption_style, (60, 35))?; + root.draw_text(&caption_md, &caption_style, (60, 35))?; root.draw_text( &format!("L6 metadata: {l6_meta_str}"), &caption_style, (60, 60), )?; + let mut right_captions = vec![format!("L5 offsets: {}", summary.l5_str)]; if !summary.l2_trims.is_empty() { - let caption = format!("L2 trims: {}", summary.l2_trims.join(", ")); - let pos = ( - (root.dim_in_pixel().0 - root.estimate_text_size(&caption, &caption_style)?.0) - as i32, - 60, - ); - root.draw_text(&caption, &caption_style, pos)?; + right_captions.push(format!("L2 trims: {}", summary.l2_trims.join(", "))); + } + if let Some(l8_trims) = summary.l8_trims.filter(|v| !v.is_empty()) { + right_captions.push(format!("L8 trims: {}", l8_trims.join(", "))); + } + + let pos_x = right_captions + .iter() + .filter_map(|c| root.estimate_text_size(c, &caption_style).ok()) + .map(|(size, _)| size) + .max() + .map_or(0, |max_size| (root.dim_in_pixel().0 - max_size) as i32); + let mut pos_y = 60; + + for caption in right_captions.iter().rev() { + root.draw_text(caption, &caption_style, (pos_x, pos_y))?; + pos_y -= 25; } root.present()?; @@ -239,49 +371,315 @@ impl Plotter { Ok(()) } + + fn draw_l2_series( + chart: &mut ChartContext>, + summary: &RpusListSummary, + trims_filter: &[TrimsFilter], + ) -> Result<()> { + let data = summary.l2_data.as_ref().unwrap(); + let stats = summary.l2_stats.as_ref().unwrap(); + + let series: [Series; 6] = [ + ( + "slope (gain)", + !trims_filter.contains(&TrimsFilter::Slope), + |e| e.trim_slope as f64, + stats.slope, + ), + ( + "offset (lift)", + !trims_filter.contains(&TrimsFilter::Offset), + |e| e.trim_offset as f64, + stats.offset, + ), + ( + "power (gamma)", + !trims_filter.contains(&TrimsFilter::Power), + |e| e.trim_power as f64, + stats.power, + ), + ( + "chroma (weight)", + !trims_filter.contains(&TrimsFilter::Chroma), + |e| e.trim_chroma_weight as f64, + stats.chroma, + ), + ( + "saturation (gain)", + !trims_filter.contains(&TrimsFilter::Saturation), + |e| e.trim_saturation_gain as f64, + stats.saturation, + ), + ( + "ms (weight)", + !trims_filter.contains(&TrimsFilter::MS), + |e| e.ms_weight as f64, + stats.ms_weight, + ), + ]; + + Self::draw_line_series(chart, data, &series) + } + + fn draw_l8_trims_series( + chart: &mut ChartContext>, + summary: &RpusListSummary, + trims_filter: &[TrimsFilter], + ) -> Result<()> { + let data = summary.l8_data.as_ref().unwrap(); + let stats = summary.l8_stats_trims.as_ref().unwrap(); + + let series: [Series; 8] = [ + ( + "slope (gain)", + !trims_filter.contains(&TrimsFilter::Slope), + |e| e.trim_slope as f64, + stats.slope, + ), + ( + "offset (lift)", + !trims_filter.contains(&TrimsFilter::Offset), + |e| e.trim_offset as f64, + stats.offset, + ), + ( + "power (gamma)", + !trims_filter.contains(&TrimsFilter::Power), + |e| e.trim_power as f64, + stats.power, + ), + ( + "chroma (weight)", + !trims_filter.contains(&TrimsFilter::Chroma), + |e| e.trim_chroma_weight as f64, + stats.chroma, + ), + ( + "saturation (gain)", + !trims_filter.contains(&TrimsFilter::Saturation), + |e| e.trim_saturation_gain as f64, + stats.saturation, + ), + ( + "ms (weight)", + !trims_filter.contains(&TrimsFilter::MS), + |e| e.ms_weight as f64, + stats.ms_weight, + ), + ( + "mid (contrast)", + !trims_filter.contains(&TrimsFilter::Mid), + |e| e.target_mid_contrast as f64, + stats.target_mid_contrast.unwrap(), + ), + ( + "clip (trim)", + !trims_filter.contains(&TrimsFilter::Clip), + |e| e.clip_trim as f64, + stats.clip_trim.unwrap(), + ), + ]; + + Self::draw_line_series(chart, data, &series) + } + + fn draw_l8_saturation_series( + chart: &mut ChartContext>, + summary: &RpusListSummary, + ) -> Result<()> { + let data = summary.l8_data.as_ref().unwrap(); + let stats = summary.l8_stats_saturation.as_ref().unwrap(); + + let series: [Series; 6] = [ + ( + "red", + true, + |e| e.saturation_vector_field0 as f64, + stats.red, + ), + ( + "yellow", + true, + |e| e.saturation_vector_field1 as f64, + stats.yellow, + ), + ( + "green", + true, + |e| e.saturation_vector_field2 as f64, + stats.green, + ), + ( + "cyan", + true, + |e| e.saturation_vector_field3 as f64, + stats.cyan, + ), + ( + "blue", + true, + |e| e.saturation_vector_field4 as f64, + stats.blue, + ), + ( + "magenta", + true, + |e| e.saturation_vector_field5 as f64, + stats.magenta, + ), + ]; + + Self::draw_line_series(chart, data, &series) + } + + fn draw_l8_hue_series( + chart: &mut ChartContext>, + summary: &RpusListSummary, + ) -> Result<()> { + let data = summary.l8_data.as_ref().unwrap(); + let stats = summary.l8_stats_hue.as_ref().unwrap(); + + let series: [Series; 6] = [ + ("red", true, |e| e.hue_vector_field0 as f64, stats.red), + ("yellow", true, |e| e.hue_vector_field1 as f64, stats.yellow), + ("green", true, |e| e.hue_vector_field2 as f64, stats.green), + ("cyan", true, |e| e.hue_vector_field3 as f64, stats.cyan), + ("blue", true, |e| e.hue_vector_field4 as f64, stats.blue), + ( + "magenta", + true, + |e| e.hue_vector_field5 as f64, + stats.magenta, + ), + ]; + + Self::draw_line_series(chart, data, &series) + } + + fn draw_line_series( + chart: &mut ChartContext>, + data: &[T], + series: &[Series], + ) -> Result<()> { + for ((name, include, field_extractor, stats), color) in series.iter().zip(COLORS.iter()) { + if !include { + continue; + } + + let label = format!( + "{name} (min: {:.0}, max: {:.0}, avg: {:.0})", + stats.0, stats.1, stats.2 + ); + let series = LineSeries::new( + (0..).zip(data.iter()).map(|(x, y)| (x, field_extractor(y))), + color, + ); + + chart + .draw_series(series)? + .label(label) + .legend(move |(x, y)| { + PathElement::new( + vec![(x, y), (x + 20, y)], + ShapeStyle { + color: color.to_rgba(), + filled: false, + stroke_width: 2, + }, + ) + }); + } + + Ok(()) + } } -pub struct PqCoord {} +pub struct PqCoord { + key_points: Vec, + range: Range, + mapper: fn(&f64, (i32, i32)) -> i32, + formatter: fn(&f64) -> String, +} + +impl From for PqCoord { + fn from(plot_type: PlotType) -> Self { + match plot_type { + PlotType::L1 => PqCoord { + key_points: vec![ + nits_to_pq(0.01), + nits_to_pq(0.1), + nits_to_pq(0.5), + nits_to_pq(1.0), + nits_to_pq(2.5), + nits_to_pq(5.0), + nits_to_pq(10.0), + nits_to_pq(25.0), + nits_to_pq(50.0), + nits_to_pq(100.0), + nits_to_pq(200.0), + nits_to_pq(400.0), + nits_to_pq(600.0), + nits_to_pq(1000.0), + nits_to_pq(2000.0), + nits_to_pq(4000.0), + nits_to_pq(10000.0), + ], + range: 0_f64..1.0_f64, + mapper: |value, limit| { + let size = limit.1 - limit.0; + (*value * size as f64) as i32 + limit.0 + }, + formatter: |value| { + let nits = (pq_to_nits(*value) * 1000.0).round() / 1000.0; + format!("{nits}") + }, + }, + PlotType::L2 | PlotType::L8T => PqCoord { + key_points: vec![ + 0.0, 512.0, 1024.0, 1536.0, 2048.0, 2560.0, 3072.0, 3584.0, 4096.0, + ], + range: 0_f64..4096.0_f64, + mapper: |value, limit| { + let norm = value / 4096.0; + let size = limit.1 - limit.0; + (norm * size as f64).round() as i32 + limit.0 + }, + formatter: |value| format!("{value}"), + }, + PlotType::L8S | PlotType::L8H => PqCoord { + key_points: vec![0.0, 32.0, 64.0, 96.0, 128.0, 160.0, 192.0, 224.0, 256.0], + range: 0_f64..256.0_f64, + mapper: |value, limit| { + let norm = value / 256.0; + let size = limit.1 - limit.0; + (norm * size as f64).round() as i32 + limit.0 + }, + formatter: |value| format!("{value}"), + }, + } + } +} impl Ranged for PqCoord { type FormatOption = NoDefaultFormatting; type ValueType = f64; fn map(&self, value: &f64, limit: (i32, i32)) -> i32 { - let size = limit.1 - limit.0; - (*value * size as f64) as i32 + limit.0 + (self.mapper)(value, limit) } fn key_points(&self, _hint: Hint) -> Vec { - vec![ - nits_to_pq(0.01), - nits_to_pq(0.1), - nits_to_pq(0.5), - nits_to_pq(1.0), - nits_to_pq(2.5), - nits_to_pq(5.0), - nits_to_pq(10.0), - nits_to_pq(25.0), - nits_to_pq(50.0), - nits_to_pq(100.0), - nits_to_pq(200.0), - nits_to_pq(400.0), - nits_to_pq(600.0), - nits_to_pq(1000.0), - nits_to_pq(2000.0), - nits_to_pq(4000.0), - nits_to_pq(10000.0), - ] + self.key_points.clone() } fn range(&self) -> Range { - 0_f64..1.0_f64 + self.range.clone() } } impl ValueFormatter for PqCoord { fn format_ext(&self, value: &f64) -> String { - let nits = (pq_to_nits(*value) * 1000.0).round() / 1000.0; - format!("{nits}") + (self.formatter)(value) } } diff --git a/src/dovi/rpu_info.rs b/src/dovi/rpu_info.rs index ed47503..1704036 100644 --- a/src/dovi/rpu_info.rs +++ b/src/dovi/rpu_info.rs @@ -1,16 +1,18 @@ +use std::collections::HashMap; use std::fmt::Write; use std::path::PathBuf; use anyhow::{Result, bail, ensure}; -use dolby_vision::rpu::vdr_dm_data::CmVersion; -use itertools::Itertools; - use dolby_vision::rpu::dovi_rpu::DoviRpu; +use dolby_vision::rpu::extension_metadata::MasteringDisplayPrimaries; use dolby_vision::rpu::extension_metadata::blocks::{ - ExtMetadataBlock, ExtMetadataBlockLevel1, ExtMetadataBlockLevel6, + ExtMetadataBlock, ExtMetadataBlockLevel1, ExtMetadataBlockLevel2, ExtMetadataBlockLevel5, + ExtMetadataBlockLevel6, ExtMetadataBlockLevel8, }; use dolby_vision::rpu::utils::parse_rpu_file; -use dolby_vision::utils::pq_to_nits; +use dolby_vision::rpu::vdr_dm_data::CmVersion; +use dolby_vision::utils::{nits_to_pq_12_bit, pq_to_nits}; +use itertools::Itertools; use super::input_from_either; use crate::commands::InfoArgs; @@ -26,11 +28,21 @@ pub struct RpusListSummary { pub profiles_str: String, pub dm_version_str: &'static str, pub dm_version_counts: Option<(usize, usize)>, + pub dmv2: bool, pub l6_meta: Option>, + pub l5_str: String, + pub l8_trims: Option>, + pub l9_mdp: Option>, pub l1_data: Vec<(f64, f64, f64)>, pub l1_stats: SummaryL1Stats, pub l2_trims: Vec, + pub l2_data: Option>, + pub l2_stats: Option, + pub l8_data: Option>, + pub l8_stats_trims: Option, + pub l8_stats_saturation: Option, + pub l8_stats_hue: Option, } pub struct SummaryL1Stats { @@ -43,6 +55,26 @@ pub struct SummaryL1Stats { pub max_min_nits: f64, } +pub struct SummaryTrimsStats { + pub slope: (f64, f64, f64), + pub offset: (f64, f64, f64), + pub power: (f64, f64, f64), + pub chroma: (f64, f64, f64), + pub saturation: (f64, f64, f64), + pub ms_weight: (f64, f64, f64), + pub target_mid_contrast: Option<(f64, f64, f64)>, + pub clip_trim: Option<(f64, f64, f64)>, +} + +pub struct SummaryL8VectorStats { + pub red: (f64, f64, f64), + pub yellow: (f64, f64, f64), + pub green: (f64, f64, f64), + pub cyan: (f64, f64, f64), + pub blue: (f64, f64, f64), + pub magenta: (f64, f64, f64), +} + impl RpuInfo { pub fn info(args: InfoArgs) -> Result<()> { let InfoArgs { @@ -89,6 +121,9 @@ impl RpuInfo { dm_version_str, dm_version_counts, l6_meta, + l5_str, + l8_trims, + l9_mdp, l1_stats, l2_trims, .. @@ -125,10 +160,20 @@ impl RpuInfo { write!(summary_str, "\n {final_str}")?; } + write!(summary_str, "\n L5 offsets: {}", l5_str)?; + if !l2_trims.is_empty() { write!(summary_str, "\n L2 trims: {}", l2_trims.join(", "))?; } + if let Some(l8_trims) = l8_trims.filter(|v| !v.is_empty()) { + write!(summary_str, "\n L8 trims: {}", l8_trims.join(", "))?; + } + + if let Some(l9_mdp) = l9_mdp.filter(|v| !v.is_empty()) { + write!(summary_str, "\n L9 MDP: {}", l9_mdp.join(", "))?; + } + println!("\n{summary_str}"); } @@ -164,7 +209,8 @@ impl RpusListSummary { }) .count(); - let (dm_version_counts, dm_version_str) = if dmv2_count == dmv1_count { + let dmv2 = dmv2_count == dmv1_count; + let (dm_version_counts, dm_version_str) = if dmv2 { (None, "2 (CM v4.0)") } else if dmv2_count == 0 { (None, "1 (CM v2.9)") @@ -333,6 +379,100 @@ impl RpusListSummary { .map(|target_nits| format!("{target_nits} nits")) .collect(); + let l5_blocks: Vec<_> = rpus + .iter() + .filter_map(|rpu| { + rpu.vdr_dm_data.as_ref()?.get_block(5).and_then(|block| { + if let ExtMetadataBlock::Level5(l5) = block { + Some(l5) + } else { + None + } + }) + }) + .unique() + .collect(); + + type L5Mapping = (&'static str, fn(&ExtMetadataBlockLevel5) -> u16); + let l5_mappings: [L5Mapping; 4] = [ + ("top", |l5| l5.active_area_top_offset), + ("bottom", |l5| l5.active_area_bottom_offset), + ("left", |l5| l5.active_area_left_offset), + ("right", |l5| l5.active_area_right_offset), + ]; + let l5_str = l5_mappings + .iter() + .map(|(area, offset_extractor)| { + l5_blocks + .iter() + .map(|l5| offset_extractor(l5)) + .minmax() + .into_option() + .map_or(format!("{area}=N/A"), |(min, max)| { + if min == max { + format!("{area}={min}") + } else { + format!("{area}={min}..{max}") + } + }) + }) + .join(", "); + + let l8_trims = if dmv2_count > 0 { + let l8_trims_str: Vec = rpus + .iter() + .filter_map(|rpu| { + rpu.vdr_dm_data.as_ref()?.get_block(8).and_then(|block| { + if let ExtMetadataBlock::Level8(l8) = block { + Some(l8.trim_target_nits()) + } else { + None + } + }) + }) + .unique() + .sorted() + .map(|target_nits| format!("{target_nits} nits")) + .collect(); + + Some(l8_trims_str) + } else { + None + }; + + let l9_mdp = if dmv2_count > 0 { + let l9_mdp_str: Vec = rpus + .iter() + .filter_map(|rpu| { + rpu.vdr_dm_data.as_ref()?.get_block(9).and_then(|block| { + if let ExtMetadataBlock::Level9(l9) = block { + Some(l9.source_primary_index) + } else { + None + } + }) + }) + .fold(HashMap::new(), |mut frames, idx| { + *frames.entry(idx).or_insert(0) += 1; + frames + }) + .into_iter() + .sorted_by_key(|e| e.0) + .map(|(idx, frames)| { + let alias = MasteringDisplayPrimaries::from(idx).to_string(); + if frames < dmv2_count { + format!("{alias} ({frames})") + } else { + alias + } + }) + .collect(); + + Some(l9_mdp_str) + } else { + None + }; + Ok(Self { count: rpus.len(), scene_count, @@ -340,10 +480,158 @@ impl RpusListSummary { profiles_str, dm_version_str, dm_version_counts, + dmv2, l6_meta, + l5_str, + l8_trims, + l9_mdp, l1_data, l1_stats, l2_trims, + l2_data: None, + l2_stats: None, + l8_data: None, + l8_stats_trims: None, + l8_stats_saturation: None, + l8_stats_hue: None, }) } + + pub fn with_l2_data(rpus: &[DoviRpu], target_nits: u16) -> Result { + let mut summary = Self::new(rpus)?; + + let target_max_pq = nits_to_pq_12_bit(target_nits.into()); + let l2_data: Vec<_> = rpus + .iter() + .map(|rpu| { + rpu.vdr_dm_data + .as_ref() + .and_then(|dm| { + dm.level_blocks_iter(2).find_map(|block| match block { + ExtMetadataBlock::Level2(l2) if l2.target_max_pq == target_max_pq => { + Some(l2.clone()) + } + _ => None, + }) + }) + .unwrap_or_default() + }) + .collect(); + + summary.l2_stats = Some(SummaryTrimsStats { + slope: Self::min_max_avg(&l2_data, |e| e.trim_slope as f64), + offset: Self::min_max_avg(&l2_data, |e| e.trim_offset as f64), + power: Self::min_max_avg(&l2_data, |e| e.trim_power as f64), + chroma: Self::min_max_avg(&l2_data, |e| e.trim_chroma_weight as f64), + saturation: Self::min_max_avg(&l2_data, |e| e.trim_saturation_gain as f64), + ms_weight: Self::min_max_avg(&l2_data, |e| e.ms_weight as f64), + target_mid_contrast: None, + clip_trim: None, + }); + summary.l2_data = Some(l2_data); + + Ok(summary) + } + + fn with_l8_data(rpus: &[DoviRpu], target_nits: u16) -> Result { + let mut summary = Self::new(rpus)?; + + let l8_data: Vec<_> = rpus + .iter() + .map(|rpu| { + rpu.vdr_dm_data + .as_ref() + .and_then(|dm| { + dm.level_blocks_iter(8).find_map(|block| match block { + ExtMetadataBlock::Level8(l8) + if l8.trim_target_nits() == target_nits => + { + Some(l8.clone()) + } + _ => None, + }) + }) + .unwrap_or_default() + }) + .collect(); + + summary.l8_data = Some(l8_data); + Ok(summary) + } + + pub fn with_l8_trims_data(rpus: &[DoviRpu], target_nits: u16) -> Result { + let mut summary = Self::with_l8_data(rpus, target_nits)?; + + if let Some(l8_data) = summary.l8_data.as_ref() { + summary.l8_stats_trims = Some(SummaryTrimsStats { + slope: Self::min_max_avg(l8_data, |e| e.trim_slope as f64), + offset: Self::min_max_avg(l8_data, |e| e.trim_offset as f64), + power: Self::min_max_avg(l8_data, |e| e.trim_power as f64), + chroma: Self::min_max_avg(l8_data, |e| e.trim_chroma_weight as f64), + saturation: Self::min_max_avg(l8_data, |e| e.trim_saturation_gain as f64), + ms_weight: Self::min_max_avg(l8_data, |e| e.ms_weight as f64), + target_mid_contrast: Some(Self::min_max_avg(l8_data, |e| { + e.target_mid_contrast as f64 + })), + clip_trim: Some(Self::min_max_avg(l8_data, |e| e.clip_trim as f64)), + }); + } + + Ok(summary) + } + + pub fn with_l8_saturation_data(rpus: &[DoviRpu], target_nits: u16) -> Result { + let mut summary = Self::with_l8_data(rpus, target_nits)?; + + if let Some(l8_data) = summary.l8_data.as_ref() { + summary.l8_stats_saturation = Some(SummaryL8VectorStats { + red: Self::min_max_avg(l8_data, |e| e.saturation_vector_field0 as f64), + yellow: Self::min_max_avg(l8_data, |e| e.saturation_vector_field1 as f64), + green: Self::min_max_avg(l8_data, |e| e.saturation_vector_field2 as f64), + cyan: Self::min_max_avg(l8_data, |e| e.saturation_vector_field3 as f64), + blue: Self::min_max_avg(l8_data, |e| e.saturation_vector_field4 as f64), + magenta: Self::min_max_avg(l8_data, |e| e.saturation_vector_field5 as f64), + }); + } + + Ok(summary) + } + + pub fn with_l8_hue_data(rpus: &[DoviRpu], target_nits: u16) -> Result { + let mut summary = Self::with_l8_data(rpus, target_nits)?; + + if let Some(l8_data) = summary.l8_data.as_ref() { + summary.l8_stats_hue = Some(SummaryL8VectorStats { + red: Self::min_max_avg(l8_data, |e| e.hue_vector_field0 as f64), + yellow: Self::min_max_avg(l8_data, |e| e.hue_vector_field1 as f64), + green: Self::min_max_avg(l8_data, |e| e.hue_vector_field2 as f64), + cyan: Self::min_max_avg(l8_data, |e| e.hue_vector_field3 as f64), + blue: Self::min_max_avg(l8_data, |e| e.hue_vector_field4 as f64), + magenta: Self::min_max_avg(l8_data, |e| e.hue_vector_field5 as f64), + }); + } + + Ok(summary) + } + + fn min_max_avg(data: &[T], field_extractor: F) -> (f64, f64, f64) + where + F: Fn(&T) -> f64, + { + let mut iter = data.iter().map(field_extractor); + let first = iter.next().unwrap(); + let (mut min, mut max, mut sum) = (first, first, first); + + for v in iter { + if v < min { + min = v; + } + if v > max { + max = v; + } + sum += v; + } + + (min, max, sum / data.len() as f64) + } }