2026-01-28 15:26:41 +01:00
|
|
|
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
|
2026-03-03 22:32:11 +01:00
|
|
|
import android.net.Uri
|
2026-02-27 15:20:57 +01:00
|
|
|
import android.util.Log
|
2026-01-28 15:26:41 +01:00
|
|
|
import androidx.camera.core.ImageCapture
|
|
|
|
|
import androidx.camera.core.ImageCaptureException
|
|
|
|
|
import androidx.camera.core.ImageProxy
|
|
|
|
|
import androidx.exifinterface.media.ExifInterface
|
2026-03-03 22:32:11 +01:00
|
|
|
import kotlinx.coroutines.Dispatchers
|
2026-01-28 15:26:41 +01:00
|
|
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
2026-03-03 22:32:11 +01:00
|
|
|
import kotlinx.coroutines.withContext
|
2026-01-29 11:13:31 +01:00
|
|
|
import no.naiv.tiltshift.effect.BlurMode
|
2026-01-28 15:26:41 +01:00
|
|
|
import no.naiv.tiltshift.effect.BlurParameters
|
|
|
|
|
import no.naiv.tiltshift.storage.PhotoSaver
|
|
|
|
|
import no.naiv.tiltshift.storage.SaveResult
|
|
|
|
|
import java.util.concurrent.Executor
|
|
|
|
|
import kotlin.coroutines.resume
|
|
|
|
|
import kotlin.math.cos
|
|
|
|
|
import kotlin.math.sin
|
2026-01-29 11:13:31 +01:00
|
|
|
import kotlin.math.sqrt
|
2026-01-28 15:26:41 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handles capturing photos with the tilt-shift effect applied.
|
|
|
|
|
*/
|
|
|
|
|
class ImageCaptureHandler(
|
|
|
|
|
private val context: Context,
|
|
|
|
|
private val photoSaver: PhotoSaver
|
|
|
|
|
) {
|
|
|
|
|
|
2026-02-27 15:20:57 +01:00
|
|
|
companion object {
|
|
|
|
|
private const val TAG = "ImageCaptureHandler"
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 15:19:41 +01:00
|
|
|
/**
|
|
|
|
|
* Holds the processed bitmap ready for saving, produced inside the
|
|
|
|
|
* camera callback (synchronous CPU work) and consumed afterwards
|
|
|
|
|
* in the caller's coroutine context.
|
|
|
|
|
*/
|
2026-03-03 22:32:11 +01:00
|
|
|
private class ProcessedCapture(
|
|
|
|
|
val originalBitmap: Bitmap,
|
|
|
|
|
val processedBitmap: Bitmap
|
|
|
|
|
)
|
2026-02-27 15:19:41 +01:00
|
|
|
|
2026-01-28 15:26:41 +01:00
|
|
|
/**
|
|
|
|
|
* Captures a photo and applies the tilt-shift effect.
|
2026-02-27 15:19:41 +01:00
|
|
|
*
|
|
|
|
|
* 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)
|
2026-01-28 15:26:41 +01:00
|
|
|
*/
|
|
|
|
|
suspend fun capturePhoto(
|
|
|
|
|
imageCapture: ImageCapture,
|
|
|
|
|
executor: Executor,
|
|
|
|
|
blurParams: BlurParameters,
|
|
|
|
|
deviceRotation: Int,
|
|
|
|
|
location: Location?,
|
|
|
|
|
isFrontCamera: Boolean
|
2026-02-27 15:19:41 +01:00
|
|
|
): SaveResult {
|
|
|
|
|
// Phase 1: capture and process the image synchronously in the callback
|
|
|
|
|
val captureResult = suspendCancellableCoroutine { continuation ->
|
|
|
|
|
imageCapture.takePicture(
|
|
|
|
|
executor,
|
|
|
|
|
object : ImageCapture.OnImageCapturedCallback() {
|
|
|
|
|
override fun onCaptureSuccess(imageProxy: ImageProxy) {
|
2026-02-27 15:20:57 +01:00
|
|
|
var currentBitmap: Bitmap? = null
|
2026-02-27 15:19:41 +01:00
|
|
|
try {
|
|
|
|
|
val imageRotation = imageProxy.imageInfo.rotationDegrees
|
|
|
|
|
|
2026-02-27 15:20:57 +01:00
|
|
|
currentBitmap = imageProxyToBitmap(imageProxy)
|
2026-02-27 15:19:41 +01:00
|
|
|
imageProxy.close()
|
|
|
|
|
|
2026-02-27 15:20:57 +01:00
|
|
|
if (currentBitmap == null) {
|
2026-02-27 15:19:41 +01:00
|
|
|
continuation.resume(
|
|
|
|
|
SaveResult.Error("Failed to convert image") as Any
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 15:20:57 +01:00
|
|
|
currentBitmap = rotateBitmap(currentBitmap, imageRotation, isFrontCamera)
|
2026-02-27 15:19:41 +01:00
|
|
|
|
2026-02-27 15:20:57 +01:00
|
|
|
val processedBitmap = applyTiltShiftEffect(currentBitmap, blurParams)
|
2026-03-03 22:32:11 +01:00
|
|
|
// Keep originalBitmap alive — both are recycled after saving
|
|
|
|
|
val original = currentBitmap
|
2026-02-27 15:20:57 +01:00
|
|
|
currentBitmap = null
|
2026-02-27 15:19:41 +01:00
|
|
|
|
2026-03-03 22:32:11 +01:00
|
|
|
continuation.resume(ProcessedCapture(original, processedBitmap))
|
2026-02-27 15:19:41 +01:00
|
|
|
} catch (e: Exception) {
|
2026-02-27 15:20:57 +01:00
|
|
|
Log.e(TAG, "Image processing failed", e)
|
|
|
|
|
currentBitmap?.recycle()
|
2026-02-27 15:19:41 +01:00
|
|
|
continuation.resume(
|
|
|
|
|
SaveResult.Error("Capture failed: ${e.message}", e) as Any
|
2026-01-28 15:26:41 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 15:19:41 +01:00
|
|
|
override fun onError(exception: ImageCaptureException) {
|
|
|
|
|
continuation.resume(
|
|
|
|
|
SaveResult.Error(
|
|
|
|
|
"Capture failed: ${exception.message}", exception
|
|
|
|
|
) as Any
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
2026-02-27 15:19:41 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 22:32:11 +01:00
|
|
|
// Phase 2: save both original and processed to disk (suspend-safe)
|
2026-02-27 15:19:41 +01:00
|
|
|
if (captureResult is ProcessedCapture) {
|
|
|
|
|
return try {
|
2026-03-03 22:32:11 +01:00
|
|
|
val thumbnail = createThumbnail(captureResult.processedBitmap)
|
|
|
|
|
val result = photoSaver.saveBitmapPair(
|
|
|
|
|
original = captureResult.originalBitmap,
|
|
|
|
|
processed = captureResult.processedBitmap,
|
|
|
|
|
orientation = ExifInterface.ORIENTATION_NORMAL,
|
|
|
|
|
location = location
|
2026-02-27 15:19:41 +01:00
|
|
|
)
|
2026-03-03 22:32:11 +01:00
|
|
|
if (result is SaveResult.Success) {
|
|
|
|
|
result.copy(thumbnail = thumbnail)
|
|
|
|
|
} else {
|
|
|
|
|
thumbnail?.recycle()
|
|
|
|
|
result
|
|
|
|
|
}
|
2026-02-27 15:19:41 +01:00
|
|
|
} finally {
|
2026-03-03 22:32:11 +01:00
|
|
|
captureResult.originalBitmap.recycle()
|
|
|
|
|
captureResult.processedBitmap.recycle()
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
2026-02-27 15:19:41 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return captureResult as SaveResult
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 22:32:11 +01:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 15:46:43 +01:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 15:26:41 +01:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 22:32:11 +01:00
|
|
|
/**
|
|
|
|
|
* Processes an existing image from the gallery through the tilt-shift pipeline.
|
|
|
|
|
* Loads the image, applies EXIF rotation, processes the effect, and saves both versions.
|
|
|
|
|
*/
|
|
|
|
|
suspend fun processExistingImage(
|
|
|
|
|
imageUri: Uri,
|
|
|
|
|
blurParams: BlurParameters,
|
|
|
|
|
location: Location?
|
|
|
|
|
): SaveResult = withContext(Dispatchers.IO) {
|
|
|
|
|
var originalBitmap: Bitmap? = null
|
|
|
|
|
var processedBitmap: Bitmap? = null
|
|
|
|
|
try {
|
|
|
|
|
originalBitmap = loadBitmapFromUri(imageUri)
|
|
|
|
|
?: return@withContext SaveResult.Error("Failed to load image")
|
|
|
|
|
|
|
|
|
|
originalBitmap = applyExifRotation(imageUri, originalBitmap)
|
|
|
|
|
|
|
|
|
|
processedBitmap = applyTiltShiftEffect(originalBitmap, blurParams)
|
|
|
|
|
|
|
|
|
|
val thumbnail = createThumbnail(processedBitmap)
|
|
|
|
|
|
|
|
|
|
val result = photoSaver.saveBitmapPair(
|
|
|
|
|
original = originalBitmap,
|
|
|
|
|
processed = processedBitmap,
|
|
|
|
|
orientation = ExifInterface.ORIENTATION_NORMAL,
|
|
|
|
|
location = location
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (result is SaveResult.Success) {
|
|
|
|
|
result.copy(thumbnail = thumbnail)
|
|
|
|
|
} else {
|
|
|
|
|
thumbnail?.recycle()
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
} catch (e: Exception) {
|
|
|
|
|
Log.e(TAG, "Gallery image processing failed", e)
|
|
|
|
|
SaveResult.Error("Processing failed: ${e.message}", e)
|
|
|
|
|
} finally {
|
|
|
|
|
originalBitmap?.recycle()
|
|
|
|
|
processedBitmap?.recycle()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Loads a bitmap from a content URI.
|
|
|
|
|
*/
|
|
|
|
|
private fun loadBitmapFromUri(uri: Uri): Bitmap? {
|
|
|
|
|
return try {
|
|
|
|
|
context.contentResolver.openInputStream(uri)?.use { stream ->
|
|
|
|
|
BitmapFactory.decodeStream(stream)
|
|
|
|
|
}
|
|
|
|
|
} 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 15:26:41 +01:00
|
|
|
/**
|
|
|
|
|
* Applies tilt-shift blur effect to a bitmap.
|
2026-01-29 11:13:31 +01:00
|
|
|
* Supports both linear and radial modes.
|
2026-02-27 15:20:16 +01:00
|
|
|
*
|
|
|
|
|
* All intermediate bitmaps are tracked and recycled in a finally block
|
|
|
|
|
* so that an OOM or other exception does not leak native memory.
|
2026-01-28 15:26:41 +01:00
|
|
|
*/
|
|
|
|
|
private fun applyTiltShiftEffect(source: Bitmap, params: BlurParameters): Bitmap {
|
|
|
|
|
val width = source.width
|
|
|
|
|
val height = source.height
|
|
|
|
|
|
2026-02-27 15:20:16 +01:00
|
|
|
var result: Bitmap? = null
|
|
|
|
|
var scaled: Bitmap? = null
|
|
|
|
|
var blurred: Bitmap? = null
|
|
|
|
|
var blurredFullSize: Bitmap? = null
|
|
|
|
|
var mask: Bitmap? = null
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-02-27 15:20:16 +01:00
|
|
|
try {
|
|
|
|
|
result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-02-27 15:20:16 +01:00
|
|
|
val scaleFactor = 4
|
|
|
|
|
val blurredWidth = width / scaleFactor
|
|
|
|
|
val blurredHeight = height / scaleFactor
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-02-27 15:20:16 +01:00
|
|
|
scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-02-27 15:20:16 +01:00
|
|
|
blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25))
|
|
|
|
|
scaled.recycle()
|
|
|
|
|
scaled = null
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-02-27 15:20:16 +01:00
|
|
|
blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true)
|
|
|
|
|
blurred.recycle()
|
|
|
|
|
blurred = null
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-02-27 15:20:16 +01:00
|
|
|
mask = createGradientMask(width, height, params)
|
|
|
|
|
|
|
|
|
|
// Composite: blend original with blurred based on mask
|
|
|
|
|
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()
|
|
|
|
|
blurredFullSize = null
|
|
|
|
|
mask.recycle()
|
|
|
|
|
mask = null
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-02-27 15:20:16 +01:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
val output = result
|
|
|
|
|
result = null // prevent finally from recycling the returned bitmap
|
|
|
|
|
return output
|
|
|
|
|
} finally {
|
|
|
|
|
result?.recycle()
|
|
|
|
|
scaled?.recycle()
|
|
|
|
|
blurred?.recycle()
|
|
|
|
|
blurredFullSize?.recycle()
|
|
|
|
|
mask?.recycle()
|
|
|
|
|
}
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates a gradient mask for the tilt-shift effect.
|
2026-01-29 11:13:31 +01:00
|
|
|
* Supports both linear and radial modes.
|
2026-01-28 15:26:41 +01:00
|
|
|
*/
|
|
|
|
|
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)
|
|
|
|
|
|
2026-01-28 15:55:17 +01:00
|
|
|
val centerX = width * params.positionX
|
|
|
|
|
val centerY = height * params.positionY
|
2026-01-29 11:13:31 +01:00
|
|
|
val focusSize = height * params.size * 0.5f
|
|
|
|
|
val transitionSize = focusSize * params.falloff
|
2026-01-28 15:26:41 +01:00
|
|
|
|
|
|
|
|
val cosAngle = cos(params.angle)
|
|
|
|
|
val sinAngle = sin(params.angle)
|
2026-01-29 11:13:31 +01:00
|
|
|
val screenAspect = width.toFloat() / height.toFloat()
|
2026-01-28 15:26:41 +01:00
|
|
|
|
|
|
|
|
for (y in 0 until height) {
|
|
|
|
|
for (x in 0 until width) {
|
2026-01-29 11:13:31 +01:00
|
|
|
val dist = when (params.mode) {
|
|
|
|
|
BlurMode.LINEAR -> {
|
|
|
|
|
// Rotate point around focus center
|
|
|
|
|
val dx = x - centerX
|
|
|
|
|
val dy = y - centerY
|
|
|
|
|
val rotatedY = -dx * sinAngle + dy * cosAngle
|
|
|
|
|
kotlin.math.abs(rotatedY)
|
|
|
|
|
}
|
|
|
|
|
BlurMode.RADIAL -> {
|
|
|
|
|
// Calculate elliptical distance from center
|
|
|
|
|
var dx = x - centerX
|
|
|
|
|
var dy = y - centerY
|
|
|
|
|
|
|
|
|
|
// Adjust for screen aspect ratio
|
|
|
|
|
dx *= screenAspect
|
|
|
|
|
|
|
|
|
|
// Rotate
|
|
|
|
|
val rotatedX = dx * cosAngle - dy * sinAngle
|
|
|
|
|
val rotatedY = dx * sinAngle + dy * cosAngle
|
|
|
|
|
|
|
|
|
|
// Apply ellipse aspect ratio
|
|
|
|
|
val adjustedX = rotatedX / params.aspectRatio
|
|
|
|
|
|
|
|
|
|
sqrt(adjustedX * adjustedX + rotatedY * rotatedY)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-28 15:26:41 +01:00
|
|
|
|
2026-01-29 11:13:31 +01:00
|
|
|
// Calculate blur amount based on distance from focus region
|
2026-01-28 15:26:41 +01:00
|
|
|
val blurAmount = when {
|
2026-01-29 11:13:31 +01:00
|
|
|
dist < focusSize -> 0f
|
|
|
|
|
dist < focusSize + transitionSize -> {
|
|
|
|
|
(dist - focusSize) / transitionSize
|
2026-01-28 15:26:41 +01:00
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|