1、加入两套抠图方案:本地和服务器
2、抠图规则添加先服务器后本地(服务器失败会使用本地,同事可以选择只使用本地抠图)
This commit is contained in:
shenzuqiang 2026-03-12 17:34:22 +08:00
parent 1f1cb1ba55
commit 5f527ca9d7
5 changed files with 263 additions and 51 deletions

View File

@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-03-12T07:23:49.466247900Z">
<DropdownSelection timestamp="2026-03-12T08:19:46.116780300Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=JRBI89BIE6AI5TG6" />
<DeviceId pluginId="PhysicalDevice" identifier="serial=Y5DELZR46DZTCI9D" />
</handle>
</Target>
</DropdownSelection>

View File

@ -11,6 +11,12 @@ import android.os.Build
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@ -37,6 +43,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@ -53,6 +60,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
@ -63,6 +71,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.toSize
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import coil3.compose.AsyncImage
@ -92,6 +101,7 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import com.img.rabbit.utils.PhotoCutter
import com.img.rabbit.viewmodel.CutoutViewModel
import kotlinx.coroutines.launch
@ -126,7 +136,7 @@ object ResourceManager {
}
@Composable
fun CutoutScreen(navController: NavController) {
fun CutoutScreen(navController: NavController, viewModel: CutoutViewModel = viewModel()) {
val context = LocalContext.current
// 图片显示区域 - 支持头像编辑(用于最终数据的导出保存Bitmap)
val graphicsLayer = rememberGraphicsLayer()
@ -185,6 +195,8 @@ fun CutoutScreen(navController: NavController) {
Manifest.permission.WRITE_EXTERNAL_STORAGE // 旧版本使用常规存储权限
}
)
// 抠图方式是否从服务器抠图
val isLoadingCutoutMethodFromService = true
// 图片选择启动器
val imagePickerLauncher = rememberLauncherForActivityResult(
@ -193,37 +205,45 @@ fun CutoutScreen(navController: NavController) {
uri?.let {
selectedImageUri.value = it
isLoading.value = true
// 执行抠图操作
Thread {
try {
val originalBitmap = ImageUtils.getBitmapFromUri(context, it)
originalBitmap?.let { bitmap ->
PhotoCutter.cutPureHead(bitmap) { croppedBitmap ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
context.mainExecutor.execute {
cutoutResultBitmap.value = croppedBitmap
isLoading.value = false
// 重置头像变换
headTransform.value = TransformState()
}
if(isLoadingCutoutMethodFromService){
// 服务器完成抠图后,再执行本地加载
viewModel.cutoutImageFromService(context, it){isSuccess, croppedBitmap ->
if (isSuccess) {
cutoutResultBitmap.value = croppedBitmap
// 重置头像变换
headTransform.value = TransformState()
isLoading.value = false
} else {
viewModel.cutoutImageFromLocal(context, it) { isSuccess, croppedBitmap ->
if (isSuccess) {
cutoutResultBitmap.value = croppedBitmap
// 重置头像变换
headTransform.value = TransformState()
} else {
CenterToast.show("抠图失败,请重试")
}
}
}
} catch (e: Exception) {
Log.e("CutoutScreen", "抠图失败: ${e.message}")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
context.mainExecutor.execute {
isLoading.value = false
CenterToast.show("抠图失败,请重试")
}
}
}
}.start()
}else{
// 本地完成抠图后,再执行加载
viewModel.cutoutImageFromLocal(context, it) { isSuccess, croppedBitmap ->
if (isSuccess) {
cutoutResultBitmap.value = croppedBitmap
// 重置头像变换
headTransform.value = TransformState()
} else {
CenterToast.show("抠图失败,请重试")
}
isLoading.value = false
}
}
}
}
// 加载服装资源
fun loadClothingResource(clothing: ClothingBean?) {
if(clothing?.clothing == null){
@ -261,18 +281,23 @@ fun CutoutScreen(navController: NavController) {
imagePickerLauncher.launch("image/*")
}
Column(
modifier = Modifier.fillMaxSize().background(Color(0xFFF4F4F4)).navigationBarsPadding()
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF4F4F4))
.navigationBarsPadding()
) {
TitleBar(navController = navController, paddingValues = it, title = "", showSave = true){
// 保存图片
coroutineScope.launch {
// 从 Layer 捕获 Bitmap
val bitmap = graphicsLayer.toImageBitmap().asAndroidBitmap()
// // 保存图片到系统相册(图片已按比例裁剪)
// saveCanvasToGallery(context, bitmap, ExportFormat.JPG){fileName, isSuccess ->
// val tips = if(isSuccess){ "已保存为: $fileName" }else{ "保存失败" }
// CenterToast.show(tips)
// }
// 保存图片到系统相册(图片已按比例裁剪)
/*
saveCanvasToGallery(context, bitmap, ExportFormat.JPG){fileName, isSuccess ->
val tips = if(isSuccess){ "已保存为: $fileName" }else{ "保存失败" }
CenterToast.show(tips)
}
*/
/*
// 保存图片到系统相册(指定尺寸如果targetWidth与targetHeight比原始值小太多会导致图片模糊)
@ -321,21 +346,37 @@ fun CutoutScreen(navController: NavController) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(start = 38.dp, end = 38.dp
.padding(
start = 38.dp, end = 38.dp
)
.aspectRatio(selectedSize.value.width/selectedSize.value.height)
.aspectRatio(selectedSize.value.width / selectedSize.value.height)
.background(Color(0xFFFFFFFF))
.border(1.dp, Color(0xFFD8D8D8))
) {
if (isLoading.value) {
// 1. 定义动画
val infiniteTransition =
rememberInfiniteTransition(label = "loading")
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "rotation"
)
// 加载中状态
Column(
modifier = Modifier.align(Alignment.Center)
) {
// 2. 应用旋转
Image(
painter = painterResource(id = R.mipmap.ic_loading),
contentDescription = "加载效果图片",
modifier = Modifier.size(48.dp)
modifier = Modifier
.size(48.dp)
.graphicsLayer { rotationZ = rotation } // 让图片转起来
)
Text(
text = "正在抠图中...",
@ -366,12 +407,28 @@ fun CutoutScreen(navController: NavController) {
// 1. 修改判定逻辑:只有当服装可见时,才进行它的 hitTest
selectedTarget = when {
// 处理发型
viewHairstyle.value == ViewType.VISIBLE && hitTest(down.position, hairTransform.value, selectedHairstyleBitmap.value, center) -> "HAIR"
viewHairstyle.value == ViewType.VISIBLE && hitTest(
down.position,
hairTransform.value,
selectedHairstyleBitmap.value,
center
) -> "HAIR"
// 处理服装
viewClothing.value == ViewType.VISIBLE && hitTest(down.position, clothesTransform.value, selectedClothingBitmap.value, center) -> "CLOTHES"
viewClothing.value == ViewType.VISIBLE && hitTest(
down.position,
clothesTransform.value,
selectedClothingBitmap.value,
center
) -> "CLOTHES"
hitTest(
down.position,
headTransform.value,
cutoutResultBitmap.value,
center
) -> "HEAD"
hitTest(down.position, headTransform.value, cutoutResultBitmap.value, center) -> "HEAD"
else -> null
}
}
@ -381,12 +438,12 @@ fun CutoutScreen(navController: NavController) {
detectTransformGestures { _, pan, zoom, rotation ->
selectedTarget?.let { target ->
// 更新逻辑保持不变...
val stateRef = when(target) {
val stateRef = when (target) {
"HEAD" -> headTransform
"CLOTHES" -> clothesTransform
else -> hairTransform
}
val bmp = when(target) {
val bmp = when (target) {
"HEAD" -> cutoutResultBitmap.value
"CLOTHES" -> selectedClothingBitmap.value
else -> selectedHairstyleBitmap.value
@ -394,7 +451,8 @@ fun CutoutScreen(navController: NavController) {
bmp?.let { b ->
val current = stateRef.value
val newScale = (current.scale * zoom).coerceIn(0.1f, 5f)
val newScale =
(current.scale * zoom).coerceIn(0.1f, 5f)
stateRef.value = current.copy(
offset = current.offset + pan,
scale = newScale,
@ -458,12 +516,14 @@ fun CutoutScreen(navController: NavController) {
} else {
// 空状态
Column(
modifier = Modifier.align(Alignment.Center).clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
){
imagePickerLauncher.launch("image/*")
}
modifier = Modifier
.align(Alignment.Center)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
imagePickerLauncher.launch("image/*")
}
) {
Image(
painter = painterResource(id = R.mipmap.ic_image_empty_pld),
@ -491,7 +551,10 @@ fun CutoutScreen(navController: NavController) {
if (selectedAppearance.value == AppearanceType.CLOTHING || selectedAppearance.value == AppearanceType.HAIRSTYLE) {
// 服装和发型查看
Row(
modifier = Modifier.fillMaxWidth().weight(1f).padding(start = 16.dp)
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(start = 16.dp)
) {
ViewOption(
viewType = viewClothing.value,
@ -500,7 +563,9 @@ fun CutoutScreen(navController: NavController) {
viewClothing.value = if (viewClothing.value == ViewType.HIDE) ViewType.VISIBLE else ViewType.HIDE
}
)
Box(modifier = Modifier.wrapContentSize().padding(end = 8.dp))
Box(modifier = Modifier
.wrapContentSize()
.padding(end = 8.dp))
ViewOption(
viewType = viewHairstyle.value,
title = "发型",
@ -511,7 +576,10 @@ fun CutoutScreen(navController: NavController) {
}
} else {
Row(
modifier = Modifier.fillMaxWidth().weight(1f).padding(start = 16.dp)
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(start = 16.dp)
) {}
}
@ -615,7 +683,7 @@ private fun ViewOption(
)
.border(
width = 1.dp,
color = if(viewType == ViewType.VISIBLE) Color(0xFF000000) else Color(0xFFEDEDED),
color = if (viewType == ViewType.VISIBLE) Color(0xFF000000) else Color(0xFFEDEDED),
shape = RoundedCornerShape(359.dp)
)
.clickable(
@ -653,7 +721,6 @@ private fun ViewOption(
}
}
enum class ViewType {
HIDE,
VISIBLE

View File

@ -0,0 +1,136 @@
package com.img.rabbit.viewmodel
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import com.img.rabbit.bean.local.ErrorBean
import com.img.rabbit.provider.api.ApiManager
import com.img.rabbit.utils.DownLoadUtils
import com.img.rabbit.utils.FileUtils
import com.img.rabbit.utils.ImageUtils
import com.img.rabbit.utils.PhotoCutter
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
import java.io.File
import java.io.IOException
import kotlin.text.ifEmpty
class CutoutViewModel : BaseViewModel() {
private val targetPath = FileUtils.instance?.cacheImageDir?.absolutePath?:""
private val targetFileName = "cutoutCache.png"
// 错误状态
val errorState = mutableStateOf<ErrorBean?>(null)
// 本地抠图
fun cutoutImageFromLocal(context: Context, uri: Uri, onResult: (isSuccess: Boolean, croppedBitmap: Bitmap?) -> Unit) {
// 执行抠图操作
Thread {
try {
val originalBitmap = ImageUtils.getBitmapFromUri(context, uri)
originalBitmap?.let { bitmap ->
PhotoCutter.cutPureHead(bitmap) { croppedBitmap ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
context.mainExecutor.execute {
onResult(true, croppedBitmap)
}
}
}
}
} catch (e: Exception) {
Log.e("CutoutScreen", "抠图失败: ${e.message}")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
context.mainExecutor.execute {
onResult(false, null)
}
}
}
}.start()
}
//服务器抠图
fun cutoutImageFromService(context: Context, uri: Uri, onResult: (isSuccess: Boolean, croppedBitmap: Bitmap?) -> Unit) {
mLaunch {
try {
// 1. 获取并压缩图片为 PNG (后端示例中使用了 .png可能只支持 PNG)
val bitmap = ImageUtils.getBitmapFromUri(context, uri) ?: throw IOException("无法获取图片")
val outputStream = java.io.ByteArrayOutputStream()
// PNG 是无损的,质量参数 100 即可
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
val compressedBytes = outputStream.toByteArray()
bitmap.recycle()
// 2. MediaType 修改为 image/png
val requestFile = RequestBody.create("image/png".toMediaTypeOrNull(), compressedBytes)
// 3. 构造 MultipartBody.Part关键强制给文件名加上 .png 后缀
// 不要直接用 uri.lastPathSegment除非你能确定它带后缀
val fileName = "cutout_${System.currentTimeMillis()}.png"
val filePart = MultipartBody.Part.createFormData("file", fileName, requestFile)
// 4. 发起请求
val response = ApiManager.serviceVo.cutoutImage(filePart)
if (response.status) {
Log.i("CutoutViewModel", "抠图成功: ${response.data}")
val resultUrl = response.data.toString()
// 将 http 替换为 https
//val resultUrl = response.data.toString().replace("http://", "https://")
saveUrlToCache(resultUrl, onResult)
} else {
errorState.value = ErrorBean(response.code.toString(), response.message.ifEmpty { "抠图失败" })
onResult(false, null)
}
} catch (e: Exception) { // 捕获更广泛的异常
e.printStackTrace()
Log.e("CutoutViewModel", "服务请求异常: ${e.message}")
onResult(false, null)
}
}
}
private fun saveUrlToCache(url: String, onResult: (isSuccess: Boolean, croppedBitmap: Bitmap?) -> Unit) {
try {
val targetFile = File(targetPath,targetFileName)
if(targetFile.exists()){
targetFile.delete()
}
DownLoadUtils.getInstance()
.initUrl(url, null)
.setFilePath(targetPath)
.setFileName(targetFileName)
.setDownCallBack(object : DownLoadUtils.DownCallBack {
override fun success(file: File) {
// 3. 下载成功后,将文件解析为 Bitmap 并回调给 UI
val bitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath)
onResult(true, bitmap)
}
override fun fail(str: String) {
onResult(false, null)
}
override fun progress(position: Long) {
// 如果需要进度可以在这里处理
}
override fun total(total: Long) {
// 获取总大小
}
override fun cancel() {
onResult(false, null)
}
})
.down()
}catch (e: Exception){
e.printStackTrace()
onResult(false, null)
}
}
}

View File

@ -115,4 +115,12 @@ interface ServiceVo {
//事件上报
@POST("/api/user/event")
suspend fun report(@Body requestBody: RequestBody): ResultVo<Any>
/**
* 图片扣取
*/
@Multipart
@POST("/api/image/segment")
suspend fun cutoutImage(@Part part: MultipartBody.Part): ResultVo<Any>
}

View File

@ -2,5 +2,6 @@
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">enrichgw.10010.com</domain>
<domain includeSubdomains="true">aliyuncs.com</domain>
</domain-config>
</network-security-config>