Skip to content
Draft
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
fcb312a
Initial mediapipe segmentation
Jan 12, 2025
772a10f
Add something that currently just blurs the whole image
Jan 12, 2025
5b582fa
Working portrait mode
Jan 12, 2025
d18a13f
Use a faster blurring algorithm & refactor to apply mask at blur time
Jan 13, 2025
cdc4085
Add coroutines lib
Jan 20, 2025
7cf8cc4
Run blurring in a coroutine
Jan 20, 2025
059cfab
Fix applying the mask when blurring
Jan 20, 2025
011f2d2
Add comment for mask blurring
Jan 20, 2025
50f0324
Add portrait mode setting
Jan 26, 2025
6c58f8e
Disable portrait mode when setting off
Jan 26, 2025
d26e1e2
Clean up the SegmenterHelper to remove stream specific code
Jan 26, 2025
780c7c9
Remove image segmenter listener
Jan 26, 2025
9e92755
Ensure rotation is correct on save
Jan 26, 2025
a1d598f
unused file
Jan 26, 2025
289799b
Fix linting errors
Jan 26, 2025
54c6cd3
Merge branch 'main' into portrait-mediapipe
Jan 26, 2025
2d4ac18
Fix build errors
Jan 26, 2025
a75f673
Use proper library management
Jan 27, 2025
98ac693
Add some proguard rules
Jan 27, 2025
196dc66
Remove more dead code
Jan 27, 2025
8391197
Use compatable coroutines version
Jan 27, 2025
a2e551f
Use actual latest mediapipe task version
Jan 27, 2025
59b8191
Another proguard rule
Jan 27, 2025
ca845fa
Use actual latest mediapipe task version
Feb 2, 2025
494e349
Remove ImageSegmenterHelper.kt
Feb 3, 2025
f7e4d8f
Remove specific portrait setting with aim of using photo modes instead
Feb 12, 2025
3b580aa
Add portrait photo mode and use in photo mode plugin
Feb 12, 2025
72d86c0
Remove imports
Feb 12, 2025
d7c3630
Move to a mode for toggling portrait
Feb 12, 2025
35179f4
Move back to CPU to make mediapipe work
Feb 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .idea/gradle.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ dependencies {

// ML Kit for Barcode/QR scanning
implementation(libs.barcode.scanning)

//Mediapipe for segmentation (portrait)
implementation(libs.tasks.vision)
implementation(libs.coroutines)
}


Expand Down
10 changes: 9 additions & 1 deletion app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,12 @@

# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-renamesourcefileattribute SourceFile

-dontwarn javax.lang.model.element.Element*
-dontwarn javax.lang.model.element.ElementKind*
-dontwarn javax.lang.model.element.Modifier*
-dontwarn javax.lang.model.type.TypeMirror*
-dontwarn javax.lang.model.type.TypeVisitor*
-dontwarn javax.lang.model.util.SimpleTypeVisitor8*
-dontwarn javax.lang.model.SourceVersion*
Binary file added app/src/main/assets/selfie_segmenter.tflite
Binary file not shown.
27 changes: 24 additions & 3 deletions app/src/main/java/co/stonephone/stonecamera/StoneCameraApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,20 @@ import androidx.camera.camera2.interop.ExperimentalCamera2Interop
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FlipCameraAndroid
Expand All @@ -20,8 +33,14 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
Expand All @@ -33,6 +52,7 @@ import co.stonephone.stonecamera.plugins.FlashPlugin
import co.stonephone.stonecamera.plugins.FocusBasePlugin
import co.stonephone.stonecamera.plugins.PhotoModePlugin
import co.stonephone.stonecamera.plugins.PinchToZoomPlugin
import co.stonephone.stonecamera.plugins.PortraitModePlugin
import co.stonephone.stonecamera.plugins.QRScannerPlugin
import co.stonephone.stonecamera.plugins.SettingLocation
import co.stonephone.stonecamera.plugins.SettingsTrayPlugin
Expand All @@ -51,6 +71,7 @@ import co.stonephone.stonecamera.utils.getAllCamerasInfo
// Order here is important, they are loaded and initialised in the order they are listed
// ZoomBar depends on ZoomBase, etc.
val PLUGINS = listOf(
PortraitModePlugin(),
QRScannerPlugin(),
ZoomBasePlugin(),
ZoomBarPlugin(),
Expand Down
302 changes: 302 additions & 0 deletions app/src/main/java/co/stonephone/stonecamera/plugins/Portrait.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
package co.stonephone.stonecamera.plugins

import android.content.ContentResolver
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.ExifInterface
import android.net.Uri
import androidx.camera.core.ImageCapture
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PersonOff
import androidx.compose.material.icons.filled.Portrait
import androidx.compose.material3.Icon
import androidx.compose.ui.graphics.Color
import co.stonephone.stonecamera.MyApplication
import co.stonephone.stonecamera.StoneCameraViewModel
import co.stonephone.stonecamera.utils.ImageSegmenterHelper
import com.google.mediapipe.framework.image.BitmapImageBuilder
import com.google.mediapipe.framework.image.ByteBufferExtractor
import com.google.mediapipe.tasks.vision.imagesegmenter.ImageSegmenterResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.nio.ByteBuffer
import java.util.Objects
import kotlin.math.max
import kotlin.math.min

class PortraitModePlugin : IPlugin {
override val id: String = "portraitModePlugin"
override val name: String = "Portrait Mode"

private lateinit var imageSegmenterHelper: ImageSegmenterHelper

override fun initialize(viewModel: StoneCameraViewModel) {
imageSegmenterHelper = ImageSegmenterHelper(
context = MyApplication.getAppContext(),
currentModel = ImageSegmenterHelper.MODEL_SELFIE_SEGMENTER,
currentDelegate = ImageSegmenterHelper.DELEGATE_CPU,
)

imageSegmenterHelper.setupImageSegmenter()
}

override fun onImageSaved(
stoneCameraViewModel: StoneCameraViewModel,
outputFileResults: ImageCapture.OutputFileResults
) {
val portraitModeSetting = stoneCameraViewModel.getSetting<String>("portraitMode") ?: "OFF"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we should do this on a "mode" basis?

E.g. extend the Photo Mode plugin? Then there will be a mode option like most other camera apps?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think this would be a good idea. Will make that change


if (Objects.equals(portraitModeSetting, "OFF")) {
return
}

CoroutineScope(Dispatchers.IO).launch {

val contentResolver: ContentResolver = MyApplication.getAppContext().contentResolver
val originalImageUri = outputFileResults.savedUri ?: return@launch

val originalImage: Bitmap = getOriginalImageBitmap(contentResolver, originalImageUri)
val rotation: Int = getOriginalImageRotation(contentResolver, originalImageUri)

val segmentationResults: ImageSegmenterResult =
imageSegmenterHelper.segmentImageFile(BitmapImageBuilder(originalImage).build())
?: return@launch

// TODO Blur mask edge with https://developer.android.com/reference/android/graphics/BlurMaskFilter
val backgroundMask: ByteBuffer =
ByteBufferExtractor.extract(segmentationResults.categoryMask().get())

val blurredImage: Bitmap =
applyBlurBasedOnMask(originalImage, backgroundMask) ?: return@launch

val rotatedBitmap = matchOriginalImageRotation(blurredImage, rotation)

val outputStream = contentResolver.openOutputStream(originalImageUri, "w")
outputStream?.use {
rotatedBitmap.compress(
Bitmap.CompressFormat.JPEG,
100,
it
)
}
}
}

private fun getOriginalImageBitmap(contentResolver: ContentResolver, imageUri: Uri): Bitmap {
val inputStream = contentResolver.openInputStream(imageUri)
val bitmap: Bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()

return bitmap
}

private fun getOriginalImageRotation(contentResolver: ContentResolver, imageUri: Uri): Int {
val exifInputStream = contentResolver.openInputStream(imageUri)
val exif = ExifInterface(exifInputStream!!)
val rotation = when (exif.getAttributeInt(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be more global for all modes? Perhaps I'm mis-understanding

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The image was being spat out sideways after blurring for some reason. No objection to making it more global

ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90
ExifInterface.ORIENTATION_ROTATE_180 -> 180
ExifInterface.ORIENTATION_ROTATE_270 -> 270
else -> 0
}
exifInputStream.close()

return rotation
}

private fun applyBlurBasedOnMask(originalImage: Bitmap, categoryMask: ByteBuffer): Bitmap? {
categoryMask.rewind()

val blurredBitmap = fastBlur(originalImage, categoryMask, 25)

return blurredBitmap
}

private fun matchOriginalImageRotation(image: Bitmap, rotation: Int): Bitmap {
if (rotation == 0) {
return image;
}
val matrix = Matrix().apply { postRotate(rotation.toFloat()) }
return Bitmap.createBitmap(
image,
0,
0,
image.width,
image.height,
matrix,
true
)
}

// Stolen from https://stackoverflow.com/questions/21418892/understanding-super-fast-blur-algorithm?fbclid=IwZXh0bgNhZW0CMTEAAR1w91ucNtw4nU-Z8Z9RyMYFVUHWxfgt7ivsE7foTkwR2wmdx2losQqQ0sk_aem_Zrf_8344PRxW6SFzutkE7g
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Borrowed from" 😉

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, isn't there a way we can shift this onto a shader?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not aware of shaders. will take a look

// Edited to apply the blur only on the mask
private fun fastBlur(original: Bitmap, mask: ByteBuffer, radius: Int): Bitmap {
if (radius < 1) {
return original
}

val img = original.copy(original.config, true)

val w = img.width
val h = img.height
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 p1: Int
var p2: Int
var yp: Int
var yi: Int
val vmin = IntArray(max(w.toDouble(), h.toDouble()).toInt())
val vmax = IntArray(max(w.toDouble(), h.toDouble()).toInt())
val pix = IntArray(w * h)

val intMask = IntArray(w * h);

var maskIndex = 0;
while (mask.hasRemaining()) {
intMask[maskIndex] = mask.get().toInt()
maskIndex += 1
}

img.getPixels(pix, 0, w, 0, 0, w, h)

val dv = IntArray(256 * div)
i = 0
while (i < 256 * div) {
dv[i] = i / div
i++
}

var yw = 0.also { yi = it }

y = 0
while (y < h) {
bsum = 0
gsum = bsum
rsum = gsum
i = -radius
while (i <= radius) {
p = pix[(yi + min(wm.toDouble(), max(i.toDouble(), 0.0))).toInt()]
rsum += (p and 0xff0000) shr 16
gsum += (p and 0x00ff00) shr 8
bsum += p and 0x0000ff
i++
}
x = 0
while (x < w) {
r[yi] = dv[rsum]
g[yi] = dv[gsum]
b[yi] = dv[bsum]

if (y == 0) {
vmin[x] = min((x + radius + 1).toDouble(), wm.toDouble()).toInt()
vmax[x] = max((x - radius).toDouble(), 0.0).toInt()
}
p1 = pix[yw + vmin[x]]
p2 = pix[yw + vmax[x]]

rsum += (p1 and 0xff0000) - (p2 and 0xff0000) shr 16
gsum += (p1 and 0x00ff00) - (p2 and 0x00ff00) shr 8
bsum += (p1 and 0x0000ff) - (p2 and 0x0000ff)
yi++
x++
}
yw += w
y++
}

x = 0
while (x < w) {
bsum = 0
gsum = bsum
rsum = gsum
yp = -radius * w
i = -radius
while (i <= radius) {
yi = (max(0.0, yp.toDouble()) + x).toInt()
rsum += r[yi]
gsum += g[yi]
bsum += b[yi]
yp += w
i++
}
yi = x
y = 0
while (y < h) {
//TODO confirm what the mask values are (as could be multiple categories)
if (intMask[yi] != 0) {
pix[yi] =
-0x1000000 or (dv.get(rsum) shl 16) or (dv.get(gsum) shl 8) or dv.get(bsum)
}
if (x == 0) {
vmin[y] = (min((y + radius + 1).toDouble(), hm.toDouble()) * w).toInt()
vmax[y] = (max((y - radius).toDouble(), 0.0) * w).toInt()
}
p1 = x + vmin[y]
p2 = x + vmax[y]

rsum += r[p1] - r[p2]
gsum += g[p1] - g[p2]
bsum += b[p1] - b[p2]
yi += w
y++
}
x++
}

img.setPixels(pix, 0, w, 0, 0, w, h)

return img
}

override val settings: (StoneCameraViewModel) -> List<PluginSetting> = { viewModel ->
listOf(
PluginSetting.EnumSetting(
key = "portraitMode",
defaultValue = "OFF",
options = listOf("OFF", "ON"),
render = { isEnabled, isSelected ->
Icon(
imageVector = when (isEnabled) {
"OFF" -> Icons.Default.PersonOff
"ON" -> Icons.Default.Portrait
else -> {
Icons.Default.PersonOff
}
},
contentDescription = when (isEnabled) {
"OFF" -> "Portrait Mode Off"
"ON" -> "Portrait Mode On"
else -> {
"Portrait Mode Off"
}
},
tint = if (isSelected) Color(0xFFFFCC00) else Color.White
)

},
onChange = { viewModel, value ->
viewModel.recreateUseCases()
},
renderLocation = SettingLocation.TOP,
label = "Portrait Mode"
)
)
}
}
Loading