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> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=JRBI89BIE6AI5TG6" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=Y5DELZR46DZTCI9D" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

View File

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