Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,15 @@ repos:
hooks:
- id: actionlint

# Composite actions under .github/actions/ are not workflows: actionlint rejects
# them ("jobs section is missing"). The GitHub action schema is checked here, the
# structure and the shellcheck of every `run:` block by check_composite_actions.py
# below (hook `check-composite-actions`).
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.38.2
hooks:
- id: check-github-actions

- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
Expand Down Expand Up @@ -571,6 +580,20 @@ repos:
exclude: '^subprojects/|^core/test/data/|^compat/python-vmaf/resource/|^python/test/resource/|^compat/python-vmaf/matlab/|^core/src/pdjson\.(c|h)$|^tools/figures/|^\.config/agent/hooks/block_evasion\.py$'
types_or: [c, c++, cuda, go, python]

- id: check-composite-actions
name: Composite actions keep their structure and shellcheck-clean run blocks
entry: python3 scripts/ci/check_composite_actions.py
language: system
files: '^(\.github/actions/|scripts/ci/check_composite_actions\.py$)'
pass_filenames: false

- id: test-check-composite-actions
name: Composite-action check keeps positive, negative and boundary cases
entry: python3 scripts/ci/tests/test_check_composite_actions.py
language: system
files: '^(scripts/ci/(check_composite_actions\.py|tests/test_check_composite_actions\.py)|\.pre-commit-config\.yaml)$'
pass_filenames: false

- id: test-check-copyright
name: Copyright and SPDX header check keeps positive, negative and boundary cases
entry: python3 scripts/ci/tests/test_check_copyright.py
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,13 @@ make `core/AGENTS.md` a generated index over `AGENTS.d/` topic pages ([ADR-1454]
and clang, x86-64 and aarch64), and no score moves.


- Composite actions under `.github/actions/` are now checked in pre-commit, CI and
`make lint-actions`: the GitHub action schema (`check-github-actions`) and, through
`scripts/ci/check_composite_actions.py`, their structure and shellcheck of every
`run:` block. actionlint reads workflows only
(`docs/development/pre-commit-hooks.md`, "Composite actions").


- **Six more CUDA twins are held to the CPU's bits by the parity gate.**
`motion_cuda` (also with `debug=true`), `motion_v2_cuda`, `psnr_cuda`,
`float_ssim_cuda` and `float_ms_ssim_cuda` (with and without `enable_lcs`)
Expand Down Expand Up @@ -2737,6 +2744,19 @@ make `core/AGENTS.md` a generated index over `AGENTS.d/` topic pages ([ADR-1454]
diagrams moved to checked figures (ADR-1508).


- **`cambi` with `full_ref=true` scores the right distorted picture at 10 bits
when the source is larger than the picture.** With `src_width` /
`src_height` above the input size, the CPU extractor converted the 10-bit
distorted plane with one copy at the input's row stride into a working
picture allocated at the source width, so every row after the first was
shifted and `cambi` and `cambi_full_reference` were wrong (10-bit Sparks
frame 0: `cambi` 0.0048 instead of 0.3734). The plane is now copied row by
row, and `cambi` no longer depends on `full_ref` or the source size. 8-, 9-,
12- and 16-bit input was not affected; the Metal twin, which runs the same
conversion on the host, is fixed with it. Upstream Netflix/vmaf has the
same code.


- **`cambi` no longer reads and writes outside its buffers on wide, short
frames, and scores tall, narrow frames the same on every path.** When the
coarsest of CAMBI's five scales had no more rows than half the window,
Expand Down
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,15 @@ lint-go:
@echo "--- gosec (exclude-generated) ---"
@gosec -exclude-generated -quiet ./...

# GitHub Actions workflow lint (actionlint). Validates all 35 workflow
# files under .github/workflows/ against .github/actionlint.yaml.
# GitHub Actions lint. actionlint validates every workflow under
# .github/workflows/ against .github/actionlint.yaml; actionlint cannot read a
# composite action, so scripts/ci/check_composite_actions.py checks those.
lint-actions:
$(call require-tool,actionlint,go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12)
@echo "--- actionlint (.github/workflows) ---"
@actionlint
@echo "--- composite actions (.github/actions): structure + shellcheck ---"
@python3 scripts/ci/check_composite_actions.py

# Fragment-tree drift check (ADR-0221). Verifies CHANGELOG.md and
# docs/adr/README.md are in sync with fragments, ADR tag pages match sources,
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/changed/composite-actions-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- Composite actions under `.github/actions/` are now checked in pre-commit, CI and
`make lint-actions`: the GitHub action schema (`check-github-actions`) and, through
`scripts/ci/check_composite_actions.py`, their structure and shellcheck of every
`run:` block. actionlint reads workflows only
(`docs/development/pre-commit-hooks.md`, "Composite actions").
11 changes: 11 additions & 0 deletions changelog.d/fixed/cambi-full-ref-wide-source-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
- **`cambi` with `full_ref=true` scores the right distorted picture at 10 bits
when the source is larger than the picture.** With `src_width` /
`src_height` above the input size, the CPU extractor converted the 10-bit
distorted plane with one copy at the input's row stride into a working
picture allocated at the source width, so every row after the first was
shifted and `cambi` and `cambi_full_reference` were wrong (10-bit Sparks
frame 0: `cambi` 0.0048 instead of 0.3734). The plane is now copied row by
row, and `cambi` no longer depends on `full_ref` or the source size. 8-, 9-,
12- and 16-bit input was not affected; the Metal twin, which runs the same
conversion on the host, is fixed with it. Upstream Netflix/vmaf has the
same code.
22 changes: 20 additions & 2 deletions core/src/feature/AGENTS.d/cambi.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@
paths:
- core/src/feature/cambi.c
- core/src/feature/cambi.h
invariant: CAMBI bounded searches, c-values window boundaries, and UTF-8 heatmap paths.
- core/test/test_cambi_full_ref_wide_source.c
invariant: CAMBI bounded searches, c-values window boundaries, row-by-row 10-bit copies, and UTF-8 heatmap paths.
---
<!-- markdownlint-disable MD013 MD032 MD060 -->
# CAMBI Searches, Window Boundaries, and Heatmap Paths

- **CAMBI bounded searches and live private helpers** (ADR-0205 / ADR-1146):
`cambi.c` is strict-clean: it contains no `NOLINT` or Cppcheck suppression.
`cambi.c` is strict-clean: its one suppression is the file-scoped
`NOLINTBEGIN(modernize-use-nullptr)` bracket that ADR-1138 gives every C
translation unit (it spells the null pointer `NULL`, as upstream does; a
`nullptr` on any non-comment line fails `scripts/dev/preflight.sh --stage
msvcism`), and it has no other `NOLINT` and no Cppcheck suppression.
Preserve the 16-step TVI bisection, the `UINT16_MAX`-bounded VLT scan, and
the `n`/partition-span bounds on quick-select without changing comparison,
pivot, swap, or accumulation order. The shared extractor callback ABI stays
Expand Down Expand Up @@ -39,6 +44,19 @@ invariant: CAMBI bounded searches, c-values window boundaries, and UTF-8 heatmap
compute the c-values on the device and do not call it; `cambi_hip` clips
each window to the frame itself (`cambi_hd_cvals_begin()`,
`hip/integer_cambi/cambi_hip_device.h`) and must keep matching these bounds.
- **CAMBI copies a same-size 10-bit plane row by row**
(`T-CAMBI-10BIT-FULLREF-WIDE-SOURCE-ROWS-2026-10-05`):
`decimate_same_size_16b()` in `cambi.c` copies one row at a time with the
input's stride and the working picture's stride. Under `full_ref` the
working pictures are allocated `MAX(src, enc)` wide, so with a source larger
than the picture their stride exceeds the input's; upstream's single
`memcpy` of `stride * height` samples shifts every row there, and `cambi`
must not depend on `full_ref` or the source size. Do not bring the single
copy back, here or in a twin that converts on the host (`cambi_metal` calls
`vmaf_cambi_preprocessing()`). `core/test/test_cambi_full_ref_wide_source.c`
fails on it, and the CAMBI case with `cpu_opts` in
`core/test/test_{cuda,sycl,hip}_exact_twins.c` holds the twin's `cambi`
equal to the CPU's under those options.
- **CAMBI heatmap paths are UTF-8 on Windows** (ADR-1182):
`mkdirp.cpp` must create each component through `vmaf_mkdir_utf8`, and
`cambi.c::open_heatmaps` must open every `.gray` file through
Expand Down
2 changes: 1 addition & 1 deletion core/src/feature/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ PSNR, SSIM, MS-SSIM, LPIPS, …). Parent: [../../AGENTS.md](../../AGENTS.md).
| `feature_extractor.cpp`, `feature_collector.cpp` | [ansnr-removal](AGENTS.d/ansnr-removal.md) | ANSNR and float_ansnr extractors remain removed per ADR-0865. |
| `brisque.c`, `brisque_math.h` | [brisque](AGENTS.d/brisque.md) | BRISQUE MATLAB pipeline parity and numerical stability assertions. |
| `cambi.c`, `cuda/integer_cambi_cuda.c`, `hip/integer_cambi_hip.c` | [cambi-gpu](AGENTS.d/cambi-gpu.md) | CAMBI GPU twins mirror host-side semantics and hybrid host/GPU dispatch contracts. |
| `cambi.c`, `cambi.h` | [cambi](AGENTS.d/cambi.md) | CAMBI bounded searches, c-values window boundaries, and UTF-8 heatmap paths. |
| `cambi.c`, `cambi.h`, `/core/test/test_cambi_full_ref_wide_source.c` | [cambi](AGENTS.d/cambi.md) | CAMBI bounded searches, c-values window boundaries, row-by-row 10-bit copies, and UTF-8 heatmap paths. |
| `ciede.c`, `/core/test/test_ciede.c` | [ciede](AGENTS.d/ciede.md) | CIEDE chroma-upsample subsample flags diverge from upstream to ensure correctness. |
| `integer_adm.c`, `integer_vif.c`, `cambi.c` | [codeql-renames](AGENTS.d/codeql-renames.md) | CodeQL declaration-hides-variable renames preserve bit-exact behavior across extractors. |
| `compat_builtin.h` | [compat-builtin](AGENTS.d/compat-builtin.md) | MSVC builtin clz shim must use _BitScanReverse, never __lzcnt. |
Expand Down
50 changes: 28 additions & 22 deletions core/src/feature/cambi.c
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,19 @@
#include "mkdirp.h"
#include "picture.h"

#ifdef _MSC_VER
#define CAMBI_NULL_POINTER NULL
#else
#define CAMBI_NULL_POINTER nullptr
#endif

#if ARCH_X86
#include "x86/cambi_avx2.h"
#include "x86/cambi_avx512.h"
#elif ARCH_AARCH64
#include "arm64/cambi_neon.h"
#endif

/* NOLINTBEGIN(modernize-use-nullptr): C translation unit. The fork builds C as
* C23, where clang-tidy also proposes the `nullptr` keyword, but MSVC's
* documented /std:clatest C23 feature set does not include `nullptr` and the
* required Windows builds compile this TU with cl.exe (C2065); upstream
* Netflix/vmaf spells the null pointer `NULL` here too. ADR-1138. */

/* Ratio of pixels for computation, must be 0 < topk <= 1.0 */
#define DEFAULT_CAMBI_TOPK_POOLING (0.6)

Expand Down Expand Up @@ -230,7 +230,7 @@ static const VmafOption options[] = {
CAMBI_OPTION("topk",
"Ratio of pixels for the spatial pooling computation, must be 0 < topk <= 1.0",
topk, VMAF_OPT_TYPE_DOUBLE, d, DEFAULT_CAMBI_TOPK_POOLING, 0.0001, 1.0,
VMAF_OPT_FLAG_FEATURE_PARAM, CAMBI_NULL_POINTER),
VMAF_OPT_FLAG_FEATURE_PARAM, NULL),
CAMBI_OPTION(
"cambi_topk",
"Ratio of pixels for the spatial pooling computation, must be 0 < cambi_topk <= 1.0",
Expand All @@ -246,16 +246,15 @@ static const VmafOption options[] = {
CAMBI_OPTION("max_log_contrast", max_log_contrast_help, max_log_contrast_opt, VMAF_OPT_TYPE_INT,
i, DEFAULT_CAMBI_MAX_LOG_CONTRAST, 0, 5, VMAF_OPT_FLAG_FEATURE_PARAM, "mlc"),
CAMBI_OPTION("heatmaps_path", "Path where heatmaps will be dumped.", heatmaps_path,
VMAF_OPT_TYPE_STRING, s, CAMBI_NULL_POINTER, 0, 0, 0, CAMBI_NULL_POINTER),
VMAF_OPT_TYPE_STRING, s, NULL, 0, 0, 0, NULL),
CAMBI_OPTION(
"full_ref",
"If true, CAMBI will be run in full-reference mode and will be computed on both the reference and distorted inputs",
full_ref, VMAF_OPT_TYPE_BOOL, b, DEFAULT_CAMBI_FULL_REF_FLAG, 0, 0, 0, CAMBI_NULL_POINTER),
full_ref, VMAF_OPT_TYPE_BOOL, b, DEFAULT_CAMBI_FULL_REF_FLAG, 0, 0, 0, NULL),
CAMBI_OPTION(
"eotf",
"Determines the EOTF used to compute the visibility thresholds. Possible values: ['bt1886', 'pq']. Default: 'bt1886'",
eotf, VMAF_OPT_TYPE_STRING, s, DEFAULT_CAMBI_EOTF, 0, 0, VMAF_OPT_FLAG_FEATURE_PARAM,
CAMBI_NULL_POINTER),
eotf, VMAF_OPT_TYPE_STRING, s, DEFAULT_CAMBI_EOTF, 0, 0, VMAF_OPT_FLAG_FEATURE_PARAM, NULL),
CAMBI_OPTION(
"cambi_eotf",
"Determines the EOTF used to compute the visibility thresholds. Possible values: ['bt1886', 'pq']. Default: 'bt1886'. If both eotf and cambi_eotf are set, cambi_eotf takes precedence.",
Expand All @@ -266,7 +265,7 @@ static const VmafOption options[] = {
"Speed up the processing by downsampling post spatial mask for resolutions >= 1080p. Min speed-up resolution possible values: [1080, 1440, 2160, 0]. Default: 0 (not applied)Note some loss of accuracy is expected with this speedup.",
cambi_high_res_speedup, VMAF_OPT_TYPE_INT, i, DEFAULT_CAMBI_HIGH_RES_SPEEDUP, 0,
CAMBI_4K_HEIGHT, VMAF_OPT_FLAG_FEATURE_PARAM, "hrs"),
{.name = CAMBI_NULL_POINTER}};
{.name = NULL}};

#undef CAMBI_OPTION

Expand Down Expand Up @@ -389,16 +388,16 @@ static int set_contrast_arrays(const uint16_t num_diffs, uint16_t **diffs_to_con
*diffs_weights = aligned_malloc(ALIGN_CEIL(sizeof(int)) * num_diffs, 32);
if (!(*diffs_weights)) {
aligned_free(*diffs_to_consider);
*diffs_to_consider = CAMBI_NULL_POINTER;
*diffs_to_consider = NULL;
return -ENOMEM;
}

*all_diffs = aligned_malloc(ALIGN_CEIL(sizeof(int)) * (2 * num_diffs + 1), 32);
if (!(*all_diffs)) {
aligned_free(*diffs_to_consider);
*diffs_to_consider = CAMBI_NULL_POINTER;
*diffs_to_consider = NULL;
aligned_free(*diffs_weights);
*diffs_weights = CAMBI_NULL_POINTER;
*diffs_weights = NULL;
return -ENOMEM;
}

Expand Down Expand Up @@ -567,8 +566,7 @@ static int setup_contrast_and_luminance(CambiState *s, int num_diffs)

err = vmaf_cambi_init_tvi_and_vlt(num_diffs, s->buffers.diffs_to_consider, s->tvi_threshold,
s->cambi_vis_lum_threshold, s->cambi_eotf, s->eotf,
s->buffers.tvi_for_diff, &s->vlt_luma, CAMBI_NULL_POINTER,
CAMBI_NULL_POINTER);
s->buffers.tvi_for_diff, &s->vlt_luma, NULL, NULL);
if (err)
return err;

Expand Down Expand Up @@ -686,7 +684,7 @@ static int open_heatmaps(CambiState *s)
static void setup_callbacks(CambiState *s)
{
VmafCambiDerivativeCalculator default_derivative;
vmaf_cambi_default_callbacks(CAMBI_NULL_POINTER, CAMBI_NULL_POINTER, &default_derivative);
vmaf_cambi_default_callbacks(NULL, NULL, &default_derivative);

s->derivative_callback = (VmafDerivativeCalculator)default_derivative;
s->calc_c_values_callback = calculate_c_values_default;
Expand Down Expand Up @@ -864,8 +862,16 @@ static void decimate_same_size_16b(const uint16_t *data, uint16_t *out_data, ptr
unsigned bpc, int shift_factor, int rounding_offset)
{
if (bpc == 10) {
// memcpy is faster in case the original bitdepth is already 10
memcpy(out_data, data, (size_t)stride * out_h * sizeof(uint16_t));
/* One row at a time, each with its own stride: under full_ref the
* working picture is allocated MAX(src, enc) wide, so its stride can
* exceed the input's, and a caller's picture may be wider than ours.
* A single copy of `stride * out_h` samples put every row after the
* first at the wrong offset
* (T-CAMBI-10BIT-FULLREF-WIDE-SOURCE-ROWS-2026-10-05). */
for (unsigned i = 0; i < out_h; i++) {
memcpy(out_data + ((ptrdiff_t)i * out_stride), data + ((ptrdiff_t)i * stride),
(size_t)out_w * sizeof(uint16_t));
}
} else {
for (unsigned i = 0; i < out_h; i++) {
for (unsigned j = 0; j < out_w; j++) {
Expand Down Expand Up @@ -1715,7 +1721,7 @@ static int close_cambi(VmafFeatureExtractor *fex)
return err;
}

static const char *provided_features[] = {"Cambi_feature_cambi_score", CAMBI_NULL_POINTER};
static const char *provided_features[] = {"Cambi_feature_cambi_score", NULL};

extern VmafFeatureExtractor vmaf_fex_cambi;
VmafFeatureExtractor vmaf_fex_cambi = {
Expand Down Expand Up @@ -2111,4 +2117,4 @@ int vmaf_cambi_test_open_heatmaps(char *path, unsigned enc_width, unsigned enc_h
return err;
}

#undef CAMBI_NULL_POINTER
/* NOLINTEND(modernize-use-nullptr) */
13 changes: 13 additions & 0 deletions core/test/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -3551,6 +3551,19 @@ test_cambi_dispatch_invariance = executable('test_cambi_dispatch_invariance',
test('test_cambi_dispatch_invariance', test_cambi_dispatch_invariance,
suite : ['fast', 'simd'])

# CAMBI full_ref with a source larger than the picture
# (T-CAMBI-10BIT-FULLREF-WIDE-SOURCE-ROWS-2026-10-05): the 10-bit same-size
# conversion copies row by row with both strides, and the distorted `cambi`
# score does not depend on full_ref or the source size at 8, 10 and 12 bits.
test_cambi_full_ref_wide_source = executable('test_cambi_full_ref_wide_source',
['test.c', 'test_cambi_full_ref_wide_source.c'],
include_directories : [libvmaf_inc, test_inc, include_directories('../src/feature/'), include_directories('../src/')],
link_with : get_option('default_library') == 'both' ? libvmaf.get_static_lib() : libvmaf,
dependencies : [pthread_dependency, math_lib, gpu_all_deps],
)
test('test_cambi_full_ref_wide_source', test_cambi_full_ref_wide_source,
suite : ['fast'])

# psnr_hvs whole-path dispatch invariance (T-PSNR-HVS-NEON-NOT-SCALAR-BITS-
# 2026-10-02): the extractor, driven through the public API with the host's
# instruction set and with every flag masked, returns the same score bits on
Expand Down
Loading
Loading