diff --git a/docs/dolby_vision_profile81.md b/docs/dolby_vision_profile81.md index 5a8f0204a..c520beaed 100644 --- a/docs/dolby_vision_profile81.md +++ b/docs/dolby_vision_profile81.md @@ -132,10 +132,15 @@ public: }; /// L1 元数据推导:avg 取 stats.avg_maxrgb_pq(PQ 域均值), -/// max 取 percentile_99(离群点防护),min 取 percentile_10_pq 后钳位。 +/// max 取 percentile_99(离群点防护)。有扩展近黑统计时,近黑覆盖率达到 1% +/// 才把 min 报告为零,否则取 percentile_1_pq;旧分析结果回退到 percentile_10_pq。 +/// 所有值随后钳位。 std::optional frame_metadata_from_stats(const platf::hdr_frame_luminance_stats_t &stats); +/// 在场景边界之间统一平滑 min/avg/max;切场时先 reset(),避免旧场景拖尾。 +class level1_temporal_filter_t { ... }; + /// frame_id 绑定的固定容量在途 RPU 队列:编码输出按 frame_index 取回对应 RPU, /// 超过最大在途帧数返回失败 —— 调用方应停止 DV 而不是错位附接。 class staged_rpu_queue_t { ... }; @@ -180,8 +185,8 @@ L1 偏移也相同 —— 但这只是巧合,不应成为依赖。`configure() 现有统计不能机械复制成 DV 的语义: - **`min_pq` 不能用绝对最小像素。** 游戏中一个黑色 UI 像素、黑边或透明合成区域就能 - 把整帧最小值钉死在零。用「原始最小值 + 低百分位亮度 + 黑像素比例 + 时间稳定器」 - 共同生成稳健最小值。 + 把整帧最小值钉死在零。分析器同时给出 PQ 第 1 百分位和首个 PQ 直方图 bin 的覆盖率; + 覆盖率达到 1% 才报告零,否则采用第 1 百分位。旧分析结果没有扩展统计时回退到 P10。 - **`avg_pq` 必须是 PQ 域平均,不是 `average_maxrgb`。** PQ 是凹函数, `PQ(mean(nits)) ≥ mean(PQ(nits))`,暗场带高光时差距是整个动态范围的大部分。 现有 `stats.avg_maxrgb_pq` 正是逐像素累加的 PQ 域平均,直接用它。 @@ -425,8 +430,10 @@ OPPO 真机解码链路 + Sony 电视 Dolby Vision 点亮);主机侧灰度 统计缺失时的保守 RPU、RPU/frame_id 严格匹配、零分配优化。 已落地:avg/max/min 推导与钳位(§3.2)、统计缺失复用上次有效值、首帧预热跳过、 -frame_index 严格绑定、队列溢出即停、稳态零分配。待实机调优:场景切换信号 -(当前 scene_refresh 恒 false,EMA 的切场检测尚未导出)。 +frame_index 严格绑定、队列溢出即停、稳态零分配。L1 min/avg/max 使用专用时域滤波器; +`scene_refresh` 由独立 GPU 样本的 PQ 均值、P10/P90 与 HDR10+ 分位分布共同判定, +切场时先清空滤波历史,重复使用同一分析样本不会重复刷新。 +待实机调优:按游戏类型校准切场阈值与近黑覆盖率阈值。 ### Phase 3:正式协议协商与降级(协商层已落地) diff --git a/src/platform/common.h b/src/platform/common.h index 2bbec3158..af12e88d5 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -522,6 +522,12 @@ namespace platf { /// and the histogram is point-sampled per analysis cell. Zero alongside a nonzero /// avg_maxrgb means the analyzer did not produce one, which HDR Vivid rejects. float avg_maxrgb_pq = 0.0f; + /// First percentile and near-black coverage from the analyzer's PQ histogram. + /// `near_black_fraction` is the fraction in histogram bin zero (PQ < 1/256). + /// The validity bit distinguishes an older analyzer from a genuinely black frame. + float percentile_1_pq = 0.0f; + float near_black_fraction = 0.0f; + bool near_black_stats_valid = false; float percentile_10_pq = 0.0f; ///< 10th percentile in normalized PQ signal space float percentile_90_pq = 0.0f; ///< 90th percentile in normalized PQ signal space /// 99th percentile of maxRGB (nits). Reported as the HDR10+ maxSCL; see diff --git a/src/platform/windows/display_vram.cpp b/src/platform/windows/display_vram.cpp index 23e5a7025..a19474f18 100644 --- a/src/platform/windows/display_vram.cpp +++ b/src/platform/windows/display_vram.cpp @@ -424,12 +424,10 @@ namespace platf::dxgi { } // Poll the previous analysis result before taking the capture mutex. - if (hdr_analysis_pending) { - read_hdr_analysis_results(); - if (hdr_luminance_stats_out.valid && !runtime_status.scene_metadata_active) { - runtime_status.scene_metadata_active = true; - ::video::update_hdr_pipeline_status(runtime_status_id, runtime_status); - } + read_hdr_analysis_results(); + if (hdr_luminance_stats_out.valid && !runtime_status.scene_metadata_active) { + runtime_status.scene_metadata_active = true; + ::video::update_hdr_pipeline_status(runtime_status_id, runtime_status); } // Acquire encoder mutex to synchronize with capture code. Normal @@ -1712,7 +1710,7 @@ namespace platf::dxgi { uav_t hdr_final_result_uav; // UAV view for pass 2 output buf_t hdr_global_histogram_buf; // 256-bin PQ histogram accumulated by pass 1 atomics uav_t hdr_global_histogram_uav; // Typed R32_UINT UAV (clearable + atomic-capable) - buf_t hdr_staging_buf; // Staging buffer for CPU readback (1 FinalResult) + buf_t hdr_staging_buf; // One non-blocking CPU readback; new analysis waits while pending buf_t hdr_analysis_cbuf; // Constant buffer for pass 1 (analysis resolution) buf_t hdr_analysis_snapshot_cbuf; // Shared converter/pass 1 params for the snapshot buf_t hdr_reduce_cbuf; // Constant buffer for pass 2 (numGroups) @@ -1721,7 +1719,7 @@ namespace platf::dxgi { uint32_t hdr_num_groups = 0; // Number of thread groups dispatched in pass 1 uint64_t hdr_analysis_frame_index = 0; // Used to downsample analysis frequency uint64_t hdr_analysis_sample_sequence = 0; // Counts completed, independent GPU samples - bool hdr_analysis_pending = false; // Whether we have results ready to read + bool hdr_analysis_pending = false; // Prevents overwriting a readback the GPU has not completed bool hdr_analysis_ready = false; // Whether the analyzer's GPU resources were created bool hdr_analysis_enabled = false; // Whether analysis runs: resources exist and the stream can carry metadata ::video::hdr_metadata::formats_t hdr_metadata_formats; // Dynamic metadata formats this stream may carry @@ -1996,7 +1994,7 @@ namespace platf::dxgi { return -1; } - // --- Staging buffer for async CPU readback (1 FinalResult only) --- + // --- Staging ring for asynchronous CPU readback --- D3D11_BUFFER_DESC staging_desc = {}; staging_desc.ByteWidth = sizeof(FinalResult); staging_desc.Usage = D3D11_USAGE_STAGING; @@ -2054,6 +2052,11 @@ namespace platf::dxgi { void dispatch_hdr_analysis(const HdrAnalysisSource &source) { if (!hdr_analysis_enabled || !source) return; + if (hdr_analysis_pending) { + // The GPU is already behind this analysis cadence. Drop the new sample + // instead of queuing progressively older metadata or blocking capture. + return; + } // Unbind render targets to avoid resource hazard (SRV vs RTV conflict) ID3D11RenderTargetView *null_rtv = nullptr; @@ -2108,9 +2111,7 @@ namespace platf::dxgi { device_ctx->CSSetConstantBuffers(0, 1, &null_cb); device_ctx->CSSetShader(nullptr, nullptr, 0); - // Copy final result to staging buffer for CPU readback next frame device_ctx->CopyResource(hdr_staging_buf.get(), hdr_final_result_buf.get()); - hdr_analysis_pending = true; } @@ -2121,8 +2122,13 @@ namespace platf::dxgi { */ void read_hdr_analysis_results() { + if (!hdr_analysis_pending) { + return; + } D3D11_MAPPED_SUBRESOURCE mapped = {}; - HRESULT status = device_ctx->Map(hdr_staging_buf.get(), 0, D3D11_MAP_READ, D3D11_MAP_FLAG_DO_NOT_WAIT, &mapped); + HRESULT status = device_ctx->Map( + hdr_staging_buf.get(), 0, + D3D11_MAP_READ, D3D11_MAP_FLAG_DO_NOT_WAIT, &mapped); if (status == DXGI_ERROR_WAS_STILL_DRAWING) { // GPU hasn't finished yet — skip this readback, try next frame @@ -2131,12 +2137,14 @@ namespace platf::dxgi { if (FAILED(status)) { BOOST_LOG(debug) << "HDR staging Map failed: " << util::log_hex(status); + hdr_analysis_pending = false; return; } auto *result = reinterpret_cast(mapped.pData); if (result->pixelCount > 0) { + hdr_luminance_stats_out = {}; hdr_luminance_stats_out.min_maxrgb = result->minMaxRGB; hdr_luminance_stats_out.max_maxrgb = result->maxMaxRGB; hdr_luminance_stats_out.avg_maxrgb = result->sumMaxRGB / static_cast(result->pixelCount); @@ -2164,6 +2172,9 @@ namespace platf::dxgi { // Retain P99 in nits for the independent HDR10+ path, and fill the nine // percentiles ST 2094-40 deployment profiles carry from the same walk. const uint32_t total = result->pixelCount; + hdr_luminance_stats_out.near_black_fraction = + static_cast(result->histogram[0]) / static_cast(total); + hdr_luminance_stats_out.near_black_stats_valid = true; const auto &percentages = ::video::hdr_metadata::hdr10plus_percentages; constexpr size_t kDistCount = percentages.size(); @@ -2187,6 +2198,9 @@ namespace platf::dxgi { if (!dist_found[p] && cumulative >= dist_targets[p]) { hdr_luminance_stats_out.distribution_maxrgb[p] = ::video::hdr_metadata::pq_to_nits(pq_bin_center); + if (p == 0) { + hdr_luminance_stats_out.percentile_1_pq = pq_bin_center; + } dist_found[p] = true; } } diff --git a/src/video.cpp b/src/video.cpp index 6b75dd6b2..819101224 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -553,7 +553,7 @@ namespace video { avcodec_ctx = std::move(other.avcodec_ctx); replacements = std::move(other.replacements); frame_timestamps = std::move(other.frame_timestamps); - hdr_ema = other.hdr_ema; + dynamic_metadata_temporal = std::move(other.dynamic_metadata_temporal); sps = std::move(other.sps); vps = std::move(other.vps); @@ -671,10 +671,9 @@ namespace video { std::vector replacements; frame_timestamp_ring_t frame_timestamps; - // Temporal filters are session-local so a new stream cannot inherit metadata - // history from the previous stream. - hdr_metadata::hdr_luminance_ema_t hdr_ema; - hdr_metadata::vivid_temporal_filter_t vivid_filter; + // Scene-aware temporal state is session-local so a new stream cannot inherit + // metadata history from the previous stream. + hdr_metadata::dynamic_metadata_temporal_state_t dynamic_metadata_temporal; cbs::nal_t sps; cbs::nal_t vps; @@ -2117,14 +2116,14 @@ namespace video { * the recommended 32-frame mean. HDR10+ keeps its independent EMA path. * * @param frame The AVFrame with pre-allocated dynamic HDR side data - * @param ema Temporally-smoothed HDR10+ luminance statistics + * @param hdr10plus_stats Temporally-smoothed HDR10+ luminance statistics * @param vivid_metadata Filtered HDR Vivid statistics in normalized PQ space * @param max_display_luminance Mapped client display peak luminance in nits */ void update_hdr_dynamic_metadata( AVFrame *frame, - const hdr_metadata::hdr_luminance_ema_t &ema, + const platf::hdr_frame_luminance_stats_t &hdr10plus_stats, const hdr_metadata::vivid_metadata_t &vivid_metadata, uint16_t max_display_luminance) { if (!frame) return; @@ -2152,12 +2151,15 @@ namespace video { // Update HDR10+ dynamic metadata auto hdr10plus_sd = av_frame_get_side_data(frame, AV_FRAME_DATA_DYNAMIC_HDR_PLUS); - if (hdr10plus_sd && ema.initialized) { + if (hdr10plus_sd && hdr10plus_stats.valid) { auto *hdr10plus = reinterpret_cast(hdr10plus_sd->data); if (hdr10plus && hdr10plus->num_windows > 0) { // The 99th percentile is what maxSCL reports; see hdr10plus_from_luminance(). const auto frame_metadata = hdr_metadata::hdr10plus_from_luminance( - ema.percentile_99, ema.avg_maxrgb, max_display_luminance, ema.distribution_maxrgb); + hdr10plus_stats.percentile_99, + hdr10plus_stats.avg_maxrgb, + max_display_luminance, + hdr10plus_stats.distribution_maxrgb); if (frame_metadata.valid) { auto ¶ms = hdr10plus->params[0]; const auto maxscl = av_make_q( @@ -2223,8 +2225,7 @@ namespace video { { auto &raw_stats = session.device->hdr_luminance_stats; if (raw_stats.valid) { - session.hdr_ema.update(raw_stats); - const auto vivid_metadata = session.vivid_filter.update(raw_stats); + const auto filtered = session.dynamic_metadata_temporal.update(raw_stats); uint16_t max_lum = 1000; auto mdm_sd = av_frame_get_side_data(frame, AV_FRAME_DATA_MASTERING_DISPLAY_METADATA); @@ -2234,7 +2235,8 @@ namespace video { max_lum = static_cast(av_q2d(mdm->max_luminance)); } } - update_hdr_dynamic_metadata(frame, session.hdr_ema, vivid_metadata, max_lum); + update_hdr_dynamic_metadata( + frame, filtered.hdr10plus_stats, filtered.vivid, max_lum); } } diff --git a/src/video_dolby_vision.cpp b/src/video_dolby_vision.cpp index 6e5ddf9e3..6e2d7df6b 100644 --- a/src/video_dolby_vision.cpp +++ b/src/video_dolby_vision.cpp @@ -160,11 +160,17 @@ namespace video::dolby_vision { const bool finite = std::isfinite(stats.avg_maxrgb_pq) && std::isfinite(stats.avg_maxrgb) && std::isfinite(stats.percentile_99) && - std::isfinite(stats.percentile_10_pq); + std::isfinite(stats.percentile_10_pq) && + (!stats.near_black_stats_valid || + (std::isfinite(stats.percentile_1_pq) && + std::isfinite(stats.near_black_fraction))); if (!finite || stats.avg_maxrgb_pq < 0.0f || stats.avg_maxrgb_pq > 1.0f || stats.percentile_99 < 0.0f || - stats.percentile_10_pq < 0.0f || stats.percentile_10_pq > 1.0f) { + stats.percentile_10_pq < 0.0f || stats.percentile_10_pq > 1.0f || + (stats.near_black_stats_valid && + (stats.percentile_1_pq < 0.0f || stats.percentile_1_pq > 1.0f || + stats.near_black_fraction < 0.0f || stats.near_black_fraction > 1.0f))) { return std::nullopt; } // Zero PQ-domain mean beside a positive linear mean means the analyzer @@ -174,13 +180,44 @@ namespace video::dolby_vision { } frame_metadata_t raw; - raw.min_pq = pq_signal_u12_rounded(stats.percentile_10_pq); + constexpr float meaningful_near_black_coverage = 0.01f; + const float robust_min_pq = stats.near_black_stats_valid ? + (stats.near_black_fraction >= meaningful_near_black_coverage ? + 0.0f : stats.percentile_1_pq) : + stats.percentile_10_pq; + raw.min_pq = pq_signal_u12_rounded(robust_min_pq); raw.max_pq = pq_code_u12_rounded(stats.percentile_99); raw.avg_pq = pq_signal_u12_rounded(stats.avg_maxrgb_pq); - raw.scene_refresh = false; return clamp_level1(raw.min_pq, raw.max_pq, raw.avg_pq); } + frame_metadata_t + level1_temporal_filter_t::update(const frame_metadata_t &raw) { + if (!initialized_) { + min_pq_ = raw.min_pq; + max_pq_ = raw.max_pq; + avg_pq_ = raw.avg_pq; + initialized_ = true; + } + else { + min_pq_ += ALPHA * (raw.min_pq - min_pq_); + max_pq_ += ALPHA * (raw.max_pq - max_pq_); + avg_pq_ += ALPHA * (raw.avg_pq - avg_pq_); + } + + auto filtered = clamp_level1( + static_cast(std::lround(min_pq_)), + static_cast(std::lround(max_pq_)), + static_cast(std::lround(avg_pq_))); + filtered.scene_refresh = raw.scene_refresh; + return filtered; + } + + void + level1_temporal_filter_t::reset() { + *this = {}; + } + bool rpu_generator_t::build_template(const session_config_t &config, bool scene_refresh, template_t &out) { std::fill(out.bytes.begin(), out.bytes.end(), 0); @@ -459,14 +496,26 @@ namespace video::dolby_vision { // Missing analysis reuses the last good values: once RPUs are flowing, // a frame without one would make the client's Dolby engine fall back to // static HDR10 mapping for that frame — a visible brightness step. - if (const auto metadata = frame_metadata_from_stats(stats)) { + if (scene_detector_.observe(stats)) { + level1_filter_.reset(); + pending_scene_refresh_ = true; + } + if (const auto raw_metadata = frame_metadata_from_stats(stats)) { + auto metadata = level1_filter_.update(*raw_metadata); + metadata.scene_refresh = pending_scene_refresh_; last_metadata_ = metadata; + // Consume the refresh only after a valid RPU can carry it. An invalid + // analyzer sample must not strand the new scene without a refresh. + pending_scene_refresh_ = false; } if (!last_metadata_) { return; // cold analyzer: this frame ships without an RPU, like HDR10+ does } - if (!queue_.stage(frame_index, generator_, *last_metadata_)) { + const auto metadata_for_frame = *last_metadata_; + // Reused luminance is intentional, but a refresh belongs to one picture. + last_metadata_->scene_refresh = false; + if (!queue_.stage(frame_index, generator_, metadata_for_frame)) { // In-flight overflow means the encoder's output can no longer be // trusted to surface in order; stop rather than risk a stale RPU // landing on a newer picture (docs §3.5). @@ -496,7 +545,10 @@ namespace video::dolby_vision { void rpu_injector_t::disable() { enabled_ = false; + scene_detector_.reset(); + level1_filter_.reset(); last_metadata_.reset(); + pending_scene_refresh_ = false; queue_.clear(); generator_.reset(); } diff --git a/src/video_dolby_vision.h b/src/video_dolby_vision.h index fd5784723..dd43e4911 100644 --- a/src/video_dolby_vision.h +++ b/src/video_dolby_vision.h @@ -65,12 +65,14 @@ namespace video::dolby_vision { * above mean(PQ(nits)) on dark frames with highlights and would anchor the * display's curve too high. max uses the 99th percentile rather than the * true peak, for the same scRGB-overshoot reason hdr10plus_from_luminance() - * reports percentile 99 as maxSCL. min uses the 10th percentile so a black - * UI pixel, letterbox bar, or transparent composite region cannot pin the - * frame minimum at zero; the clamp to [0, 12] bounds it regardless. + * reports percentile 99 as maxSCL. min uses the first percentile, but only + * reports zero when at least one percent of the histogram is in its near-black + * bin. That keeps a few black UI pixels from pinning the frame minimum while + * preserving materially black scenes. Older analyzer output falls back to P10. * - * Callers should pass EMA-smoothed stats (hdr_luminance_ema_t::smoothed()) - * for temporal stability, exactly as the HDR10+ path does. + * This function is a stateless mapping. The injector applies its dedicated + * Level 1 temporal filter after conversion so min/avg/max are smoothed as one + * coherent Dolby metadata tuple. * * Returns nullopt when the stats are absent or carry the signature of an * analyzer that never filled the PQ-domain mean (zero avg_maxrgb_pq beside @@ -79,6 +81,24 @@ namespace video::dolby_vision { std::optional frame_metadata_from_stats(const platf::hdr_frame_luminance_stats_t &stats); + /** Smooth the three Dolby L1 fields together between scene boundaries. */ + class level1_temporal_filter_t { + public: + frame_metadata_t + update(const frame_metadata_t &raw); + + void + reset(); + + private: + static constexpr float ALPHA = 0.15f; + + float min_pq_ = 0.0f; + float max_pq_ = 0.0f; + float avg_pq_ = 0.0f; + bool initialized_ = false; + }; + struct session_config_t { /// Mastering display peak in nits (L6 max_display_mastering_luminance). /// Content metadata only — the client display's peak belongs to @@ -249,7 +269,10 @@ namespace video::dolby_vision { private: rpu_generator_t generator_; staged_rpu_queue_t queue_; + hdr_metadata::scene_change_detector_t scene_detector_; + level1_temporal_filter_t level1_filter_; std::optional last_metadata_; + bool pending_scene_refresh_ = false; bool enabled_ = false; }; diff --git a/src/video_hdr_metadata.h b/src/video_hdr_metadata.h index 53664de26..702677a7f 100644 --- a/src/video_hdr_metadata.h +++ b/src/video_hdr_metadata.h @@ -330,6 +330,88 @@ namespace video::hdr_metadata { return pq_to_u12(nits_to_pq(nits > 0.0f ? nits : 1000.0f)); } + namespace detail { + inline bool + has_valid_scene_metrics(const platf::hdr_frame_luminance_stats_t &stats) { + return stats.valid && + std::isfinite(stats.avg_maxrgb_pq) && + std::isfinite(stats.percentile_10_pq) && + std::isfinite(stats.percentile_90_pq); + } + } // namespace detail + + /** + * Detect metadata-relevant scene discontinuities without encoder lookahead. + * + * The analyzer runs below the video frame rate, so only a new sample_sequence + * may advance this state. Comparing repeated samples would turn one transition + * into several scene refreshes. The decision is made in PQ space from the mean, + * low/high percentiles, and the HDR10+ distribution; a lone scRGB peak is not + * enough to call a cut because cursors, subtitles, and compositor overshoot can + * all create one. + */ + class scene_change_detector_t { + public: + bool + observe(const platf::hdr_frame_luminance_stats_t &stats) { + if (!detail::has_valid_scene_metrics(stats)) { + return false; + } + if (initialized_ && stats.sample_sequence != 0 && stats.sample_sequence == last_sample_sequence_) { + return false; + } + + const bool scene_change = !initialized_ || differs_materially(previous_, stats); + previous_ = stats; + last_sample_sequence_ = stats.sample_sequence; + initialized_ = true; + return scene_change; + } + + void + reset() { + *this = {}; + } + + private: + static bool + differs_materially( + const platf::hdr_frame_luminance_stats_t &previous, + const platf::hdr_frame_luminance_stats_t ¤t) { + const float mean_delta = std::abs(current.avg_maxrgb_pq - previous.avg_maxrgb_pq); + const float low_delta = std::abs(current.percentile_10_pq - previous.percentile_10_pq); + const float high_delta = std::abs(current.percentile_90_pq - previous.percentile_90_pq); + + float distribution_delta_sum = 0.0f; + float distribution_delta_max = 0.0f; + size_t distribution_count = 0; + for (size_t i = 0; i < std::size(current.distribution_maxrgb); ++i) { + const float previous_nits = previous.distribution_maxrgb[i]; + const float current_nits = current.distribution_maxrgb[i]; + if (!std::isfinite(previous_nits) || !std::isfinite(current_nits) || + previous_nits < 0.0f || current_nits < 0.0f) { + continue; + } + const float delta = std::abs(nits_to_pq(current_nits) - nits_to_pq(previous_nits)); + distribution_delta_sum += delta; + distribution_delta_max = std::max(distribution_delta_max, delta); + ++distribution_count; + } + const float distribution_delta_mean = distribution_count > 0 ? + distribution_delta_sum / distribution_count : + 0.0f; + + return mean_delta >= 0.10f || + std::max(low_delta, high_delta) >= 0.18f || + (distribution_delta_mean >= 0.10f && distribution_delta_max >= 0.14f) || + (mean_delta >= 0.06f && distribution_delta_mean >= 0.06f); + } + + platf::hdr_frame_luminance_stats_t previous_ {}; + uint64_t last_sample_sequence_ = 0; + bool initialized_ = false; + }; + struct vivid_metadata_t { uint16_t minimum_maxrgb_pq = 0; uint16_t average_maxrgb_pq = 0; @@ -548,6 +630,51 @@ namespace video::hdr_metadata { uint32_t maximum_sum_ = 0; }; + struct dynamic_metadata_temporal_result_t { + platf::hdr_frame_luminance_stats_t hdr10plus_stats {}; + vivid_metadata_t vivid {}; + }; + + /** + * Apply the shared scene-aware temporal policy before format-specific + * serialization. Native encoders and the AVCodec path both own one instance + * per session so their HDR10+ and HDR Vivid behavior cannot drift apart. + */ + class dynamic_metadata_temporal_state_t { + public: + dynamic_metadata_temporal_result_t + update(const platf::hdr_frame_luminance_stats_t &stats) { + if (!detail::has_valid_scene_metrics(stats)) { + return {}; + } + + const bool scene_change = scene_detector_.observe(stats); + if (scene_change) { + hdr10plus_ema_.reset(); + vivid_filter_.reset(); + } + + const auto vivid = vivid_filter_.update(stats); + hdr10plus_ema_.update(stats); + return { + .hdr10plus_stats = hdr10plus_ema_.smoothed(stats), + .vivid = vivid, + }; + } + + void + reset() { + scene_detector_.reset(); + hdr10plus_ema_.reset(); + vivid_filter_.reset(); + } + + private: + scene_change_detector_t scene_detector_; + hdr_luminance_ema_t hdr10plus_ema_; + vivid_temporal_filter_t vivid_filter_; + }; + /** * Gates HDR Vivid at stream startup until several independent GPU readbacks * describe a sane, stable HLG picture. The caller owns the wall-clock timeout @@ -895,17 +1022,16 @@ namespace video::hdr_metadata { return payloads; } - const auto vivid = vivid_filter_.update(stats); - hdr10plus_ema_.update(stats); + const auto filtered = temporal_state_.update(stats); if (formats_.hdr10plus) { const auto size = serialize_hdr10plus_t35( - hdr10plus_ema_.smoothed(stats), max_display_luminance, hdr10plus_storage_); + filtered.hdr10plus_stats, max_display_luminance, hdr10plus_storage_); if (size > 0) { payloads.hdr10plus = std::span(hdr10plus_storage_).first(size); } } - if (formats_.vivid && serialize_vivid_t35(vivid, vivid_storage_) > 0) { + if (formats_.vivid && serialize_vivid_t35(filtered.vivid, vivid_storage_) > 0) { payloads.vivid = vivid_storage_; } return payloads; @@ -914,15 +1040,13 @@ namespace video::hdr_metadata { /// Drop temporal history, e.g. when an encoder is recreated mid-session. void reset() { - hdr10plus_ema_.reset(); - vivid_filter_.reset(); + temporal_state_.reset(); vivid_storage_.clear(); } private: formats_t formats_ {}; - hdr_luminance_ema_t hdr10plus_ema_; - vivid_temporal_filter_t vivid_filter_; + dynamic_metadata_temporal_state_t temporal_state_; std::array hdr10plus_storage_ {}; std::vector vivid_storage_; }; diff --git a/tests/unit/test_video_dolby_vision.cpp b/tests/unit/test_video_dolby_vision.cpp index 7f140eb2b..33f845cc1 100644 --- a/tests/unit/test_video_dolby_vision.cpp +++ b/tests/unit/test_video_dolby_vision.cpp @@ -12,6 +12,7 @@ #include "../tests_common.h" +#include #include namespace { @@ -321,6 +322,51 @@ TEST(DolbyVisionRpu, DerivesLevel1FromStats) { EXPECT_FALSE(frame_metadata_from_stats(stats).has_value()); } +TEST(DolbyVisionRpu, UsesNearBlackCoverageForARobustMinimum) { + platf::hdr_frame_luminance_stats_t stats; + stats.valid = true; + stats.avg_maxrgb_pq = 0.5f; + stats.avg_maxrgb = 120.0f; + stats.percentile_99 = 1000.0f; + stats.percentile_10_pq = 0.05f; + stats.percentile_1_pq = 0.001f; // -> PQ code 4 + stats.near_black_stats_valid = true; + + // A few dark pixels retain the robust first percentile instead of forcing black. + stats.near_black_fraction = 0.005f; + auto metadata = frame_metadata_from_stats(stats); + ASSERT_TRUE(metadata.has_value()); + EXPECT_EQ(metadata->min_pq, 4); + + // A material black region is part of the scene and should be represented as zero. + stats.near_black_fraction = 0.02f; + metadata = frame_metadata_from_stats(stats); + ASSERT_TRUE(metadata.has_value()); + EXPECT_EQ(metadata->min_pq, 0); + + stats.near_black_fraction = std::numeric_limits::quiet_NaN(); + EXPECT_FALSE(frame_metadata_from_stats(stats).has_value()); +} + +TEST(DolbyVisionRpu, SmoothsAllLevel1FieldsAndResetsAtSceneBoundaries) { + video::dolby_vision::level1_temporal_filter_t filter; + + frame_metadata_t first { .min_pq = 0, .max_pq = 2081, .avg_pq = 819 }; + EXPECT_EQ(filter.update(first).min_pq, first.min_pq); + + frame_metadata_t next { .min_pq = 12, .max_pq = 4081, .avg_pq = 2819 }; + const auto smoothed = filter.update(next); + EXPECT_EQ(smoothed.min_pq, 2); + EXPECT_EQ(smoothed.max_pq, 2381); + EXPECT_EQ(smoothed.avg_pq, 1119); + + filter.reset(); + const auto reset = filter.update(next); + EXPECT_EQ(reset.min_pq, next.min_pq); + EXPECT_EQ(reset.max_pq, next.max_pq); + EXPECT_EQ(reset.avg_pq, next.avg_pq); +} + TEST(DolbyVisionRpu, EmitsNothingBeforeConfigure) { rpu_generator_t generator; const auto nal = generator.generate(typical_metadata()); @@ -572,6 +618,17 @@ namespace { return count; } + parsed_rpu_t + parse_injected_rpu(const bytes_t &au) { + for (size_t i = 0; i + 3 < au.size(); ++i) { + if (au[i] == 0x00 && au[i + 1] == 0x00 && au[i + 2] == 0x01 && au[i + 3] == 0x7C) { + return parse_nal(std::span(au).subspan(i + 3)); + } + } + ADD_FAILURE() << "injected access unit contains no Dolby Vision RPU"; + return {}; + } + } // namespace TEST(DolbyVisionInjector, IsInertBeforeConfigure) { @@ -640,6 +697,74 @@ TEST(DolbyVisionInjector, ReusesLastAnalysisWhenStatsGoMissing) { EXPECT_EQ(count_rpu_nals(au), 1u) << "conservative metadata beats a metadata-less frame"; } +TEST(DolbyVisionInjector, MarksEachDetectedSceneExactlyOnce) { + rpu_injector_t injector; + ASSERT_TRUE(injector.configure(video::dolby_vision::session_config_t {})); + + auto first_scene = valid_stats(); + first_scene.sample_sequence = 1; + first_scene.percentile_90_pq = 0.60f; + std::fill(std::begin(first_scene.distribution_maxrgb), + std::end(first_scene.distribution_maxrgb), 100.0f); + + injector.stage(1, first_scene); + bytes_t first_au = make_au(); + injector.inject(1, first_au); + EXPECT_EQ(parse_injected_rpu(first_au).scene_refresh, 1u); + + // Reusing the same analyzer sample must not repeat scene_refresh. + injector.stage(2, first_scene); + bytes_t repeated_au = make_au(); + injector.inject(2, repeated_au); + EXPECT_EQ(parse_injected_rpu(repeated_au).scene_refresh, 0u); + + auto next_scene = first_scene; + next_scene.sample_sequence = 2; + std::fill(std::begin(next_scene.distribution_maxrgb), + std::end(next_scene.distribution_maxrgb), 1000.0f); + injector.stage(3, next_scene); + bytes_t next_au = make_au(); + injector.inject(3, next_au); + EXPECT_EQ(parse_injected_rpu(next_au).scene_refresh, 1u); +} + +TEST(DolbyVisionInjector, DefersSceneRefreshUntilMetadataIsValid) { + rpu_injector_t injector; + ASSERT_TRUE(injector.configure(video::dolby_vision::session_config_t {})); + + auto first_scene = valid_stats(); + first_scene.sample_sequence = 1; + first_scene.percentile_90_pq = 0.60f; + std::fill(std::begin(first_scene.distribution_maxrgb), + std::end(first_scene.distribution_maxrgb), 100.0f); + injector.stage(1, first_scene); + bytes_t first_au = make_au(); + injector.inject(1, first_au); + ASSERT_EQ(parse_injected_rpu(first_au).scene_refresh, 1u); + + auto invalid_cut = first_scene; + invalid_cut.sample_sequence = 2; + invalid_cut.near_black_stats_valid = true; + invalid_cut.percentile_1_pq = 0.001f; + invalid_cut.near_black_fraction = std::numeric_limits::quiet_NaN(); + std::fill(std::begin(invalid_cut.distribution_maxrgb), + std::end(invalid_cut.distribution_maxrgb), 1000.0f); + injector.stage(2, invalid_cut); + bytes_t invalid_au = make_au(); + injector.inject(2, invalid_au); + // The conservative previous metadata is reused without falsely claiming that + // the new scene's invalid analysis was applied. + EXPECT_EQ(parse_injected_rpu(invalid_au).scene_refresh, 0u); + + auto recovered = invalid_cut; + recovered.sample_sequence = 3; + recovered.near_black_fraction = 0.02f; + injector.stage(3, recovered); + bytes_t recovered_au = make_au(); + injector.inject(3, recovered_au); + EXPECT_EQ(parse_injected_rpu(recovered_au).scene_refresh, 1u); +} + TEST(DolbyVisionInjector, OverflowDisablesTheSession) { rpu_injector_t injector; ASSERT_TRUE(injector.configure(video::dolby_vision::session_config_t {})); diff --git a/tests/unit/test_video_hdr_metadata.cpp b/tests/unit/test_video_hdr_metadata.cpp index 0cf8ed182..0342bba4b 100644 --- a/tests/unit/test_video_hdr_metadata.cpp +++ b/tests/unit/test_video_hdr_metadata.cpp @@ -439,6 +439,93 @@ TEST(HdrDynamicMetadata, SmoothsHdr10PlusLuminanceAcrossFrames) { EXPECT_FLOAT_EQ(ema.percentile_99, 0.0f); } +TEST(HdrDynamicMetadata, DetectsDistributionSceneChangesOnlyOnNewSamples) { + const auto scene = [](uint64_t sequence, float mean_pq, float distribution_nits) { + platf::hdr_frame_luminance_stats_t stats {}; + stats.sample_sequence = sequence; + stats.avg_maxrgb_pq = mean_pq; + stats.percentile_10_pq = 0.10f; + stats.percentile_90_pq = 0.60f; + stats.max_maxrgb = 1000.0f; + std::fill(std::begin(stats.distribution_maxrgb), std::end(stats.distribution_maxrgb), distribution_nits); + stats.valid = true; + return stats; + }; + + video::hdr_metadata::scene_change_detector_t detector; + EXPECT_TRUE(detector.observe(scene(1, 0.30f, 100.0f))); + + // Four encoded frames may reuse one GPU readback. It must remain one event. + EXPECT_FALSE(detector.observe(scene(1, 0.30f, 100.0f))); + + EXPECT_FALSE(detector.observe(scene(2, 0.32f, 110.0f))); + + // The exact peak is unchanged, but the luminance distribution belongs to a + // different scene. Peak-ratio detection alone misses this transition. + EXPECT_TRUE(detector.observe(scene(3, 0.33f, 1000.0f))); +} + +TEST(HdrDynamicMetadata, InvalidPqSamplesDoNotPoisonTemporalState) { + const auto scene = [](uint64_t sequence, float mean_pq, float nits) { + platf::hdr_frame_luminance_stats_t stats {}; + stats.sample_sequence = sequence; + stats.min_maxrgb = nits; + stats.max_maxrgb = nits; + stats.avg_maxrgb = nits; + stats.avg_maxrgb_pq = mean_pq; + stats.percentile_10_pq = mean_pq; + stats.percentile_90_pq = mean_pq; + stats.percentile_99 = nits; + std::fill(std::begin(stats.distribution_maxrgb), std::end(stats.distribution_maxrgb), nits); + stats.valid = true; + return stats; + }; + + video::hdr_metadata::dynamic_metadata_temporal_state_t temporal; + ASSERT_TRUE(temporal.update(scene(1, 0.20f, 10.0f)).vivid.valid); + + auto invalid = scene(2, 0.45f, 15.0f); + invalid.percentile_10_pq = std::numeric_limits::quiet_NaN(); + const auto rejected = temporal.update(invalid); + EXPECT_FALSE(rejected.hdr10plus_stats.valid); + EXPECT_FALSE(rejected.vivid.valid); + + // The rejected sample must neither become the scene baseline nor enter either + // temporal filter. This cut therefore snaps both formats to the new scene. + const auto cut = temporal.update(scene(3, 0.70f, 20.0f)); + EXPECT_FLOAT_EQ(cut.hdr10plus_stats.percentile_99, 20.0f); + EXPECT_EQ(cut.vivid.average_maxrgb_pq, video::hdr_metadata::pq_to_u12(0.70f)); +} + +TEST(HdrDynamicMetadata, SceneChangeResetsTheVividWindow) { + const auto scene = [](uint64_t sequence, float mean_pq, float nits) { + platf::hdr_frame_luminance_stats_t stats {}; + stats.sample_sequence = sequence; + stats.min_maxrgb = nits; + stats.max_maxrgb = nits; + stats.avg_maxrgb = nits; + stats.avg_maxrgb_pq = mean_pq; + stats.percentile_10_pq = mean_pq; + stats.percentile_90_pq = mean_pq; + stats.percentile_99 = nits; + std::fill(std::begin(stats.distribution_maxrgb), std::end(stats.distribution_maxrgb), nits); + stats.valid = true; + return stats; + }; + + video::hdr_metadata::dynamic_metadata_builder_t builder; + builder.configure({ .hdr10plus = false, .vivid = true }); + for (int frame = 0; frame < 32; ++frame) { + ASSERT_FALSE(builder.build(scene(1, 0.20f, 10.0f), 1000).vivid.empty()); + } + + const auto payload = builder.build(scene(2, 0.70f, 1000.0f), 1000).vivid; + ASSERT_FALSE(payload.empty()); + const std::vector bytes(payload.begin(), payload.end()); + size_t bit_offset = 6 * 8 + 12; // T.35 prefix + minimum_maxrgb + EXPECT_EQ(read_bits(bytes, bit_offset, 12), video::hdr_metadata::pq_to_u12(0.70f)); +} + TEST(HdrDynamicMetadata, SmoothedStatsSubstituteOnlyTheFilteredFields) { platf::hdr_frame_luminance_stats_t raw {}; raw.avg_maxrgb = 100.0f;