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() 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 + } +}