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>
This commit is contained in:
parent
5b9aedd109
commit
88d04515e2
5 changed files with 579 additions and 501 deletions
|
|
@ -18,6 +18,7 @@ import no.naiv.tiltshift.effect.BlurMode
|
||||||
import no.naiv.tiltshift.effect.BlurParameters
|
import no.naiv.tiltshift.effect.BlurParameters
|
||||||
import no.naiv.tiltshift.storage.PhotoSaver
|
import no.naiv.tiltshift.storage.PhotoSaver
|
||||||
import no.naiv.tiltshift.storage.SaveResult
|
import no.naiv.tiltshift.storage.SaveResult
|
||||||
|
import no.naiv.tiltshift.util.StackBlur
|
||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
|
|
@ -386,7 +387,7 @@ class ImageCaptureHandler(
|
||||||
|
|
||||||
scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
|
scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
|
||||||
|
|
||||||
blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25))
|
blurred = StackBlur.blur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25))
|
||||||
scaled.recycle()
|
scaled.recycle()
|
||||||
scaled = null
|
scaled = null
|
||||||
|
|
||||||
|
|
@ -530,230 +531,4 @@ class ImageCaptureHandler(
|
||||||
return fullPixels
|
return fullPixels
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,16 @@
|
||||||
package no.naiv.tiltshift.ui
|
package no.naiv.tiltshift.ui
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.SurfaceTexture
|
import android.graphics.SurfaceTexture
|
||||||
import android.opengl.GLSurfaceView
|
import android.opengl.GLSurfaceView
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.systemGestureExclusion
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
|
@ -29,24 +26,21 @@ import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.systemGestureExclusion
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.PickVisualMediaRequest
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.FlipCameraAndroid
|
import androidx.compose.material.icons.filled.FlipCameraAndroid
|
||||||
import androidx.compose.material.icons.filled.PhotoLibrary
|
|
||||||
import androidx.compose.material.icons.filled.RestartAlt
|
|
||||||
import androidx.compose.material.icons.filled.LocationOff
|
import androidx.compose.material.icons.filled.LocationOff
|
||||||
import androidx.compose.material.icons.filled.LocationOn
|
import androidx.compose.material.icons.filled.LocationOn
|
||||||
|
import androidx.compose.material.icons.filled.PhotoLibrary
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Slider
|
|
||||||
import androidx.compose.material3.SliderDefaults
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
|
@ -65,10 +59,9 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.semantics.LiveRegionMode
|
import androidx.compose.ui.semantics.LiveRegionMode
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.liveRegion
|
import androidx.compose.ui.semantics.liveRegion
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.semantics.role
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.stateDescription
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -79,7 +72,6 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import no.naiv.tiltshift.effect.BlurMode
|
|
||||||
import no.naiv.tiltshift.effect.BlurParameters
|
import no.naiv.tiltshift.effect.BlurParameters
|
||||||
import no.naiv.tiltshift.effect.TiltShiftRenderer
|
import no.naiv.tiltshift.effect.TiltShiftRenderer
|
||||||
import no.naiv.tiltshift.ui.theme.AppColors
|
import no.naiv.tiltshift.ui.theme.AppColors
|
||||||
|
|
@ -608,265 +600,3 @@ fun CameraScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mode toggle for Linear / Radial blur modes.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ModeToggle(
|
|
||||||
currentMode: BlurMode,
|
|
||||||
onModeChange: (BlurMode) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = modifier
|
|
||||||
.clip(RoundedCornerShape(20.dp))
|
|
||||||
.background(AppColors.OverlayDark)
|
|
||||||
.padding(4.dp),
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
ModeButton(
|
|
||||||
text = "Linear",
|
|
||||||
isSelected = currentMode == BlurMode.LINEAR,
|
|
||||||
onClick = { onModeChange(BlurMode.LINEAR) }
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
ModeButton(
|
|
||||||
text = "Radial",
|
|
||||||
isSelected = currentMode == BlurMode.RADIAL,
|
|
||||||
onClick = { onModeChange(BlurMode.RADIAL) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ModeButton(
|
|
||||||
text: String,
|
|
||||||
isSelected: Boolean,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(if (isSelected) AppColors.Accent else Color.Transparent)
|
|
||||||
.clickable(role = Role.Button, onClick = onClick)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
|
||||||
.semantics {
|
|
||||||
stateDescription = if (isSelected) "Selected" else "Not selected"
|
|
||||||
contentDescription = "$text blur mode"
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
color = if (isSelected) Color.Black else Color.White,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Control panel with sliders for blur parameters.
|
|
||||||
* Includes position/size/angle sliders as gesture alternatives for accessibility.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ControlPanel(
|
|
||||||
params: BlurParameters,
|
|
||||||
onParamsChange: (BlurParameters) -> Unit,
|
|
||||||
onReset: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val currentParams by rememberUpdatedState(params)
|
|
||||||
val currentOnParamsChange by rememberUpdatedState(onParamsChange)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.width(200.dp)
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(AppColors.OverlayDarker)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
// Reset button
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.End
|
|
||||||
) {
|
|
||||||
IconButton(
|
|
||||||
onClick = onReset,
|
|
||||||
modifier = Modifier.size(32.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.RestartAlt,
|
|
||||||
contentDescription = "Reset all parameters to defaults",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blur intensity slider
|
|
||||||
SliderControl(
|
|
||||||
label = "Blur",
|
|
||||||
value = params.blurAmount,
|
|
||||||
valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR,
|
|
||||||
formatValue = { "${(it * 100).toInt()}%" },
|
|
||||||
onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Falloff slider
|
|
||||||
SliderControl(
|
|
||||||
label = "Falloff",
|
|
||||||
value = params.falloff,
|
|
||||||
valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF,
|
|
||||||
formatValue = { "${(it * 100).toInt()}%" },
|
|
||||||
onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Size slider (gesture alternative for pinch-to-resize)
|
|
||||||
SliderControl(
|
|
||||||
label = "Size",
|
|
||||||
value = params.size,
|
|
||||||
valueRange = BlurParameters.MIN_SIZE..BlurParameters.MAX_SIZE,
|
|
||||||
formatValue = { "${(it * 100).toInt()}%" },
|
|
||||||
onValueChange = { currentOnParamsChange(currentParams.copy(size = it)) }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Aspect ratio slider (radial mode only)
|
|
||||||
if (params.mode == BlurMode.RADIAL) {
|
|
||||||
SliderControl(
|
|
||||||
label = "Shape",
|
|
||||||
value = params.aspectRatio,
|
|
||||||
valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT,
|
|
||||||
formatValue = { "%.1f:1".format(it) },
|
|
||||||
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Angle slider (gesture alternative for two-finger rotate)
|
|
||||||
SliderControl(
|
|
||||||
label = "Angle",
|
|
||||||
value = params.angle,
|
|
||||||
valueRange = (-Math.PI.toFloat())..Math.PI.toFloat(),
|
|
||||||
formatValue = { "${(it * 180f / Math.PI.toFloat()).toInt()}°" },
|
|
||||||
onValueChange = { currentOnParamsChange(currentParams.copy(angle = it)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SliderControl(
|
|
||||||
label: String,
|
|
||||||
value: Float,
|
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
|
||||||
formatValue: (Float) -> String = { "${(it * 100).toInt()}%" },
|
|
||||||
onValueChange: (Float) -> Unit
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 12.sp
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = formatValue(value),
|
|
||||||
color = Color.White.copy(alpha = 0.7f),
|
|
||||||
fontSize = 12.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Slider(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
valueRange = valueRange,
|
|
||||||
colors = SliderDefaults.colors(
|
|
||||||
thumbColor = AppColors.Accent,
|
|
||||||
activeTrackColor = AppColors.Accent,
|
|
||||||
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
|
||||||
.height(24.dp)
|
|
||||||
.semantics { contentDescription = "$label: ${formatValue(value)}" }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Capture button with processing indicator.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun CaptureButton(
|
|
||||||
isCapturing: Boolean,
|
|
||||||
isProcessing: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val outerSize = 72.dp
|
|
||||||
val innerSize = if (isCapturing) 48.dp else 60.dp
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.size(outerSize)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.border(4.dp, Color.White, CircleShape)
|
|
||||||
.clickable(
|
|
||||||
enabled = !isCapturing,
|
|
||||||
role = Role.Button,
|
|
||||||
onClick = onClick
|
|
||||||
)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = "Capture photo with tilt-shift effect"
|
|
||||||
if (isCapturing) stateDescription = "Processing photo"
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(innerSize)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(if (isCapturing) AppColors.Accent else Color.White),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
if (isProcessing) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
color = Color.Black,
|
|
||||||
strokeWidth = 3.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rounded thumbnail of the last captured photo.
|
|
||||||
* Tapping opens the image in the default photo viewer.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun LastPhotoThumbnail(
|
|
||||||
thumbnail: Bitmap?,
|
|
||||||
onTap: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = thumbnail != null,
|
|
||||||
enter = fadeIn() + scaleIn(initialScale = 0.6f),
|
|
||||||
exit = fadeOut(),
|
|
||||||
modifier = modifier
|
|
||||||
) {
|
|
||||||
thumbnail?.let { bmp ->
|
|
||||||
Image(
|
|
||||||
bitmap = bmp.asImageBitmap(),
|
|
||||||
contentDescription = "Last captured photo. Tap to open in viewer.",
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(52.dp)
|
|
||||||
.clip(RoundedCornerShape(10.dp))
|
|
||||||
.border(2.dp, Color.White, RoundedCornerShape(10.dp))
|
|
||||||
.clickable(role = Role.Button, onClick = onTap)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
107
app/src/main/java/no/naiv/tiltshift/ui/CaptureControls.kt
Normal file
107
app/src/main/java/no/naiv/tiltshift/ui/CaptureControls.kt
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
package no.naiv.tiltshift.ui
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import no.naiv.tiltshift.ui.theme.AppColors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture button with processing indicator.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CaptureButton(
|
||||||
|
isCapturing: Boolean,
|
||||||
|
isProcessing: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val outerSize = 72.dp
|
||||||
|
val innerSize = if (isCapturing) 48.dp else 60.dp
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(outerSize)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.border(4.dp, Color.White, CircleShape)
|
||||||
|
.clickable(
|
||||||
|
enabled = !isCapturing,
|
||||||
|
role = Role.Button,
|
||||||
|
onClick = onClick
|
||||||
|
)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = "Capture photo with tilt-shift effect"
|
||||||
|
if (isCapturing) stateDescription = "Processing photo"
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(innerSize)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (isCapturing) AppColors.Accent else Color.White),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (isProcessing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = Color.Black,
|
||||||
|
strokeWidth = 3.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rounded thumbnail of the last captured photo.
|
||||||
|
* Tapping opens the image in the default photo viewer.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LastPhotoThumbnail(
|
||||||
|
thumbnail: Bitmap?,
|
||||||
|
onTap: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = thumbnail != null,
|
||||||
|
enter = fadeIn() + scaleIn(initialScale = 0.6f),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
thumbnail?.let { bmp ->
|
||||||
|
Image(
|
||||||
|
bitmap = bmp.asImageBitmap(),
|
||||||
|
contentDescription = "Last captured photo. Tap to open in viewer.",
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(52.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.border(2.dp, Color.White, RoundedCornerShape(10.dp))
|
||||||
|
.clickable(role = Role.Button, onClick = onTap)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt
Normal file
218
app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
package no.naiv.tiltshift.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.RestartAlt
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.SliderDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import no.naiv.tiltshift.effect.BlurMode
|
||||||
|
import no.naiv.tiltshift.effect.BlurParameters
|
||||||
|
import no.naiv.tiltshift.ui.theme.AppColors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mode toggle for Linear / Radial blur modes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ModeToggle(
|
||||||
|
currentMode: BlurMode,
|
||||||
|
onModeChange: (BlurMode) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(AppColors.OverlayDark)
|
||||||
|
.padding(4.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
ModeButton(
|
||||||
|
text = "Linear",
|
||||||
|
isSelected = currentMode == BlurMode.LINEAR,
|
||||||
|
onClick = { onModeChange(BlurMode.LINEAR) }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
ModeButton(
|
||||||
|
text = "Radial",
|
||||||
|
isSelected = currentMode == BlurMode.RADIAL,
|
||||||
|
onClick = { onModeChange(BlurMode.RADIAL) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ModeButton(
|
||||||
|
text: String,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(if (isSelected) AppColors.Accent else Color.Transparent)
|
||||||
|
.clickable(role = Role.Button, onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
.semantics {
|
||||||
|
stateDescription = if (isSelected) "Selected" else "Not selected"
|
||||||
|
contentDescription = "$text blur mode"
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = if (isSelected) Color.Black else Color.White,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control panel with sliders for blur parameters.
|
||||||
|
* Includes position/size/angle sliders as gesture alternatives for accessibility.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ControlPanel(
|
||||||
|
params: BlurParameters,
|
||||||
|
onParamsChange: (BlurParameters) -> Unit,
|
||||||
|
onReset: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val currentParams by rememberUpdatedState(params)
|
||||||
|
val currentOnParamsChange by rememberUpdatedState(onParamsChange)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.width(200.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(AppColors.OverlayDarker)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
// Reset button
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onReset,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.RestartAlt,
|
||||||
|
contentDescription = "Reset all parameters to defaults",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderControl(
|
||||||
|
label = "Blur",
|
||||||
|
value = params.blurAmount,
|
||||||
|
valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR,
|
||||||
|
formatValue = { "${(it * 100).toInt()}%" },
|
||||||
|
onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
SliderControl(
|
||||||
|
label = "Falloff",
|
||||||
|
value = params.falloff,
|
||||||
|
valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF,
|
||||||
|
formatValue = { "${(it * 100).toInt()}%" },
|
||||||
|
onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
SliderControl(
|
||||||
|
label = "Size",
|
||||||
|
value = params.size,
|
||||||
|
valueRange = BlurParameters.MIN_SIZE..BlurParameters.MAX_SIZE,
|
||||||
|
formatValue = { "${(it * 100).toInt()}%" },
|
||||||
|
onValueChange = { currentOnParamsChange(currentParams.copy(size = it)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (params.mode == BlurMode.RADIAL) {
|
||||||
|
SliderControl(
|
||||||
|
label = "Shape",
|
||||||
|
value = params.aspectRatio,
|
||||||
|
valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT,
|
||||||
|
formatValue = { "%.1f:1".format(it) },
|
||||||
|
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderControl(
|
||||||
|
label = "Angle",
|
||||||
|
value = params.angle,
|
||||||
|
valueRange = (-Math.PI.toFloat())..Math.PI.toFloat(),
|
||||||
|
formatValue = { "${(it * 180f / Math.PI.toFloat()).toInt()}°" },
|
||||||
|
onValueChange = { currentOnParamsChange(currentParams.copy(angle = it)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SliderControl(
|
||||||
|
label: String,
|
||||||
|
value: Float,
|
||||||
|
valueRange: ClosedFloatingPointRange<Float>,
|
||||||
|
formatValue: (Float) -> String = { "${(it * 100).toInt()}%" },
|
||||||
|
onValueChange: (Float) -> Unit
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatValue(value),
|
||||||
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
valueRange = valueRange,
|
||||||
|
colors = SliderDefaults.colors(
|
||||||
|
thumbColor = AppColors.Accent,
|
||||||
|
activeTrackColor = AppColors.Accent,
|
||||||
|
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.height(24.dp)
|
||||||
|
.semantics { contentDescription = "$label: ${formatValue(value)}" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
248
app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt
Normal file
248
app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
package no.naiv.tiltshift.util
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast stack blur algorithm for CPU-based bitmap blurring.
|
||||||
|
*
|
||||||
|
* Used by the capture/gallery pipeline where GPU shaders aren't available.
|
||||||
|
* Stack blur is an approximation of Gaussian blur that runs in O(n) per pixel
|
||||||
|
* regardless of radius, making it suitable for large images.
|
||||||
|
*/
|
||||||
|
object StackBlur {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies stack blur to a bitmap and returns a new blurred bitmap.
|
||||||
|
* The source bitmap is not modified.
|
||||||
|
*
|
||||||
|
* @param bitmap Source bitmap to blur.
|
||||||
|
* @param radius Blur radius (1-25). Larger = more blur.
|
||||||
|
* @return A new blurred bitmap. The caller owns it and must recycle it.
|
||||||
|
*/
|
||||||
|
fun blur(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
|
||||||
|
|
||||||
|
// Horizontal pass
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical pass
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue