[PR] avfilter/vf_colordetect: add threshold option to permit fuzzy matching (PR #24181)

Niklas Haas via ffmpeg-devel <[email protected]>
Newsgroups gmane.comp.video.ffmpeg.devel
Message-ID <[email protected]>
PR #24181 opened by Niklas Haas (haasn)
URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/24181
Patch URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/24181.patch

Enabled by default for 10-bit content (one point of deviation), disabled for 8-bit.


>From 91b14f9a80848baa3c319d78114aaf7f2c165fff Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Mon, 17 Aug 2026 12:29:59 +0200
Subject: [PATCH 1/4] avfilter/vf_colordetect: add support for full range alpha
 offset

This allows us to add a threshold for alpha comparisons to the full range
path as well.

The AArch64 changes were LLM-assisted, since I'm not familiar with that
architecture. However, the logic checks out to me.

Also adjusts the checkasm test to make sure there are no rounding differences
between the C and assembly implementations. Tested correct across 100k seeds
on x86.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 libavfilter/aarch64/vf_colordetect_neon.S | 12 +++++++++++
 libavfilter/vf_colordetectdsp.h           |  4 ++--
 libavfilter/x86/vf_colordetect.asm        |  2 ++
 tests/checkasm/vf_colordetect.c           | 25 +++++++++++++++++------
 4 files changed, 35 insertions(+), 8 deletions(-)

diff --git a/libavfilter/aarch64/vf_colordetect_neon.S b/libavfilter/aarch64/vf_colordetect_neon.S
index f3cca16fed..ada21d4ba4 100644
--- a/libavfilter/aarch64/vf_colordetect_neon.S
+++ b/libavfilter/aarch64/vf_colordetect_neon.S
@@ -177,13 +177,17 @@ endfunc
  * x4: ptrdiff_t width,
  * x5: ptrdiff_t height,
  * w6: int alpha_max,
+ * w7: int mpeg_range (unused)
+ * [sp]: int offset
  */
 function ff_detect_alpha_full_neon, export=1
+        ldr             w13, [sp]
         ands            x7, x4, #15             // width % 16
         bic             x8, x4, #15             // width / 16 * 16
         movi            v0.16b, #0
         movi            v1.16b, #255
         dup             v2.16b, w6              // alpha_max
+        dup             v18.16b, w13            // offset
         sub             x1, x1, x8              // color_stride - aligned_width
         sub             x3, x3, x8              // alpha_stride - aligned_width
         b.eq            1f
@@ -197,6 +201,7 @@ function ff_detect_alpha_full_neon, export=1
         ldr             q5, [x0], #16
         ldr             q6, [x2], #16
         subs            x12, x12, #16
+        uqsub           v5.16b, v5.16b, v18.16b // color - offset
         cmhi            v7.16b, v5.16b, v6.16b
         cmeq            v16.16b, v6.16b, v2.16b
         orr             v0.16b, v0.16b, v7.16b
@@ -207,6 +212,7 @@ function ff_detect_alpha_full_neon, export=1
         // handle loop tail
         ldr             q5, [x0]
         ldr             q6, [x2]
+        uqsub           v5.16b, v5.16b, v18.16b // color - offset
         cmhi            v7.16b, v5.16b, v6.16b
         cmeq            v16.16b, v6.16b, v2.16b
         and             v7.16b, v7.16b, v3.16b
@@ -242,13 +248,17 @@ endfunc
  * x4: ptrdiff_t width,
  * x5: ptrdiff_t height,
  * w6: int alpha_max,
+ * w7: int mpeg_range (unused)
+ * [sp]: int offset
  */
 function ff_detect_alpha16_full_neon, export=1
+        ldr             w13, [sp]
         ands            x7, x4, #7              // width % 8
         bic             x8, x4, #7              // width / 8 * 8
         movi            v0.8h, #0
         movi            v1.16b, #255
         dup             v2.8h, w6               // alpha_max
+        dup             v18.8h, w13             // offset
         sub             x1, x1, x8, lsl #1      // color_stride - (aligned_width * 2)
         sub             x3, x3, x8, lsl #1      // alpha_stride - (aligned_width * 2)
         b.eq            1f
@@ -262,6 +272,7 @@ function ff_detect_alpha16_full_neon, export=1
         ldr             q5, [x0], #16
         ldr             q6, [x2], #16
         subs            x12, x12, #8
+        uqsub           v5.8h, v5.8h, v18.8h    // color - offset
         cmhi            v7.8h, v5.8h, v6.8h
         cmeq            v16.8h, v6.8h, v2.8h
         orr             v0.16b, v0.16b, v7.16b
@@ -272,6 +283,7 @@ function ff_detect_alpha16_full_neon, export=1
         // handle loop tail
         ldr             q5, [x0]
         ldr             q6, [x2]
+        uqsub           v5.8h, v5.8h, v18.8h    // color - offset
         cmhi            v7.8h, v5.8h, v6.8h
         cmeq            v16.8h, v6.8h, v2.8h
         and             v7.16b, v7.16b, v3.16b
diff --git a/libavfilter/vf_colordetectdsp.h b/libavfilter/vf_colordetectdsp.h
index ca4727b589..df3fc27906 100644
--- a/libavfilter/vf_colordetectdsp.h
+++ b/libavfilter/vf_colordetectdsp.h
@@ -119,7 +119,7 @@ ff_detect_alpha_full_c(const uint8_t *color, ptrdiff_t color_stride,
     while (height--) {
         uint8_t straight = 0;
         for (int x = 0; x < width; x++) {
-            straight  |= color[x] > alpha[x];
+            straight  |= color[x] > alpha[x] + offset;
             transparent |= alpha[x] != alpha_max;
         }
         if (straight)
@@ -163,7 +163,7 @@ ff_detect_alpha16_full_c(const uint8_t *color, ptrdiff_t color_stride,
         const uint16_t *alpha16 = (const uint16_t *) alpha;
         uint8_t straight = 0;
         for (int x = 0; x < width; x++) {
-            straight  |= color16[x] > alpha16[x];
+            straight  |= color16[x] > alpha16[x] + offset;
             transparent |= alpha16[x] != alpha_max;
         }
         if (straight)
diff --git a/libavfilter/x86/vf_colordetect.asm b/libavfilter/x86/vf_colordetect.asm
index 7a96b6933c..f0e4508c63 100644
--- a/libavfilter/x86/vf_colordetect.asm
+++ b/libavfilter/x86/vf_colordetect.asm
@@ -92,6 +92,7 @@ cglobal detect_alpha%1_%3, 1, 6, 7, color, ret, alpha, x, width, height
     vpbroadcast%2 m5, r8m ; offset
 %else
     vpbroadcast%1 m3, r6m ; alpha_max
+    vpbroadcast%1 m4, r8m ; offset
 %endif
     mova m6, m3
     xor retd, retd
@@ -102,6 +103,7 @@ cglobal detect_alpha%1_%3, 1, 6, 7, color, ret, alpha, x, width, height
         movu m1, [colorq + xq]
         movu m2, [alphaq + xq]
         pand m6, m2
+        psubus%1 m1, m4
         pmaxu%1 m1, m2
     %else
         pmovzx%1%2 m1, [colorq + xq]
diff --git a/tests/checkasm/vf_colordetect.c b/tests/checkasm/vf_colordetect.c
index 471f77fcc7..f5739c6567 100644
--- a/tests/checkasm/vf_colordetect.c
+++ b/tests/checkasm/vf_colordetect.c
@@ -75,9 +75,16 @@ static void check_alpha_detect(int depth, enum AVColorRange range)
     const int mpeg_max = 235 << (depth - 8);
     const int alpha_max = (1 << depth) - 1;
     const int mpeg_range = mpeg_max - mpeg_min;
-    const int offset = alpha_max * mpeg_min + (1 << (depth - 1));
     int res_ref, res_new;
 
+    int offset;
+    int threshold = checkasm_rand() % FFMIN(HEIGHT, mpeg_min);
+    if (range == AVCOL_RANGE_JPEG) {
+        offset = threshold;
+    } else {
+        offset = alpha_max * (mpeg_min + threshold) + (1 << (depth - 1));
+    }
+
     FFColorDetectDSPContext dsp = {0};
     ff_color_detect_dsp_init(&dsp, depth, range);
 
@@ -89,13 +96,19 @@ static void check_alpha_detect(int depth, enum AVColorRange range)
     memset(luma,  0x80, HEIGHT * STRIDE);
     memset(alpha, 0xF0, HEIGHT * STRIDE);
 
-    /* Try and force overflow */
+    /* Try and force overflow and edge cases */
     if (depth > 8 && range == AVCOL_RANGE_MPEG) {
-        ((uint16_t *) luma)[0] = 235 << (depth - 8);
-        ((uint16_t *) luma)[1] =  16 << (depth - 8);
+        for (int i = 0; i < threshold; i++) {
+            uint16_t *line = (uint16_t *) (luma + i * STRIDE);
+            line[0] = (235 << (depth - 8)) + i;
+            line[1] = ( 16 << (depth - 8)) - i;
+        }
     } else {
-        luma[0] = 235;
-        luma[1] = 16;
+        for (int i = 0; i < threshold; i++) {
+            uint8_t *line = luma + i * STRIDE;
+            line[0] = 235 + i;
+            line[1] = 16 - i;
+        }
     }
 
     /* Place an out-of-range value in a random position near the center */
-- 
2.52.0


>From 43f2de87f90abbce660bb34d79cdafe6448e2e4f Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Mon, 17 Aug 2026 12:38:24 +0200
Subject: [PATCH 2/4] avfilter/vf_colordetect: precompute alpha detection
 fields

In particular, we also store s->mpeg_range explicitly due to the upcoming
commit, which will allow these values to deviates between the range detection
and alpha mode detection passes.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 libavfilter/vf_colordetect.c | 50 ++++++++++++++++++++++--------------
 1 file changed, 31 insertions(+), 19 deletions(-)

diff --git a/libavfilter/vf_colordetect.c b/libavfilter/vf_colordetect.c
index 397e278110..c8782db37e 100644
--- a/libavfilter/vf_colordetect.c
+++ b/libavfilter/vf_colordetect.c
@@ -52,10 +52,17 @@ typedef struct ColorDetectContext {
     const AVPixFmtDescriptor *desc;
     int nb_threads;
     int depth;
+    int range;
     int idx_a;
+
+    /* for color range detection only */
     int mpeg_min;
     int mpeg_max;
 
+    /* for alpha detection only */
+    int mpeg_range;
+    int offset;
+
     atomic_int detected_range; // enum AVColorRange
     atomic_int detected_alpha; // enum FFAlphaDetect
 } ColorDetectContext;
@@ -98,6 +105,7 @@ static int config_input(AVFilterLink *inlink)
     ColorDetectContext *s = ctx->priv;
     const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(inlink->format);
     const int depth = desc->comp[0].depth;
+    const int range = (1 << depth) - 1;
     const int mpeg_min =  16 << (depth - 8);
     const int mpeg_max = 235 << (depth - 8);
     if (depth > 16) /* not currently possible; prevent future bugs */
@@ -105,9 +113,29 @@ static int config_input(AVFilterLink *inlink)
 
     s->desc = desc;
     s->depth = depth;
+    s->range = range;
+    s->nb_threads = ff_filter_get_nb_threads(ctx);
+
+    /* Color range detection: */
     s->mpeg_min = mpeg_min;
     s->mpeg_max = mpeg_max;
-    s->nb_threads = ff_filter_get_nb_threads(ctx);
+
+    /**
+     * Alpha mode detection:
+     *
+     * To check if a value is out of range, we need to compare the color value
+     * against the maximum possible color for a given alpha value.
+     *   x > ((mpeg_max - mpeg_min) / pixel_max) * a + mpeg_min
+     *
+     * This simplifies to:
+     *   (x - mpeg_min) * pixel_max > (mpeg_max - mpeg_min) * a
+     *   = range * x - offset > mpeg_range * a in the below formula.
+     *
+     * We subtract an additional offset of (1 << (depth - 1)) to account for
+     * rounding errors in the value of `x`.
+     */
+    s->mpeg_range = mpeg_max - mpeg_min;
+    s->offset = range * mpeg_min + (1 << (s->depth - 1));
 
     if (desc->flags & AV_PIX_FMT_FLAG_RGB) {
         atomic_init(&s->detected_range, AVCOL_RANGE_JPEG);
@@ -159,28 +187,12 @@ static int detect_alpha(AVFilterContext *ctx, void *arg,
     const ptrdiff_t alpha_stride = in->linesize[s->idx_a];
     const uint8_t *alpha = in->data[s->idx_a] + y_start * alpha_stride;
 
-    /**
-     * To check if a value is out of range, we need to compare the color value
-     * against the maximum possible color for a given alpha value.
-     *   x > ((mpeg_max - mpeg_min) / pixel_max) * a + mpeg_min
-     *
-     * This simplifies to:
-     *   (x - mpeg_min) * pixel_max > (mpeg_max - mpeg_min) * a
-     *   = alpha_max * x - offset > mpeg_range * a in the below formula.
-     *
-     * We subtract an additional offset of (1 << (depth - 1)) to account for
-     * rounding errors in the value of `x`.
-     */
-    const int alpha_max = (1 << s->depth) - 1;
-    const int mpeg_range = s->mpeg_max - s->mpeg_min;
-    const int offset = alpha_max * s->mpeg_min + (1 << (s->depth - 1));
-
     int ret = 0;
     for (int i = 0; i < nb_planes; i++) {
         const ptrdiff_t stride = in->linesize[i];
         ret = s->dsp.detect_alpha(in->data[i] + y_start * stride, stride,
-                                  alpha, alpha_stride, w, h_slice, alpha_max,
-                                  mpeg_range, offset);
+                                  alpha, alpha_stride, w, h_slice, s->range,
+                                  s->mpeg_range, s->offset);
         ret |= atomic_fetch_or_explicit(&s->detected_alpha, ret, memory_order_relaxed);
         if (ret == FF_ALPHA_STRAIGHT)
             break;
-- 
2.52.0


>From 441a238d27e64d917647fad5b725eccd8bdedeb0 Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Mon, 17 Aug 2026 12:56:07 +0200
Subject: [PATCH 3/4] avfilter/vf_colordetect: add threshold option

This is useful to filter false positives due to encoding noise in lossily
compressed sources.

The reason this is limited to 0.05 (= 5%) is twofold:

1. Higher values would mostly defeat the purpose of this filter, since a
   deviation of >5% is enough to fully blur the distinction between JPEG and
   MPEG range, let alone premultiplied and straight alpha. There's no useful
   signal to extract with such a high tolerance for noise.

2. Higher values would overflow the `offset` value in the 8-bit SIMD. This
   could also be solved by clamping, but the first point is the salient one
   anyways.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 doc/filters.texi             |  4 ++++
 libavfilter/vf_colordetect.c | 25 +++++++++++++++++++------
 2 files changed, 23 insertions(+), 6 deletions(-)

diff --git a/doc/filters.texi b/doc/filters.texi
index 51885fddde..6f05c61354 100644
--- a/doc/filters.texi
+++ b/doc/filters.texi
@@ -9941,6 +9941,10 @@ premultiplied. Also detects if the alpha plane is fully opaque or not.
 Enable detection of all of the above properties. This is the default.
 @end table
 
+@item threshold
+Allow pixels to exceed the expected bounds by this fraction of the full scale
+value range before treating it as evidence of (respectively) full range or
+straight alpha. Value range is from 0 to 0.05. Defaults to 0.0.
 @end table
 
 @section colorize
diff --git a/libavfilter/vf_colordetect.c b/libavfilter/vf_colordetect.c
index c8782db37e..3750b7d86a 100644
--- a/libavfilter/vf_colordetect.c
+++ b/libavfilter/vf_colordetect.c
@@ -48,6 +48,7 @@ typedef struct ColorDetectContext {
     const AVClass *class;
     FFColorDetectDSPContext dsp;
     unsigned mode;
+    float threshold;
 
     const AVPixFmtDescriptor *desc;
     int nb_threads;
@@ -75,6 +76,9 @@ static const AVOption colordetect_options[] = {
         { "color_range", "Detect (YUV) color range", 0, AV_OPT_TYPE_CONST, {.i64 = COLOR_DETECT_COLOR_RANGE}, 0, 0, FLAGS, .unit = "mode" },
         { "alpha_mode",  "Detect alpha mode",        0, AV_OPT_TYPE_CONST, {.i64 = COLOR_DETECT_ALPHA_MODE }, 0, 0, FLAGS, .unit = "mode" },
         { "all",         "Detect all supported properties", 0, AV_OPT_TYPE_CONST, {.i64 = -1}, 0, 0, FLAGS, .unit = "mode" },
+
+    /* Note: threshold should not be increased past ~0.4 as it overflows 8-bit SIMD otherwise */
+    { "threshold", "Detection threshold, as a fraction of the full range", OFFSET(threshold), AV_OPT_TYPE_FLOAT, {.dbl = 0.0}, 0.0, 0.05, FLAGS },
     { NULL }
 };
 
@@ -106,6 +110,7 @@ static int config_input(AVFilterLink *inlink)
     const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(inlink->format);
     const int depth = desc->comp[0].depth;
     const int range = (1 << depth) - 1;
+    const int threshold = lrintf(s->threshold * range);
     const int mpeg_min =  16 << (depth - 8);
     const int mpeg_max = 235 << (depth - 8);
     if (depth > 16) /* not currently possible; prevent future bugs */
@@ -117,25 +122,33 @@ static int config_input(AVFilterLink *inlink)
     s->nb_threads = ff_filter_get_nb_threads(ctx);
 
     /* Color range detection: */
-    s->mpeg_min = mpeg_min;
-    s->mpeg_max = mpeg_max;
+    s->mpeg_min = av_clip_uintp2(mpeg_min - threshold, depth);
+    s->mpeg_max = av_clip_uintp2(mpeg_max + threshold, depth);
 
     /**
      * Alpha mode detection:
      *
      * To check if a value is out of range, we need to compare the color value
      * against the maximum possible color for a given alpha value.
-     *   x > ((mpeg_max - mpeg_min) / pixel_max) * a + mpeg_min
+     *   x > ((mpeg_max - mpeg_min) / pixel_max) * a + mpeg_min + threshold
      *
      * This simplifies to:
-     *   (x - mpeg_min) * pixel_max > (mpeg_max - mpeg_min) * a
+     *   (x - mpeg_min - threshold) * pixel_max > (mpeg_max - mpeg_min) * a
      *   = range * x - offset > mpeg_range * a in the below formula.
      *
      * We subtract an additional offset of (1 << (depth - 1)) to account for
      * rounding errors in the value of `x`.
+     *
+     * For full range input this degenerates to `x > a + threshold`, so the
+     * threshold is passed through directly, without the `range` scaling.
      */
-    s->mpeg_range = mpeg_max - mpeg_min;
-    s->offset = range * mpeg_min + (1 << (s->depth - 1));
+    if (inlink->color_range == AVCOL_RANGE_JPEG) {
+        s->mpeg_range = range;
+        s->offset = threshold;
+    } else {
+        s->mpeg_range = mpeg_max - mpeg_min;
+        s->offset = range * (mpeg_min + threshold) + (1 << (depth - 1));
+    }
 
     if (desc->flags & AV_PIX_FMT_FLAG_RGB) {
         atomic_init(&s->detected_range, AVCOL_RANGE_JPEG);
-- 
2.52.0


>From 2509a0c80fc7e4c7ce0a8eb93a0d3abdc5be70bf Mon Sep 17 00:00:00 2001
From: Niklas Haas <[email protected]>
Date: Mon, 17 Aug 2026 13:08:41 +0200
Subject: [PATCH 4/4] avfilter/vf_colordetect: increase threshold default to
 0.001

I split this off from the commit adding the option to distinguish between the
change in code (which should be a no-op by default) and the change in behavior
(which is a deliberate deviation) when bisecting.

0.001 = 0.1% permits just one 10-bit code point of deviation, while
requiring tight bounds for 8-bit content.

Sponsored-by: nxtedition AB
Signed-off-by: Niklas Haas <[email protected]>
---
 doc/filters.texi             | 2 +-
 libavfilter/vf_colordetect.c | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/doc/filters.texi b/doc/filters.texi
index 6f05c61354..4a228adda3 100644
--- a/doc/filters.texi
+++ b/doc/filters.texi
@@ -9944,7 +9944,7 @@ Enable detection of all of the above properties. This is the default.
 @item threshold
 Allow pixels to exceed the expected bounds by this fraction of the full scale
 value range before treating it as evidence of (respectively) full range or
-straight alpha. Value range is from 0 to 0.05. Defaults to 0.0.
+straight alpha. Value range is from 0 to 0.05. Defaults to 0.001 (=0.1%).
 @end table
 
 @section colorize
diff --git a/libavfilter/vf_colordetect.c b/libavfilter/vf_colordetect.c
index 3750b7d86a..4fffaa4968 100644
--- a/libavfilter/vf_colordetect.c
+++ b/libavfilter/vf_colordetect.c
@@ -78,7 +78,7 @@ static const AVOption colordetect_options[] = {
         { "all",         "Detect all supported properties", 0, AV_OPT_TYPE_CONST, {.i64 = -1}, 0, 0, FLAGS, .unit = "mode" },
 
     /* Note: threshold should not be increased past ~0.4 as it overflows 8-bit SIMD otherwise */
-    { "threshold", "Detection threshold, as a fraction of the full range", OFFSET(threshold), AV_OPT_TYPE_FLOAT, {.dbl = 0.0}, 0.0, 0.05, FLAGS },
+    { "threshold", "Detection threshold, as a fraction of the full range", OFFSET(threshold), AV_OPT_TYPE_FLOAT, {.dbl = 0.001}, 0.0, 0.05, FLAGS },
     { NULL }
 };
 
-- 
2.52.0

_______________________________________________
ffmpeg-devel mailing list -- [email protected]
To unsubscribe send an email to [email protected]
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.