|
| 1 | +import 'dart:io'; |
| 2 | + |
| 3 | +import 'package:camera/camera.dart'; |
| 4 | +import 'package:flutter/material.dart'; |
| 5 | +import 'package:flutter/services.dart'; |
| 6 | +import 'package:google_mlkit_commons/google_mlkit_commons.dart'; |
| 7 | + |
| 8 | +class CameraView extends StatefulWidget { |
| 9 | + CameraView( |
| 10 | + {Key? key, |
| 11 | + required this.customPaint, |
| 12 | + required this.onImage, |
| 13 | + this.onCameraFeedReady, |
| 14 | + this.onDetectorViewModeChanged, |
| 15 | + this.onCameraLensDirectionChanged, |
| 16 | + this.initialCameraLensDirection = CameraLensDirection.back}) |
| 17 | + : super(key: key); |
| 18 | + |
| 19 | + final CustomPaint? customPaint; |
| 20 | + final Function(InputImage inputImage) onImage; |
| 21 | + final VoidCallback? onCameraFeedReady; |
| 22 | + final VoidCallback? onDetectorViewModeChanged; |
| 23 | + final Function(CameraLensDirection direction)? onCameraLensDirectionChanged; |
| 24 | + final CameraLensDirection initialCameraLensDirection; |
| 25 | + |
| 26 | + @override |
| 27 | + State<CameraView> createState() => _CameraViewState(); |
| 28 | +} |
| 29 | + |
| 30 | +class _CameraViewState extends State<CameraView> { |
| 31 | + static List<CameraDescription> _cameras = []; |
| 32 | + CameraController? _controller; |
| 33 | + int _cameraIndex = -1; |
| 34 | + double _currentZoomLevel = 1.0; |
| 35 | + double _minAvailableZoom = 1.0; |
| 36 | + double _maxAvailableZoom = 1.0; |
| 37 | + double _minAvailableExposureOffset = 0.0; |
| 38 | + double _maxAvailableExposureOffset = 0.0; |
| 39 | + double _currentExposureOffset = 0.0; |
| 40 | + bool _changingCameraLens = false; |
| 41 | + |
| 42 | + @override |
| 43 | + void initState() { |
| 44 | + super.initState(); |
| 45 | + |
| 46 | + _initialize(); |
| 47 | + } |
| 48 | + |
| 49 | + void _initialize() async { |
| 50 | + if (_cameras.isEmpty) { |
| 51 | + _cameras = await availableCameras(); |
| 52 | + } |
| 53 | + for (var i = 0; i < _cameras.length; i++) { |
| 54 | + if (_cameras[i].lensDirection == widget.initialCameraLensDirection) { |
| 55 | + _cameraIndex = i; |
| 56 | + break; |
| 57 | + } |
| 58 | + } |
| 59 | + if (_cameraIndex != -1) { |
| 60 | + _startLiveFeed(); |
| 61 | + } |
| 62 | + } |
| 63 | + |
| 64 | + @override |
| 65 | + void dispose() { |
| 66 | + _stopLiveFeed(); |
| 67 | + super.dispose(); |
| 68 | + } |
| 69 | + |
| 70 | + @override |
| 71 | + Widget build(BuildContext context) { |
| 72 | + return Scaffold(body: _liveFeedBody()); |
| 73 | + } |
| 74 | + |
| 75 | + Widget _liveFeedBody() { |
| 76 | + if (_cameras.isEmpty) return Container(); |
| 77 | + if (_controller == null) return Container(); |
| 78 | + if (_controller?.value.isInitialized == false) return Container(); |
| 79 | + return Container( |
| 80 | + color: Colors.black, |
| 81 | + child: Stack( |
| 82 | + fit: StackFit.expand, |
| 83 | + children: <Widget>[ |
| 84 | + Center( |
| 85 | + child: _changingCameraLens |
| 86 | + ? Center( |
| 87 | + child: const Text('Changing camera lens'), |
| 88 | + ) |
| 89 | + : CameraPreview( |
| 90 | + _controller!, |
| 91 | + child: widget.customPaint, |
| 92 | + ), |
| 93 | + ), |
| 94 | + _backButton(), |
| 95 | + _switchLiveCameraToggle(), |
| 96 | + _detectionViewModeToggle(), |
| 97 | + _zoomControl(), |
| 98 | + _exposureControl(), |
| 99 | + ], |
| 100 | + ), |
| 101 | + ); |
| 102 | + } |
| 103 | + |
| 104 | + Widget _backButton() => Positioned( |
| 105 | + top: 40, |
| 106 | + left: 8, |
| 107 | + child: SizedBox( |
| 108 | + height: 50.0, |
| 109 | + width: 50.0, |
| 110 | + child: FloatingActionButton( |
| 111 | + heroTag: Object(), |
| 112 | + onPressed: () => Navigator.of(context).pop(), |
| 113 | + backgroundColor: Colors.black54, |
| 114 | + child: Icon( |
| 115 | + Icons.arrow_back_ios_outlined, |
| 116 | + size: 20, |
| 117 | + ), |
| 118 | + ), |
| 119 | + ), |
| 120 | + ); |
| 121 | + |
| 122 | + Widget _detectionViewModeToggle() => Positioned( |
| 123 | + bottom: 8, |
| 124 | + left: 8, |
| 125 | + child: SizedBox( |
| 126 | + height: 50.0, |
| 127 | + width: 50.0, |
| 128 | + child: FloatingActionButton( |
| 129 | + heroTag: Object(), |
| 130 | + onPressed: widget.onDetectorViewModeChanged, |
| 131 | + backgroundColor: Colors.black54, |
| 132 | + child: Icon( |
| 133 | + Icons.photo_library_outlined, |
| 134 | + size: 25, |
| 135 | + ), |
| 136 | + ), |
| 137 | + ), |
| 138 | + ); |
| 139 | + |
| 140 | + Widget _switchLiveCameraToggle() => Positioned( |
| 141 | + bottom: 8, |
| 142 | + right: 8, |
| 143 | + child: SizedBox( |
| 144 | + height: 50.0, |
| 145 | + width: 50.0, |
| 146 | + child: FloatingActionButton( |
| 147 | + heroTag: Object(), |
| 148 | + onPressed: _switchLiveCamera, |
| 149 | + backgroundColor: Colors.black54, |
| 150 | + child: Icon( |
| 151 | + Platform.isIOS |
| 152 | + ? Icons.flip_camera_ios_outlined |
| 153 | + : Icons.flip_camera_android_outlined, |
| 154 | + size: 25, |
| 155 | + ), |
| 156 | + ), |
| 157 | + ), |
| 158 | + ); |
| 159 | + |
| 160 | + Widget _zoomControl() => Positioned( |
| 161 | + bottom: 16, |
| 162 | + left: 0, |
| 163 | + right: 0, |
| 164 | + child: Align( |
| 165 | + alignment: Alignment.bottomCenter, |
| 166 | + child: SizedBox( |
| 167 | + width: 250, |
| 168 | + child: Row( |
| 169 | + mainAxisAlignment: MainAxisAlignment.center, |
| 170 | + crossAxisAlignment: CrossAxisAlignment.center, |
| 171 | + children: [ |
| 172 | + Expanded( |
| 173 | + child: Slider( |
| 174 | + value: _currentZoomLevel, |
| 175 | + min: _minAvailableZoom, |
| 176 | + max: _maxAvailableZoom, |
| 177 | + activeColor: Colors.white, |
| 178 | + inactiveColor: Colors.white30, |
| 179 | + onChanged: (value) async { |
| 180 | + setState(() { |
| 181 | + _currentZoomLevel = value; |
| 182 | + }); |
| 183 | + await _controller?.setZoomLevel(value); |
| 184 | + }, |
| 185 | + ), |
| 186 | + ), |
| 187 | + Container( |
| 188 | + width: 50, |
| 189 | + decoration: BoxDecoration( |
| 190 | + color: Colors.black54, |
| 191 | + borderRadius: BorderRadius.circular(10.0), |
| 192 | + ), |
| 193 | + child: Padding( |
| 194 | + padding: const EdgeInsets.all(8.0), |
| 195 | + child: Center( |
| 196 | + child: Text( |
| 197 | + '${_currentZoomLevel.toStringAsFixed(1)}x', |
| 198 | + style: TextStyle(color: Colors.white), |
| 199 | + ), |
| 200 | + ), |
| 201 | + ), |
| 202 | + ), |
| 203 | + ], |
| 204 | + ), |
| 205 | + ), |
| 206 | + ), |
| 207 | + ); |
| 208 | + |
| 209 | + Widget _exposureControl() => Positioned( |
| 210 | + top: 40, |
| 211 | + right: 8, |
| 212 | + child: ConstrainedBox( |
| 213 | + constraints: BoxConstraints( |
| 214 | + maxHeight: 250, |
| 215 | + ), |
| 216 | + child: Column(children: [ |
| 217 | + Container( |
| 218 | + width: 55, |
| 219 | + decoration: BoxDecoration( |
| 220 | + color: Colors.black54, |
| 221 | + borderRadius: BorderRadius.circular(10.0), |
| 222 | + ), |
| 223 | + child: Padding( |
| 224 | + padding: const EdgeInsets.all(8.0), |
| 225 | + child: Center( |
| 226 | + child: Text( |
| 227 | + '${_currentExposureOffset.toStringAsFixed(1)}x', |
| 228 | + style: TextStyle(color: Colors.white), |
| 229 | + ), |
| 230 | + ), |
| 231 | + ), |
| 232 | + ), |
| 233 | + Expanded( |
| 234 | + child: RotatedBox( |
| 235 | + quarterTurns: 3, |
| 236 | + child: SizedBox( |
| 237 | + height: 30, |
| 238 | + child: Slider( |
| 239 | + value: _currentExposureOffset, |
| 240 | + min: _minAvailableExposureOffset, |
| 241 | + max: _maxAvailableExposureOffset, |
| 242 | + activeColor: Colors.white, |
| 243 | + inactiveColor: Colors.white30, |
| 244 | + onChanged: (value) async { |
| 245 | + setState(() { |
| 246 | + _currentExposureOffset = value; |
| 247 | + }); |
| 248 | + await _controller?.setExposureOffset(value); |
| 249 | + }, |
| 250 | + ), |
| 251 | + ), |
| 252 | + ), |
| 253 | + ) |
| 254 | + ]), |
| 255 | + ), |
| 256 | + ); |
| 257 | + |
| 258 | + Future _startLiveFeed() async { |
| 259 | + final camera = _cameras[_cameraIndex]; |
| 260 | + _controller = CameraController( |
| 261 | + camera, |
| 262 | + // Set to ResolutionPreset.high. Do NOT set it to ResolutionPreset.max because for some phones does NOT work. |
| 263 | + ResolutionPreset.high, |
| 264 | + enableAudio: false, |
| 265 | + imageFormatGroup: Platform.isAndroid |
| 266 | + ? ImageFormatGroup.nv21 |
| 267 | + : ImageFormatGroup.bgra8888, |
| 268 | + ); |
| 269 | + _controller?.initialize().then((_) { |
| 270 | + if (!mounted) { |
| 271 | + return; |
| 272 | + } |
| 273 | + _controller?.getMinZoomLevel().then((value) { |
| 274 | + _currentZoomLevel = value; |
| 275 | + _minAvailableZoom = value; |
| 276 | + }); |
| 277 | + _controller?.getMaxZoomLevel().then((value) { |
| 278 | + _maxAvailableZoom = value; |
| 279 | + }); |
| 280 | + _currentExposureOffset = 0.0; |
| 281 | + _controller?.getMinExposureOffset().then((value) { |
| 282 | + _minAvailableExposureOffset = value; |
| 283 | + }); |
| 284 | + _controller?.getMaxExposureOffset().then((value) { |
| 285 | + _maxAvailableExposureOffset = value; |
| 286 | + }); |
| 287 | + _controller?.startImageStream(_processCameraImage).then((value) { |
| 288 | + if (widget.onCameraFeedReady != null) { |
| 289 | + widget.onCameraFeedReady!(); |
| 290 | + } |
| 291 | + if (widget.onCameraLensDirectionChanged != null) { |
| 292 | + widget.onCameraLensDirectionChanged!(camera.lensDirection); |
| 293 | + } |
| 294 | + }); |
| 295 | + setState(() {}); |
| 296 | + }); |
| 297 | + } |
| 298 | + |
| 299 | + Future _stopLiveFeed() async { |
| 300 | + await _controller?.stopImageStream(); |
| 301 | + await _controller?.dispose(); |
| 302 | + _controller = null; |
| 303 | + } |
| 304 | + |
| 305 | + Future _switchLiveCamera() async { |
| 306 | + setState(() => _changingCameraLens = true); |
| 307 | + _cameraIndex = (_cameraIndex + 1) % _cameras.length; |
| 308 | + |
| 309 | + await _stopLiveFeed(); |
| 310 | + await _startLiveFeed(); |
| 311 | + setState(() => _changingCameraLens = false); |
| 312 | + } |
| 313 | + |
| 314 | + void _processCameraImage(CameraImage image) { |
| 315 | + final inputImage = _inputImageFromCameraImage(image); |
| 316 | + if (inputImage == null) return; |
| 317 | + widget.onImage(inputImage); |
| 318 | + } |
| 319 | + |
| 320 | + final _orientations = { |
| 321 | + DeviceOrientation.portraitUp: 0, |
| 322 | + DeviceOrientation.landscapeLeft: 90, |
| 323 | + DeviceOrientation.portraitDown: 180, |
| 324 | + DeviceOrientation.landscapeRight: 270, |
| 325 | + }; |
| 326 | + |
| 327 | + InputImage? _inputImageFromCameraImage(CameraImage image) { |
| 328 | + if (_controller == null) return null; |
| 329 | + |
| 330 | + // get image rotation |
| 331 | + // it is used in android to convert the InputImage from Dart to Java: https://github.com/flutter-ml/google_ml_kit_flutter/blob/master/packages/google_mlkit_commons/android/src/main/java/com/google_mlkit_commons/InputImageConverter.java |
| 332 | + // `rotation` is not used in iOS to convert the InputImage from Dart to Obj-C: https://github.com/flutter-ml/google_ml_kit_flutter/blob/master/packages/google_mlkit_commons/ios/Classes/MLKVisionImage%2BFlutterPlugin.m |
| 333 | + // in both platforms `rotation` and `camera.lensDirection` can be used to compensate `x` and `y` coordinates on a canvas: https://github.com/flutter-ml/google_ml_kit_flutter/blob/master/packages/example/lib/vision_detector_views/painters/coordinates_translator.dart |
| 334 | + final camera = _cameras[_cameraIndex]; |
| 335 | + final sensorOrientation = camera.sensorOrientation; |
| 336 | + // print( |
| 337 | + // 'lensDirection: ${camera.lensDirection}, sensorOrientation: $sensorOrientation, ${_controller?.value.deviceOrientation} ${_controller?.value.lockedCaptureOrientation} ${_controller?.value.isCaptureOrientationLocked}'); |
| 338 | + InputImageRotation? rotation; |
| 339 | + if (Platform.isIOS) { |
| 340 | + rotation = InputImageRotationValue.fromRawValue(sensorOrientation); |
| 341 | + } else if (Platform.isAndroid) { |
| 342 | + var rotationCompensation = |
| 343 | + _orientations[_controller!.value.deviceOrientation]; |
| 344 | + if (rotationCompensation == null) return null; |
| 345 | + if (camera.lensDirection == CameraLensDirection.front) { |
| 346 | + // front-facing |
| 347 | + rotationCompensation = (sensorOrientation + rotationCompensation) % 360; |
| 348 | + } else { |
| 349 | + // back-facing |
| 350 | + rotationCompensation = |
| 351 | + (sensorOrientation - rotationCompensation + 360) % 360; |
| 352 | + } |
| 353 | + rotation = InputImageRotationValue.fromRawValue(rotationCompensation); |
| 354 | + // print('rotationCompensation: $rotationCompensation'); |
| 355 | + } |
| 356 | + if (rotation == null) return null; |
| 357 | + // print('final rotation: $rotation'); |
| 358 | + |
| 359 | + // get image format |
| 360 | + final format = InputImageFormatValue.fromRawValue(image.format.raw); |
| 361 | + // validate format depending on platform |
| 362 | + // only supported formats: |
| 363 | + // * nv21 for Android |
| 364 | + // * bgra8888 for iOS |
| 365 | + if (format == null || |
| 366 | + (Platform.isAndroid && format != InputImageFormat.nv21) || |
| 367 | + (Platform.isIOS && format != InputImageFormat.bgra8888)) return null; |
| 368 | + |
| 369 | + // since format is constraint to nv21 or bgra8888, both only have one plane |
| 370 | + if (image.planes.length != 1) return null; |
| 371 | + final plane = image.planes.first; |
| 372 | + |
| 373 | + // compose InputImage using bytes |
| 374 | + return InputImage.fromBytes( |
| 375 | + bytes: plane.bytes, |
| 376 | + metadata: InputImageMetadata( |
| 377 | + size: Size(image.width.toDouble(), image.height.toDouble()), |
| 378 | + rotation: rotation, // used only in Android |
| 379 | + format: format, // used only in iOS |
| 380 | + bytesPerRow: plane.bytesPerRow, // used only in iOS |
| 381 | + ), |
| 382 | + ); |
| 383 | + } |
| 384 | +} |
| 385 | + |
0 commit comments