From dd174f13d19a33cea4faa29996ebc9db87c96fd7 Mon Sep 17 00:00:00 2001 From: temcguir Date: Sun, 15 Nov 2026 00:58:40 +0000 Subject: [PATCH] Skip preview stabilization for Auto stabilization in image-only mode With the default Auto stabilization mode, preview stabilization is turned on whenever the lens supports it, including in image-only capture mode, where no video can be recorded. Preview stabilization crops the field of view. In front camera testing, image-only stills were cropped by about 1.16x in each dimension and scaled back up to full size, which made them visibly softer with no change in exposure or gain. In image-only mode, StabilizationMode.AUTO now resolves to OPTICAL when the lens supports it and to OFF otherwise. Other capture modes are unchanged, and explicitly selecting StabilizationMode.ON still enables preview stabilization in image-only mode. The resolution logic moves out of CameraXCameraSystem into a top-level function so that it can be unit tested. Optical stabilization now also turns preview and video stabilization off explicitly. It previously left them unset, so the camera's default applied. On a device whose default enables video stabilization when no video capture use case is attached, an image-only session with optical stabilization ran with video stabilization on. --- README.md | 3 +- .../core/camera/CameraXCameraSystemTest.kt | 48 ++++ .../core/camera/CameraSession.kt | 8 +- .../core/camera/CameraXCameraSystem.kt | 24 +- .../camera/StabilizationModeResolution.kt | 56 +++++ .../camera/StabilizationModeResolutionTest.kt | 216 ++++++++++++++++++ 6 files changed, 330 insertions(+), 25 deletions(-) create mode 100644 core/camera/src/main/java/com/google/jetpackcamera/core/camera/StabilizationModeResolution.kt create mode 100644 core/camera/src/test/java/com/google/jetpackcamera/core/camera/StabilizationModeResolutionTest.kt diff --git a/README.md b/README.md index b13307a74a..9cae917a71 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,8 @@ This feature offers multiple stabilization modes to enhance video smoothness and following stabilization modes are offered as options by JCA * **Auto:** Automatically enables stabilization based on the device's capabilities and current - recording settings. + recording settings. In image-only capture mode, “Auto” uses optical stabilization if the lens + supports it and does not turn on preview stabilization, which crops the captured image. * **On:** Activates stabilization for both the preview and recorded video streams, providing a smooth viewing experience during capture and playback. * **High Quality:** Applies a high-quality stabilization algorithm to the recorded video stream, diff --git a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt index 3dc22a66d6..9d56d3d189 100644 --- a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt +++ b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt @@ -351,6 +351,54 @@ class CameraXCameraSystemTest { runSetStabilizationModeTest(StabilizationMode.OFF, CaptureMode.IMAGE_ONLY) } + @Test + fun setStabilizationMode_on_imageOnly_updatesCameraState(): Unit = runBlocking { + runSetStabilizationModeTest(StabilizationMode.ON, CaptureMode.IMAGE_ONLY) + } + + @Test + fun setStabilizationMode_optical_imageOnly_updatesCameraState(): Unit = runBlocking { + runSetStabilizationModeTest(StabilizationMode.OPTICAL, CaptureMode.IMAGE_ONLY) + } + + @Test + fun autoStabilization_imageOnly_doesNotUsePreviewStabilization(): Unit = runBlocking { + // Arrange. + val cameraSystem = createAndInitCameraXCameraSystem( + appSettings = CameraAppSettings(stabilizationMode = StabilizationMode.AUTO) + ) + val supportedStabilizationModes = + cameraSystem.getSystemConstraints().value?.forCurrentLens( + DEFAULT_CAMERA_APP_SETTINGS + )?.supportedStabilizationModes + assume().withMessage("Preview stabilization not supported, skip the test.") + .that(supportedStabilizationModes?.contains(StabilizationMode.ON)).isTrue() + val expectedImageOnlyMode = + if (supportedStabilizationModes?.contains(StabilizationMode.OPTICAL) == true) { + StabilizationMode.OPTICAL + } else { + StabilizationMode.OFF + } + + cameraSystem.startCameraAndWaitUntilRunning() + val stabilizationCheck: ReceiveChannel = + cameraSystem.getCurrentCameraState() + .map { it.stabilizationMode } + .produceIn(this) + + // Ensure AUTO resolves to preview stabilization while video can be recorded + stabilizationCheck.awaitValue(StabilizationMode.ON) + + // Act. + cameraSystem.setCaptureMode(CaptureMode.IMAGE_ONLY) + + // Assert. + stabilizationCheck.awaitValue(expectedImageOnlyMode) + + // Clean-up. + stabilizationCheck.cancel() + } + private suspend fun CoroutineScope.runSetStabilizationModeTest( stabilizationMode: StabilizationMode, captureMode: CaptureMode? = null diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt index cd1fb74050..d2e4a1c61e 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraSession.kt @@ -971,7 +971,13 @@ private fun createPreviewUseCase( } when (effectiveStabilizationMode) { StabilizationMode.ON -> setPreviewStabilizationEnabled(true) - StabilizationMode.OPTICAL -> setOpticalStabilizationModeEnabled(true) + StabilizationMode.OPTICAL -> { + setOpticalStabilizationModeEnabled(true) + // Explicitly disable preview and video stabilization. If left unset, the camera's + // default applies, which can turn on video stabilization when there isn't a video + // capture use case attached, such as in IMAGE_ONLY mode + setPreviewStabilizationEnabled(false) + } StabilizationMode.OFF -> { setOpticalStabilizationModeEnabled(false) // Setting this to false in Preview use case will disable video stabilization, even in diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt index 3ce2d87beb..e8bbfc09ed 100644 --- a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/CameraXCameraSystem.kt @@ -642,6 +642,7 @@ class CameraXCameraSystem( val resolvedStabilizationMode = resolveStabilizationMode( requestedStabilizationMode = currentCameraSettings.stabilizationMode, + captureMode = currentCameraSettings.captureMode, targetFrameRate = currentCameraSettings.targetFrameRate, cameraConstraints = cameraConstraints ) @@ -729,29 +730,6 @@ class CameraXCameraSystem( } } - private fun resolveStabilizationMode( - requestedStabilizationMode: StabilizationMode, - targetFrameRate: Int, - cameraConstraints: CameraConstraints - ): StabilizationMode = with(cameraConstraints) { - // Convert AUTO stabilization mode to the first supported stabilization mode - val stabilizationMode = if (requestedStabilizationMode == StabilizationMode.AUTO) { - // Choose between ON, OPTICAL, or OFF, depending on support, in that order - sequenceOf(StabilizationMode.ON, StabilizationMode.OPTICAL, StabilizationMode.OFF) - .first { - it in supportedStabilizationModes && - targetFrameRate !in it.unsupportedFpsSet - } - } else { - requestedStabilizationMode - } - - // Check that the stabilization mode can be supported, otherwise return OFF - stabilizationMode.takeIf { - it in supportedStabilizationModes && targetFrameRate !in it.unsupportedFpsSet - } ?: StabilizationMode.OFF - } - override suspend fun takePicture(onCaptureStarted: (() -> Unit)) { val imageCaptureUseCase = boundImageCapture?.useCase ?: throw RuntimeException("Attempted take picture with null imageCapture use case") diff --git a/core/camera/src/main/java/com/google/jetpackcamera/core/camera/StabilizationModeResolution.kt b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/StabilizationModeResolution.kt new file mode 100644 index 0000000000..d593e35b7c --- /dev/null +++ b/core/camera/src/main/java/com/google/jetpackcamera/core/camera/StabilizationModeResolution.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.core.camera + +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.StabilizationMode +import com.google.jetpackcamera.settings.model.CameraConstraints + +/** + * Resolves [requestedStabilizationMode] to the [StabilizationMode] to apply to a camera session. + * + * [StabilizationMode.AUTO] resolves to the first mode in the following order that + * [cameraConstraints] supports at [targetFrameRate]: + * - In [CaptureMode.IMAGE_ONLY]: [StabilizationMode.OPTICAL], then [StabilizationMode.OFF]. + * Preview stabilization ([StabilizationMode.ON]) is intended for video and crops the field of + * view, so it is not chosen automatically when no video can be recorded. + * - In other capture modes: [StabilizationMode.ON], [StabilizationMode.OPTICAL], then + * [StabilizationMode.OFF]. + * + * Any other requested mode is returned unchanged if it is supported at [targetFrameRate], and + * resolves to [StabilizationMode.OFF] otherwise. This includes [StabilizationMode.ON] in + * [CaptureMode.IMAGE_ONLY], so preview stabilization can still be selected explicitly. + */ +internal fun resolveStabilizationMode( + requestedStabilizationMode: StabilizationMode, + captureMode: CaptureMode, + targetFrameRate: Int, + cameraConstraints: CameraConstraints +): StabilizationMode = with(cameraConstraints) { + fun StabilizationMode.isSupported(): Boolean = + this in supportedStabilizationModes && targetFrameRate !in unsupportedFpsSet + + if (requestedStabilizationMode == StabilizationMode.AUTO) { + val autoCandidates = if (captureMode == CaptureMode.IMAGE_ONLY) { + listOf(StabilizationMode.OPTICAL) + } else { + listOf(StabilizationMode.ON, StabilizationMode.OPTICAL) + } + autoCandidates.firstOrNull { it.isSupported() } ?: StabilizationMode.OFF + } else { + requestedStabilizationMode.takeIf { it.isSupported() } ?: StabilizationMode.OFF + } +} diff --git a/core/camera/src/test/java/com/google/jetpackcamera/core/camera/StabilizationModeResolutionTest.kt b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/StabilizationModeResolutionTest.kt new file mode 100644 index 0000000000..c3813c4f6d --- /dev/null +++ b/core/camera/src/test/java/com/google/jetpackcamera/core/camera/StabilizationModeResolutionTest.kt @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.core.camera + +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.StabilizationMode +import com.google.jetpackcamera.model.TARGET_FPS_60 +import com.google.jetpackcamera.model.TARGET_FPS_AUTO +import com.google.jetpackcamera.settings.model.CameraConstraints +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class StabilizationModeResolutionTest { + + @Test + fun auto_standard_previewAndOpticalSupported_resolvesToOn() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OPTICAL, + StabilizationMode.OFF + ) + + assertThat(resolve(StabilizationMode.AUTO, CaptureMode.STANDARD, constraints)) + .isEqualTo(StabilizationMode.ON) + } + + @Test + fun auto_videoOnly_previewAndOpticalSupported_resolvesToOn() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OPTICAL, + StabilizationMode.OFF + ) + + assertThat(resolve(StabilizationMode.AUTO, CaptureMode.VIDEO_ONLY, constraints)) + .isEqualTo(StabilizationMode.ON) + } + + @Test + fun auto_standard_onlyOpticalSupported_resolvesToOptical() { + val constraints = cameraConstraints(StabilizationMode.OPTICAL, StabilizationMode.OFF) + + assertThat(resolve(StabilizationMode.AUTO, CaptureMode.STANDARD, constraints)) + .isEqualTo(StabilizationMode.OPTICAL) + } + + @Test + fun auto_standard_noStabilizationSupported_resolvesToOff() { + val constraints = cameraConstraints(StabilizationMode.OFF) + + assertThat(resolve(StabilizationMode.AUTO, CaptureMode.STANDARD, constraints)) + .isEqualTo(StabilizationMode.OFF) + } + + @Test + fun auto_standard_previewUnsupportedAtFrameRate_resolvesToOptical() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OPTICAL, + StabilizationMode.OFF, + unsupportedStabilizationFpsMap = mapOf(StabilizationMode.ON to setOf(TARGET_FPS_60)) + ) + + assertThat( + resolve( + StabilizationMode.AUTO, + CaptureMode.STANDARD, + constraints, + targetFrameRate = TARGET_FPS_60 + ) + ).isEqualTo(StabilizationMode.OPTICAL) + } + + @Test + fun auto_imageOnly_previewAndOpticalSupported_resolvesToOptical() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OPTICAL, + StabilizationMode.OFF + ) + + assertThat(resolve(StabilizationMode.AUTO, CaptureMode.IMAGE_ONLY, constraints)) + .isEqualTo(StabilizationMode.OPTICAL) + } + + @Test + fun auto_imageOnly_onlyPreviewSupported_resolvesToOff() { + val constraints = cameraConstraints(StabilizationMode.ON, StabilizationMode.OFF) + + assertThat(resolve(StabilizationMode.AUTO, CaptureMode.IMAGE_ONLY, constraints)) + .isEqualTo(StabilizationMode.OFF) + } + + @Test + fun auto_imageOnly_opticalUnsupportedAtFrameRate_resolvesToOff() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OPTICAL, + StabilizationMode.OFF, + unsupportedStabilizationFpsMap = mapOf( + StabilizationMode.OPTICAL to setOf(TARGET_FPS_60) + ) + ) + + assertThat( + resolve( + StabilizationMode.AUTO, + CaptureMode.IMAGE_ONLY, + constraints, + targetFrameRate = TARGET_FPS_60 + ) + ).isEqualTo(StabilizationMode.OFF) + } + + @Test + fun on_imageOnly_previewSupported_resolvesToOn() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OPTICAL, + StabilizationMode.OFF + ) + + assertThat(resolve(StabilizationMode.ON, CaptureMode.IMAGE_ONLY, constraints)) + .isEqualTo(StabilizationMode.ON) + } + + @Test + fun highQuality_imageOnly_supported_resolvesToHighQuality() { + val constraints = cameraConstraints(StabilizationMode.HIGH_QUALITY, StabilizationMode.OFF) + + assertThat(resolve(StabilizationMode.HIGH_QUALITY, CaptureMode.IMAGE_ONLY, constraints)) + .isEqualTo(StabilizationMode.HIGH_QUALITY) + } + + @Test + fun explicitMode_unsupportedByLens_resolvesToOff() { + val constraints = cameraConstraints(StabilizationMode.OPTICAL, StabilizationMode.OFF) + + assertThat(resolve(StabilizationMode.ON, CaptureMode.STANDARD, constraints)) + .isEqualTo(StabilizationMode.OFF) + } + + @Test + fun explicitMode_unsupportedAtFrameRate_resolvesToOff() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OFF, + unsupportedStabilizationFpsMap = mapOf(StabilizationMode.ON to setOf(TARGET_FPS_60)) + ) + + assertThat( + resolve( + StabilizationMode.ON, + CaptureMode.STANDARD, + constraints, + targetFrameRate = TARGET_FPS_60 + ) + ).isEqualTo(StabilizationMode.OFF) + } + + @Test + fun off_resolvesToOff() { + val constraints = cameraConstraints( + StabilizationMode.ON, + StabilizationMode.OPTICAL, + StabilizationMode.OFF + ) + + assertThat(resolve(StabilizationMode.OFF, CaptureMode.STANDARD, constraints)) + .isEqualTo(StabilizationMode.OFF) + } + + private fun resolve( + requestedStabilizationMode: StabilizationMode, + captureMode: CaptureMode, + cameraConstraints: CameraConstraints, + targetFrameRate: Int = TARGET_FPS_AUTO + ): StabilizationMode = resolveStabilizationMode( + requestedStabilizationMode = requestedStabilizationMode, + captureMode = captureMode, + targetFrameRate = targetFrameRate, + cameraConstraints = cameraConstraints + ) + + private fun cameraConstraints( + vararg supportedStabilizationModes: StabilizationMode, + unsupportedStabilizationFpsMap: Map> = emptyMap() + ) = CameraConstraints( + supportedStabilizationModes = supportedStabilizationModes.toSet(), + supportedFixedFrameRates = emptySet(), + supportedDynamicRanges = emptySet(), + supportedVideoQualitiesMap = emptyMap(), + supportedImageFormatsMap = emptyMap(), + supportedIlluminants = emptySet(), + supportedFlashModes = emptySet(), + supportedZoomRange = null, + unsupportedStabilizationFpsMap = unsupportedStabilizationFpsMap, + supportedTestPatterns = emptySet() + ) +}