From 9466618a92c5d0c8e31a921f85aba369b89c94f4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 22 Aug 2026 11:30:44 -0400 Subject: [PATCH 1/2] fix(scanner): ask for the analysis stream through ResolutionSelector setTargetResolution is only a hint. On a 4:3 sensor CameraX resolved the 1920x1080 request down to a 720x720 square, and had done so for as long as the line has existed -- nothing in the source read wrong, the request and the reality simply differed. A square crop costs range twice. It keeps the sensor's full height but only three quarters of its width, so the frame covers 57.6 degrees instead of 72.5, and it spans that narrower view with 720 pixels instead of 1920: 12.5 pixels per degree where the lens can give 26.5. Naming the aspect ratio explicitly gets the full-width 16:9 stream the scanner was always meant to have. Measured on a Solana Seeker, the smallest decodable code goes from 7.76 to 5.40 degrees of arc -- 1.44x the scan range. 1080p specifically, not more. The native detector normalises every threshold to a 480px baseline (scaling_rate = MIN(rows, cols) / 480), so the smallest decodable code grows almost as fast as the frame does: 1440p is a wash and 4K measures slightly worse while quadrupling the per-frame work. Two things fall out for free. The negotiated 1920x1080 plane is tightly packed where the 720x720 one had a 768-byte stride, so the unpad copy that ran on every frame becomes a no-op. And setTargetResolution is deprecated. All of the above is one device's negotiation. Which resolution a given camera returns is a property of that camera, so the guard added alongside this asserts the invariant rather than the measured numbers. --- .../com/getcode/ui/scanner/CodeScanner.kt | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt b/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt index 19381b0e76..8b19708d03 100644 --- a/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt +++ b/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt @@ -8,6 +8,9 @@ import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.Preview +import androidx.camera.core.resolutionselector.AspectRatioStrategy +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.compose.animation.AnimatedVisibility @@ -93,9 +96,31 @@ fun CodeScanner( .build() } + // Ask for the analysis stream through ResolutionSelector rather than the deprecated + // setTargetResolution. The old API is only a hint, and on a 4:3 sensor it resolves a 1920x1080 + // request down to a 720x720 square -- which costs range twice over. A square crop keeps the + // sensor's full height but only three quarters of its width, so the frame covers 57.6 degrees + // instead of 72.5, and it spans that narrower view with 720 pixels instead of 1920: 12.5 + // pixels per degree where the lens can give 26.5. Naming the aspect ratio explicitly gets the + // full-width 16:9 stream the scanner was always meant to have. + // + // 1080p specifically, not more. The native detector normalises every threshold to a 480px + // baseline (scaling_rate = MIN(rows, cols) / 480), so the smallest decodable code grows almost + // as fast as the frame does and the angular floor barely moves above 1080p -- 4K measures + // slightly *worse* while quadrupling the per-frame work. val imageAnalysis = remember { + val resolution = ResolutionSelector.Builder() + .setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY) + .setResolutionStrategy( + ResolutionStrategy( + Size(1920, 1080), + ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER, + ) + ) + .build() + ImageAnalysis.Builder() - .setTargetResolution(Size(1920, 1080)) + .setResolutionSelector(resolution) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) .build() From efbd16cdae82325a07934d4fd14ac9998772b77d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 22 Aug 2026 11:30:45 -0400 Subject: [PATCH 2/2] test(scanner): measure scan range and guard the analysis resolution The resolution bug was invisible by inspection -- the only symptom was that scanning felt short-ranged -- so pin it with a test that asserts on what the camera delivers rather than on what the code asks for. AnalysisResolutionTest binds the same use cases CodeScanner binds and requires a full-width 16:9 stream with a short side of at least 1080; both halves matter, since the short side sets how small a code can be and the aspect ratio decides how much of the lens is in the frame at all. It also records what the alternatives negotiate, so the choice can be re-checked on other hardware rather than taken on faith. KikCodeRangeTest answers the question that motivated the change: how small a code can get before the detector stops seeing it. It bisects the floor at each candidate resolution and converts the result to angular size and range, which is what makes "raise the resolution" visibly the wrong fix -- the pixel floor climbs almost as fast as the frame, so the gain above 1080p is nil. A throughput pass alongside it bounds the cost: worst case 11.5ms a frame against a 30fps camera. Both are measurements on synthetic, ideal frames -- perfect focus, no motion blur, no sensor noise -- taken on a Solana Seeker. The ratios between resolutions are the trustworthy part; real-world range is strictly worse than the absolute numbers, and another camera may negotiate differently. --- vendor/kik/scanner/build.gradle.kts | 3 + .../src/androidTest/AndroidManifest.xml | 11 + .../com/kik/scan/AnalysisResolutionTest.kt | 236 +++++++++++++ .../kotlin/com/kik/scan/KikCodeRangeTest.kt | 332 ++++++++++++++++++ 4 files changed, 582 insertions(+) create mode 100644 vendor/kik/scanner/src/androidTest/AndroidManifest.xml create mode 100644 vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/AnalysisResolutionTest.kt create mode 100644 vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/KikCodeRangeTest.kt diff --git a/vendor/kik/scanner/build.gradle.kts b/vendor/kik/scanner/build.gradle.kts index ab1cbd9ea3..562fe3c860 100644 --- a/vendor/kik/scanner/build.gradle.kts +++ b/vendor/kik/scanner/build.gradle.kts @@ -42,4 +42,7 @@ dependencies { androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.kotlin.test.junit) androidTestImplementation(libs.kotlinx.coroutines.core) + androidTestImplementation(libs.androidx.camerax.camera2) + androidTestImplementation(libs.androidx.camerax.lifecycle) + androidTestImplementation(libs.testing.androidx.test.rules) } diff --git a/vendor/kik/scanner/src/androidTest/AndroidManifest.xml b/vendor/kik/scanner/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..bff7606ac7 --- /dev/null +++ b/vendor/kik/scanner/src/androidTest/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/AnalysisResolutionTest.kt b/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/AnalysisResolutionTest.kt new file mode 100644 index 0000000000..f3c9445668 --- /dev/null +++ b/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/AnalysisResolutionTest.kt @@ -0,0 +1,236 @@ +package com.kik.scan + +import android.Manifest +import android.util.Log +import android.util.Size +import android.view.Surface +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.core.resolutionselector.AspectRatioStrategy +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.math.sqrt +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Which analysis resolution the device actually hands the scanner. + * + * `CodeScanner` asks for 1920x1080 through the deprecated `setTargetResolution`, which is a + * *request*: CameraX resolves it against the supported sizes, the target rotation, and the + * surface-combination limits of whatever else is bound. What comes back decides scan range, because + * the native detector's minimum feature sizes are all normalised to `MIN(rows, cols) / 480` -- so + * asking for 1080p and getting less tightens every threshold in angular terms. + * + * This binds the same use cases the app binds and records the negotiated size rather than the + * requested one, then does the same for the candidate replacements so the fix can be chosen from + * measurements instead of from the documentation. + */ +@RunWith(AndroidJUnit4::class) +class AnalysisResolutionTest { + + @get:Rule + val cameraPermission: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.CAMERA) + + /** Minimal lifecycle owner parked in RESUMED, so `bindToLifecycle` opens the camera. */ + private class TestLifecycleOwner : LifecycleOwner { + private val registry = LifecycleRegistry(this) + override val lifecycle: Lifecycle get() = registry + fun resume() = registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + fun destroy() = registry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + } + + private val selector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + + /** + * Binds [analysis] alongside a preview, waits for a real frame, and logs what arrived. + * Returns the size the camera actually delivered, or null if no frame arrived in time. + */ + private fun measure(label: String, analysis: ImageAnalysis): Size? { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val provider = ProcessCameraProvider.getInstance(context).get(10, TimeUnit.SECONDS) + val preview = Preview.Builder().build() + val owner = TestLifecycleOwner() + val instrumentation = InstrumentationRegistry.getInstrumentation() + val executor = Executors.newSingleThreadExecutor() + + instrumentation.runOnMainSync { + provider.unbindAll() + provider.bindToLifecycle(owner, selector, preview, analysis) + owner.resume() + } + + try { + val frame = CountDownLatch(1) + var frameSize: Size? = null + var rowStride = -1 + var pixelStride = -1 + analysis.setAnalyzer(executor) { image -> + if (frameSize == null) { + frameSize = Size(image.width, image.height) + rowStride = image.planes[0].rowStride + pixelStride = image.planes[0].pixelStride + frame.countDown() + } + image.close() + } + val gotFrame = frame.await(15, TimeUnit.SECONDS) + val size = frameSize ?: analysis.resolutionInfo?.resolution + + val detail = if (size != null) { + val shortSide = minOf(size.width, size.height) + val scalingRate = shortSide / 480.0 + val minArea = 220 * scalingRate + val discPx = 2 * sqrt(minArea / Math.PI) + // The centre disc is INNER_RING_RATIO (0.32) of the whole code graphic. + val codePx = discPx / 0.32 + " scaling_rate=${"%.2f".format(scalingRate)}" + + " minCentreDisc=${"%.1f".format(discPx)}px" + + " impliedMinCode=${"%.0f".format(codePx)}px" + + " (${"%.1f".format(codePx / size.width.toDouble() * 100)}% of frame width)" + } else { + " " + } + + Log.i( + TAG, + "$label -> negotiated=${analysis.resolutionInfo?.resolution} frame=$size " + + "rowStride=$rowStride pixelStride=$pixelStride packed=${rowStride == size?.width} " + + "gotFrame=$gotFrame preview=${preview.resolutionInfo?.resolution}$detail", + ) + return size + } finally { + instrumentation.runOnMainSync { + analysis.clearAnalyzer() + provider.unbindAll() + owner.destroy() + } + executor.shutdown() + } + } + + /** The analysis configuration `CodeScanner` builds. Kept in step with it by hand. */ + private fun shippingAnalysis(): ImageAnalysis { + val resolution = ResolutionSelector.Builder() + .setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY) + .setResolutionStrategy( + ResolutionStrategy( + Size(1920, 1080), + ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER, + ) + ) + .build() + + return ImageAnalysis.Builder() + .setResolutionSelector(resolution) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) + .build() + } + + /** + * The scanner must be handed a full-width 16:9 stream, and this is the only place that is + * checked. + * + * The bug this guards against is invisible by inspection: the code asked for 1920x1080 for + * years and got a 720x720 square, because `setTargetResolution` is a hint the camera is free to + * resolve however it likes. Nothing in the source read wrong -- the request and the reality + * simply differed, and the only symptom was that scanning felt short-ranged. + * + * Both halves of the assertion matter. The short side sets how small a code can be and still + * clear the detector's thresholds; the aspect ratio decides how much of the lens's field of + * view is in the frame at all, since a square crop off a 4:3 sensor silently discards a quarter + * of the width. + */ + @Test + fun shippingConfigurationGetsFullWidthSixteenByNine() { + val negotiated = measure("shipping (ResolutionSelector 16:9 1080p)", shippingAnalysis()) + + assertNotNull(negotiated, "camera delivered no frame") + val shortSide = minOf(negotiated.width, negotiated.height) + val aspect = maxOf(negotiated.width, negotiated.height).toDouble() / shortSide + + assertTrue( + shortSide >= 1080, + "analysis stream is $negotiated -- short side $shortSide is below 1080, which raises " + + "the smallest decodable code and costs scan range", + ) + assertTrue( + aspect >= 1.6, + "analysis stream is $negotiated -- aspect ratio ${"%.2f".format(aspect)} is narrower " + + "than 16:9, so the frame is cropped in from the sensor's full width and the " + + "scanner cannot see the edges of the lens's field of view", + ) + } + + /** + * Not a guard -- a record of what the alternatives negotiate on this device, so the choice + * above can be re-checked rather than taken on faith. Logs only; a device that resolves these + * differently is informative, not broken. + */ + @Test + fun alternativeConfigurationsForComparison() { + measure( + "deprecated setTargetResolution(1920x1080)", + ImageAnalysis.Builder() + .setTargetResolution(Size(1920, 1080)) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) + .build(), + ) + + // The same request with the rotation pinned, to rule out the target-rotation frame as the + // reason the deprecated path lands where it does. + measure( + "deprecated setTargetResolution(1920x1080)+ROTATION_0", + ImageAnalysis.Builder() + .setTargetResolution(Size(1920, 1080)) + .setTargetRotation(Surface.ROTATION_0) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) + .build(), + ) + + // Above the chosen resolution. Reachable on this device, but the range sweep in + // KikCodeRangeTest shows it decodes no smaller an angular target while costing four times + // the per-frame work. + val fourK = ResolutionSelector.Builder() + .setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY) + .setResolutionStrategy( + ResolutionStrategy( + Size(3840, 2160), + ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER, + ) + ) + .setAllowedResolutionMode(ResolutionSelector.PREFER_HIGHER_RESOLUTION_OVER_CAPTURE_RATE) + .build() + measure( + "ResolutionSelector 16:9 2160p", + ImageAnalysis.Builder() + .setResolutionSelector(fourK) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) + .build(), + ) + } + + private companion object { + const val TAG = "KikCodeRange" + } +} diff --git a/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/KikCodeRangeTest.kt b/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/KikCodeRangeTest.kt new file mode 100644 index 0000000000..6d3a883469 --- /dev/null +++ b/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/KikCodeRangeTest.kt @@ -0,0 +1,332 @@ +package com.kik.scan + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +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 +import com.kik.kikx.kincodes.KikCodeContentRendererImpl +import com.kik.kikx.models.ScannableKikCode +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.math.atan +import kotlin.system.measureNanoTime +import kotlin.math.tan + +/** + * How small can a code get before the scanner stops decoding it? + * + * The native detector normalises its minimum feature sizes to a 480px baseline + * (`scaling_rate = MIN(rows, cols) / 480`), so the smallest decodable code shrinks as the analysis + * frame grows -- but only linearly in one term and quadratically in another, and the data rings put + * their own floor under it. Reasoning about the constants gives a bound, not an answer. This + * measures it. + * + * The result converts straight into scan range: a code of physical width `W` decodes out to + * `W / (2 * tan(theta / 2))`, where `theta` is the angular size the measurement lands on for a given + * analysis resolution and camera field of view. + * + * These frames are synthetic and ideal -- perfect focus, no motion blur, no sensor noise, dead-on + * perpendicular, maximum contrast. Real range is strictly worse. The number to trust here is the + * *ratio* between resolutions, not the absolute distance. + */ +@RunWith(AndroidJUnit4::class) +class KikCodeRangeTest { + + private val renderer = KikCodeContentRendererImpl().apply { + badge = requireNotNull( + ContextCompat.getDrawable( + InstrumentationRegistry.getInstrumentation().context, + com.kik.kikx.test.R.drawable.ic_logo_round_white, + ) + ) + } + private val scanner = KikCodeScannerImpl() + + /** + * What the analysis stream might be, paired with the slice of horizontal field of view each one + * actually covers. + * + * The sensor is 4:3, so how much of the lens's 72.5-degree horizontal sweep lands in the frame + * depends on the aspect ratio, not just the pixel count: 16:9 and 4:3 streams keep the full + * width and crop vertically, while a square stream throws away a quarter of the width and is + * left with the vertical field of view instead. That crop is invisible in the pixel dimensions + * and is exactly what makes the shipping configuration worse than its short side suggests. + */ + private data class AnalysisSize(val width: Int, val height: Int, val hfovDegrees: Double) + + private val resolutions = listOf( + // What Seeker actually negotiates today for the deprecated 1920x1080 request. + AnalysisSize(720, 720, SQUARE_HFOV_DEGREES), + // What the preview gets: full width, 4:3. + AnalysisSize(1440, 1080, FULL_HFOV_DEGREES), + AnalysisSize(1280, 720, FULL_HFOV_DEGREES), + // What the code asks for. + AnalysisSize(1920, 1080, FULL_HFOV_DEGREES), + AnalysisSize(2560, 1440, FULL_HFOV_DEGREES), + AnalysisSize(3840, 2160, FULL_HFOV_DEGREES), + ) + + private fun encodeRemoteCode(seed: Int): Pair { + val payload = ByteArray(REMOTE_PAYLOAD_BYTES) { ((it * 7 + seed) and 0xFF).toByte() } + return payload to requireNotNull(Scanner.encode(payload)) { "native encode returned null" } + } + + /** Renders [encoded] at exactly [codePx] wide, centred in a packed `width x height` Y plane. */ + private fun renderFrame(encoded: ByteArray, width: Int, height: Int, codePx: Int): ByteArray { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + canvas.drawColor(Color.BLACK) + + canvas.save() + canvas.translate((width - codePx) / 2f, (height - codePx) / 2f) + renderer.render(encoded, codePx, canvas) + canvas.restore() + + val pixels = IntArray(width * height) + bitmap.getPixels(pixels, 0, width, 0, 0, width, height) + bitmap.recycle() + + val plane = ByteArray(width * height) + for (i in 0 until width * height) { + val p = pixels[i] + plane[i] = ( + ( + 77 * ((p shr 16) and 0xFF) + + 150 * ((p shr 8) and 0xFF) + + 29 * (p and 0xFF) + ) shr 8 + ).toByte() + } + return plane + } + + private fun decodes(encoded: ByteArray, payload: ByteArray, w: Int, h: Int, codePx: Int): Boolean { + val plane = renderFrame(encoded, w, h, codePx) + val converted = LuminancePlane.unpad(plane, w, h, w, 1) + val result = runBlocking { + scanner.scanKikCode(converted, w, h, ScanQuality.Best).getOrNull() + } + return result is ScannableKikCode.RemoteKikCode && result.payloadId.contentEquals(payload) + } + + /** + * Smallest code width in pixels that still decodes, or -1 if even a large one fails. + * + * Bisects on the assumption that decodability is monotone in size, then walks down from the + * bisection result to catch a threshold that is ragged rather than sharp -- it keeps stepping + * until [GIVE_UP_RUN] consecutive sizes fail, so an isolated miss above the true floor does not + * end the search early. + */ + private fun minDecodablePx(encoded: ByteArray, payload: ByteArray, w: Int, h: Int): Int { + var hi = (minOf(w, h) * 0.9f).toInt() + if (!decodes(encoded, payload, w, h, hi)) return -1 + + var lo = MIN_PROBE_PX + while (lo < hi) { + val mid = (lo + hi) / 2 + if (decodes(encoded, payload, w, h, mid)) hi = mid else lo = mid + 1 + } + + var best = hi + var run = 0 + var size = hi - 1 + while (size >= MIN_PROBE_PX && run < GIVE_UP_RUN) { + if (decodes(encoded, payload, w, h, size)) { + best = size + run = 0 + } else { + run++ + } + size-- + } + return best + } + + @Test + fun smallestDecodableCodeByAnalysisResolution() { + val seeds = listOf(3, 11, 29) + + for ((w, h, hfov) in resolutions) { + val perSeed = seeds.map { seed -> + val (payload, encoded) = encodeRemoteCode(seed) + minDecodablePx(encoded, payload, w, h) + } + val worst = perSeed.max() + + // Angular size of the smallest decodable code, given the frame spans hfov degrees. + val degrees = worst.toDouble() / w * hfov + // A code of physical width W decodes out to W / (2 tan(theta/2)). + val rangeFactor = 1.0 / (2.0 * tan(Math.toRadians(degrees) / 2.0)) + + Log.i( + TAG, + "res=${w}x$h hfov=${"%.1f".format(hfov)} minCodePx=$worst (seeds=$perSeed) " + + "angular=${"%.3f".format(degrees)}deg " + + "range=${"%.2f".format(rangeFactor)}x code width", + ) + } + + // Sanity floor: the detector cannot need more than half the frame to work, or the sweep + // above measured something other than what it thinks it did. + val target = resolutions.first { it.width == 1920 } + val (payload, encoded) = encodeRemoteCode(3) + val at1080 = minDecodablePx(encoded, payload, target.width, target.height) + require(at1080 in MIN_PROBE_PX..(target.height / 2)) { "implausible 1080p floor: $at1080" } + } + + /** + * What the extra pixels cost per frame. + * + * Range is only half the trade: the detector runs an unsharp mask twice, a threshold, and a + * contour pass over the *whole* frame, so its work scales with area. If 1080p cannot keep up + * with the camera the analyzer starts dropping frames, and dropped frames cost range too -- + * just further downstream, where it is much harder to see. + * + * Both a frame with a code and a frame of ellipse clutter are timed. The clutter case is the + * one that matters: a code is found early and short-circuits, while clutter makes the detector + * evaluate and reject every candidate, which is exactly what a phone pointed at a room does. + */ + @Test + fun detectorThroughputByResolution() { + val (_, encoded) = encodeRemoteCode(3) + + // Prepared up front so frame construction never lands inside a timed section. + val frames = resolutions.associate { (w, h, _) -> + (w to h) to Pair( + LuminancePlane.unpad( + renderFrame(encoded, w, h, (minOf(w, h) * 0.4f).toInt()), w, h, w, 1, + ), + LuminancePlane.unpad(clutterPlane(w, h), w, h, w, 1), + ) + } + + for ((w, h, _) in resolutions) { + val (code, clutter) = frames.getValue(w to h) + repeat(WARMUP_FRAMES) { + runBlocking { scanner.scanKikCode(code, w, h, ScanQuality.Best) } + runBlocking { scanner.scanKikCode(clutter, w, h, ScanQuality.Best) } + } + } + + // Round-robin rather than resolution-by-resolution. Timing each resolution to completion in + // turn lets CPU frequency scaling drift across the run and attribute itself to whichever + // resolution happened to be measured while the clocks were low -- which is how an 8MP frame + // ends up "faster" than a 1.5MP one. Interleaving spreads any drift evenly. + val codeSamples = resolutions.associate { (w, h, _) -> (w to h) to mutableListOf() } + val clutterSamples = resolutions.associate { (w, h, _) -> (w to h) to mutableListOf() } + + repeat(TIMED_FRAMES) { + for ((w, h, _) in resolutions) { + val (code, clutter) = frames.getValue(w to h) + codeSamples.getValue(w to h) += measureNanoTime { + runBlocking { scanner.scanKikCode(code, w, h, ScanQuality.Best) } + } / 1_000_000.0 + clutterSamples.getValue(w to h) += measureNanoTime { + runBlocking { scanner.scanKikCode(clutter, w, h, ScanQuality.Best) } + } / 1_000_000.0 + } + } + + for ((w, h, _) in resolutions) { + val codeMs = codeSamples.getValue(w to h).sorted().let { it[it.size / 2] } + val clutterMs = clutterSamples.getValue(w to h).sorted().let { it[it.size / 2] } + val worst = maxOf(codeMs, clutterMs) + Log.i( + TAG, + "res=${w}x$h megapixels=${"%.2f".format(w * h / 1_000_000.0)} " + + "code=${"%.1f".format(codeMs)}ms clutter=${"%.1f".format(clutterMs)}ms " + + "maxSustainedFps=${"%.1f".format(1000.0 / worst)}", + ) + } + } + + /** + * A frame full of ellipses that are not a code -- the detector's worst case, since every one is + * a candidate it must evaluate and discard. Deterministic so runs are comparable. + */ + private fun clutterPlane(width: Int, height: Int): ByteArray { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + canvas.drawColor(Color.BLACK) + val paint = android.graphics.Paint().apply { isAntiAlias = true } + var state = 0x2545F491 + fun next(bound: Int): Int { + state = state * 1103515245 + 12345 + return ((state ushr 16) and 0x7FFF) % bound + } + repeat(CLUTTER_BLOBS) { + paint.color = if (next(2) == 0) Color.WHITE else Color.GRAY + val r = (next(minOf(width, height) / 16) + 6).toFloat() + canvas.drawOval( + next(width) - r, next(height) - r, next(width) + r, next(height) + r, paint, + ) + } + val pixels = IntArray(width * height) + bitmap.getPixels(pixels, 0, width, 0, 0, width, height) + bitmap.recycle() + return ByteArray(width * height) { i -> + val p = pixels[i] + ( + ( + 77 * ((p shr 16) and 0xFF) + + 150 * ((p shr 8) and 0xFF) + + 29 * (p and 0xFF) + ) shr 8 + ).toByte() + } + } + + /** + * The other half of the range equation: how many pixels a degree of field of view is worth. + * Logged from the camera the scanner actually binds so the conversion above is anchored to this + * device rather than to an assumed lens. + */ + @Test + fun reportCameraGeometry() { + val ctx = InstrumentationRegistry.getInstrumentation().targetContext + val manager = ctx.getSystemService(android.hardware.camera2.CameraManager::class.java) + for (id in manager.cameraIdList) { + val chars = manager.getCameraCharacteristics(id) + val facing = chars.get(android.hardware.camera2.CameraCharacteristics.LENS_FACING) + if (facing != android.hardware.camera2.CameraCharacteristics.LENS_FACING_BACK) continue + val focal = chars.get( + android.hardware.camera2.CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS + )?.firstOrNull() ?: continue + val size = chars.get( + android.hardware.camera2.CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE + ) ?: continue + val hfov = 2 * Math.toDegrees(atan((size.width / 2.0) / focal)) + Log.i(TAG, "camera $id back f=${focal}mm sensor=${size.width}x${size.height}mm hFOV=${"%.1f".format(hfov)}deg") + } + } + + private companion object { + const val TAG = "KikCodeRange" + const val REMOTE_PAYLOAD_BYTES = 20 + const val MIN_PROBE_PX = 24 + const val GIVE_UP_RUN = 12 + const val WARMUP_FRAMES = 5 + const val TIMED_FRAMES = 25 + const val CLUTTER_BLOBS = 120 + + /** + * Horizontal field of view of the back camera this device binds, in degrees, when the + * stream keeps the sensor's full width. Confirmed on device by [reportCameraGeometry]. + */ + const val FULL_HFOV_DEGREES = 72.5 + + /** + * The same lens seen through a square stream. A 1:1 crop of a 4:3 sensor keeps the full + * height and only three quarters of the width, so it sees the *vertical* field of view -- + * a fifth of the horizontal sweep simply is not in the frame to be scanned. + */ + const val SQUARE_HFOV_DEGREES = 57.6 + } +}