From bb57d422d0bc842cbde430955825553ab7b976f4 Mon Sep 17 00:00:00 2001 From: Melchi Aaron Zedek Date: Thu, 12 Nov 2026 10:22:20 +0530 Subject: [PATCH] craft-video: HEVC 4:2:2 8/10-bit decoding (Main 4:2:2 10) (#1087) Port of storytold/filmcraft#1952, reviewed and approved there before the decoder moved to craft-libs. 4:2:2 streams that use no range-extension coding tools (what cameras and libx265 write) now decode: - chroma geometry from the SPS chroma format (ChromaFormat shifts) in the picture store, intra, inter, transform tree, deblocking and SAO; - Table 8-3 IntraPredModeC mapping for ChromaArrayType 2; - stacked chroma transform blocks with their second cbf flags; - chroma QP = Min(qPi, 51) for non-4:2:0 (Table 8-10 applies to 4:2:0 only); - chroma motion vectors scaled per axis; - a PPS that scales SAO offsets (a RExt tool) is refused as unsupported. Tests: 22 libx265 4:2:2 fixtures (8- and 10-bit) compared sample-exactly with ffmpeg, the Table 8-3 derivation, and the SAO offset scale check. --- crates/video/src/hevc/decode/README.md | 31 ++++-- crates/video/src/hevc/decode/decoder.rs | 20 ++-- crates/video/src/hevc/decode/filter.rs | 68 +++++++++----- crates/video/src/hevc/decode/inter.rs | 85 +++++++++++------ crates/video/src/hevc/decode/mod.rs | 47 +++++++++- crates/video/src/hevc/decode/params.rs | 19 +++- crates/video/src/hevc/decode/picture.rs | 69 ++++++++------ crates/video/src/hevc/decode/slicedec.rs | 8 +- .../src/hevc/decode/slicedec/coding_unit.rs | 32 ++++--- .../hevc/decode/slicedec/prediction_unit.rs | 13 +-- .../hevc/decode/slicedec/transform_tree.rs | 82 ++++++++++------ crates/video/src/hevc/decode/synth_tests.rs | 53 +++++++++++ crates/video/src/hevc/decode/tables.rs | 49 ++++++++++ crates/video/tests/hevc_decode/common/mod.rs | 94 +++++++++++++++---- crates/video/tests/hevc_decode/conformance.rs | 32 +++++-- crates/video/tests/hevc_decode/robustness.rs | 2 +- 16 files changed, 526 insertions(+), 178 deletions(-) diff --git a/crates/video/src/hevc/decode/README.md b/crates/video/src/hevc/decode/README.md index 8cef717f3..dc56233e3 100644 --- a/crates/video/src/hevc/decode/README.md +++ b/crates/video/src/hevc/decode/README.md @@ -31,18 +31,19 @@ and fixture generators. | Frame-level multithreading (CTB-row granularity dependencies) | done | | Draft mode (non-reference pictures skip deblocking / SAO; reduced-resolution playback only) | done | | Tiles / WPP | parsed and decoded sequentially within a picture (parallelism is across pictures) | -| 4:2:2 / 4:4:4 / monochrome, range-extension tools (RExt), 12-bit+ with extended precision | not yet (`Error::Unsupported`) | +| 4:2:2 (Main 4:2:2 10 / RExt profile): chroma geometry, Table 8-3 intra mode mapping, stacked chroma transform blocks with their second cbf flags, chroma QP = Min(qPi, 51), chroma motion vectors, deblocking / SAO geometry; 8–12-bit | implemented; bit-exact on the 8- and 10-bit libx265 fixtures and a Fujifilm X-S20 clip; 12-bit not yet validated | +| 4:4:4 / monochrome, range-extension coding tools (RDPCM, cross-component prediction, extended precision, persistent Rice adaptation, bypass alignment, chroma QP offset lists) | not yet (`Error::Unsupported`) | | SCC, multilayer (SHVC / MV-HEVC), 3D extensions | not supported (layers > 0 are ignored) | ## API ```rust -use craft_video::hevc::decode::{Decoder, Plane}; +use craft_video::hevc::decode::{ChromaFormat, Decoder, Plane}; let mut dec = Decoder::new(); // or Decoder::with_threads(n), Decoder::from_hvcc(&hvcc)? for (pts, access_unit) in access_units { for pic in dec.decode(access_unit, pts)? { - // pic.width / pic.height (cropped), pic.bit_depth, + // pic.width / pic.height (cropped), pic.bit_depth, pic.chroma (ChromaFormat::C420 / C422), // pic.y / pic.u / pic.v: Plane::U8(Vec) for 8-bit, Plane::U16(Vec) above, // pic.y_stride / pic.uv_stride, pic.pts, pic.poc, pic.key (IRAP), pic.color (VUI), pic.sar } @@ -112,7 +113,8 @@ with ffmpeg; tests print a message and skip when ffmpeg is absent). - Robustness: hvcC input with pts round trip; randomly corrupted, truncated and garbage input never panics or deadlocks (single- and multi-threaded). - Conformance (`tests/it/conformance.rs`): every fixture is decoded with 1 thread, 3 threads and every - core and compared sample-exactly with `ffmpeg -f rawvideo -pix_fmt yuv420p / yuv420p10le`; on mismatch + core and compared sample-exactly with `ffmpeg -f rawvideo -pix_fmt yuv420p / yuv420p10le / + yuv422p / yuv422p10le`; on mismatch the first differing frame, plane, sample, CTB and 8x8 block are reported. Output pts/POC order is checked too, and that no picture is flagged draft without draft mode. `compare_detects_mismatch` guards the harness itself. Draft mode (`draft_mode_changes_only_flagged_non_reference_pictures`): @@ -148,6 +150,12 @@ libx265 unless noted (x265 enables WPP by default, so most streams carry entry p | hd_1080p | 1920x1080 | preset fast | | uhd_4k_main10 | 3840x2160 | Main 10 ultrafast | | vt_main / vt_main10 | 640x360 | Apple VideoToolbox (hevc_videotoolbox), macOS only | +| intra_422 / intra_422_10 / intra_422_10_nosao_nodbk | 352x288 | 4:2:2 8 / 10-bit intra, with and without the in-loop filters | +| medium_422_10 / slow_422 / p_only_422_10 / bframes_422_10 | 352x288 | 4:2:2 presets, P only, B pyramid | +| no_sao_422_10 / deblock_m3_3_422_10 / rect_amp_422_10 / tskip_422_10 / weightp_422_10 | 352x288 | 4:2:2 tool toggles | +| qp_422_10 / crf51_422_10 / lossless_422_10 | 352x288 / 176x144 | 4:2:2 adaptive QP (cu_qp_delta), quantizer extremes, lossless | +| ctu32_tu4_422_10 / ctu16_422_10 / scaling_list_422_10 / constrained_intra_422_10 / slices4_422_10 | 352x288 | 4:2:2 block structure, scaling lists, constrained intra, slices | +| odd_1918x1078_422_10 / uhd_4k_422_10 | 1918x1078 / 3840x2160 | 4:2:2 cropping and 4K | | synthetic (7 streams) | 96x64 | tiles, PCM, slices, dependent segments, LTR, list modification | `cargo test -p craft-video --features hevc-decode,threads --test hevc_decode conformance::coverage_report -- --ignored --nocapture` prints the @@ -164,6 +172,10 @@ average 50-100), so these are lower bounds: | 1080p Main, x265 medium CRF 24, 60 frames | ~75 fps | ~225 fps | | 2160p Main 10, x265 fast CRF 26, 20 frames | ~20 fps | ~95 fps | +4:2:2 10-bit at 2160p (a Fujifilm X-S20 clip, 87 Mbit/s LongGOP) decodes at about 17 fps with 16 +threads on a Ryzen 7 7730U laptop (the `hevc_hevcdec` example, all pictures output): below real time +for 24–30 fps material. + No intrinsics: the hot loops are written to auto-vectorise. M4.8 profiling (FilmCraft's `cargo xtask bench`, macOS `sample`) found the inverse transform at over half of the decoder's CPU time: it evaluated the full matrix product with strided table reads. It now sums @@ -200,12 +212,11 @@ context-coded bins are branch-free. Single-threaded 4K now splits into CABAC res ## Known gaps -- 4:2:2 (Main 4:2:2 10, RExt) is the next step: chroma geometry is 4:2:0-specific in `slicedec` - (chroma TU placement, second chroma cbf flags, Table 8-3 intra mode mapping), `inter` (vertical - chroma MV scaling), `filter` (chroma edge spacing, QpC = Min(qPi, 51)) and `picture`. Range - extension tools (implicit / explicit RDPCM, cross-component prediction, extended precision, - persistent Rice adaptation, bypass alignment, chroma QP offset lists) are rejected with - `Error::Unsupported`. +- 4:4:4 and monochrome streams, and the range extension coding tools (implicit / explicit RDPCM, + cross-component prediction, extended precision, persistent Rice adaptation, bypass alignment, + chroma QP offset lists, SAO offset scaling) are rejected with `Error::Unsupported`. 4:2:2 + streams that use none of those tools (what cameras and libx265 write) decode. +- 12-bit 4:2:2 is implemented but no fixture or ffmpeg comparison covers `yuv422p12le` yet. - Tiles and WPP substreams are decoded sequentially (entry points are parsed but the decoder finds substream starts itself); parallelism is at the picture level. - Error concealment is minimal: missing references are replaced by grey pictures, undecodable diff --git a/crates/video/src/hevc/decode/decoder.rs b/crates/video/src/hevc/decode/decoder.rs index 686ad7d10..a09e87ab9 100644 --- a/crates/video/src/hevc/decode/decoder.rs +++ b/crates/video/src/hevc/decode/decoder.rs @@ -6,12 +6,12 @@ //! block per row on the reference data they read, so several pictures decode concurrently. use crate::hevc::decode::dpb::{Dpb, Output, OutputMeta, PocState, RefPicSet, build_ref_lists}; -use crate::hevc::decode::error::{Error, Result, ensure, invalid}; +use crate::hevc::decode::error::{Error, Result, ensure, invalid, unsupported}; use crate::hevc::decode::params::{Layout, Pps, Sps, Vps}; use crate::hevc::decode::picture::{Frame, FrameRef, Sample, stores_u8}; use crate::hevc::decode::slice::{NalHeader, SliceHeader, nal_type}; use crate::hevc::decode::slicedec::{Blk, F_INTRA, F_SKIP, PicState, SliceDecoder, SliceJob}; -use crate::hevc::decode::{ColorInfo, Picture}; +use crate::hevc::decode::{ChromaFormat, ColorInfo, Picture}; use craft_media::bitstream::{annexb_nals, length_prefixed_nals, unescape_rbsp}; use std::collections::VecDeque; use std::sync::{Arc, Mutex}; @@ -490,6 +490,7 @@ impl Decoder { Some(a) => { a.width != sps.width || a.height != sps.height + || a.chroma_format_idc != sps.chroma_format_idc || a.bit_depth_luma != sps.bit_depth_luma || a.bit_depth_chroma != sps.bit_depth_chroma || a.log2_ctb != sps.log2_ctb @@ -520,7 +521,10 @@ impl Decoder { let max_lsb = sps.max_poc_lsb(); let poc = self.poc_state.compute(sh, max_lsb, irap_no_rasl); let (w, h) = (sps.width as usize, sps.height as usize); - let template = Frame::new(0, 0, w, h, sps.log2_ctb, sps.bit_depth_luma, sps.bit_depth_chroma); + let Some(chroma) = ChromaFormat::from_idc(sps.chroma_array_type()) else { + return unsupported(format!("chroma_format_idc {}", sps.chroma_format_idc)); + }; + let template = Frame::new(0, 0, w, h, chroma, sps.log2_ctb, sps.bit_depth_luma, sps.bit_depth_chroma); let next_id = &mut self.next_id; let gray_reference = &mut self.gray_reference; let mut missing = 0u64; @@ -549,7 +553,7 @@ impl Decoder { let _ = scaling; let id = self.next_id; self.next_id += 1; - let frame = Arc::new(Frame::new(id, poc, w, h, sps.log2_ctb, sps.bit_depth_luma, sps.bit_depth_chroma)); + let frame = Arc::new(Frame::new(id, poc, w, h, chroma, sps.log2_ctb, sps.bit_depth_luma, sps.bit_depth_chroma)); let output = sh.pic_output && !(hdr.is_rasl() && self.skip_rasl); // Draft mode: a sub-layer non-reference picture of the highest sub-layer is never used // for prediction, so its in-loop filters can be left out without changing anything else. @@ -695,7 +699,8 @@ fn output_meta(sps: &Sps, pts: i64, key: bool, draft: bool) -> OutputMeta { fn make_picture(o: &Output) -> Picture { let (cx, cy, cw, ch) = o.meta.crop; let (cx, cy, cw, ch) = (cx as usize, cy as usize, cw as usize, ch as usize); - let (ccw, cch) = (cw.div_ceil(2), ch.div_ceil(2)); + let chroma = o.frame.chroma; + let (ccw, cch) = (cw.div_ceil(chroma.sub_width()), ch.div_ceil(chroma.sub_height())); let bd = o.meta.bit_depth; let (y, u, v) = o.frame.cropped_planes((cx, cy, cw, ch), bd <= 8); Picture { @@ -703,6 +708,7 @@ fn make_picture(o: &Output) -> Picture { height: ch as u32, chroma_width: ccw as u32, chroma_height: cch as u32, + chroma, bit_depth: bd, y, u, @@ -742,7 +748,7 @@ mod tests { draft: false, }); for id in 0..(MAX_QUEUED_AFTER_ERROR as u32 * 4) { - let frame = Arc::new(Frame::new(id, id as i32, 16, 16, 4, 8, 8)); + let frame = Arc::new(Frame::new(id, id as i32, 16, 16, ChromaFormat::C420, 4, 8, 8)); decoder.out_queue.push_back(Output { frame, poc: id as i32, meta: meta.clone() }); } decoder @@ -770,7 +776,7 @@ mod tests { bit_depth: 8, draft: false, }); - decoder.out_queue.push_back(Output { frame: Arc::new(Frame::new(0, 0, 16, 16, 4, 8, 8)), poc: 0, meta }); + decoder.out_queue.push_back(Output { frame: Arc::new(Frame::new(0, 0, 16, 16, ChromaFormat::C420, 4, 8, 8)), poc: 0, meta }); assert!(decoder.check_budget(&sps).is_err()); } diff --git a/crates/video/src/hevc/decode/filter.rs b/crates/video/src/hevc/decode/filter.rs index 1455b57f7..d5b94161d 100644 --- a/crates/video/src/hevc/decode/filter.rs +++ b/crates/video/src/hevc/decode/filter.rs @@ -7,7 +7,7 @@ use crate::hevc::decode::picture::{ColMv, FrameRow, Sample}; use crate::hevc::decode::slicedec::{E_PU_H, E_PU_V, E_TU_H, E_TU_V, F_CBF, F_CODED, F_INTRA, F_NOFILTER, MvField, PicState}; use crate::hevc::decode::spec_tables::{BETA_TABLE, TC_TABLE}; -use crate::hevc::decode::tables::qpc_420; +use crate::hevc::decode::tables::qpc; /// Called after each decoded CTB of CTB row `ry`. pub fn ctb_decoded(pic: &mut PicState, ry: usize) { @@ -141,6 +141,10 @@ fn deblock_row(pic: &mut PicState, r: usize) { let (w, w4) = (pic.width, pic.w4); let (bd, bdc) = (pic.bd_y, pic.bd_c); let (cb_off, cr_off) = (pic.pps.cb_qp_offset, pic.pps.cr_qp_offset); + // chroma geometry: edges on the 8x8 chroma sample grid (8.7.2, edgeSpacing), each segment + // 4 chroma samples long, with bS taken from the luma segment at its first sample + let (sx, sy) = pic.chroma.shifts(); + let is420 = sy == 1; // vertical edges let mut bs_v = vec![0i8; w4 * ((y1 - y0) / 4)]; for by in y0 / 4..y1 / 4 { @@ -164,10 +168,11 @@ fn deblock_row(pic: &mut PicState, r: usize) { filter_luma(&mut pic.planes[0], EdgeSeg { o: y * w + x, step: 1, along: w }, beta, EdgeFilter { tc, nfp, nfq, bd }); } } - // chroma vertical edges (8x8 chroma grid = 16 luma samples), bS == 2 only + // chroma vertical edges (8 chroma samples = 16 luma samples apart; a 4-line chroma segment + // spans 4 << sy luma lines), bS == 2 only let cw = pic.cwidth; - for by in (y0 / 4..y1 / 4).step_by(2) { - for bx in (4..w4).step_by(4) { + for by in (y0 / 4..y1 / 4).step_by(1 << sy) { + for bx in (2 << sx..w4).step_by(2 << sx) { if bs_v[(by - y0 / 4) * w4 + bx] != 2 { continue; } @@ -178,9 +183,13 @@ fn deblock_row(pic: &mut PicState, r: usize) { let (nfp, nfq) = (p.flags & F_NOFILTER != 0, q.flags & F_NOFILTER != 0); for (c, off) in [(1, cb_off), (2, cr_off)] { let qpi = ((p.qp as i32 + q.qp as i32 + 1) >> 1) + off; - let qt = (qpc_420(qpi) + 2 + tc_off).clamp(0, 53); + let qt = (qpc(qpi, is420) + 2 + tc_off).clamp(0, 53); let tc = (TC_TABLE[qt as usize] as i32) << (bdc - 8); - filter_chroma(&mut pic.planes[c], EdgeSeg { o: (y / 2) * cw + x / 2, step: 1, along: cw }, EdgeFilter { tc, nfp, nfq, bd: bdc }); + filter_chroma( + &mut pic.planes[c], + EdgeSeg { o: (y >> sy) * cw + (x >> sx), step: 1, along: cw }, + EdgeFilter { tc, nfp, nfq, bd: bdc }, + ); } } } @@ -208,8 +217,10 @@ fn deblock_row(pic: &mut PicState, r: usize) { filter_luma(&mut pic.planes[0], EdgeSeg { o: y * w + x, step: w, along: 1 }, beta, EdgeFilter { tc, nfp, nfq, bd }); } } - for by in (y0 / 4..y1 / 4).filter(|b| b % 4 == 0 && *b > 0) { - for bx in (0..w4).step_by(2) { + // chroma horizontal edges (8 chroma lines = 8 << sy luma lines apart; a 4-sample chroma + // segment spans 4 << sx luma samples), bS == 2 only + for by in (y0 / 4..y1 / 4).filter(|b| b % (2 << sy) == 0 && *b > 0) { + for bx in (0..w4).step_by(1 << sx) { if bs_h[(by - y0 / 4) * w4 + bx] != 2 { continue; } @@ -220,9 +231,13 @@ fn deblock_row(pic: &mut PicState, r: usize) { let (nfp, nfq) = (p.flags & F_NOFILTER != 0, q.flags & F_NOFILTER != 0); for (c, off) in [(1, cb_off), (2, cr_off)] { let qpi = ((p.qp as i32 + q.qp as i32 + 1) >> 1) + off; - let qt = (qpc_420(qpi) + 2 + tc_off).clamp(0, 53); + let qt = (qpc(qpi, is420) + 2 + tc_off).clamp(0, 53); let tc = (TC_TABLE[qt as usize] as i32) << (bdc - 8); - filter_chroma(&mut pic.planes[c], EdgeSeg { o: (y / 2) * cw + x / 2, step: cw, along: 1 }, EdgeFilter { tc, nfp, nfq, bd: bdc }); + filter_chroma( + &mut pic.planes[c], + EdgeSeg { o: (y >> sy) * cw + (x >> sx), step: cw, along: 1 }, + EdgeFilter { tc, nfp, nfq, bd: bdc }, + ); } } } @@ -379,11 +394,13 @@ fn filter_chroma(s: &mut [S], seg: EdgeSeg, ef: EdgeFilter) { /// SAO of CTB row `r` from the deblocked planes into new row buffers (luma, Cb, Cr). fn sao_row(pic: &PicState, r: usize) -> [Vec; 3] { let ctb = 1usize << pic.log2_ctb; + let (sx, sy) = pic.chroma.shifts(); let mut out: [Vec; 3] = [Vec::new(), Vec::new(), Vec::new()]; for c in 0..3 { - let (w, h, cs) = if c == 0 { (pic.width, pic.height, ctb) } else { (pic.cwidth, pic.cheight, ctb / 2) }; - let y0 = r * cs; - let y1 = (y0 + cs).min(h); + // CTB size in this component's samples, horizontally and vertically + let (w, h, csx, csy) = if c == 0 { (pic.width, pic.height, ctb, ctb) } else { (pic.cwidth, pic.cheight, ctb >> sx, ctb >> sy) }; + let y0 = r * csy; + let y1 = (y0 + csy).min(h); out[c] = pic.planes[c][y0 * w..y1 * w].to_vec(); for rx in (0..pic.wctb).filter(|_| !pic.draft) { let rs = r * pic.wctb + rx; @@ -399,8 +416,8 @@ fn sao_row(pic: &PicState, r: usize) -> [Vec; 3] { if p.type_idx[c] == 0 { continue; } - let x0 = rx * cs; - let x1 = (x0 + cs).min(w); + let x0 = rx * csx; + let x1 = (x0 + csx).min(w); sao_ctb(pic, &SaoBlock { c, rs, x0, x1, y0, y1, w, h }, &p, &mut out[c]); } } @@ -425,7 +442,8 @@ fn sao_ctb(pic: &PicState, blk: &SaoBlock, p: &crate::hevc::decode let SaoBlock { c, rs, x0, x1, y0, y1, w, h } = *blk; let bd = if c == 0 { pic.bd_y } else { pic.bd_c }; let max = (1i32 << bd) - 1; - let sc = if c == 0 { 0 } else { 1 }; + // shifts from this component's sample positions to luma positions + let (scx, scy) = if c == 0 { (0, 0) } else { pic.chroma.shifts() }; let offs = p.offsets[c].map(|v| v as i32); let plane = &pic.planes[c]; if p.type_idx[c] == 1 { @@ -439,16 +457,17 @@ fn sao_ctb(pic: &PicState, blk: &SaoBlock, p: &crate::hevc::decode // Samples of CUs that must not be modified (transquant bypass, PCM without loop filter) get // their deblocked values back. if pic.pps.transquant_bypass || pic.sps.pcm_loop_filter_disabled { - let n = 4 >> sc; - for by in ((y0 << sc) >> 2)..(((y1 << sc) + 3) >> 2).min(pic.h4) { - let ya = (by * 4) >> sc; - for bx in ((x0 << sc) >> 2)..(((x1 << sc) + 3) >> 2).min(pic.w4) { + // one 4x4 luma block covers (4 >> scx) x (4 >> scy) samples of this component + let (nx, ny) = (4 >> scx, 4 >> scy); + for by in ((y0 << scy) >> 2)..(((y1 << scy) + 3) >> 2).min(pic.h4) { + let ya = (by * 4) >> scy; + for bx in ((x0 << scx) >> 2)..(((x1 << scx) + 3) >> 2).min(pic.w4) { if pic.blk[by * pic.w4 + bx].flags & F_NOFILTER == 0 { continue; } - let xa = (bx * 4) >> sc; - let xb = (xa + n).min(x1); - for y in ya..(ya + n).min(y1) { + let xa = (bx * 4) >> scx; + let xb = (xa + nx).min(x1); + for y in ya..(ya + ny).min(y1) { dst[(y - y0) * w + xa..(y - y0) * w + xb].copy_from_slice(&plane[y * w + xa..y * w + xb]); } } @@ -625,8 +644,6 @@ fn sao_band_run(src: &[S], out: &mut [S], shift: u32, (band_pos, offs fn publish_row(pic: &mut PicState, r: usize) { let ctb = 1usize << pic.log2_ctb; let (y0, y1) = (r * ctb, ((r + 1) * ctb).min(pic.height)); - let (c0, c1) = (y0 / 2, y1.div_ceil(2)); - let (w, cw) = (pic.width, pic.cwidth); let w16 = pic.width.div_ceil(16); let rows16 = (y1 - y0).div_ceil(16); let mut col = vec![ColMv::default(); w16 * rows16]; @@ -662,7 +679,6 @@ fn publish_row(pic: &mut PicState, r: usize) { } } let [oy, ocb, ocr] = sao_row(pic, r); - let _ = (w, cw, c0, c1); let row = FrameRow { y: oy.into(), cb: ocb.into(), cr: ocr.into(), col: col.into() }; pic.frame.publish(r, row); } diff --git a/crates/video/src/hevc/decode/inter.rs b/crates/video/src/hevc/decode/inter.rs index 237d6c511..bf4e1f784 100644 --- a/crates/video/src/hevc/decode/inter.rs +++ b/crates/video/src/hevc/decode/inter.rs @@ -272,19 +272,28 @@ pub fn mc_luma(f: &Frame, (x, y): (i32, i32), mv: [i16; 2], (w, h): ( predict_block::(f, 0, (xi - 3, yi - 3), &k, out, sc); } -/// Chroma prediction samples of a w x h chroma block at chroma position (x, y) with a 1/8-sample -/// vector (4:2:0). -pub fn mc_chroma(f: &Frame, c: usize, (x, y): (i32, i32), mv: [i16; 2], (w, h): (usize, usize), out: &mut [i16], sc: &mut McScratch) { +/// Chroma prediction samples of a w x h chroma block at chroma position (x, y) displaced by the +/// chroma motion vector `mv` in units of 1/8 chroma sample (mvCLX of 8.5.3.2.10: the luma vector +/// times 2 / SubWidthC horizontally and 2 / SubHeightC vertically; see [`chroma_mv`]). +pub fn mc_chroma(f: &Frame, c: usize, (x, y): (i32, i32), mv: [i32; 2], (w, h): (usize, usize), out: &mut [i16], sc: &mut McScratch) { let bd = f.bit_depth_c; let (fx, fy) = ((mv[0] & 7) as usize, (mv[1] & 7) as usize); - let xi = x + (mv[0] as i32 >> 3); - let yi = y + (mv[1] as i32 >> 3); + let xi = x + (mv[0] >> 3); + let yi = y + (mv[1] >> 3); let shift1 = bd.min(12) - 8; let shift3 = 14 - bd; let k = Interp { fx, fy, hf: &CHROMA_FILTER[fx], vf: &CHROMA_FILTER[fy], w, h, shift1, shift3 }; predict_block::(f, c + 1, (xi - 1, yi - 1), &k, out, sc); } +/// The chroma motion vector in 1/8 chroma sample units (8-228 / 8-229 of 8.5.3.2.10) of the luma +/// quarter-sample vector `mv` for chroma shifts `(sx, sy)` = (log2 SubWidthC, log2 SubHeightC): +/// `mv * 2 / SubWidthC` horizontally, `mv * 2 / SubHeightC` vertically (both exact). +#[inline(always)] +pub fn chroma_mv(mv: [i16; 2], (sx, sy): (u32, u32)) -> [i32; 2] { + [(mv[0] as i32 * 2) >> sx, (mv[1] as i32 * 2) >> sy] +} + /// Calls `$f::<_, W>(args)` with `W` the prediction block width `$w` as a constant (block widths are /// fixed sizes; a constant width lets the row loops vectorise even for 4- and 8-sample rows), or /// `$f::<0>` for any other width. @@ -391,6 +400,7 @@ pub fn put_weighted_bi( #[cfg(test)] mod tests { use super::*; + use crate::hevc::decode::ChromaFormat; struct Rng(u64); impl Rng { @@ -530,36 +540,40 @@ mod tests { } } - fn frame(rng: &mut Rng, w: usize, h: usize, bd: u32) -> Frame { - let like = Frame::new(0, 0, w, h, 4, bd, bd); + fn frame(rng: &mut Rng, w: usize, h: usize, chroma: ChromaFormat, bd: u32) -> Frame { + let like = Frame::new(0, 0, w, h, chroma, 4, bd, bd); let max = (1i32 << bd) - 1; + let n = (w >> chroma.shifts().0) * (h >> chroma.shifts().1); let y: Vec = (0..w * h).map(|_| rng.range(0, max) as u16).collect(); - let c: Vec = (0..w * h / 4).map(|_| rng.range(0, max) as u16).collect(); - let c2: Vec = (0..w * h / 4).map(|_| rng.range(0, max) as u16).collect(); + let c: Vec = (0..n).map(|_| rng.range(0, max) as u16).collect(); + let c2: Vec = (0..n).map(|_| rng.range(0, max) as u16).collect(); Frame::from_planes(1, 0, &like, &y, &c, &c2) } /// Interpolation from the reference lines in place gives the same prediction samples as from - /// an edge-replicated copy of the window, for every fractional position, block size and bit - /// depth, inside the picture and next to its edges (where in-place reads are used for windows - /// that cross only the top or bottom edge). + /// an edge-replicated copy of the window, for every fractional position, block size, chroma + /// format and bit depth, inside the picture and next to its edges (where in-place reads are + /// used for windows that cross only the top or bottom edge). #[test] fn in_place_window_matches_the_copied_window() { - fn check(rng: &mut Rng, bd: u32) { + fn check(rng: &mut Rng, chroma: ChromaFormat, bd: u32) { let mut sc = McScratch::default(); - let f = frame(rng, 96, 64, bd); + let f = frame(rng, 96, 64, chroma, bd); + let (sx, sy) = chroma.shifts(); let (mut in_place, mut copied) = (0, 0); for _ in 0..2000 { let c = rng.range(0, 2) as usize; let (w, h) = ([4usize, 8, 12, 16, 24, 32][rng.range(0, 5) as usize], [4usize, 8, 16, 32][rng.range(0, 3) as usize]); - let (w, h) = if c == 0 { (w, h) } else { (w / 2, h / 2) }; + let (w, h) = if c == 0 { (w, h) } else { (w >> sx, h >> sy) }; let (x, y) = (rng.range(-12, 100), rng.range(-12, 70)); let mv = [rng.range(-64, 64) as i16, rng.range(-64, 64) as i16]; + let mvc = chroma_mv(mv, (sx, sy)); let mut a = vec![0i16; w * h]; let mut b = vec![0i16; w * h]; let (t, frac, unit) = if c == 0 { (8usize, 3, 2) } else { (4, 7, 3) }; - let (fx, fy) = ((mv[0] & frac) as usize, (mv[1] & frac) as usize); - let (x0, y0) = (x + (mv[0] as i32 >> unit) - (t as i32 / 2 - 1), y + (mv[1] as i32 >> unit) - (t as i32 / 2 - 1)); + let mvu = if c == 0 { [mv[0] as i32, mv[1] as i32] } else { mvc }; + let (fx, fy) = ((mvu[0] & frac) as usize, (mvu[1] & frac) as usize); + let (x0, y0) = (x + (mvu[0] >> unit) - (t as i32 / 2 - 1), y + (mvu[1] >> unit) - (t as i32 / 2 - 1)); let (shift1, shift3) = (bd - 8, 14 - bd); let mut rows: Vec<&[S]> = vec![&[]; h + t - 1]; if f.window_rows(c, x0, y0, w + t - 1, &mut rows) { @@ -580,17 +594,29 @@ mod tests { let k = Interp { fx, fy, hf: &LUMA_FILTER[fx], vf: &LUMA_FILTER[fy], w, h, shift1, shift3 }; interpolate::(strided(&win, WIN), &mut tmp, &k, &mut b); } else { - mc_chroma::(&f, c - 1, (x, y), mv, (w, h), &mut a, &mut sc); + mc_chroma::(&f, c - 1, (x, y), mvc, (w, h), &mut a, &mut sc); let k = Interp { fx, fy, hf: &CHROMA_FILTER[fx], vf: &CHROMA_FILTER[fy], w, h, shift1, shift3 }; interpolate::(strided(&win, WIN), &mut tmp, &k, &mut b); } - assert_eq!(a, b, "plane {c} bd {bd} at ({x}, {y}) mv {mv:?} {w}x{h}"); + assert_eq!(a, b, "plane {c} {chroma:?} bd {bd} at ({x}, {y}) mv {mv:?} {w}x{h}"); } assert!(in_place > 500 && copied > 100, "{in_place} in place, {copied} copied"); } let mut rng = Rng(0x5851_f42d_4c95_7f2d); - check::(&mut rng, 8); - check::(&mut rng, 10); + for chroma in [ChromaFormat::C420, ChromaFormat::C422] { + check::(&mut rng, chroma, 8); + check::(&mut rng, chroma, 10); + } + } + + /// The chroma vector of 8-228 / 8-229: the luma vector in 4:2:0 (1/8 chroma sample units), + /// doubled vertically in 4:2:2 (only even vertical fractions). + #[test] + fn chroma_vectors_follow_the_subsampling() { + assert_eq!(chroma_mv([5, -7], ChromaFormat::C420.shifts()), [5, -7]); + assert_eq!(chroma_mv([5, -7], ChromaFormat::C422.shifts()), [5, -14]); + assert_eq!(chroma_mv([i16::MIN, i16::MAX], ChromaFormat::C422.shifts()), [i16::MIN as i32, i16::MAX as i32 * 2]); + assert_eq!(chroma_mv([i16::MIN, i16::MAX], ChromaFormat::C420.shifts()), [i16::MIN as i32, i16::MAX as i32]); } /// The full-sample uni-prediction shortcut (`Frame::copy_block`) gives what interpolation and @@ -598,9 +624,10 @@ mod tests { /// ones as `u16`). #[test] fn full_sample_copy_matches_interpolation() { - fn check(rng: &mut Rng, bd: u32) { + fn check(rng: &mut Rng, chroma: ChromaFormat, bd: u32) { let mut sc = McScratch::default(); - let f = frame(rng, 48, 40, bd); + let f = frame(rng, 48, 40, chroma, bd); + let (sx, sy) = chroma.shifts(); for _ in 0..300 { let (w, h) = ([4usize, 8, 12, 16, 24, 32][rng.range(0, 5) as usize], [4usize, 8, 16][rng.range(0, 2) as usize]); let (x, y) = (rng.range(-40, 80), rng.range(-40, 70)); @@ -613,17 +640,19 @@ mod tests { // (the window really was read: not all samples equal the default) assert!(a.iter().any(|&v| v != S::default()) || w * h < 8); for c in 1..3 { - let (cw, ch) = (w / 2, h / 2); - mc_chroma::(&f, c - 1, (0, 0), [(x * 8) as i16, (y * 8) as i16], (cw, ch), &mut pred, &mut sc); + let (cw, ch) = (w >> sx, h >> sy); + mc_chroma::(&f, c - 1, (0, 0), [x * 8, y * 8], (cw, ch), &mut pred, &mut sc); let (mut a, mut b) = (vec![S::default(); cw * ch], vec![S::default(); cw * ch]); put_uni(&pred, cw, ch, bd, &mut a, cw); f.copy_block(c, (x, y), (cw, ch), &mut b, cw); - assert_eq!(a, b, "chroma {c} bd {bd} at ({x}, {y}) {cw}x{ch}"); + assert_eq!(a, b, "chroma {c} {chroma:?} bd {bd} at ({x}, {y}) {cw}x{ch}"); } } } let mut rng = Rng(0x0123_4567_89ab_cdef); - check::(&mut rng, 8); - check::(&mut rng, 10); + for chroma in [ChromaFormat::C420, ChromaFormat::C422] { + check::(&mut rng, chroma, 8); + check::(&mut rng, chroma, 10); + } } } diff --git a/crates/video/src/hevc/decode/mod.rs b/crates/video/src/hevc/decode/mod.rs index 0d92b7941..00ec97738 100644 --- a/crates/video/src/hevc/decode/mod.rs +++ b/crates/video/src/hevc/decode/mod.rs @@ -1,7 +1,7 @@ //! Clean-room pure-Rust H.265 / HEVC decoder, implemented from ITU-T Rec. H.265 (ISO/IEC 23008-2). //! -//! Supported: Main and Main 10 profiles (4:2:0, 8/10-bit; up to 12-bit 4:2:0 without range-extension -//! tools), all coding tools of those profiles: CABAC, coding / transform quadtrees, AMP, PCM, +//! Supported: Main, Main 10 and Main 4:2:2 10 profiles (4:2:0 and 4:2:2, 8/10-bit; up to 12-bit +//! without range-extension tools), all coding tools of those profiles: CABAC, coding / transform quadtrees, AMP, PCM, //! transform skip, scaling lists, sign data hiding, 35 intra modes with strong intra smoothing, //! merge / AMVP / TMVP, weighted prediction, deblocking, SAO, tiles, wavefront parallel processing //! (parsing), dependent slice segments, short- and long-term reference picture sets. @@ -52,6 +52,45 @@ pub struct ColorInfo { pub matrix: u8, } +/// Chroma sampling of a decoded picture (`chroma_format_idc`; 4:4:4 and monochrome are not +/// decoded). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChromaFormat { + /// 4:2:0: chroma planes are half the luma width and height. + C420, + /// 4:2:2: chroma planes are half the luma width and the full luma height. + C422, +} + +impl ChromaFormat { + /// The decoded format for `chroma_format_idc`, if this decoder handles it. + pub fn from_idc(idc: u32) -> Option { + match idc { + 1 => Some(ChromaFormat::C420), + 2 => Some(ChromaFormat::C422), + _ => None, + } + } + /// log2(SubWidthC), log2(SubHeightC): the shifts from luma to chroma sample positions. + #[inline(always)] + pub fn shifts(self) -> (u32, u32) { + match self { + ChromaFormat::C420 => (1, 1), + ChromaFormat::C422 => (1, 0), + } + } + /// SubWidthC. + #[inline(always)] + pub fn sub_width(self) -> usize { + 1 << self.shifts().0 + } + /// SubHeightC. + #[inline(always)] + pub fn sub_height(self) -> usize { + 1 << self.shifts().1 + } +} + /// Samples of one plane: 8-bit streams produce `U8`, higher bit depths `U16` (value range /// `0..(1 << bit_depth)`). #[derive(Clone, Debug, PartialEq, Eq)] @@ -98,7 +137,7 @@ impl Plane { } } -/// A decoded, cropped picture in planar 4:2:0. +/// A decoded, cropped picture in planar 4:2:0 or 4:2:2 ([`Picture::chroma`]). #[derive(Clone, Debug)] pub struct Picture { /// Cropped luma width. @@ -107,6 +146,8 @@ pub struct Picture { pub height: u32, pub chroma_width: u32, pub chroma_height: u32, + /// Chroma sampling of the `u` / `v` planes. + pub chroma: ChromaFormat, /// Luma bit depth (chroma uses the same storage type). pub bit_depth: u32, pub y: Plane, diff --git a/crates/video/src/hevc/decode/params.rs b/crates/video/src/hevc/decode/params.rs index 2f4ba00d2..97b1874d9 100644 --- a/crates/video/src/hevc/decode/params.rs +++ b/crates/video/src/hevc/decode/params.rs @@ -659,8 +659,14 @@ impl Sps { /// Features this decoder does not handle yet. pub fn check_supported(&self) -> Result<()> { - if self.chroma_format_idc != 1 { - return unsupported(format!("chroma_format_idc {} (only 4:2:0)", self.chroma_format_idc)); + if self.chroma_array_type() != 1 && self.chroma_array_type() != 2 { + let what = match self.chroma_format_idc { + 0 => "monochrome".to_string(), + 3 if self.separate_colour_plane => "4:4:4 with separate colour planes".to_string(), + 3 => "4:4:4".to_string(), + idc => format!("chroma_format_idc {idc}"), + }; + return unsupported(format!("{what} (only 4:2:0 and 4:2:2)")); } if self.bit_depth_luma > 12 || self.bit_depth_chroma > 12 { return unsupported(format!("bit depth {}/{}", self.bit_depth_luma, self.bit_depth_chroma)); @@ -801,7 +807,14 @@ impl Pps { any |= r.read_ue()? != 0; } any |= r.read_flag()?; // cross_component_prediction - any |= r.read_flag()?; // chroma_qp_offset_list + let chroma_qp_offset_list = r.read_flag()?; + any |= chroma_qp_offset_list; + if !chroma_qp_offset_list { + // (with the list enabled the PPS is unsupported already and the list sits + // before these fields) + any |= r.read_ue()? != 0; // log2_sao_offset_scale_luma + any |= r.read_ue()? != 0; // log2_sao_offset_scale_chroma + } range_extension = any; } } diff --git a/crates/video/src/hevc/decode/picture.rs b/crates/video/src/hevc/decode/picture.rs index b701479cd..4a37ba6dd 100644 --- a/crates/video/src/hevc/decode/picture.rs +++ b/crates/video/src/hevc/decode/picture.rs @@ -2,7 +2,7 @@ //! deblocking and SAO) together with the compressed motion field used for temporal motion vector //! prediction. -use crate::hevc::decode::Plane; +use crate::hevc::decode::{ChromaFormat, Plane}; use std::sync::{Arc, OnceLock}; /// Motion data of one 16x16 block of a decoded picture (8.5.3.2.9 uses the motion of the top-left @@ -128,6 +128,7 @@ pub struct Frame { pub height: usize, pub cwidth: usize, pub cheight: usize, + pub chroma: ChromaFormat, pub log2_ctb: u32, pub bit_depth: u32, pub bit_depth_c: u32, @@ -148,13 +149,21 @@ fn wait(cell: &OnceLock) -> Option<&T> { } impl Frame { - pub fn new(id: u32, poc: i32, width: usize, height: usize, log2_ctb: u32, bit_depth: u32, bit_depth_c: u32) -> Self { + #[allow(clippy::too_many_arguments)] + pub fn new(id: u32, poc: i32, width: usize, height: usize, chroma: ChromaFormat, log2_ctb: u32, bit_depth: u32, bit_depth_c: u32) -> Self { let n = height.div_ceil(1 << log2_ctb); fn cells(n: usize) -> Rows { S::wrap_rows((0..n).map(|_| OnceLock::new()).collect()) } let rows = if stores_u8(bit_depth, bit_depth_c) { cells::(n) } else { cells::(n) }; - Frame { id, poc, width, height, cwidth: width / 2, cheight: height / 2, log2_ctb, bit_depth, bit_depth_c, rows } + let (sx, sy) = chroma.shifts(); + Frame { id, poc, width, height, cwidth: width >> sx, cheight: height >> sy, chroma, log2_ctb, bit_depth, bit_depth_c, rows } + } + + /// log2 of the chroma lines per CTB row (log2(CtbSizeY / SubHeightC)). + #[inline(always)] + pub fn log2_ctb_c(&self) -> u32 { + self.log2_ctb - self.chroma.shifts().1 } pub fn num_rows(&self) -> usize { @@ -239,7 +248,7 @@ impl Frame { /// Copy a w x h window of chroma component `c` (0 = Cb, 1 = Cr) with edge replication. pub fn chroma_window(&self, c: usize, (x0, y0): (i32, i32), (w, h): (usize, usize), out: &mut [i16], os: usize) { let (cw, ch) = (self.cwidth as i32, self.cheight as i32); - let sh = self.log2_ctb - 1; + let sh = self.log2_ctb_c(); let mask = (1i32 << sh) - 1; for r in 0..h { let yy = (y0 + r as i32).clamp(0, ch - 1); @@ -255,7 +264,7 @@ impl Frame { /// (8-228, 8-229): the reference window of a prediction block without copying it. False (and /// `rows` partly filled) when the runs are not inside the picture horizontally. pub fn window_rows<'f, S: Sample>(&'f self, c: usize, x0: i32, y0: i32, w: usize, rows: &mut [&'f [S]]) -> bool { - let (pw, ph, sh) = if c == 0 { (self.width, self.height, self.log2_ctb) } else { (self.cwidth, self.cheight, self.log2_ctb - 1) }; + let (pw, ph, sh) = if c == 0 { (self.width, self.height, self.log2_ctb) } else { (self.cwidth, self.cheight, self.log2_ctb_c()) }; let Some(x) = usize::try_from(x0).ok().filter(|&x| x.saturating_add(w) <= pw) else { return false }; let mask = (1usize << sh) - 1; let max_y = ph as i32 - 1; @@ -285,7 +294,7 @@ impl Frame { /// Copy the w x h block at (x0, y0) of plane `c` (0 = luma, 1 = Cb, 2 = Cr) into `dst` /// (stride `ds`), samples outside the picture replicated from its edges (8-228, 8-229). pub fn copy_block(&self, c: usize, (x0, y0): (i32, i32), (w, h): (usize, usize), dst: &mut [S], ds: usize) { - let (pw, ph, sh) = if c == 0 { (self.width, self.height, self.log2_ctb) } else { (self.cwidth, self.cheight, self.log2_ctb - 1) }; + let (pw, ph, sh) = if c == 0 { (self.width, self.height, self.log2_ctb) } else { (self.cwidth, self.cheight, self.log2_ctb_c()) }; let inside = usize::try_from(x0).ok().filter(|&x| x.saturating_add(w) <= pw); for (r, d) in dst.chunks_mut(ds).take(h).enumerate() { let yy = y0.saturating_add(r as i32).min(ph as i32 - 1).max(0) as usize; @@ -347,8 +356,9 @@ impl Frame { let Some(row) = self.row::(r / ctb) else { break }; put(&mut y, &row.y, (r % ctb) * self.width + cx, cw); } - let (ccx, ccy, ccw, cch) = (cx / 2, cy / 2, cw.div_ceil(2), ch.div_ceil(2)); - let cctb = ctb / 2; + let (sw, sh) = (self.chroma.sub_width(), self.chroma.sub_height()); + let (ccx, ccy, ccw, cch) = (cx / sw, cy / sh, cw.div_ceil(sw), ch.div_ceil(sh)); + let cctb = ctb / sh; u.reserve_exact(ccw.saturating_mul(cch)); v.reserve_exact(ccw.saturating_mul(cch)); for r in ccy..ccy.saturating_add(cch) { @@ -371,7 +381,7 @@ impl Frame { continue; } let lines = f.row_lines(r); - let clines = lines.div_ceil(2); + let clines = lines.div_ceil(f.chroma.sub_height()); f.publish( r, FrameRow:: { @@ -391,7 +401,7 @@ impl Frame { /// A mid-grey frame with the geometry of `like`. pub fn gray(id: u32, poc: i32, like: &Frame) -> Frame { - let f = Frame::new(id, poc, like.width, like.height, like.log2_ctb, like.bit_depth, like.bit_depth_c); + let f = Frame::new(id, poc, like.width, like.height, like.chroma, like.log2_ctb, like.bit_depth, like.bit_depth_c); f.publish_gray(); f } @@ -406,6 +416,7 @@ impl Frame { height: self.height, cwidth: self.cwidth, cheight: self.cheight, + chroma: self.chroma, log2_ctb: self.log2_ctb, bit_depth: self.bit_depth, bit_depth_c: self.bit_depth_c, @@ -418,13 +429,14 @@ impl Frame { pub fn from_planes(id: u32, poc: i32, like: &Frame, y: &[u16], cb: &[u16], cr: &[u16]) -> Frame { fn fill(f: &Frame, y: &[u16], cb: &[u16], cr: &[u16]) { let ctb = 1usize << f.log2_ctb; + let sh = f.chroma.sub_height(); let w16 = f.width16(); let conv = |s: &[u16]| -> Box<[S]> { s.iter().map(|&v| S::from_u16(v)).collect() }; for r in 0..f.num_rows() { let lines = f.row_lines(r); let (y0, y1) = (r * ctb * f.width, (r * ctb + lines) * f.width); - let clines = lines.div_ceil(2); - let (c0, c1) = (r * ctb / 2 * f.cwidth, (r * ctb / 2 + clines) * f.cwidth); + let clines = lines.div_ceil(sh); + let (c0, c1) = (r * ctb / sh * f.cwidth, (r * ctb / sh + clines) * f.cwidth); f.publish( r, FrameRow:: { @@ -436,7 +448,7 @@ impl Frame { ); } } - let f = Frame::new(id, poc, like.width, like.height, like.log2_ctb, like.bit_depth, like.bit_depth_c); + let f = Frame::new(id, poc, like.width, like.height, like.chroma, like.log2_ctb, like.bit_depth, like.bit_depth_c); match &f.rows { Rows::U8(_) => fill::(&f, y, cb, cr), Rows::U16(_) => fill::(&f, y, cb, cr), @@ -540,7 +552,8 @@ pub type RefPic = crate::hevc::decode::dpb::Ref; mod tests { use super::*; - fn planes(w: usize, h: usize, bd: u32, seed: u64) -> (Vec, Vec, Vec) { + fn planes(w: usize, h: usize, chroma: ChromaFormat, bd: u32, seed: u64) -> (Vec, Vec, Vec) { + let (cw, ch) = (w / chroma.sub_width(), h / chroma.sub_height()); let mut x = seed; let mut next = move || { x ^= x << 13; @@ -549,33 +562,35 @@ mod tests { (x % (1 << bd)) as u16 }; let y = (0..w * h).map(|_| next()).collect(); - let cb = (0..w * h / 4).map(|_| next()).collect(); - let cr = (0..w * h / 4).map(|_| next()).collect(); + let cb = (0..cw * ch).map(|_| next()).collect(); + let cr = (0..cw * ch).map(|_| next()).collect(); (y, cb, cr) } /// 8-bit pictures are stored as `u8`, deeper ones as `u16`, and both crop back to exactly the - /// samples they were given. + /// samples they were given, in 4:2:0 and 4:2:2. #[test] fn storage_type_follows_the_bit_depth_and_crops_exactly() { let (w, h) = (40usize, 36usize); let crop = (2usize, 4usize, 33usize, 27usize); - for bd in [8u32, 10] { - let (y, cb, cr) = planes(w, h, bd, 0x9e37_79b9_7f4a_7c15 + bd as u64); - let like = Frame::new(0, 0, w, h, 4, bd, bd); + for (bd, chroma) in [(8u32, ChromaFormat::C420), (10, ChromaFormat::C420), (8, ChromaFormat::C422), (10, ChromaFormat::C422)] { + let (sw, sh) = (chroma.sub_width(), chroma.sub_height()); + let (y, cb, cr) = planes(w, h, chroma, bd, 0x9e37_79b9_7f4a_7c15 + bd as u64); + let like = Frame::new(0, 0, w, h, chroma, 4, bd, bd); let f = Frame::from_planes(1, 0, &like, &y, &cb, &cr); assert_eq!(matches!(f.rows, Rows::U8(_)), bd == 8); assert!(f.is_complete()); let (py, pu, pv) = f.cropped_planes(crop, bd == 8); let (cx, cy, cw, ch) = crop; let want_y: Vec = (cy..cy + ch).flat_map(|r| y[r * w + cx..r * w + cx + cw].to_vec()).collect(); - let (ccw, cch) = (cw.div_ceil(2), ch.div_ceil(2)); + let (ccw, cch) = (cw.div_ceil(sw), ch.div_ceil(sh)); + let pw = w / sw; let want_c = - |p: &[u16]| -> Vec { (cy / 2..cy / 2 + cch).flat_map(|r| p[r * w / 2 + cx / 2..r * w / 2 + cx / 2 + ccw].to_vec()).collect() }; + |p: &[u16]| -> Vec { (cy / sh..cy / sh + cch).flat_map(|r| p[r * pw + cx / sw..r * pw + cx / sw + ccw].to_vec()).collect() }; let got = |p: &Plane| -> Vec { (0..p.len()).map(|i| p.get(i)).collect() }; - assert_eq!(got(&py), want_y, "luma bd {bd}"); - assert_eq!(got(&pu), want_c(&cb), "cb bd {bd}"); - assert_eq!(got(&pv), want_c(&cr), "cr bd {bd}"); + assert_eq!(got(&py), want_y, "luma bd {bd} {chroma:?}"); + assert_eq!(got(&pu), want_c(&cb), "cb bd {bd} {chroma:?}"); + assert_eq!(got(&pv), want_c(&cr), "cr bd {bd} {chroma:?}"); assert_eq!(py.as_u8().is_some(), bd == 8); } } @@ -602,7 +617,7 @@ mod tests { /// nothing instead of a panic, and a grey frame of either type is complete. #[test] fn wrong_sample_type_is_harmless() { - let f = Frame::new(0, 0, 32, 32, 4, 8, 8); + let f = Frame::new(0, 0, 32, 32, ChromaFormat::C420, 4, 8, 8); f.publish(0, FrameRow:: { y: vec![1; 16 * 32].into(), cb: vec![1; 8 * 16].into(), cr: vec![1; 8 * 16].into(), col: Box::new([]) }); assert!(!f.is_published(0)); f.publish_gray(); @@ -615,7 +630,7 @@ mod tests { let mut dst = [5u16; 16]; f.copy_block::(0, (0, 0), (4, 4), &mut dst, 4); assert!(dst.iter().all(|&v| v == 5)); - let g = Frame::gray(1, 0, &Frame::new(0, 0, 16, 8, 4, 10, 10)); + let g = Frame::gray(1, 0, &Frame::new(0, 0, 16, 8, ChromaFormat::C420, 4, 10, 10)); assert!(g.is_complete()); assert_eq!(g.row::(0).map(|r| r.cb[0]), Some(512)); assert_eq!(g.col_mv(15, 7), ColMv::default()); diff --git a/crates/video/src/hevc/decode/slicedec.rs b/crates/video/src/hevc/decode/slicedec.rs index f8ae5753f..102b74719 100644 --- a/crates/video/src/hevc/decode/slicedec.rs +++ b/crates/video/src/hevc/decode/slicedec.rs @@ -3,6 +3,7 @@ //! compensation, scaling and inverse transforms. In-loop filters and row publication live in //! `filter.rs`. +use crate::hevc::decode::ChromaFormat; use crate::hevc::decode::cabac::{Cabac, Contexts, init_contexts}; use crate::hevc::decode::error::{Result, ensure, invalid}; use crate::hevc::decode::inter; @@ -15,7 +16,7 @@ use crate::hevc::decode::spec_tables::{ INTRA_CHROMA, LAST_X_PREFIX, LAST_Y_PREFIX, MERGE_FLAG, MERGE_IDX, MVP_FLAG, PART_MODE, PRED_MODE, PREV_INTRA_LUMA, REF_IDX, RQT_ROOT_CBF, SAO_MERGE, SAO_TYPE, SIG_COEFF, SPLIT_CU, SPLIT_TRANSFORM, TRANSFORM_SKIP, }; -use crate::hevc::decode::tables::{CTX_IDX_MAP, LEVEL_SCALE, SCAN_4, qpc_420, scan_order}; +use crate::hevc::decode::tables::{CTX_IDX_MAP, INTRA_CHROMA_MODE_422, LEVEL_SCALE, SCAN_4, qpc, scan_order}; use crate::hevc::decode::transform; use std::sync::Arc; @@ -114,6 +115,7 @@ pub struct PicState { pub height: usize, pub cwidth: usize, pub cheight: usize, + pub chroma: ChromaFormat, pub w4: usize, pub h4: usize, pub log2_ctb: u32, @@ -147,7 +149,8 @@ pub struct PicState { impl PicState { pub fn new(frame: FrameRef, sps: Arc, pps: Arc, layout: Arc, scaling: Option>>>) -> Self { let (width, height) = (sps.width as usize, sps.height as usize); - let (cwidth, cheight) = (width / 2, height / 2); + let chroma = frame.chroma; + let (cwidth, cheight) = (frame.cwidth, frame.cheight); let (w4, h4) = (width.div_ceil(4), height.div_ceil(4)); let log2_ctb = sps.log2_ctb; let (wctb, hctb) = (sps.pic_width_in_ctbs() as usize, sps.pic_height_in_ctbs() as usize); @@ -164,6 +167,7 @@ impl PicState { height, cwidth, cheight, + chroma, w4, h4, log2_ctb, diff --git a/crates/video/src/hevc/decode/slicedec/coding_unit.rs b/crates/video/src/hevc/decode/slicedec/coding_unit.rs index 7f9e4d3bb..5a1cef81a 100644 --- a/crates/video/src/hevc/decode/slicedec/coding_unit.rs +++ b/crates/video/src/hevc/decode/slicedec/coding_unit.rs @@ -1,7 +1,7 @@ use super::transform_tree::TtNode; use super::{ - Blk, CU_SKIP, CU_TRANSQUANT_BYPASS, E_PU_H, E_PU_V, E_TU_H, E_TU_V, F_CODED, F_INTRA, F_NOFILTER, F_SKIP, INTRA_CHROMA, PART_MODE, PRED_MODE, - PREV_INTRA_LUMA, PartMode, RQT_ROOT_CBF, Result, SPLIT_CU, Sample, SliceDecoder, ensure, qpc_420, + Blk, CU_SKIP, CU_TRANSQUANT_BYPASS, ChromaFormat, E_PU_H, E_PU_V, E_TU_H, E_TU_V, F_CODED, F_INTRA, F_NOFILTER, F_SKIP, INTRA_CHROMA, + INTRA_CHROMA_MODE_422, PART_MODE, PRED_MODE, PREV_INTRA_LUMA, PartMode, RQT_ROOT_CBF, Result, SPLIT_CU, Sample, SliceDecoder, ensure, qpc, }; use crate::hevc::decode::mvpred::PredBlock; @@ -81,8 +81,9 @@ impl<'a, S: Sample> SliceDecoder<'a, S> { let offc = 6 * (self.pic.bd_c as i32 - 8); let qpi_cb = (self.qp_y + self.pps.cb_qp_offset + self.sh.cb_qp_offset).clamp(-offc, 57); let qpi_cr = (self.qp_y + self.pps.cr_qp_offset + self.sh.cr_qp_offset).clamp(-offc, 57); - self.qp_cb = qpc_420(qpi_cb) + offc; - self.qp_cr = qpc_420(qpi_cr) + offc; + let is420 = self.pic.chroma == ChromaFormat::C420; + self.qp_cb = qpc(qpi_cb, is420) + offc; + self.qp_cr = qpc(qpi_cr, is420) + offc; } fn coding_unit(&mut self, x0: i32, y0: i32, log2: u32, depth: u32) -> Result<()> { @@ -147,7 +148,7 @@ impl<'a, S: Sample> SliceDecoder<'a, S> { } self.parse_intra_modes(x0, y0, n, part)?; self.max_trafo_depth = self.sps.max_th_depth_intra + self.intra_split as u32; - self.transform_tree(TtNode { x0, y0, xb: x0, yb: y0, log2, blk_idx: 0 }, 0, [true, true])?; + self.transform_tree(TtNode { x0, y0, xb: x0, yb: y0, log2, blk_idx: 0 }, 0, [[true; 2]; 2])?; } else { self.intra_split = false; let mut merge2nx2n = false; @@ -179,7 +180,7 @@ impl<'a, S: Sample> SliceDecoder<'a, S> { let root_cbf = if !merge2nx2n { self.dd(RQT_ROOT_CBF) == 1 } else { true }; if root_cbf { self.max_trafo_depth = self.sps.max_th_depth_inter; - self.transform_tree(TtNode { x0, y0, xb: x0, yb: y0, log2, blk_idx: 0 }, 0, [true, true])?; + self.transform_tree(TtNode { x0, y0, xb: x0, yb: y0, log2, blk_idx: 0 }, 0, [[true; 2]; 2])?; } } self.finish_cu(x0, y0, n); @@ -251,8 +252,10 @@ impl<'a, S: Sample> SliceDecoder<'a, S> { let start = self.c.bit_pos().div_ceil(8); let data = self.c.data(); let n = 1usize << log2; + let (csx, csy) = self.pic.chroma.shifts(); + let (ncw, nch) = (n >> csx, n >> csy); let (bl, bc) = (self.sps.pcm_bit_depth_luma, self.sps.pcm_bit_depth_chroma); - let total_bits = n * n * bl as usize + 2 * (n / 2) * (n / 2) * bc as usize; + let total_bits = n * n * bl as usize + 2 * ncw * nch * bc as usize; ensure!(start + total_bits.div_ceil(8) <= data.len(), "PCM samples truncated"); let mut r = craft_media::bitstream::BitReader::new(&data[start..]); let (sy, sc) = (self.pic.bd_y - bl, self.pic.bd_c - bc); @@ -265,10 +268,10 @@ impl<'a, S: Sample> SliceDecoder<'a, S> { } let cw = self.pic.cwidth; for c in 1..3 { - for y in 0..n / 2 { - for x in 0..n / 2 { + for y in 0..nch { + for x in 0..ncw { let v = r.read_bits(bc)? as u16; - self.pic.planes[c][(y0 as usize / 2 + y) * cw + x0 as usize / 2 + x] = S::from_u16(v << sc); + self.pic.planes[c][((y0 as usize >> csy) + y) * cw + (x0 as usize >> csx) + x] = S::from_u16(v << sc); } } } @@ -336,16 +339,21 @@ impl<'a, S: Sample> SliceDecoder<'a, S> { self.fill_blk(xp, yp, pb, pb, |b| b.ipm = mode as u8); } } - // chroma mode (4:2:0: one per CU, derived from the first PU's luma mode) + // chroma mode (one per CU in 4:2:0 and 4:2:2, derived from the first PU's luma mode: + // Table 8-2, then the 4:2:2 mapping of Table 8-3) let icpm = if self.dd(INTRA_CHROMA) == 0 { 4 } else { self.bypass_bits(2) }; let luma = self.pic.blk_at(x0, y0).ipm as u32; - self.intra_chroma_mode = match icpm { + let mode = match icpm { 4 => luma, _ => { let m = [0, 26, 10, 1][icpm as usize]; if m == luma { 34 } else { m } } }; + self.intra_chroma_mode = match self.pic.chroma { + ChromaFormat::C420 => mode, + ChromaFormat::C422 => INTRA_CHROMA_MODE_422.get(mode as usize).copied().unwrap_or(mode as u8) as u32, + }; Ok(()) } } diff --git a/crates/video/src/hevc/decode/slicedec/prediction_unit.rs b/crates/video/src/hevc/decode/slicedec/prediction_unit.rs index 158efc7bf..d044684fb 100644 --- a/crates/video/src/hevc/decode/slicedec/prediction_unit.rs +++ b/crates/video/src/hevc/decode/slicedec/prediction_unit.rs @@ -129,8 +129,9 @@ impl<'a, S: Sample> SliceDecoder<'a, S> { }; let bi = f.pred(0) && f.pred(1); let pwt = self.sh.pwt.as_ref().filter(|_| weighted); + let (sx, sy) = self.pic.chroma.shifts(); for c in 0..3 { - let (x, y, bw, bh, bd) = if c == 0 { (xp, yp, w, h, self.pic.bd_y) } else { (xp / 2, yp / 2, w / 2, h / 2, self.pic.bd_c) }; + let (x, y, bw, bh, bd) = if c == 0 { (xp, yp, w, h, self.pic.bd_y) } else { (xp >> sx, yp >> sy, w >> sx, h >> sy, self.pic.bd_c) }; // Explicit weights equal to the default ones (weight 2^denom and offset 0 for every // list used, as signalled for most slices of a weighted-prediction stream) give exactly // the default prediction: with log2WD = denom + shift1 the weighted sums of 8-265 / @@ -140,14 +141,14 @@ impl<'a, S: Sample> SliceDecoder<'a, S> { // ((s << shift3) + off) >> shift3 (8-228, 8-262) is the reference sample itself. if !bi && pwt.is_none() { let l = if f.pred(0) { 0 } else { 1 }; - let frac = if c == 0 { 3 } else { 7 }; - let mv = f.mv[l]; + // the vector in this component's units: quarter luma samples, 1/8 chroma samples + let (mv, frac, sh) = + if c == 0 { ([f.mv[l][0] as i32, f.mv[l][1] as i32], 3, 2) } else { (inter::chroma_mv(f.mv[l], (sx, sy)), 7, 3) }; if f.pred(l) && mv[0] & frac == 0 && mv[1] & frac == 0 { - let sh = if c == 0 { 2 } else { 3 }; let stride = if c == 0 { self.pic.width } else { self.pic.cwidth }; let frame = &self.refs[l][f.ref_idx[l] as usize].frame; let dst = &mut self.pic.planes[c][y as usize * stride + x as usize..]; - frame.copy_block(c, (x + (mv[0] as i32 >> sh), y + (mv[1] as i32 >> sh)), (bw, bh), dst, stride); + frame.copy_block(c, (x + (mv[0] >> sh), y + (mv[1] >> sh)), (bw, bh), dst, stride); continue; } } @@ -162,7 +163,7 @@ impl<'a, S: Sample> SliceDecoder<'a, S> { if c == 0 { inter::mc_luma::(frame, (x, y), f.mv[l], (bw, bh), buf, &mut self.mc); } else { - inter::mc_chroma::(frame, c - 1, (x, y), f.mv[l], (bw, bh), buf, &mut self.mc); + inter::mc_chroma::(frame, c - 1, (x, y), inter::chroma_mv(f.mv[l], (sx, sy)), (bw, bh), buf, &mut self.mc); } } let stride = if c == 0 { self.pic.width } else { self.pic.cwidth }; diff --git a/crates/video/src/hevc/decode/slicedec/transform_tree.rs b/crates/video/src/hevc/decode/slicedec/transform_tree.rs index 195c37b4c..8512c341a 100644 --- a/crates/video/src/hevc/decode/slicedec/transform_tree.rs +++ b/crates/video/src/hevc/decode/slicedec/transform_tree.rs @@ -1,7 +1,7 @@ use super::{ - CBF_CHROMA, CBF_LUMA, CODED_SUB_BLOCK, CTX_IDX_MAP, CU_QP_DELTA_ABS, E_TU_H, E_TU_V, F_CBF, F_INTRA, GT1, GT2, LAST_X_PREFIX, LAST_Y_PREFIX, - LEVEL_SCALE, PartMode, Result, SCAN_4, SIG_COEFF, SPLIT_TRANSFORM, Sample, SliceDecoder, TRANSFORM_SKIP, ensure, for_runs, intra, scan_order, - transform, + CBF_CHROMA, CBF_LUMA, CODED_SUB_BLOCK, CTX_IDX_MAP, CU_QP_DELTA_ABS, ChromaFormat, E_TU_H, E_TU_V, F_CBF, F_INTRA, GT1, GT2, LAST_X_PREFIX, + LAST_Y_PREFIX, LEVEL_SCALE, PartMode, Result, SCAN_4, SIG_COEFF, SPLIT_TRANSFORM, Sample, SliceDecoder, TRANSFORM_SKIP, ensure, for_runs, intra, + scan_order, transform, }; /// Significant scan positions of one sub-block, in decoding (descending) order. @@ -107,7 +107,9 @@ pub(super) struct TtNode { } impl<'a, S: Sample> SliceDecoder<'a, S> { - pub(super) fn transform_tree(&mut self, node: TtNode, depth: u32, parent_cbf_c: [bool; 2]) -> Result<()> { + /// `parent_cbf_c[c][t]`: cbf_cb / cbf_cr (c) of the parent node, for the upper (t = 0) and, in + /// 4:2:2, the lower (t = 1) chroma block (7.3.8.8). + pub(super) fn transform_tree(&mut self, node: TtNode, depth: u32, parent_cbf_c: [[bool; 2]; 2]) -> Result<()> { let TtNode { x0, y0, log2, .. } = node; let split = if log2 <= self.sps.log2_max_tb && log2 > self.sps.log2_min_tb && depth < self.max_trafo_depth && !(self.intra_split && depth == 0) { @@ -117,18 +119,25 @@ impl<'a, S: Sample> SliceDecoder<'a, S> { let inter_split = self.sps.max_th_depth_inter == 0 && !self.cu_intra && self.cu_part != PartMode::P2Nx2N && depth == 0; log2 > self.sps.log2_max_tb || (self.intra_split && depth == 0) || inter_split }; - let mut cbf_c = [false, false]; + let mut cbf_c = [[false; 2]; 2]; if log2 > 2 { + // the second cbf of each chroma component (the lower block of the 4:2:2 chroma + // rectangle) is coded for a leaf, and at 8x8 (whose chroma is coded with the 4x4 luma + // blocks) also when split + let second = self.pic.chroma == ChromaFormat::C422 && (!split || log2 == 3); for (c, cbf) in cbf_c.iter_mut().enumerate() { - if depth == 0 || parent_cbf_c[c] { - *cbf = self.dd(CBF_CHROMA + depth as usize) == 1; + if depth == 0 || parent_cbf_c[c][0] { + cbf[0] = self.dd(CBF_CHROMA + depth as usize) == 1; + if second { + cbf[1] = self.dd(CBF_CHROMA + depth as usize) == 1; + } } } } else { // 4x4 luma blocks: chroma cbfs are those of the parent (coded at blkIdx 3) cbf_c = parent_cbf_c; if depth == 0 { - cbf_c = [false, false]; + cbf_c = [[false; 2]; 2]; } } if split { @@ -139,8 +148,8 @@ impl<'a, S: Sample> SliceDecoder<'a, S> { } return Ok(()); } - let cbf_luma = - if self.cu_intra || depth != 0 || cbf_c[0] || cbf_c[1] { self.dd(CBF_LUMA + if depth == 0 { 1 } else { 0 }) == 1 } else { true }; + let any_cbf_c = cbf_c.iter().flatten().any(|&b| b); + let cbf_luma = if self.cu_intra || depth != 0 || any_cbf_c { self.dd(CBF_LUMA + if depth == 0 { 1 } else { 0 }) == 1 } else { true }; let n = 1 << log2; if depth > 0 { self.mark_edges(x0, y0, n, n, E_TU_V, E_TU_H); @@ -148,11 +157,11 @@ impl<'a, S: Sample> SliceDecoder<'a, S> { self.transform_unit(node, cbf_luma, cbf_c) } - fn transform_unit(&mut self, node: TtNode, cbf_luma: bool, cbf_c: [bool; 2]) -> Result<()> { + fn transform_unit(&mut self, node: TtNode, cbf_luma: bool, cbf_c: [[bool; 2]; 2]) -> Result<()> { let TtNode { x0, y0, xb, yb, log2, blk_idx } = node; let n = 1i32 << log2; let chroma_here = log2 > 2 || blk_idx == 3; - let cbf_chroma = cbf_c[0] || cbf_c[1]; + let cbf_chroma = cbf_c.iter().flatten().any(|&b| b); if (cbf_luma || cbf_chroma) && self.pps.cu_qp_delta_enabled && !self.is_cu_qp_delta_coded { // cu_qp_delta_abs: prefix TU(5) with ctx 0,1,1,1,1, suffix EG0 let mut v = 0; @@ -191,15 +200,24 @@ impl<'a, S: Sample> SliceDecoder<'a, S> { if !chroma_here { return Ok(()); } - let (xc, yc, log2c) = if log2 > 2 { (x0 / 2, y0 / 2, log2 - 1) } else { (xb / 2, yb / 2, 2) }; + // Chroma blocks of this transform unit (7.3.8.10): square blocks of (luma size) / + // SubWidthC at the chroma position of the unit (of the 8x8 parent for 4x4 luma blocks); + // in 4:2:2 the chroma rectangle is twice as tall and coded as two stacked square blocks, + // the lower one predicted from the reconstructed upper one (8.4.4.1). + let (sx, sy) = self.pic.chroma.shifts(); + let (xc, yc, log2c) = if log2 > 2 { (x0 >> sx, y0 >> sy, log2 - 1) } else { (xb >> sx, yb >> sy, 2) }; + let blocks = if self.pic.chroma == ChromaFormat::C422 { 2 } else { 1 }; for c in 1..3 { - if self.cu_intra { - let mode = self.intra_chroma_mode; - self.intra_predict(xc, yc, log2c, c, mode); - } - if cbf_c[c - 1] { - let qp = if c == 1 { self.qp_cb } else { self.qp_cr }; - self.residual(xc, yc, log2c, c, qp)?; + for t in 0..blocks { + let yt = yc + ((t as i32) << log2c); + if self.cu_intra { + let mode = self.intra_chroma_mode; + self.intra_predict(xc, yt, log2c, c, mode); + } + if cbf_c[c - 1][t] { + let qp = if c == 1 { self.qp_cb } else { self.qp_cr }; + self.residual(xc, yt, log2c, c, qp)?; + } } } Ok(()) @@ -208,25 +226,31 @@ impl<'a, S: Sample> SliceDecoder<'a, S> { /// Intra prediction of an n x n block of component `c` at component position (x0, y0). fn intra_predict(&mut self, x0: i32, y0: i32, log2: u32, c: usize, mode: u32) { let n = 1usize << log2; - let sc = if c == 0 { 1 } else { 2 }; - let (xl, yl) = (x0 * sc, y0 * sc); - let unit = 4 / sc as usize; // component samples per 4x4 luma block + // shifts from component to luma positions (8.4.4.2.1: the availability of neighbouring + // samples is derived at the luma positions (xNbCmp * SubWidthC, yNbCmp * SubHeightC)) + let (sx, sy) = if c == 0 { (0, 0) } else { self.pic.chroma.shifts() }; + let (xl, yl) = (x0 << sx, y0 << sy); + // component samples per 4x4 luma block, down the left column and along the top row + // (4:2:2 chroma: 4 rows but 2 columns) + let (unit_y, unit_x) = (4usize >> sy, 4usize >> sx); let stride = if c == 0 { self.pic.width } else { self.pic.cwidth }; let constrained = self.pps.constrained_intra_pred; let mut al = [false; 129]; let mut at = [false; 129]; let addr = self.slice_addr; let pic = &*self.pic; - let units = (2 * n / unit) as u32; - let mut ml = pic.zavail_run((xl, yl), (xl - 1, yl), true, units, addr); - let mut mt = pic.zavail_run((xl, yl), (xl, yl - 1), false, units, addr); + let (units_l, units_t) = ((2 * n / unit_y) as u32, (2 * n / unit_x) as u32); + let mut ml = pic.zavail_run((xl, yl), (xl - 1, yl), true, units_l, addr); + let mut mt = pic.zavail_run((xl, yl), (xl, yl - 1), false, units_t, addr); let mut corner = pic.zavail(xl, yl, xl - 1, yl - 1, addr); if constrained { let intra = |x: i32, y: i32| pic.blk_at(x, y).flags & F_INTRA != 0; - for k in 0..units { + for k in 0..units_l { if ml & (1 << k) != 0 && !intra(xl - 1, yl + 4 * k as i32) { ml &= !(1 << k); } + } + for k in 0..units_t { if mt & (1 << k) != 0 && !intra(xl + 4 * k as i32, yl - 1) { mt &= !(1 << k); } @@ -242,14 +266,14 @@ impl<'a, S: Sample> SliceDecoder<'a, S> { r.left[0] = v; r.top[0] = v; } - for_runs(ml, unit, |a, b| { + for_runs(ml, unit_y, |a, b| { let left = (y0 as usize + a) * stride + x0 as usize - 1; al[1 + a..1 + b].fill(true); for (k, v) in r.left[1 + a..1 + b].iter_mut().enumerate() { *v = plane[left + k * stride].to_u16(); } }); - for_runs(mt, unit, |a, b| { + for_runs(mt, unit_x, |a, b| { let top = (y0 as usize - 1) * stride + x0 as usize; at[1 + a..1 + b].fill(true); for (v, s) in r.top[1 + a..1 + b].iter_mut().zip(&plane[top + a..top + b]) { diff --git a/crates/video/src/hevc/decode/synth_tests.rs b/crates/video/src/hevc/decode/synth_tests.rs index 9c873b6be..d227eedbf 100644 --- a/crates/video/src/hevc/decode/synth_tests.rs +++ b/crates/video/src/hevc/decode/synth_tests.rs @@ -751,3 +751,56 @@ fn hvcc_rejects_a_valid_picture_slice_without_starting_a_picture() { assert!(decoder.flush().is_empty()); assert_eq!(decoder.stats().pictures, 0); } + +/// The RBSP of a minimal PPS (no transform skip) with a pps_range_extension() whose only +/// non-default fields are the SAO offset scales. +fn pps_rbsp_with_sao_offset_scale(luma: u32, chroma: u32) -> Vec { + let mut w = BitWriter::new(); + w.write_ue(0); // pps_pic_parameter_set_id + w.write_ue(0); // pps_seq_parameter_set_id + w.write_bits(0, 1); // dependent slice segments + w.write_bits(0, 1); // output flag present + w.write_bits(0, 3); // num_extra_slice_header_bits + w.write_bits(0, 1); // sign data hiding + w.write_bits(0, 1); // cabac_init_present + w.write_ue(0); + w.write_ue(0); + w.write_se(0); // init_qp 26 + w.write_bits(0, 1); // constrained intra + w.write_bits(0, 1); // transform skip + w.write_bits(0, 1); // cu_qp_delta + w.write_se(0); + w.write_se(0); + w.write_bits(0, 1); // slice chroma qp offsets present + w.write_bits(0, 1); // weighted pred + w.write_bits(0, 1); // weighted bipred + w.write_bits(0, 1); // transquant bypass + w.write_bits(0, 1); // tiles + w.write_bits(0, 1); // entropy coding sync + w.write_bits(1, 1); // loop filter across slices + w.write_bits(0, 1); // deblocking control + w.write_bits(0, 1); // scaling list + w.write_bits(0, 1); // lists modification + w.write_ue(0); // log2_parallel_merge_level - 2 + w.write_bits(0, 1); // slice header extension + w.write_bits(1, 1); // pps_extension_present + w.write_bits(0x80, 8); // pps_range_extension_flag only + w.write_bits(0, 1); // cross_component_prediction + w.write_bits(0, 1); // chroma_qp_offset_list + w.write_ue(luma); // log2_sao_offset_scale_luma + w.write_ue(chroma); // log2_sao_offset_scale_chroma + w.rbsp_trailing(); + w.finish() +} + +/// SAO offsets are applied unscaled, so a PPS scaling them (a range-extension tool) must be +/// refused rather than decoded to wrong samples; a range extension that enables nothing is fine. +#[test] +fn a_pps_scaling_sao_offsets_is_unsupported() { + let plain = crate::hevc::decode::params::Pps::parse(&pps_rbsp_with_sao_offset_scale(0, 0)).expect("parses"); + assert!(plain.check_supported().is_ok()); + for (luma, chroma) in [(1, 0), (0, 2)] { + let pps = crate::hevc::decode::params::Pps::parse(&pps_rbsp_with_sao_offset_scale(luma, chroma)).expect("parses"); + assert!(pps.check_supported().is_err(), "luma {luma}, chroma {chroma}"); + } +} diff --git a/crates/video/src/hevc/decode/tables.rs b/crates/video/src/hevc/decode/tables.rs index 6089e418f..7e20bd99e 100644 --- a/crates/video/src/hevc/decode/tables.rs +++ b/crates/video/src/hevc/decode/tables.rs @@ -68,6 +68,18 @@ pub fn qpc_420(qpi: i32) -> i32 { } } +/// Table 8-3: IntraPredModeC for modeIdx (Table 8-2) when ChromaArrayType is 2 (the 4:2:2 +/// chroma intra prediction mode: the luma direction with its horizontal component halved). +pub static INTRA_CHROMA_MODE_422: [u8; 35] = + [0, 1, 2, 2, 2, 2, 3, 5, 7, 8, 10, 12, 13, 15, 17, 18, 19, 20, 21, 22, 23, 23, 24, 24, 25, 25, 26, 27, 27, 28, 28, 29, 29, 30, 31]; + +/// QpC for the index qPi (8.6.1, 8.7.2.5.5): Table 8-10 when ChromaArrayType is 1, otherwise +/// Min(qPi, 51). +#[inline] +pub fn qpc(qpi: i32, chroma_array_type_1: bool) -> i32 { + if chroma_array_type_1 { qpc_420(qpi) } else { qpi.min(51) } +} + /// Table 9-50 ctxIdxMap for 4x4 sig_coeff_flag contexts. pub static CTX_IDX_MAP: [u8; 16] = [0, 1, 4, 5, 2, 3, 4, 5, 6, 6, 8, 8, 7, 7, 8, 8]; @@ -81,6 +93,40 @@ pub static DST_MATRIX: [[i8; 4]; 4] = [[29, 55, 74, 84], [74, 74, 0, -74], [84, mod tests { use super::*; + /// Table 8-3 (Rec. ITU-T H.265 v8 (08/2021), 8.4.3) is the 4:2:2 form of each luma direction: + /// chroma has half the horizontal resolution, so the direction of mode m (Table 8-5's + /// intraPredAngle, horizontal modes 2..=17 and vertical 18..=34) with its horizontal component + /// halved, mapped to the angular mode nearest in angle. Planar and DC stay. This rule + /// reproduces every one of the 35 entries, so a mistyped entry fails here without an oracle. + #[test] + fn the_422_chroma_mode_table_is_the_luma_direction_with_half_the_width() { + let dir = |m: usize| -> (f64, f64) { + let a = f64::from(INTRA_PRED_ANGLE[m]); + if m < 18 { (-32.0, a) } else { (a, -32.0) } + }; + let angle = |(x, y): (f64, f64)| y.atan2(x); + let gap = |a: f64, b: f64| { + let d = (a - b).rem_euclid(std::f64::consts::TAU); + d.min(std::f64::consts::TAU - d) + }; + for m in 0..35usize { + let want = if m < 2 { + m + } else { + let (x, y) = dir(m); + let t = angle((x / 2.0, y)); + let mut best = 2; + for k in 2..35 { + if gap(angle(dir(k)), t) < gap(angle(dir(best)), t) - 1e-12 { + best = k; + } + } + best + }; + assert_eq!(INTRA_CHROMA_MODE_422[m] as usize, want, "luma mode {m}"); + } + } + #[test] fn diagonal_scan_4x4() { let s = &SCAN_4[0]; @@ -119,5 +165,8 @@ mod tests { assert_eq!(qpc_420(30), 29); assert_eq!(qpc_420(43), 37); assert_eq!(qpc_420(51), 45); + assert_eq!(qpc(43, true), 37); + assert_eq!(qpc(43, false), 43); + assert_eq!(qpc(57, false), 51); } } diff --git a/crates/video/tests/hevc_decode/common/mod.rs b/crates/video/tests/hevc_decode/common/mod.rs index 896567a38..b086aa1ce 100644 --- a/crates/video/tests/hevc_decode/common/mod.rs +++ b/crates/video/tests/hevc_decode/common/mod.rs @@ -2,7 +2,7 @@ //! comparison helpers. #![allow(dead_code)] -use craft_video::hevc::decode::{Decoder, Picture}; +use craft_video::hevc::decode::{ChromaFormat, Decoder, Picture}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -18,6 +18,8 @@ pub struct Fixture { pub filter: &'static str, /// 10 (yuv420p10le) or 8. pub bit_depth: u32, + /// 4:2:0 or 4:2:2. + pub chroma: ChromaFormat, /// Encoder arguments (libx265 unless `-c:v` is given). pub args: &'static [&'static str], } @@ -26,7 +28,10 @@ const NOISE: &str = "noise=alls=12:allf=t+u"; macro_rules! fx { ($name:expr, $src:expr, $w:expr, $h:expr, $n:expr, $filter:expr, $bd:expr, [$($a:expr),* $(,)?]) => { - Fixture { name: $name, source: $src, width: $w, height: $h, frames: $n, filter: $filter, bit_depth: $bd, args: &[$($a),*] } + Fixture { name: $name, source: $src, width: $w, height: $h, frames: $n, filter: $filter, bit_depth: $bd, chroma: ChromaFormat::C420, args: &[$($a),*] } + }; + ($name:expr, $src:expr, $w:expr, $h:expr, $n:expr, $filter:expr, $bd:expr, 422, [$($a:expr),* $(,)?]) => { + Fixture { name: $name, source: $src, width: $w, height: $h, frames: $n, filter: $filter, bit_depth: $bd, chroma: ChromaFormat::C422, args: &[$($a),*] } }; } @@ -73,6 +78,39 @@ pub const FIXTURES: &[Fixture] = &[ fx!("odd_1918x1078", "testsrc2", 1918, 1078, 3, NOISE, 8, ["-preset", "veryfast", "-x265-params", "log-level=error"]), fx!("hd_1080p", "testsrc2", 1920, 1080, 3, "", 8, ["-preset", "fast", "-x265-params", "log-level=error"]), fx!("uhd_4k_main10", "testsrc2", 3840, 2160, 2, "", 10, ["-preset", "ultrafast", "-x265-params", "log-level=error"]), + // 4:2:2 (Main 4:2:2 10 / RExt profile without range-extension tools: libx265 with yuv422p / yuv422p10le) + fx!("intra_422", "testsrc2", 352, 288, 3, NOISE, 8, 422, ["-x265-params", "keyint=1:log-level=error"]), + fx!("intra_422_10", "testsrc2", 352, 288, 3, NOISE, 10, 422, ["-x265-params", "keyint=1:log-level=error"]), + fx!("intra_422_10_nosao_nodbk", "testsrc2", 352, 288, 2, NOISE, 10, 422, ["-x265-params", "keyint=1:no-sao=1:no-deblock=1:log-level=error"]), + fx!("medium_422_10", "testsrc2", 352, 288, 20, NOISE, 10, 422, ["-preset", "medium", "-x265-params", "log-level=error"]), + fx!("slow_422", "mandelbrot", 352, 288, 12, NOISE, 8, 422, ["-preset", "slow", "-x265-params", "log-level=error"]), + fx!("p_only_422_10", "testsrc2", 352, 288, 20, NOISE, 10, 422, ["-x265-params", "bframes=0:log-level=error"]), + fx!("bframes_422_10", "testsrc2", 352, 288, 30, NOISE, 10, 422, ["-x265-params", "bframes=8:b-pyramid=1:ref=4:log-level=error"]), + fx!("no_sao_422_10", "testsrc2", 352, 288, 12, NOISE, 10, 422, ["-x265-params", "no-sao=1:log-level=error"]), + fx!("deblock_m3_3_422_10", "testsrc2", 352, 288, 12, NOISE, 10, 422, ["-x265-params", "deblock=-3,3:log-level=error"]), + fx!("rect_amp_422_10", "mandelbrot", 352, 288, 12, NOISE, 10, 422, ["-x265-params", "rect=1:amp=1:log-level=error"]), + fx!("tskip_422_10", "testsrc2", 352, 288, 12, NOISE, 10, 422, ["-x265-params", "tskip=1:log-level=error"]), + fx!("weightp_422_10", "testsrc2", 352, 288, 20, "fade=in:0:18", 10, 422, ["-x265-params", "weightp=1:weightb=1:log-level=error"]), + fx!("qp_422_10", "mandelbrot", 352, 288, 8, NOISE, 10, 422, ["-x265-params", "aq-mode=3:cutree=1:log-level=error"]), + fx!("crf51_422_10", "testsrc2", 352, 288, 8, NOISE, 10, 422, ["-crf", "51", "-x265-params", "log-level=error"]), + fx!("lossless_422_10", "testsrc2", 176, 144, 4, NOISE, 10, 422, ["-x265-params", "lossless=1:log-level=error"]), + fx!( + "ctu32_tu4_422_10", + "testsrc2", + 352, + 288, + 12, + NOISE, + 10, + 422, + ["-x265-params", "ctu=32:tu-intra-depth=4:tu-inter-depth=4:max-tu-size=16:log-level=error"] + ), + fx!("ctu16_422_10", "testsrc2", 352, 288, 8, NOISE, 10, 422, ["-x265-params", "ctu=16:log-level=error"]), + fx!("scaling_list_422_10", "testsrc2", 352, 288, 8, NOISE, 10, 422, ["-x265-params", "scaling-list=default:log-level=error"]), + fx!("constrained_intra_422_10", "testsrc2", 352, 288, 8, NOISE, 10, 422, ["-x265-params", "constrained-intra=1:log-level=error"]), + fx!("slices4_422_10", "testsrc2", 352, 288, 8, NOISE, 10, 422, ["-x265-params", "slices=4:log-level=error"]), + fx!("odd_1918x1078_422_10", "testsrc2", 1918, 1078, 3, NOISE, 10, 422, ["-preset", "veryfast", "-x265-params", "log-level=error"]), + fx!("uhd_4k_422_10", "testsrc2", 3840, 2160, 2, "", 10, 422, ["-preset", "ultrafast", "-x265-params", "log-level=error"]), // performance reference streams (tests/it/perf.rs) fx!("bench_1080p", "testsrc2", 1920, 1080, 60, "noise=alls=3:allf=t", 8, ["-preset", "medium", "-crf", "24", "-x265-params", "log-level=error"]), fx!("bench_4k", "testsrc2", 3840, 2160, 20, "noise=alls=3:allf=t", 10, ["-preset", "fast", "-crf", "26", "-x265-params", "log-level=error"]), @@ -108,8 +146,21 @@ fn run(cmd: &mut Command) -> bool { } } -pub fn pix_fmt(bd: u32) -> &'static str { - if bd > 8 { "yuv420p10le" } else { "yuv420p" } +pub fn pix_fmt(bd: u32, chroma: ChromaFormat) -> &'static str { + match (chroma, bd > 8) { + (ChromaFormat::C420, false) => "yuv420p", + (ChromaFormat::C420, true) => "yuv420p10le", + (ChromaFormat::C422, false) => "yuv422p", + (ChromaFormat::C422, true) => "yuv422p10le", + } +} + +/// Bytes of one raw frame of the fixture as ffmpeg writes it. +pub fn frame_size(f: &Fixture) -> usize { + let (w, h) = (f.width as usize, f.height as usize); + let bps = if f.bit_depth > 8 { 2 } else { 1 }; + let (cw, ch) = (w.div_ceil(f.chroma.sub_width()), h.div_ceil(f.chroma.sub_height())); + (w * h + 2 * cw * ch) * bps } /// The pixel format ffprobe reports for the video stream in `path`. @@ -129,7 +180,7 @@ pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { let yuv = dir.join(format!("{}.yuv", f.name)); if !hevc.exists() { let tmp = craft_media_testkit::temp_path(&hevc); - let mut vf = format!("{}=size={}x{}:rate=25,format={}", f.source, f.width, f.height, pix_fmt(f.bit_depth)); + let mut vf = format!("{}=size={}x{}:rate=25,format={}", f.source, f.width, f.height, pix_fmt(f.bit_depth, f.chroma)); if !f.filter.is_empty() { vf.push(','); vf.push_str(f.filter); @@ -140,7 +191,7 @@ pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { if !f.args.contains(&"-c:v") { c.args(["-c:v", "libx265"]); } - c.args(["-pix_fmt", pix_fmt(f.bit_depth)]); + c.args(["-pix_fmt", pix_fmt(f.bit_depth, f.chroma)]); c.args(f.args); c.args(["-f", "hevc"]).arg(&tmp); if !run(&mut c) { @@ -157,10 +208,10 @@ pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { // 10, and the reference decode scales that up to 10 bits, so no sample would match. let probe = craft_media_testkit::ffprobe()?; let got = stream_pix_fmt(&probe, &hevc); - if got.as_deref() != Some(pix_fmt(f.bit_depth)) { + if got.as_deref() != Some(pix_fmt(f.bit_depth, f.chroma)) { let _ = std::fs::remove_file(&hevc); let _ = std::fs::remove_file(&yuv); - let reason = format!("the encoder made {} instead of {}", got.as_deref().unwrap_or("no stream"), pix_fmt(f.bit_depth)); + let reason = format!("the encoder made {} instead of {}", got.as_deref().unwrap_or("no stream"), pix_fmt(f.bit_depth, f.chroma)); craft_media_testkit::skip_test(f.name, &format!("{reason} (libx265 built for 8 bits only?)")); return None; } @@ -169,7 +220,7 @@ pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { let tmp = craft_media_testkit::temp_path(&yuv); let mut c = Command::new(&ff); c.args(["-hide_banner", "-loglevel", "error", "-y", "-i"]).arg(&hevc); - c.args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", pix_fmt(f.bit_depth)]).arg(&tmp); + c.args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", pix_fmt(f.bit_depth, f.chroma)]).arg(&tmp); assert!(run(&mut c), "reference decode failed for {}", f.name); craft_media_testkit::fixtures::publish(&tmp, &yuv).unwrap(); } @@ -246,11 +297,13 @@ pub fn decode_file_opts(path: &Path, threads: usize, draft: bool) -> Result Result<(), String> { +/// Compare decoded pictures with a raw planar reference (yuv420p / yuv420p10le / yuv422p / +/// yuv422p10le); returns a diagnostic on the first mismatch (frame, plane, sample position, CTB +/// (64x64) and 8x8 block). +pub fn compare(pics: &[Picture], reference: &[u8], w: usize, h: usize, bd: u32, chroma: ChromaFormat) -> Result<(), String> { let bps = if bd > 8 { 2 } else { 1 }; - let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + let (sw, sh) = (chroma.sub_width(), chroma.sub_height()); + let (cw, ch) = (w.div_ceil(sw), h.div_ceil(sh)); let fsize = (w * h + 2 * cw * ch) * bps; let nref = reference.len() / fsize; for (i, p) in pics.iter().enumerate() { @@ -260,11 +313,15 @@ pub fn compare(pics: &[Picture], reference: &[u8], w: usize, h: usize, bd: u32) if p.width as usize != w || p.height as usize != h { return Err(format!("frame {i}: size {}x{} != {}x{}", p.width, p.height, w, h)); } + if p.chroma != chroma || p.chroma_width as usize != cw || p.chroma_height as usize != ch { + return Err(format!("frame {i}: chroma {:?} {}x{} != {chroma:?} {cw}x{ch}", p.chroma, p.chroma_width, p.chroma_height)); + } let f = &reference[i * fsize..(i + 1) * fsize]; let sample = |buf: &[u8], k: usize| if bps == 2 { u16::from_le_bytes([buf[2 * k], buf[2 * k + 1]]) } else { buf[k] as u16 }; let (ys, cs) = (w * h * bps, cw * ch * bps); - let planes = [("Y", &p.y, &f[..ys], w, h, 1), ("U", &p.u, &f[ys..ys + cs], cw, ch, 2), ("V", &p.v, &f[ys + cs..], cw, ch, 2)]; - for (name, got, exp, pw, ph, sub) in planes { + let planes = + [("Y", &p.y, &f[..ys], w, h, (1, 1)), ("U", &p.u, &f[ys..ys + cs], cw, ch, (sw, sh)), ("V", &p.v, &f[ys + cs..], cw, ch, (sw, sh))]; + for (name, got, exp, pw, ph, (subx, suby)) in planes { let mut first = None; let mut count = 0usize; let mut maxd = 0i32; @@ -281,7 +338,7 @@ pub fn compare(pics: &[Picture], reference: &[u8], w: usize, h: usize, bd: u32) } } if let Some((x, y, a, b)) = first { - let (lx, ly) = (x * sub, y * sub); + let (lx, ly) = (x * subx, y * suby); return Err(format!( "frame {i} (poc {}, pts {}) plane {name}: first mismatch at ({x},{y}) [luma ({lx},{ly}), CTB64 ({},{}), 8x8 ({},{})]: got {a} want {b}; {count} samples differ, max diff {maxd}", p.poc, @@ -310,11 +367,12 @@ pub fn check_fixture(name: &str) -> Result { let pics = match decode_file_threads(&hevc, threads) { Ok(p) => p, Err((au, e, partial)) => { - let cmp = compare(&partial, &reference, f.width as usize, f.height as usize, f.bit_depth).err().unwrap_or_default(); + let cmp = compare(&partial, &reference, f.width as usize, f.height as usize, f.bit_depth, f.chroma).err().unwrap_or_default(); return Err(format!("{name} (threads {threads}): decode error at access unit {au}: {e} (after {} pictures) {cmp}", partial.len())); } }; - compare(&pics, &reference, f.width as usize, f.height as usize, f.bit_depth).map_err(|e| format!("{name} (threads {threads}): {e}"))?; + compare(&pics, &reference, f.width as usize, f.height as usize, f.bit_depth, f.chroma) + .map_err(|e| format!("{name} (threads {threads}): {e}"))?; check_pts(&pics).map_err(|e| format!("{name} (threads {threads}): {e}"))?; if pics.iter().any(|p| p.draft) { return Err(format!("{name} (threads {threads}): picture flagged draft without draft mode")); diff --git a/crates/video/tests/hevc_decode/conformance.rs b/crates/video/tests/hevc_decode/conformance.rs index d33f3ffce..08602d079 100644 --- a/crates/video/tests/hevc_decode/conformance.rs +++ b/crates/video/tests/hevc_decode/conformance.rs @@ -60,6 +60,28 @@ fixture_tests!( uhd_4k_main10, vt_main, vt_main10, + intra_422, + intra_422_10, + intra_422_10_nosao_nodbk, + medium_422_10, + slow_422, + p_only_422_10, + bframes_422_10, + no_sao_422_10, + deblock_m3_3_422_10, + rect_amp_422_10, + tskip_422_10, + weightp_422_10, + qp_422_10, + crf51_422_10, + lossless_422_10, + ctu32_tu4_422_10, + ctu16_422_10, + scaling_list_422_10, + constrained_intra_422_10, + slices4_422_10, + odd_1918x1078_422_10, + uhd_4k_422_10, ); /// Draft mode: sub-layer non-reference pictures skip deblocking and SAO and are flagged; every @@ -67,16 +89,14 @@ fixture_tests!( #[test] fn draft_mode_changes_only_flagged_non_reference_pictures() { let mut checked = 0; - for name in ["bframes", "medium", "slow", "weightp_main10"] { + for name in ["bframes", "medium", "slow", "weightp_main10", "bframes_422_10"] { let f = common::fixture(name); let Some((hevc, yuv)) = common::ensure(f) else { eprintln!("skipped {name}"); continue; }; let reference = std::fs::read(&yuv).unwrap(); - let (w, h) = (f.width as usize, f.height as usize); - let bps = if f.bit_depth > 8 { 2 } else { 1 }; - let fsize = (w * h + 2 * w.div_ceil(2) * h.div_ceil(2)) * bps; + let fsize = common::frame_size(f); for threads in [1, 3] { let pics = common::decode_file_opts(&hevc, threads, true).unwrap_or_else(|(au, e, _)| panic!("{name}: error at {au}: {e}")); common::check_pts(&pics).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -125,10 +145,10 @@ fn compare_detects_mismatch() { let Some((hevc, yuv)) = common::ensure(f) else { return }; let pics = common::decode_file_threads(&hevc, 1).map_err(|e| e.1).unwrap(); let mut reference = std::fs::read(yuv).unwrap(); - common::compare(&pics, &reference, f.width as usize, f.height as usize, f.bit_depth).unwrap(); + common::compare(&pics, &reference, f.width as usize, f.height as usize, f.bit_depth, f.chroma).unwrap(); let pos = reference.len() - 7; reference[pos] ^= 1; - let err = common::compare(&pics, &reference, f.width as usize, f.height as usize, f.bit_depth).unwrap_err(); + let err = common::compare(&pics, &reference, f.width as usize, f.height as usize, f.bit_depth, f.chroma).unwrap_err(); assert!(err.contains("plane V"), "{err}"); } diff --git a/crates/video/tests/hevc_decode/robustness.rs b/crates/video/tests/hevc_decode/robustness.rs index 27c457342..a6c50ef41 100644 --- a/crates/video/tests/hevc_decode/robustness.rs +++ b/crates/video/tests/hevc_decode/robustness.rs @@ -57,7 +57,7 @@ fn hvcc_input_matches_reference() { } pics.extend(dec.flush()); let reference = std::fs::read(yuv).unwrap(); - common::compare(&pics, &reference, f.width as usize, f.height as usize, f.bit_depth).unwrap(); + common::compare(&pics, &reference, f.width as usize, f.height as usize, f.bit_depth, f.chroma).unwrap(); let mut pts: Vec = pics.iter().map(|p| p.pts).collect(); pts.sort(); assert_eq!(pts, (0..samples.len() as i64).map(|i| i * 1000).collect::>());