tilt-shift-camera/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt
Ole-Morten Duesund e3e05af0b8 Fix image orientation, reduce sensitivity, fix overlay clipping
Image orientation:
- Actually rotate captured bitmap using ImageProxy.rotationDegrees
- Save with EXIF ORIENTATION_NORMAL (bitmap already correctly oriented)
- Handle front camera mirroring

Gesture sensitivity (halved again):
- Position drag: 0.15x (was 0.3x)
- Rotation: 0.2x (was 0.4x)
- Size pinch: 0.25x (was 0.5x)
- Zoom pinch: 0.4x (was 0.6x)

Overlay drawing:
- Use screen diagonal to calculate extended geometry
- Draw lines and rectangles that extend beyond screen bounds
- Prevents clipping when tilt-shift effect is rotated

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 15:46:43 +01:00

466 lines
14 KiB
Kotlin

package no.naiv.tiltshift.camera
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RenderEffect
import android.graphics.Shader
import android.location.Location
import android.os.Build
import android.view.Surface
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy
import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.suspendCancellableCoroutine
import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.storage.PhotoSaver
import no.naiv.tiltshift.storage.SaveResult
import no.naiv.tiltshift.util.OrientationDetector
import java.io.File
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.math.cos
import kotlin.math.sin
/**
* Handles capturing photos with the tilt-shift effect applied.
*/
class ImageCaptureHandler(
private val context: Context,
private val photoSaver: PhotoSaver
) {
/**
* Captures a photo and applies the tilt-shift effect.
*/
suspend fun capturePhoto(
imageCapture: ImageCapture,
executor: Executor,
blurParams: BlurParameters,
deviceRotation: Int,
location: Location?,
isFrontCamera: Boolean
): SaveResult = suspendCancellableCoroutine { continuation ->
imageCapture.takePicture(
executor,
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(imageProxy: ImageProxy) {
try {
// Get rotation from ImageProxy (sensor orientation)
val imageRotation = imageProxy.imageInfo.rotationDegrees
// Convert ImageProxy to Bitmap
var bitmap = imageProxyToBitmap(imageProxy)
imageProxy.close()
if (bitmap == null) {
continuation.resume(SaveResult.Error("Failed to convert image"))
return
}
// Rotate bitmap to correct orientation
// Camera sensor is landscape, we need to rotate for portrait
bitmap = rotateBitmap(bitmap, imageRotation, isFrontCamera)
// Apply tilt-shift effect to the correctly oriented image
val processedBitmap = applyTiltShiftEffect(bitmap, blurParams)
bitmap.recycle()
// Save with EXIF orientation as NORMAL (bitmap is already rotated)
kotlinx.coroutines.runBlocking {
val result = photoSaver.saveBitmap(
processedBitmap,
ExifInterface.ORIENTATION_NORMAL,
location
)
processedBitmap.recycle()
continuation.resume(result)
}
} catch (e: Exception) {
continuation.resume(SaveResult.Error("Capture failed: ${e.message}", e))
}
}
override fun onError(exception: ImageCaptureException) {
continuation.resume(
SaveResult.Error("Capture failed: ${exception.message}", exception)
)
}
}
)
}
/**
* 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)
}
/**
* Applies tilt-shift blur effect to a bitmap.
* This is a software fallback - on newer devices we could use RenderScript/RenderEffect.
*/
private fun applyTiltShiftEffect(source: Bitmap, params: BlurParameters): Bitmap {
val width = source.width
val height = source.height
// Create output bitmap
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
// For performance, we use a scaled-down version for blur and composite
val scaleFactor = 4 // Blur a 1/4 size image for speed
val blurredWidth = width / scaleFactor
val blurredHeight = height / scaleFactor
// Create scaled bitmap for blur
val scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
// Apply stack blur (fast approximation)
val blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25))
scaled.recycle()
// Scale blurred back up
val blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true)
blurred.recycle()
// Create gradient mask based on tilt-shift parameters
val mask = createGradientMask(width, height, params)
// Composite: blend original with blurred based on mask
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
val pixels = IntArray(width * height)
val blurredPixels = IntArray(width * height)
val maskPixels = IntArray(width * height)
source.getPixels(pixels, 0, width, 0, 0, width, height)
blurredFullSize.getPixels(blurredPixels, 0, width, 0, 0, width, height)
mask.getPixels(maskPixels, 0, width, 0, 0, width, height)
blurredFullSize.recycle()
mask.recycle()
for (i in pixels.indices) {
val maskAlpha = (maskPixels[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)
return result
}
/**
* Creates a gradient mask for the tilt-shift effect.
*/
private fun createGradientMask(width: Int, height: Int, params: BlurParameters): Bitmap {
val mask = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val pixels = IntArray(width * height)
val centerY = height * params.position
val focusHalfHeight = height * params.size * 0.5f
val transitionHeight = focusHalfHeight * 0.5f
val cosAngle = cos(params.angle)
val sinAngle = sin(params.angle)
for (y in 0 until height) {
for (x in 0 until width) {
// Rotate point around center
val dx = x - width / 2f
val dy = y - centerY
val rotatedY = -dx * sinAngle + dy * cosAngle
// Calculate blur amount based on distance from focus line
val dist = kotlin.math.abs(rotatedY)
val blurAmount = when {
dist < focusHalfHeight -> 0f
dist < focusHalfHeight + transitionHeight -> {
(dist - focusHalfHeight) / transitionHeight
}
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
}
}
mask.setPixels(pixels, 0, width, 0, 0, width, height)
return mask
}
/**
* Fast stack blur algorithm.
*/
private fun stackBlur(bitmap: Bitmap, radius: Int): Bitmap {
if (radius < 1) return bitmap.copy(Bitmap.Config.ARGB_8888, true)
val w = bitmap.width
val h = bitmap.height
val pix = IntArray(w * h)
bitmap.getPixels(pix, 0, w, 0, 0, w, h)
val wm = w - 1
val hm = h - 1
val wh = w * h
val div = radius + radius + 1
val r = IntArray(wh)
val g = IntArray(wh)
val b = IntArray(wh)
var rsum: Int
var gsum: Int
var bsum: Int
var x: Int
var y: Int
var i: Int
var p: Int
var yp: Int
var yi: Int
var yw: Int
val vmin = IntArray(maxOf(w, h))
var divsum = (div + 1) shr 1
divsum *= divsum
val dv = IntArray(256 * divsum)
for (i2 in 0 until 256 * divsum) {
dv[i2] = (i2 / divsum)
}
yi = 0
yw = 0
val stack = Array(div) { IntArray(3) }
var stackpointer: Int
var stackstart: Int
var sir: IntArray
var rbs: Int
val r1 = radius + 1
var routsum: Int
var goutsum: Int
var boutsum: Int
var rinsum: Int
var ginsum: Int
var binsum: Int
for (y2 in 0 until h) {
rinsum = 0
ginsum = 0
binsum = 0
routsum = 0
goutsum = 0
boutsum = 0
rsum = 0
gsum = 0
bsum = 0
for (i2 in -radius..radius) {
p = pix[yi + minOf(wm, maxOf(i2, 0))]
sir = stack[i2 + radius]
sir[0] = (p and 0xff0000) shr 16
sir[1] = (p and 0x00ff00) shr 8
sir[2] = (p and 0x0000ff)
rbs = r1 - kotlin.math.abs(i2)
rsum += sir[0] * rbs
gsum += sir[1] * rbs
bsum += sir[2] * rbs
if (i2 > 0) {
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
} else {
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
}
}
stackpointer = radius
for (x2 in 0 until w) {
r[yi] = dv[rsum]
g[yi] = dv[gsum]
b[yi] = dv[bsum]
rsum -= routsum
gsum -= goutsum
bsum -= boutsum
stackstart = stackpointer - radius + div
sir = stack[stackstart % div]
routsum -= sir[0]
goutsum -= sir[1]
boutsum -= sir[2]
if (y2 == 0) {
vmin[x2] = minOf(x2 + radius + 1, wm)
}
p = pix[yw + vmin[x2]]
sir[0] = (p and 0xff0000) shr 16
sir[1] = (p and 0x00ff00) shr 8
sir[2] = (p and 0x0000ff)
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
rsum += rinsum
gsum += ginsum
bsum += binsum
stackpointer = (stackpointer + 1) % div
sir = stack[(stackpointer) % div]
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
rinsum -= sir[0]
ginsum -= sir[1]
binsum -= sir[2]
yi++
}
yw += w
}
for (x2 in 0 until w) {
rinsum = 0
ginsum = 0
binsum = 0
routsum = 0
goutsum = 0
boutsum = 0
rsum = 0
gsum = 0
bsum = 0
yp = -radius * w
for (i2 in -radius..radius) {
yi = maxOf(0, yp) + x2
sir = stack[i2 + radius]
sir[0] = r[yi]
sir[1] = g[yi]
sir[2] = b[yi]
rbs = r1 - kotlin.math.abs(i2)
rsum += r[yi] * rbs
gsum += g[yi] * rbs
bsum += b[yi] * rbs
if (i2 > 0) {
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
} else {
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
}
if (i2 < hm) {
yp += w
}
}
yi = x2
stackpointer = radius
for (y2 in 0 until h) {
pix[yi] = (0xff000000.toInt() and pix[yi]) or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum]
rsum -= routsum
gsum -= goutsum
bsum -= boutsum
stackstart = stackpointer - radius + div
sir = stack[stackstart % div]
routsum -= sir[0]
goutsum -= sir[1]
boutsum -= sir[2]
if (x2 == 0) {
vmin[y2] = minOf(y2 + r1, hm) * w
}
p = x2 + vmin[y2]
sir[0] = r[p]
sir[1] = g[p]
sir[2] = b[p]
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
rsum += rinsum
gsum += ginsum
bsum += binsum
stackpointer = (stackpointer + 1) % div
sir = stack[stackpointer]
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
rinsum -= sir[0]
ginsum -= sir[1]
binsum -= sir[2]
yi += w
}
}
val result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
result.setPixels(pix, 0, w, 0, 0, w, h)
return result
}
}