diff --git a/lib/utilities/camera_preview_frame.dart b/lib/utilities/camera_preview_frame.dart index bdf83533b..c6c4911a3 100644 --- a/lib/utilities/camera_preview_frame.dart +++ b/lib/utilities/camera_preview_frame.dart @@ -75,3 +75,20 @@ img.Image imageFromMirroredPreviewRgba( order: img.ChannelOrder.rgba, ); } + +/// A 64-bit FNV-1a hash of [rgba], for telling a new camera frame from a +/// repeat of the last one. +/// +/// Frames from a live sensor never repeat byte for byte, so two captures +/// with the same fingerprint mean the preview has stopped updating. This is +/// a full pass over the buffer; an indexed loop keeps it to a few +/// milliseconds for a 1280 px frame. +int previewFrameFingerprint(Uint8List rgba) { + var hash = 0xcbf29ce484222325; + final length = rgba.length; + for (var i = 0; i < length; i++) { + hash ^= rgba[i]; + hash *= 0x100000001b3; + } + return hash; +} diff --git a/lib/utilities/qr_image_decoder.dart b/lib/utilities/qr_image_decoder.dart index be063ccdb..308ba893b 100644 --- a/lib/utilities/qr_image_decoder.dart +++ b/lib/utilities/qr_image_decoder.dart @@ -1,3 +1,4 @@ +import 'dart:math' as math; import 'dart:typed_data'; import 'package:image/image.dart' as img; @@ -18,7 +19,26 @@ String? decodeQrFromImageBytes(Uint8List bytes) { /// Decodes a QR code from [image], whatever its bit depth, channel count or /// palette. Returns null if no QR code is found. -String? decodeQrFromImage(img.Image image) { +/// +/// If [maxDimension] is set, larger images are scaled down to fit it first. +/// Some webcams hand back full sensor-size photos (3264x2448 from a USB +/// webcam on Windows), which took 2-3 s per frame to scan on an older laptop. +String? decodeQrFromImage(img.Image image, {int? maxDimension}) { + if (maxDimension != null && + math.max(image.width, image.height) > maxDimension) { + image = image.width >= image.height + ? img.copyResize( + image, + width: maxDimension, + interpolation: img.Interpolation.average, + ) + : img.copyResize( + image, + height: maxDimension, + interpolation: img.Interpolation.average, + ); + } + final LuminanceSource source; try { source = RGBLuminanceSource(image.width, image.height, _argbPixels(image)); diff --git a/lib/widgets/desktop/qr_code_scanner_dialog.dart b/lib/widgets/desktop/qr_code_scanner_dialog.dart index 2ff1a2c2a..38a53de05 100644 --- a/lib/widgets/desktop/qr_code_scanner_dialog.dart +++ b/lib/widgets/desktop/qr_code_scanner_dialog.dart @@ -62,9 +62,14 @@ class _QrCodeScannerDialogState extends State { Size? _windowsPreviewSize; bool _loggedBlankWindowsFrame = false; bool _hasWindowsFrame = false; - - // Bigger captures cost more to read back and scan without helping zxing. - static const _maxWindowsFrameDimension = 1280; + // camera_windows keeps showing the last frame after the capture engine + // fails, and toImage() keeps returning it. The fingerprint tells a new + // frame from a repeat; the error event tells us the camera is gone at once. + int? _lastWindowsFrameFingerprint; + bool _loggedRepeatedWindowsFrame = false; + bool _windowsCameraLost = false; + StreamSubscription? _windowsErrorSub; + bool _cameraStopped = false; @override void initState() { @@ -174,6 +179,11 @@ class _QrCodeScannerDialogState extends State { await _cameraWindowsPlugin.dispose(_cameraId); return false; } + // The plugin's only closing event comes from our own dispose(), so + // the error event is the one that matters. + _windowsErrorSub = _cameraWindowsPlugin + .onCameraError(_cameraId) + .listen((e) => _onWindowsCameraLost(e.description)); Logging.instance.d( "Windows Camera initialized with ID: $_cameraId, " "preview $_windowsPreviewSize", @@ -231,27 +241,56 @@ class _QrCodeScannerDialogState extends State { return false; } - /// Shows an error once no usable frame has arrived for [_frameTimeout]. + /// Shows an error once no new frame has arrived for [_frameTimeout]. void _checkFrameTimeout() { if (_sinceLastFrame.elapsed >= _frameTimeout && _cameraError == null && mounted) { setState(() { _cameraError = - "The camera is not returning images. You can still " + "The camera preview has stopped updating. You can still " "use \"Select file\" to scan a QR code image."; }); } } + /// The plugin reported a capture engine error. Its preview keeps the last + /// frame and it never restarts the stream, so stop scanning and say so now + /// rather than waiting for the frame timeout. + void _onWindowsCameraLost(String description) { + // The engine can report one failure several times, and an event already + // queued can still arrive after the dialog started closing. + if (_windowsCameraLost || !_isScanning) return; + _windowsCameraLost = true; + _isScanning = false; + Logging.instance.w("Windows camera error: $description"); + // FormatMessage text ends in ".\r\n", and unknown errors come through + // empty. + final detail = description.trim().replaceFirst(RegExp(r"\.$"), ""); + if (!mounted) return; + setState(() { + _cameraError = + "The camera stopped working" + "${detail.isEmpty ? "" : " ($detail)"}. Close and reopen the " + "scanner to try again, or use \"Select file\" to scan a QR code " + "image."; + }); + } + Future _stopCamera() async { _isScanning = false; + // Runs from PopScope and from dispose(); the second call has nothing to + // do. + if (_cameraStopped) return; + _cameraStopped = true; try { if (Platform.isLinux && _cameraLinuxPlugin != null) { _cameraLinuxPlugin!.stopCamera(); Logging.instance.d("Linux Camera stopped"); } else if (Platform.isWindows && _cameraWindowsPlugin != null) { + await _windowsErrorSub?.cancel(); + _windowsErrorSub = null; // if (_cameraId >= 0) { await _cameraWindowsPlugin.dispose(_cameraId); Logging.instance.d("Windows Camera stopped with ID: $_cameraId"); @@ -273,8 +312,16 @@ class _QrCodeScannerDialogState extends State { } } + @override + void dispose() { + // PopScope normally gets here first; this covers a route removed without + // a pop. + unawaited(_stopCamera()); + super.dispose(); + } + /// Grabs the frame the Windows preview is showing, in memory. Returns null - /// when there is nothing to scan yet. + /// when there is nothing to scan yet, or nothing new since the last call. Future _captureWindowsPreviewFrame() async { final boundary = _windowsPreviewKey.currentContext?.findRenderObject(); // Not laid out or painted yet, e.g. just after the camera opened. @@ -294,7 +341,7 @@ class _QrCodeScannerDialogState extends State { if (preview != null && preview.width > 0 && preview.height > 0) { final fit = min( 1.0, - _maxWindowsFrameDimension / max(preview.width, preview.height), + _cameraFrameMaxDimension / max(preview.width, preview.height), ); // toImage() rounds each side up, and the laid-out box is rarely exactly // the camera's aspect ratio, so take the smaller ratio and stay a hair @@ -336,6 +383,21 @@ class _QrCodeScannerDialogState extends State { } _loggedBlankWindowsFrame = false; + // A repeat of the last capture means the preview has stopped updating, + // so treat it like no frame and let the frame timeout notice. + final fingerprint = previewFrameFingerprint(rgba); + if (fingerprint == _lastWindowsFrameFingerprint) { + if (!_loggedRepeatedWindowsFrame) { + _loggedRepeatedWindowsFrame = true; + Logging.instance.w( + "Windows camera preview frame has not changed, waiting for a new one", + ); + } + return null; + } + _lastWindowsFrameFingerprint = fingerprint; + _loggedRepeatedWindowsFrame = false; + // camera_windows mirrors the preview, so flip it back for zxing. return imageFromMirroredPreviewRgba(rgba, width: width, height: height); } @@ -406,7 +468,10 @@ class _QrCodeScannerDialogState extends State { }); } - final String? scanResult = await _scanImage(image); + final String? scanResult = await _scanImage( + image, + maxDimension: _cameraFrameMaxDimension, + ); if (scanResult != null && scanResult.isNotEmpty) { await _stopCamera(); @@ -441,7 +506,13 @@ class _QrCodeScannerDialogState extends State { } } - Future _scanImage(img.Image image) async => decodeQrFromImage(image); + // Camera frames bigger than this are scaled down before scanning, and + // Windows preview captures are taken at no more than this. Files picked + // with "Select file" are scanned at full size. + static const _cameraFrameMaxDimension = 1280; + + Future _scanImage(img.Image image, {int? maxDimension}) async => + decodeQrFromImage(image, maxDimension: maxDimension); Widget _buildCameraView(BuildContext context) { if (Platform.isWindows && _isCameraOpen) { diff --git a/test/utilities/camera_preview_frame_test.dart b/test/utilities/camera_preview_frame_test.dart index 90591ed33..ffc7cadb5 100644 --- a/test/utilities/camera_preview_frame_test.dart +++ b/test/utilities/camera_preview_frame_test.dart @@ -128,4 +128,42 @@ void main() { ); }); }); + + group("previewFrameFingerprint", () { + test("is the same for identical frames", () { + final a = Uint8List.fromList( + List.generate(4 * 64, (i) => (i * 7) & 0xff), + ); + final b = Uint8List.fromList(a); + expect(previewFrameFingerprint(a), previewFrameFingerprint(b)); + }); + + test("changes when one byte changes", () { + final a = Uint8List.fromList( + List.generate(4 * 64, (i) => (i * 7) & 0xff), + ); + final b = Uint8List.fromList(a); + b[b.length ~/ 2] ^= 1; + expect(previewFrameFingerprint(a), isNot(previewFrameFingerprint(b))); + }); + + test("changes when a pixel is moved", () { + // Same bytes, different order: a plain sum would miss this. + final a = _rgba([ + [1, 2, 3, 255], + [4, 5, 6, 255], + ]); + final b = _rgba([ + [4, 5, 6, 255], + [1, 2, 3, 255], + ]); + expect(previewFrameFingerprint(a), isNot(previewFrameFingerprint(b))); + }); + + test("distinguishes frames of different lengths", () { + final a = Uint8List(16); + final b = Uint8List(20); + expect(previewFrameFingerprint(a), isNot(previewFrameFingerprint(b))); + }); + }); } diff --git a/test/utilities/qr_image_decoder_test.dart b/test/utilities/qr_image_decoder_test.dart index 0f38f18e1..dd2ee0917 100644 --- a/test/utilities/qr_image_decoder_test.dart +++ b/test/utilities/qr_image_decoder_test.dart @@ -68,6 +68,21 @@ void main() { expect(decodeQrFromImage(decoded), _address); }); + test("decodes a large camera-sized frame scaled down to maxDimension", () { + // A QR code held up to a 3264x2448 webcam, filling about a third of the + // frame's height. + final frame = img.Image(width: 3264, height: 2448); + img.fill(frame, color: img.ColorRgb8(200, 200, 200)); + final code = _qrImage(_address, scale: 18); + img.compositeImage(frame, code, dstX: 1200, dstY: 700); + final jpeg = img.decodeJpg(img.encodeJpg(frame, quality: 85))!; + expect(decodeQrFromImage(jpeg, maxDimension: 1280), _address); + }); + + test("leaves images within maxDimension alone", () { + expect(decodeQrFromImage(rgb, maxDimension: 1280), _address); + }); + test("decodes a JPEG", () { final bytes = Uint8List.fromList(img.encodeJpg(rgb, quality: 85)); expect(decodeQrFromImageBytes(bytes), _address);