Dev:
1、加入两套抠图方案:本地和服务器 2、抠图规则添加先服务器后本地(服务器失败会使用本地,同事可以选择只使用本地抠图)
This commit is contained in:
parent
1f1cb1ba55
commit
5f527ca9d7
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue