From 441d18a8bb1003825d7455b1252c29694b7b484c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 21 Aug 2026 22:22:47 -0400 Subject: [PATCH 1/2] fix(scanner): stop the badge glyph's dot from stealing the centre ellipse A code is located by its centre badge, but the badge is a disc with the Flipcash "F" knocked out of it, and the glyph's round dot is itself a near-perfect ellipse sitting ~0.08D from the badge centre. Once the code is drawn large enough in frame, that dot clears the candidate filters and the two ellipses end up in the same prune bucket. The prune dropped the wrong one. Its rule was "discard this candidate if a nearby one is at most twice its area", which is order-dependent and, for a big/small pair, always discards the big one. The badge disappeared and the scan was left searching from the dot, which yields one finder point instead of nine. Measured on a 1920x1080 frame: a code occupying 540px decodes, 756 and 972 do not. Prune only true near-duplicates -- the same physical circle fitted twice from the inner and outer edge of its stroke, which are comparable in area. A much smaller neighbour is a different feature nested inside, so keep both and let the search decide; the badge sorts first and still hits on the first iteration, so the happy path is unchanged. The sweep harness masked this by substituting a plain white oval for the badge. It now renders the real artwork, which is what put the dot in frame in the first place. --- .../kotlin/com/kik/scan/KikCodeScanTest.kt | 15 ++++++++++++--- .../res/drawable/ic_logo_round_white.xml | 10 ++++++++++ vendor/kik/scanner/src/main/cpp/scan/scanner.cpp | 10 +++++++++- 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 vendor/kik/scanner/src/androidTest/res/drawable/ic_logo_round_white.xml diff --git a/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/KikCodeScanTest.kt b/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/KikCodeScanTest.kt index 66e0e96a6a..c2d613cb5f 100644 --- a/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/KikCodeScanTest.kt +++ b/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/KikCodeScanTest.kt @@ -3,11 +3,11 @@ package com.kik.scan import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color -import android.graphics.drawable.ShapeDrawable -import android.graphics.drawable.shapes.OvalShape import android.os.Debug import android.util.Log +import androidx.core.content.ContextCompat import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry import com.getcode.codes.kikcode.LuminancePlane import com.kik.kikx.kikcodes.ScanQuality import com.kik.kikx.kikcodes.implementation.KikCodeScannerImpl @@ -36,9 +36,18 @@ class KikCodeScanTest { /** * The detector locates a code by its centre ellipse, so the badge well must be filled — in the * app that is the round logo drawable. An empty well is simply not scannable. + * + * Use the *real* artwork, not a plain white oval. The logo knocks its glyph out of the disc + * with the even-odd rule, and the glyph's round dot is itself an ellipse the detector can + * latch onto; a substituted oval has no dot and quietly hides that whole class of bug. */ private val renderer = KikCodeContentRendererImpl().apply { - badge = ShapeDrawable(OvalShape()).apply { paint.color = Color.WHITE } + badge = requireNotNull( + ContextCompat.getDrawable( + InstrumentationRegistry.getInstrumentation().context, + com.kik.kikx.test.R.drawable.ic_logo_round_white, + ) + ) } private val scanner = KikCodeScannerImpl() diff --git a/vendor/kik/scanner/src/androidTest/res/drawable/ic_logo_round_white.xml b/vendor/kik/scanner/src/androidTest/res/drawable/ic_logo_round_white.xml new file mode 100644 index 0000000000..28c3d72128 --- /dev/null +++ b/vendor/kik/scanner/src/androidTest/res/drawable/ic_logo_round_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/vendor/kik/scanner/src/main/cpp/scan/scanner.cpp b/vendor/kik/scanner/src/main/cpp/scan/scanner.cpp index 368c55d74f..fb37cbad90 100644 --- a/vendor/kik/scanner/src/main/cpp/scan/scanner.cpp +++ b/vendor/kik/scanner/src/main/cpp/scan/scanner.cpp @@ -728,7 +728,15 @@ bool detectKikCode(Mat &greyscale, Mat *out_progress, uint32_t device_quality, u float dist = sqrt(pow(center1.x - center2.x, 2) + pow(center1.y - center2.y, 2)); - if (dist < 50 && 2 * potential_ellipses[i].size.area() > potential_ellipses[j].size.area()) { + float area1 = potential_ellipses[i].size.area(); + float area2 = potential_ellipses[j].size.area(); + + // Only prune true near-duplicates -- the same physical circle fitted twice from the + // inner and outer edge of its stroke, which are comparable in area. A nearby ellipse + // that is much smaller is a *different* feature nested inside this one (e.g. the dot + // knocked out of the centre badge's glyph), and dropping the enclosing candidate in + // its favour loses the only ellipse that can yield finder points. + if (dist < 50 && 2 * area1 > area2 && 2 * area2 > area1) { allowed = false; break; } From 8204586d4354d78184563d10297c7831eb4fbebf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 21 Aug 2026 22:48:52 -0400 Subject: [PATCH 2/2] perf(scanner): drop two full-frame adaptive thresholds that nothing reads detectKikCode opened with a pair of 21x21 Gaussian adaptive thresholds into `whitish` and `blackish`. Neither result is ever read: - `whitish` is unconditionally overwritten a few dozen lines later by the global `threshold(greyscale, whitish, 170, 255, THRESH_BINARY)` before anything touches it. - `blackish` is only read on the inverted-code path (`!check_high`), and that path recomputes it itself, lazily, behind `blackish_created` -- with a different algorithm (ADAPTIVE_THRESH_MEAN_C at the quality-dependent width). So the block was two full-frame adaptive thresholds per analyzed frame, thrown away every time. Measured on an S25 Ultra over 30 iterations per case: 1280x720 code present 10.44ms -> 4.99ms 1280x720 no code 14.30ms -> 8.90ms 1920x1080 code present 8.27ms -> 2.79ms 1920x1080 no code 10.01ms -> 4.47ms That is ~5.5ms off every frame the analyzer processes, hit or miss -- larger than the luminance-plane fast path it stacks on. The instrumented sweep still decodes 18/18 and stride parity is unchanged, which is what you would expect from deleting values nothing consumes. iOS never carried this block. --- .../kik/scanner/src/main/cpp/scan/scanner.cpp | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/vendor/kik/scanner/src/main/cpp/scan/scanner.cpp b/vendor/kik/scanner/src/main/cpp/scan/scanner.cpp index fb37cbad90..937def5211 100644 --- a/vendor/kik/scanner/src/main/cpp/scan/scanner.cpp +++ b/vendor/kik/scanner/src/main/cpp/scan/scanner.cpp @@ -417,23 +417,6 @@ bool detectKikCode(Mat &greyscale, Mat *out_progress, uint32_t device_quality, u Mat whitish; Mat blackish; - // --- START OF EDIT --- - // we switch to an inverted scheme (dark is high, light is low) if the - // center ellipse is dark, but we don't want to compute the extra threshold everytime - // so we only do this when needed. - // - // Using adaptive thresholding is more robust to lighting changes than a global threshold. - // It calculates a threshold for smaller regions, making it less susceptible to shadows or glare. - // A block size of 11 or higher is a good starting point and must be an odd number. - // The constant 'C' (here, 5) is subtracted from the mean, which helps in finding features. - - // For finding dark features on a light background. - adaptiveThreshold(greyscale, blackish, 255, ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY_INV, 21, 5); - - // For finding light features on a dark background. - adaptiveThreshold(greyscale, whitish, 255, ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY, 21, 5); - // --- END OF EDIT --- - // we switch to an inverted scheme (dark is high, light is low) if the // center ellipse is dark, but we don't want to compute the extra threshold everytime // so we only do this when necessary