Committed by
GitHub
Merge pull request #592 from ryuta46/imp/zoom-scale-improvements
feat: Improvements about zoom scale value and ultra-wide camera
Showing
6 changed files
with
119 additions
and
4 deletions
| @@ -138,6 +138,7 @@ class MobileScanner( | @@ -138,6 +138,7 @@ class MobileScanner( | ||
| 138 | torch: Boolean, | 138 | torch: Boolean, |
| 139 | detectionSpeed: DetectionSpeed, | 139 | detectionSpeed: DetectionSpeed, |
| 140 | torchStateCallback: TorchStateCallback, | 140 | torchStateCallback: TorchStateCallback, |
| 141 | + zoomScaleStateCallback: ZoomScaleStateCallback, | ||
| 141 | mobileScannerStartedCallback: MobileScannerStartedCallback, | 142 | mobileScannerStartedCallback: MobileScannerStartedCallback, |
| 142 | detectionTimeout: Long | 143 | detectionTimeout: Long |
| 143 | ) { | 144 | ) { |
| @@ -201,6 +202,11 @@ class MobileScanner( | @@ -201,6 +202,11 @@ class MobileScanner( | ||
| 201 | torchStateCallback(state) | 202 | torchStateCallback(state) |
| 202 | } | 203 | } |
| 203 | 204 | ||
| 205 | + // Register the zoom scale listener | ||
| 206 | + camera!!.cameraInfo.zoomState.observe(activity) { state -> | ||
| 207 | + zoomScaleStateCallback(state.linearZoom.toDouble()) | ||
| 208 | + } | ||
| 209 | + | ||
| 204 | 210 | ||
| 205 | // Enable torch if provided | 211 | // Enable torch if provided |
| 206 | camera!!.cameraControl.enableTorch(torch) | 212 | camera!!.cameraControl.enableTorch(torch) |
| @@ -283,4 +289,12 @@ class MobileScanner( | @@ -283,4 +289,12 @@ class MobileScanner( | ||
| 283 | camera!!.cameraControl.setLinearZoom(scale.toFloat()) | 289 | camera!!.cameraControl.setLinearZoom(scale.toFloat()) |
| 284 | } | 290 | } |
| 285 | 291 | ||
| 292 | + /** | ||
| 293 | + * Reset the zoom rate of the camera. | ||
| 294 | + */ | ||
| 295 | + fun resetScale() { | ||
| 296 | + if (camera == null) throw ZoomWhenStopped() | ||
| 297 | + camera!!.cameraControl.setZoomRatio(1f) | ||
| 298 | + } | ||
| 299 | + | ||
| 286 | } | 300 | } |
| @@ -6,4 +6,5 @@ typealias MobileScannerCallback = (barcodes: List<Map<String, Any?>>, image: Byt | @@ -6,4 +6,5 @@ typealias MobileScannerCallback = (barcodes: List<Map<String, Any?>>, image: Byt | ||
| 6 | typealias AnalyzerCallback = (barcodes: List<Map<String, Any?>>?) -> Unit | 6 | typealias AnalyzerCallback = (barcodes: List<Map<String, Any?>>?) -> Unit |
| 7 | typealias MobileScannerErrorCallback = (error: String) -> Unit | 7 | typealias MobileScannerErrorCallback = (error: String) -> Unit |
| 8 | typealias TorchStateCallback = (state: Int) -> Unit | 8 | typealias TorchStateCallback = (state: Int) -> Unit |
| 9 | +typealias ZoomScaleStateCallback = (zoomScale: Double) -> Unit | ||
| 9 | typealias MobileScannerStartedCallback = (parameters: MobileScannerStartParameters) -> Unit | 10 | typealias MobileScannerStartedCallback = (parameters: MobileScannerStartParameters) -> Unit |
| @@ -70,6 +70,10 @@ class MobileScannerHandler( | @@ -70,6 +70,10 @@ class MobileScannerHandler( | ||
| 70 | barcodeHandler.publishEvent(mapOf("name" to "torchState", "data" to state)) | 70 | barcodeHandler.publishEvent(mapOf("name" to "torchState", "data" to state)) |
| 71 | } | 71 | } |
| 72 | 72 | ||
| 73 | + private val zoomScaleStateCallback: ZoomScaleStateCallback = {zoomScale: Double -> | ||
| 74 | + barcodeHandler.publishEvent(mapOf("name" to "zoomScaleState", "data" to zoomScale)) | ||
| 75 | + } | ||
| 76 | + | ||
| 73 | init { | 77 | init { |
| 74 | methodChannel = MethodChannel(binaryMessenger, | 78 | methodChannel = MethodChannel(binaryMessenger, |
| 75 | "dev.steenbakker.mobile_scanner/scanner/method") | 79 | "dev.steenbakker.mobile_scanner/scanner/method") |
| @@ -115,6 +119,7 @@ class MobileScannerHandler( | @@ -115,6 +119,7 @@ class MobileScannerHandler( | ||
| 115 | "stop" -> stop(result) | 119 | "stop" -> stop(result) |
| 116 | "analyzeImage" -> analyzeImage(call, result) | 120 | "analyzeImage" -> analyzeImage(call, result) |
| 117 | "setScale" -> setScale(call, result) | 121 | "setScale" -> setScale(call, result) |
| 122 | + "resetScale" -> resetScale(call, result) | ||
| 118 | "updateScanWindow" -> updateScanWindow(call) | 123 | "updateScanWindow" -> updateScanWindow(call) |
| 119 | else -> result.notImplemented() | 124 | else -> result.notImplemented() |
| 120 | } | 125 | } |
| @@ -152,7 +157,7 @@ class MobileScannerHandler( | @@ -152,7 +157,7 @@ class MobileScannerHandler( | ||
| 152 | val detectionSpeed: DetectionSpeed = DetectionSpeed.values().first { it.intValue == speed} | 157 | val detectionSpeed: DetectionSpeed = DetectionSpeed.values().first { it.intValue == speed} |
| 153 | 158 | ||
| 154 | try { | 159 | try { |
| 155 | - mobileScanner!!.start(barcodeScannerOptions, returnImage, position, torch, detectionSpeed, torchStateCallback, mobileScannerStartedCallback = { | 160 | + mobileScanner!!.start(barcodeScannerOptions, returnImage, position, torch, detectionSpeed, torchStateCallback, zoomScaleStateCallback, mobileScannerStartedCallback = { |
| 156 | result.success(mapOf( | 161 | result.success(mapOf( |
| 157 | "textureId" to it.id, | 162 | "textureId" to it.id, |
| 158 | "size" to mapOf("width" to it.width, "height" to it.height), | 163 | "size" to mapOf("width" to it.width, "height" to it.height), |
| @@ -229,6 +234,15 @@ class MobileScannerHandler( | @@ -229,6 +234,15 @@ class MobileScannerHandler( | ||
| 229 | } | 234 | } |
| 230 | } | 235 | } |
| 231 | 236 | ||
| 237 | + private fun resetScale(call: MethodCall, result: MethodChannel.Result) { | ||
| 238 | + try { | ||
| 239 | + mobileScanner!!.resetScale() | ||
| 240 | + result.success(null) | ||
| 241 | + } catch (e: ZoomWhenStopped) { | ||
| 242 | + result.error("MobileScanner", "Called resetScale() while stopped!", null) | ||
| 243 | + } | ||
| 244 | + } | ||
| 245 | + | ||
| 232 | private fun updateScanWindow(call: MethodCall) { | 246 | private fun updateScanWindow(call: MethodCall) { |
| 233 | mobileScanner!!.scanWindow = call.argument<List<Float>?>("rect") | 247 | mobileScanner!!.scanWindow = call.argument<List<Float>?>("rect") |
| 234 | } | 248 | } |
| @@ -13,6 +13,7 @@ import MLKitBarcodeScanning | @@ -13,6 +13,7 @@ import MLKitBarcodeScanning | ||
| 13 | 13 | ||
| 14 | typealias MobileScannerCallback = ((Array<Barcode>?, Error?, UIImage) -> ()) | 14 | typealias MobileScannerCallback = ((Array<Barcode>?, Error?, UIImage) -> ()) |
| 15 | typealias TorchModeChangeCallback = ((Int?) -> ()) | 15 | typealias TorchModeChangeCallback = ((Int?) -> ()) |
| 16 | +typealias ZoomScaleChangeCallback = ((Double?) -> ()) | ||
| 16 | 17 | ||
| 17 | public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, FlutterTexture { | 18 | public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, FlutterTexture { |
| 18 | /// Capture session of the camera | 19 | /// Capture session of the camera |
| @@ -36,6 +37,9 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | @@ -36,6 +37,9 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | ||
| 36 | /// When torch mode is changes, this callback will be called | 37 | /// When torch mode is changes, this callback will be called |
| 37 | let torchModeChangeCallback: TorchModeChangeCallback | 38 | let torchModeChangeCallback: TorchModeChangeCallback |
| 38 | 39 | ||
| 40 | + /// When zoom scale is changes, this callback will be called | ||
| 41 | + let zoomScaleChangeCallback: ZoomScaleChangeCallback | ||
| 42 | + | ||
| 39 | /// If provided, the Flutter registry will be used to send the output of the CaptureOutput to a Flutter texture. | 43 | /// If provided, the Flutter registry will be used to send the output of the CaptureOutput to a Flutter texture. |
| 40 | private let registry: FlutterTextureRegistry? | 44 | private let registry: FlutterTextureRegistry? |
| 41 | 45 | ||
| @@ -47,10 +51,13 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | @@ -47,10 +51,13 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | ||
| 47 | 51 | ||
| 48 | var detectionSpeed: DetectionSpeed = DetectionSpeed.noDuplicates | 52 | var detectionSpeed: DetectionSpeed = DetectionSpeed.noDuplicates |
| 49 | 53 | ||
| 50 | - init(registry: FlutterTextureRegistry?, mobileScannerCallback: @escaping MobileScannerCallback, torchModeChangeCallback: @escaping TorchModeChangeCallback) { | 54 | + var standardZoomFactor: CGFloat = 1 |
| 55 | + | ||
| 56 | + init(registry: FlutterTextureRegistry?, mobileScannerCallback: @escaping MobileScannerCallback, torchModeChangeCallback: @escaping TorchModeChangeCallback, zoomScaleChangeCallback: @escaping ZoomScaleChangeCallback) { | ||
| 51 | self.registry = registry | 57 | self.registry = registry |
| 52 | self.mobileScannerCallback = mobileScannerCallback | 58 | self.mobileScannerCallback = mobileScannerCallback |
| 53 | self.torchModeChangeCallback = torchModeChangeCallback | 59 | self.torchModeChangeCallback = torchModeChangeCallback |
| 60 | + self.zoomScaleChangeCallback = zoomScaleChangeCallback | ||
| 54 | super.init() | 61 | super.init() |
| 55 | } | 62 | } |
| 56 | 63 | ||
| @@ -133,6 +140,21 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | @@ -133,6 +140,21 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | ||
| 133 | } | 140 | } |
| 134 | 141 | ||
| 135 | device.addObserver(self, forKeyPath: #keyPath(AVCaptureDevice.torchMode), options: .new, context: nil) | 142 | device.addObserver(self, forKeyPath: #keyPath(AVCaptureDevice.torchMode), options: .new, context: nil) |
| 143 | + device.addObserver(self, forKeyPath: #keyPath(AVCaptureDevice.videoZoomFactor), options: .new, context: nil) | ||
| 144 | + | ||
| 145 | + // Check the zoom factor at switching from ultra wide camera to wide camera. | ||
| 146 | + standardZoomFactor = 1 | ||
| 147 | + if #available(iOS 13.0, *) { | ||
| 148 | + for (index, actualDevice) in device.constituentDevices.enumerated() { | ||
| 149 | + if (actualDevice.deviceType != .builtInUltraWideCamera) { | ||
| 150 | + if index > 0 && index <= device.virtualDeviceSwitchOverVideoZoomFactors.count { | ||
| 151 | + standardZoomFactor = CGFloat(truncating: device.virtualDeviceSwitchOverVideoZoomFactors[index - 1]) | ||
| 152 | + } | ||
| 153 | + break | ||
| 154 | + } | ||
| 155 | + } | ||
| 156 | + } | ||
| 157 | + | ||
| 136 | do { | 158 | do { |
| 137 | try device.lockForConfiguration() | 159 | try device.lockForConfiguration() |
| 138 | if device.isFocusModeSupported(.continuousAutoFocus) { | 160 | if device.isFocusModeSupported(.continuousAutoFocus) { |
| @@ -181,6 +203,13 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | @@ -181,6 +203,13 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | ||
| 181 | } catch { | 203 | } catch { |
| 182 | print("Failed to set initial torch state.") | 204 | print("Failed to set initial torch state.") |
| 183 | } | 205 | } |
| 206 | + | ||
| 207 | + do { | ||
| 208 | + try resetScale() | ||
| 209 | + } catch { | ||
| 210 | + print("Failed to reset zoom scale") | ||
| 211 | + } | ||
| 212 | + | ||
| 184 | let dimensions = CMVideoFormatDescriptionGetDimensions(device.activeFormat.formatDescription) | 213 | let dimensions = CMVideoFormatDescriptionGetDimensions(device.activeFormat.formatDescription) |
| 185 | 214 | ||
| 186 | return MobileScannerStartParameters(width: Double(dimensions.height), height: Double(dimensions.width), hasTorch: device.hasTorch, textureId: textureId) | 215 | return MobileScannerStartParameters(width: Double(dimensions.height), height: Double(dimensions.width), hasTorch: device.hasTorch, textureId: textureId) |
| @@ -199,6 +228,7 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | @@ -199,6 +228,7 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | ||
| 199 | captureSession.removeOutput(output) | 228 | captureSession.removeOutput(output) |
| 200 | } | 229 | } |
| 201 | device.removeObserver(self, forKeyPath: #keyPath(AVCaptureDevice.torchMode)) | 230 | device.removeObserver(self, forKeyPath: #keyPath(AVCaptureDevice.torchMode)) |
| 231 | + device.removeObserver(self, forKeyPath: #keyPath(AVCaptureDevice.videoZoomFactor)) | ||
| 202 | registry?.unregisterTexture(textureId) | 232 | registry?.unregisterTexture(textureId) |
| 203 | textureId = nil | 233 | textureId = nil |
| 204 | captureSession = nil | 234 | captureSession = nil |
| @@ -228,6 +258,10 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | @@ -228,6 +258,10 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | ||
| 228 | // off = 0; on = 1; auto = 2; | 258 | // off = 0; on = 1; auto = 2; |
| 229 | let state = change?[.newKey] as? Int | 259 | let state = change?[.newKey] as? Int |
| 230 | torchModeChangeCallback(state) | 260 | torchModeChangeCallback(state) |
| 261 | + case "videoZoomFactor": | ||
| 262 | + let zoomFactor = change?[.newKey] as? CGFloat ?? 1 | ||
| 263 | + let zoomScale = (zoomFactor - 1) / 4 | ||
| 264 | + zoomScaleChangeCallback(Double(zoomScale)) | ||
| 231 | default: | 265 | default: |
| 232 | break | 266 | break |
| 233 | } | 267 | } |
| @@ -252,7 +286,7 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | @@ -252,7 +286,7 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | ||
| 252 | actualScale = min(maxZoomFactor, actualScale) | 286 | actualScale = min(maxZoomFactor, actualScale) |
| 253 | 287 | ||
| 254 | // Limit to 1.0 scale | 288 | // Limit to 1.0 scale |
| 255 | - device.ramp(toVideoZoomFactor: actualScale, withRate: 5) | 289 | + device.videoZoomFactor = actualScale |
| 256 | device.unlockForConfiguration() | 290 | device.unlockForConfiguration() |
| 257 | } catch { | 291 | } catch { |
| 258 | throw MobileScannerError.zoomError(error) | 292 | throw MobileScannerError.zoomError(error) |
| @@ -260,6 +294,22 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | @@ -260,6 +294,22 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega | ||
| 260 | 294 | ||
| 261 | } | 295 | } |
| 262 | 296 | ||
| 297 | + /// Reset the zoom factor of the camera | ||
| 298 | + func resetScale() throws { | ||
| 299 | + if (device == nil) { | ||
| 300 | + throw MobileScannerError.zoomWhenStopped | ||
| 301 | + } | ||
| 302 | + | ||
| 303 | + do { | ||
| 304 | + try device.lockForConfiguration() | ||
| 305 | + device.videoZoomFactor = standardZoomFactor | ||
| 306 | + device.unlockForConfiguration() | ||
| 307 | + } catch { | ||
| 308 | + throw MobileScannerError.zoomError(error) | ||
| 309 | + } | ||
| 310 | + } | ||
| 311 | + | ||
| 312 | + | ||
| 263 | /// Analyze a single image | 313 | /// Analyze a single image |
| 264 | func analyzeImage(image: UIImage, position: AVCaptureDevice.Position, callback: @escaping BarcodeScanningCallback) { | 314 | func analyzeImage(image: UIImage, position: AVCaptureDevice.Position, callback: @escaping BarcodeScanningCallback) { |
| 265 | let image = VisionImage(image: image) | 315 | let image = VisionImage(image: image) |
| @@ -57,6 +57,8 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin { | @@ -57,6 +57,8 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin { | ||
| 57 | } | 57 | } |
| 58 | }, torchModeChangeCallback: { torchState in | 58 | }, torchModeChangeCallback: { torchState in |
| 59 | barcodeHandler.publishEvent(["name": "torchState", "data": torchState]) | 59 | barcodeHandler.publishEvent(["name": "torchState", "data": torchState]) |
| 60 | + }, zoomScaleChangeCallback: { zoomScale in | ||
| 61 | + barcodeHandler.publishEvent(["name": "zoomScaleState", "data": zoomScale]) | ||
| 60 | }) | 62 | }) |
| 61 | self.barcodeHandler = barcodeHandler | 63 | self.barcodeHandler = barcodeHandler |
| 62 | super.init() | 64 | super.init() |
| @@ -85,6 +87,8 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin { | @@ -85,6 +87,8 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin { | ||
| 85 | analyzeImage(call, result) | 87 | analyzeImage(call, result) |
| 86 | case "setScale": | 88 | case "setScale": |
| 87 | setScale(call, result) | 89 | setScale(call, result) |
| 90 | + case "resetScale": | ||
| 91 | + resetScale(call, result) | ||
| 88 | case "updateScanWindow": | 92 | case "updateScanWindow": |
| 89 | updateScanWindow(call, result) | 93 | updateScanWindow(call, result) |
| 90 | default: | 94 | default: |
| @@ -187,7 +191,28 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin { | @@ -187,7 +191,28 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin { | ||
| 187 | } | 191 | } |
| 188 | result(nil) | 192 | result(nil) |
| 189 | } | 193 | } |
| 190 | - | 194 | + |
| 195 | + /// Reset the zoomScale | ||
| 196 | + private func resetScale(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { | ||
| 197 | + do { | ||
| 198 | + try mobileScanner.resetScale() | ||
| 199 | + } catch MobileScannerError.zoomWhenStopped { | ||
| 200 | + result(FlutterError(code: "MobileScanner", | ||
| 201 | + message: "Called resetScale() while stopped!", | ||
| 202 | + details: nil)) | ||
| 203 | + } catch MobileScannerError.zoomError(let error) { | ||
| 204 | + result(FlutterError(code: "MobileScanner", | ||
| 205 | + message: "Error while zooming.", | ||
| 206 | + details: error)) | ||
| 207 | + } catch { | ||
| 208 | + result(FlutterError(code: "MobileScanner", | ||
| 209 | + message: "Error while zooming.", | ||
| 210 | + details: nil)) | ||
| 211 | + } | ||
| 212 | + result(nil) | ||
| 213 | + } | ||
| 214 | + | ||
| 215 | + | ||
| 191 | /// Toggles the torch | 216 | /// Toggles the torch |
| 192 | func updateScanWindow(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { | 217 | func updateScanWindow(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { |
| 193 | let scanWindowData: Array? = (call.arguments as? [String: Any])?["rect"] as? [CGFloat] | 218 | let scanWindowData: Array? = (call.arguments as? [String: Any])?["rect"] as? [CGFloat] |
| @@ -85,6 +85,9 @@ class MobileScannerController { | @@ -85,6 +85,9 @@ class MobileScannerController { | ||
| 85 | late final ValueNotifier<CameraFacing> cameraFacingState = | 85 | late final ValueNotifier<CameraFacing> cameraFacingState = |
| 86 | ValueNotifier(facing); | 86 | ValueNotifier(facing); |
| 87 | 87 | ||
| 88 | + /// A notifier that provides zoomScale. | ||
| 89 | + final ValueNotifier<double> zoomScaleState = ValueNotifier(0.0); | ||
| 90 | + | ||
| 88 | bool isStarting = false; | 91 | bool isStarting = false; |
| 89 | 92 | ||
| 90 | /// A notifier that provides availability of the Torch (Flash) | 93 | /// A notifier that provides availability of the Torch (Flash) |
| @@ -318,6 +321,11 @@ class MobileScannerController { | @@ -318,6 +321,11 @@ class MobileScannerController { | ||
| 318 | await _methodChannel.invokeMethod('setScale', zoomScale); | 321 | await _methodChannel.invokeMethod('setScale', zoomScale); |
| 319 | } | 322 | } |
| 320 | 323 | ||
| 324 | + /// Reset the zoomScale of the camera to use standard scale 1x. | ||
| 325 | + Future<void> resetZoomScale() async { | ||
| 326 | + await _methodChannel.invokeMethod('resetScale'); | ||
| 327 | + } | ||
| 328 | + | ||
| 321 | /// Disposes the MobileScannerController and closes all listeners. | 329 | /// Disposes the MobileScannerController and closes all listeners. |
| 322 | /// | 330 | /// |
| 323 | /// If you call this, you cannot use this controller object anymore. | 331 | /// If you call this, you cannot use this controller object anymore. |
| @@ -337,6 +345,9 @@ class MobileScannerController { | @@ -337,6 +345,9 @@ class MobileScannerController { | ||
| 337 | final state = TorchState.values[data as int? ?? 0]; | 345 | final state = TorchState.values[data as int? ?? 0]; |
| 338 | torchState.value = state; | 346 | torchState.value = state; |
| 339 | break; | 347 | break; |
| 348 | + case 'zoomScaleState': | ||
| 349 | + zoomScaleState.value = data as double? ?? 0.0; | ||
| 350 | + break; | ||
| 340 | case 'barcode': | 351 | case 'barcode': |
| 341 | if (data == null) return; | 352 | if (data == null) return; |
| 342 | final parsed = (data as List) | 353 | final parsed = (data as List) |
-
Please register or login to post a comment