tilt-shift-camera/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt
Ole-Morten Duesund 88d04515e2 Extract StackBlur and split CameraScreen into smaller files
Move the ~220-line stack blur algorithm from ImageCaptureHandler into
its own util/StackBlur.kt object, making it independently testable
and reducing ImageCaptureHandler from 759 to 531 lines.

Split CameraScreen.kt (873 lines) by extracting:
- ControlPanel.kt: ModeToggle, ControlPanel, SliderControl
- CaptureControls.kt: CaptureButton, LastPhotoThumbnail

CameraScreen.kt is now 609 lines focused on layout and state wiring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:50:30 +01:00

534 lines
20 KiB
Kotlin

package no.naiv.tiltshift.camera
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.location.Location
import android.net.Uri
import android.util.Log
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy
import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.storage.PhotoSaver
import no.naiv.tiltshift.storage.SaveResult
import no.naiv.tiltshift.util.StackBlur
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Handles capturing photos with the tilt-shift effect applied.
*/
class ImageCaptureHandler(
private val context: Context,
private val photoSaver: PhotoSaver
) {
companion object {
private const val TAG = "ImageCaptureHandler"
/** Maximum decoded image dimension to prevent OOM from huge gallery images. */
private const val MAX_IMAGE_DIMENSION = 4096
/** Scale factor for downscaling blur and mask computations. */
private const val SCALE_FACTOR = 4
}
/**
* Type-safe outcome of the camera capture callback.
* Eliminates unsafe `as Any` / `as SaveResult` casts.
*/
private sealed class CaptureOutcome {
class Processed(val original: Bitmap, val processed: Bitmap) : CaptureOutcome()
class Failed(val result: SaveResult.Error) : CaptureOutcome()
}
/**
* Captures a photo and applies the tilt-shift effect.
*
* Phase 1 (inside suspendCancellableCoroutine / camera callback):
* decode -> rotate -> apply effect (synchronous CPU work only)
*
* Phase 2 (after continuation resumes, back in coroutine context):
* save bitmap via PhotoSaver (suspend-safe)
*/
suspend fun capturePhoto(
imageCapture: ImageCapture,
executor: Executor,
blurParams: BlurParameters,
deviceRotation: Int,
location: Location?,
isFrontCamera: Boolean
): SaveResult {
// Phase 1: capture and process the image synchronously in the callback
val captureResult = suspendCancellableCoroutine<CaptureOutcome> { continuation ->
imageCapture.takePicture(
executor,
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(imageProxy: ImageProxy) {
var currentBitmap: Bitmap? = null
try {
val imageRotation = imageProxy.imageInfo.rotationDegrees
currentBitmap = imageProxyToBitmap(imageProxy)
if (currentBitmap == null) {
continuation.resume(
CaptureOutcome.Failed(SaveResult.Error("Failed to convert image"))
)
return
}
currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera)
val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams)
val original = currentBitmap
currentBitmap = null
continuation.resume(CaptureOutcome.Processed(original, processedBitmap))
} catch (e: Exception) {
Log.e(TAG, "Image processing failed", e)
currentBitmap?.recycle()
continuation.resume(
CaptureOutcome.Failed(SaveResult.Error("Failed to process image. Please try again.", e))
)
} finally {
imageProxy.close()
}
}
override fun onError(exception: ImageCaptureException) {
Log.e(TAG, "Image capture failed", exception)
continuation.resume(
CaptureOutcome.Failed(SaveResult.Error("Failed to capture photo. Please try again.", exception))
)
}
}
)
}
// Phase 2: save both original and processed to disk (suspend-safe)
return when (captureResult) {
is CaptureOutcome.Failed -> captureResult.result
is CaptureOutcome.Processed -> {
var thumbnail: Bitmap? = null
try {
thumbnail = createThumbnail(captureResult.processed)
val result = photoSaver.saveBitmapPair(
original = captureResult.original,
processed = captureResult.processed,
orientation = ExifInterface.ORIENTATION_NORMAL,
location = location
)
if (result is SaveResult.Success) {
val output = result.copy(thumbnail = thumbnail)
thumbnail = null // prevent finally from recycling the returned thumbnail
output
} else {
result
}
} finally {
thumbnail?.recycle()
captureResult.original.recycle()
captureResult.processed.recycle()
}
}
}
}
/**
* Creates a small thumbnail copy of a bitmap for in-app preview.
*/
private fun createThumbnail(source: Bitmap, maxSize: Int = 160): Bitmap? {
return try {
val scale = maxSize.toFloat() / maxOf(source.width, source.height)
val width = (source.width * scale).toInt()
val height = (source.height * scale).toInt()
Bitmap.createScaledBitmap(source, width, height, true)
} catch (e: Exception) {
Log.w(TAG, "Failed to create thumbnail", e)
null
}
}
/**
* Rotates a bitmap to the correct orientation.
*/
private fun rotateBitmap(bitmap: Bitmap, rotationDegrees: Int, isFrontCamera: Boolean): Bitmap {
if (rotationDegrees == 0 && !isFrontCamera) {
return bitmap
}
val matrix = Matrix()
// Apply rotation
if (rotationDegrees != 0) {
matrix.postRotate(rotationDegrees.toFloat())
}
// Mirror for front camera
if (isFrontCamera) {
matrix.postScale(-1f, 1f)
}
val rotated = Bitmap.createBitmap(
bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true
)
if (rotated != bitmap) {
bitmap.recycle()
}
return rotated
}
private fun imageProxyToBitmap(imageProxy: ImageProxy): Bitmap? {
val buffer = imageProxy.planes[0].buffer
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}
/**
* Loads a gallery image and applies EXIF rotation, returning the bitmap for preview.
* The caller owns the returned bitmap and is responsible for recycling it.
*/
suspend fun loadGalleryImage(imageUri: Uri): Bitmap? = withContext(Dispatchers.IO) {
try {
val bitmap = loadBitmapFromUri(imageUri)
?: return@withContext null
applyExifRotation(imageUri, bitmap)
} catch (e: Exception) {
Log.e(TAG, "Failed to load gallery image for preview", e)
null
}
}
/**
* Processes an existing image from the gallery through the tilt-shift pipeline.
* Loads the image, applies EXIF rotation, processes the effect, and saves the result.
*/
suspend fun processExistingImage(
imageUri: Uri,
blurParams: BlurParameters,
location: Location?
): SaveResult = withContext(Dispatchers.IO) {
var originalBitmap: Bitmap? = null
var processedBitmap: Bitmap? = null
var thumbnail: Bitmap? = null
try {
originalBitmap = loadBitmapFromUri(imageUri)
?: return@withContext SaveResult.Error("Failed to load image")
originalBitmap = applyExifRotation(imageUri, originalBitmap)
processedBitmap = applyTiltShiftEffect(originalBitmap, blurParams)
thumbnail = createThumbnail(processedBitmap)
val result = photoSaver.saveBitmap(
bitmap = processedBitmap,
orientation = ExifInterface.ORIENTATION_NORMAL,
location = location
)
if (result is SaveResult.Success) {
val output = result.copy(thumbnail = thumbnail)
thumbnail = null // prevent finally from recycling the returned thumbnail
output
} else {
result
}
} catch (e: SecurityException) {
Log.e(TAG, "Permission denied while processing gallery image", e)
SaveResult.Error("Permission denied. Please grant access and try again.", e)
} catch (e: Exception) {
Log.e(TAG, "Gallery image processing failed", e)
SaveResult.Error("Failed to process image. Please try again.", e)
} finally {
thumbnail?.recycle()
originalBitmap?.recycle()
processedBitmap?.recycle()
}
}
/**
* Loads a bitmap from a content URI with dimension bounds checking
* to prevent OOM from extremely large images.
*/
private fun loadBitmapFromUri(uri: Uri): Bitmap? {
return try {
// First pass: read dimensions without decoding pixels
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use { stream ->
BitmapFactory.decodeStream(stream, null, options)
} ?: run {
Log.e(TAG, "Could not open input stream for URI (dimensions pass): $uri")
return null
}
if (options.outWidth <= 0 || options.outHeight <= 0) {
Log.e(TAG, "Image has invalid dimensions: ${options.outWidth}x${options.outHeight}, mime: ${options.outMimeType}")
return null
}
// Calculate sample size to stay within MAX_IMAGE_DIMENSION
val maxDim = maxOf(options.outWidth, options.outHeight)
val sampleSize = if (maxDim > MAX_IMAGE_DIMENSION) {
var sample = 1
while (maxDim / sample > MAX_IMAGE_DIMENSION) sample *= 2
sample
} else 1
// Second pass: decode with sample size
val decodeOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize }
val bitmap = context.contentResolver.openInputStream(uri)?.use { stream ->
BitmapFactory.decodeStream(stream, null, decodeOptions)
}
if (bitmap == null) {
Log.e(TAG, "BitmapFactory.decodeStream returned null for URI: $uri (mime: ${options.outMimeType})")
}
bitmap
} catch (e: SecurityException) {
Log.e(TAG, "Permission denied loading bitmap from URI", e)
null
} catch (e: Exception) {
Log.e(TAG, "Failed to load bitmap from URI", e)
null
}
}
/**
* Reads EXIF orientation from a content URI and applies the
* required rotation/flip to the bitmap.
*/
private fun applyExifRotation(uri: Uri, bitmap: Bitmap): Bitmap {
val orientation = try {
context.contentResolver.openInputStream(uri)?.use { stream ->
ExifInterface(stream).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
} ?: ExifInterface.ORIENTATION_NORMAL
} catch (e: Exception) {
Log.w(TAG, "Failed to read EXIF orientation", e)
ExifInterface.ORIENTATION_NORMAL
}
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(270f)
matrix.postScale(-1f, 1f)
}
else -> return bitmap
}
val rotated = Bitmap.createBitmap(
bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true
)
if (rotated != bitmap) {
bitmap.recycle()
}
return rotated
}
/**
* Applies tilt-shift effect to a bitmap for real-time preview.
* Runs on [Dispatchers.IO]. The caller owns the returned bitmap.
*/
suspend fun applyTiltShiftPreview(source: Bitmap, params: BlurParameters): Bitmap =
withContext(Dispatchers.IO) {
applyTiltShiftEffect(source, params)
}
/**
* Applies tilt-shift blur effect to a bitmap.
* Supports both linear and radial modes.
*
* The gradient mask is computed at 1/4 resolution (matching the blur downscale)
* and upscaled for compositing, reducing peak memory by ~93%.
*
* All intermediate bitmaps are tracked and recycled in a finally block
* so that an OOM or other exception does not leak native memory.
*/
private fun applyTiltShiftEffect(source: Bitmap, params: BlurParameters): Bitmap {
val width = source.width
val height = source.height
var result: Bitmap? = null
var scaled: Bitmap? = null
var blurred: Bitmap? = null
var blurredFullSize: Bitmap? = null
try {
result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val blurredWidth = width / SCALE_FACTOR
val blurredHeight = height / SCALE_FACTOR
scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
blurred = StackBlur.blur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25))
scaled.recycle()
scaled = null
blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true)
blurred.recycle()
blurred = null
// Compute mask at reduced resolution and upscale to avoid full-res per-pixel trig
val maskPixels = createGradientMaskPixels(blurredWidth, blurredHeight, params)
val fullMaskPixels = upscaleMask(maskPixels, blurredWidth, blurredHeight, width, height)
// Composite: blend original with blurred based on mask
val pixels = IntArray(width * height)
val blurredPixels = IntArray(width * height)
source.getPixels(pixels, 0, width, 0, 0, width, height)
blurredFullSize.getPixels(blurredPixels, 0, width, 0, 0, width, height)
blurredFullSize.recycle()
blurredFullSize = null
for (i in pixels.indices) {
val maskAlpha = (fullMaskPixels[i] and 0xFF) / 255f
val origR = (pixels[i] shr 16) and 0xFF
val origG = (pixels[i] shr 8) and 0xFF
val origB = pixels[i] and 0xFF
val blurR = (blurredPixels[i] shr 16) and 0xFF
val blurG = (blurredPixels[i] shr 8) and 0xFF
val blurB = blurredPixels[i] and 0xFF
val r = (origR * (1 - maskAlpha) + blurR * maskAlpha).toInt()
val g = (origG * (1 - maskAlpha) + blurG * maskAlpha).toInt()
val b = (origB * (1 - maskAlpha) + blurB * maskAlpha).toInt()
pixels[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
}
result.setPixels(pixels, 0, width, 0, 0, width, height)
val output = result
result = null // prevent finally from recycling the returned bitmap
return output
} finally {
result?.recycle()
scaled?.recycle()
blurred?.recycle()
blurredFullSize?.recycle()
}
}
/**
* Creates a gradient mask as a pixel array at the given dimensions.
* Returns packed ARGB ints where the blue channel encodes blur amount.
*/
private fun createGradientMaskPixels(width: Int, height: Int, params: BlurParameters): IntArray {
val pixels = IntArray(width * height)
val centerX = width * params.positionX
val centerY = height * params.positionY
val focusSize = height * params.size * 0.5f
val transitionSize = focusSize * params.falloff
val cosAngle = cos(params.angle)
val sinAngle = sin(params.angle)
val screenAspect = width.toFloat() / height.toFloat()
for (y in 0 until height) {
for (x in 0 until width) {
val dist = when (params.mode) {
BlurMode.LINEAR -> {
val dx = x - centerX
val dy = y - centerY
val rotatedY = -dx * sinAngle + dy * cosAngle
kotlin.math.abs(rotatedY)
}
BlurMode.RADIAL -> {
var dx = x - centerX
var dy = y - centerY
dx *= screenAspect
val rotatedX = dx * cosAngle - dy * sinAngle
val rotatedY = dx * sinAngle + dy * cosAngle
val adjustedX = rotatedX / params.aspectRatio
sqrt(adjustedX * adjustedX + rotatedY * rotatedY)
}
}
val blurAmount = when {
dist < focusSize -> 0f
dist < focusSize + transitionSize -> {
(dist - focusSize) / transitionSize
}
else -> 1f
}
val gray = (blurAmount * 255).toInt().coerceIn(0, 255)
pixels[y * width + x] = (0xFF shl 24) or (gray shl 16) or (gray shl 8) or gray
}
}
return pixels
}
/**
* Bilinear upscale of a mask pixel array from small dimensions to full dimensions.
*/
private fun upscaleMask(
smallPixels: IntArray,
smallW: Int, smallH: Int,
fullW: Int, fullH: Int
): IntArray {
val fullPixels = IntArray(fullW * fullH)
val xRatio = smallW.toFloat() / fullW
val yRatio = smallH.toFloat() / fullH
for (y in 0 until fullH) {
val srcY = y * yRatio
val y0 = srcY.toInt().coerceIn(0, smallH - 1)
val y1 = (y0 + 1).coerceIn(0, smallH - 1)
val yFrac = srcY - y0
for (x in 0 until fullW) {
val srcX = x * xRatio
val x0 = srcX.toInt().coerceIn(0, smallW - 1)
val x1 = (x0 + 1).coerceIn(0, smallW - 1)
val xFrac = srcX - x0
// Bilinear interpolation on the blue channel (all channels are equal)
val v00 = smallPixels[y0 * smallW + x0] and 0xFF
val v10 = smallPixels[y0 * smallW + x1] and 0xFF
val v01 = smallPixels[y1 * smallW + x0] and 0xFF
val v11 = smallPixels[y1 * smallW + x1] and 0xFF
val top = v00 + (v10 - v00) * xFrac
val bottom = v01 + (v11 - v01) * xFrac
val gray = (top + (bottom - top) * yFrac).toInt().coerceIn(0, 255)
fullPixels[y * fullW + x] = (0xFF shl 24) or (gray shl 16) or (gray shl 8) or gray
}
}
return fullPixels
}
}