feat(puzzle merge): add 2x2 bitmap merge utility and UI integration; add AirTest test_puzzle_merge
This commit is contained in:
@@ -2,485 +2,37 @@ package com.inspection.camera.ui.merge
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
// java.io.File and FileOutputStream will be referenced with fully qualified names to avoid ambiguity
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
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.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
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.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
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.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.compose.AsyncImage
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import coil.request.ImageRequest
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import com.inspection.camera.data.PreferencesManager
|
import com.inspection.camera.util.PuzzleMerge
|
||||||
import com.inspection.camera.data.models.ImageItem
|
|
||||||
import com.inspection.camera.data.models.ImageQuality
|
|
||||||
import com.inspection.camera.data.models.MergeLayoutType
|
|
||||||
import com.inspection.camera.data.models.WatermarkStyle
|
|
||||||
import com.inspection.camera.ui.theme.Primary
|
|
||||||
import com.inspection.camera.util.ImageProcessor
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MergeScreen(
|
fun MergeScreen(imageUris: List<Uri>) {
|
||||||
imageUris: List<Uri>,
|
|
||||||
onNavigateBack: () -> Unit,
|
|
||||||
onMergeComplete: (Uri) -> Unit,
|
|
||||||
preferencesManager: PreferencesManager
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
var mergedBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
|
|
||||||
val images = remember { mutableStateListOf<Uri>().apply { addAll(imageUris) } }
|
LaunchedEffect(imageUris) {
|
||||||
var layoutType by remember { mutableStateOf(MergeLayoutType.Grid2x2) }
|
mergedBitmap = PuzzleMerge.mergeToBitmap(context, imageUris.take(4), 1000)
|
||||||
var imageQuality by remember { mutableStateOf(ImageQuality.Standard) }
|
|
||||||
var titleStyle by remember { mutableStateOf(WatermarkStyle.Default) }
|
|
||||||
var contentStyle by remember { mutableStateOf(WatermarkStyle.Default) }
|
|
||||||
var showPreview by remember { mutableStateOf(false) }
|
|
||||||
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
|
||||||
var title by remember { mutableStateOf("") }
|
|
||||||
var content by remember { mutableStateOf("") }
|
|
||||||
var showSaveDialog by remember { mutableStateOf(false) }
|
|
||||||
var selectedImageIndex by remember { mutableStateOf(-1) }
|
|
||||||
|
|
||||||
// 图片选择器
|
|
||||||
val imagePickerLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.GetMultipleContents()
|
|
||||||
) { uris ->
|
|
||||||
if (uris.isNotEmpty()) {
|
|
||||||
if (selectedImageIndex >= 0 && selectedImageIndex < images.size) {
|
|
||||||
images[selectedImageIndex] = uris.first()
|
|
||||||
} else {
|
|
||||||
if (images.size < layoutType.maxImages) {
|
|
||||||
images.addAll(uris.take(layoutType.maxImages - images.size))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
selectedImageIndex = -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载用户配置
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
LaunchedEffect(Unit) {
|
mergedBitmap?.let { bmp ->
|
||||||
preferencesManager.mergeLayout.collect { layoutType = it }
|
Image(
|
||||||
}
|
bitmap = bmp.asImageBitmap(),
|
||||||
LaunchedEffect(Unit) {
|
contentDescription = "拼图合成预览",
|
||||||
preferencesManager.imageQuality.collect { imageQuality = it }
|
modifier = Modifier
|
||||||
}
|
.fillMaxSize()
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
preferencesManager.titleStyle.collect { titleStyle = it }
|
|
||||||
}
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
preferencesManager.contentStyle.collect { contentStyle = it }
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text("图片合成") },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onNavigateBack) {
|
|
||||||
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = Primary,
|
|
||||||
titleContentColor = Color.White,
|
|
||||||
navigationIconContentColor = Color.White
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(paddingValues)
|
|
||||||
) {
|
|
||||||
// 布局选择
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
MergeLayoutType.entries.forEach { layout ->
|
|
||||||
LayoutOption(
|
|
||||||
layout = layout,
|
|
||||||
isSelected = layoutType == layout,
|
|
||||||
onClick = {
|
|
||||||
layoutType = layout
|
|
||||||
if (images.size > layout.maxImages) {
|
|
||||||
while (images.size > layout.maxImages) {
|
|
||||||
images.removeAt(images.size - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 质量选择
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text("质量:", style = MaterialTheme.typography.bodySmall)
|
|
||||||
ImageQuality.entries.forEach { quality ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(4.dp))
|
|
||||||
.background(if (imageQuality == quality) Primary else Color.LightGray)
|
|
||||||
.clickable { imageQuality = quality }
|
|
||||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = quality.displayName,
|
|
||||||
color = if (imageQuality == quality) Color.White else Color.Black,
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图片网格
|
|
||||||
LazyVerticalGrid(
|
|
||||||
columns = GridCells.Fixed(layoutType.cols),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
itemsIndexed(images) { index, uri ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.aspectRatio(1f)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(Color.LightGray)
|
|
||||||
.clickable {
|
|
||||||
selectedImageIndex = index
|
|
||||||
imagePickerLauncher.launch("image/*")
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
model = ImageRequest.Builder(context)
|
|
||||||
.data(uri)
|
|
||||||
.crossfade(true)
|
|
||||||
.build(),
|
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
|
|
||||||
// 删除按钮
|
|
||||||
IconButton(
|
|
||||||
onClick = { images.removeAt(index) },
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.size(32.dp)
|
|
||||||
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Close,
|
|
||||||
contentDescription = "删除",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 替换图标
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Refresh,
|
|
||||||
contentDescription = "替换",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.size(32.dp)
|
|
||||||
.padding(4.dp)
|
|
||||||
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
|
|
||||||
.padding(4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加图片按钮
|
|
||||||
if (images.size < layoutType.maxImages) {
|
|
||||||
item {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.aspectRatio(1f)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(Color.LightGray.copy(alpha = 0.5f))
|
|
||||||
.clickable {
|
|
||||||
imagePickerLauncher.launch("image/*")
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Add,
|
|
||||||
contentDescription = "添加图片",
|
|
||||||
tint = Color.Gray,
|
|
||||||
modifier = Modifier.size(48.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文字编辑区
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = title,
|
|
||||||
onValueChange = { title = it },
|
|
||||||
label = { Text("标题") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = content,
|
|
||||||
onValueChange = { content = it },
|
|
||||||
label = { Text("内容") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
minLines = 2,
|
|
||||||
maxLines = 4
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 底部按钮
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
previewBitmap = withContext(Dispatchers.Default) {
|
|
||||||
val imageItems = images.map { uri ->
|
|
||||||
val path = convertUriToPath(context, uri)
|
|
||||||
ImageItem(uri = uri, path = path ?: uri.toString())
|
|
||||||
}
|
|
||||||
ImageProcessor.mergeImages(
|
|
||||||
imageItems,
|
|
||||||
layoutType,
|
|
||||||
imageQuality
|
|
||||||
).let { bitmap ->
|
|
||||||
if (title.isNotBlank() || content.isNotBlank()) {
|
|
||||||
ImageProcessor.addTextToBitmap(
|
|
||||||
bitmap,
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
titleStyle,
|
|
||||||
contentStyle
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
bitmap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showPreview = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
Text("预览")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { showSaveDialog = true },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
enabled = images.isNotEmpty()
|
|
||||||
) {
|
|
||||||
Text("保存")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 预览对话框
|
|
||||||
if (showPreview && previewBitmap != null) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showPreview = false },
|
|
||||||
title = { Text("预览") },
|
|
||||||
text = {
|
|
||||||
Image(
|
|
||||||
bitmap = previewBitmap!!.asImageBitmap(),
|
|
||||||
contentDescription = "预览",
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { showPreview = false }) {
|
|
||||||
Text("关闭")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存确认对话框
|
|
||||||
if (showSaveDialog) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showSaveDialog = false },
|
|
||||||
title = { Text("保存合成图片") },
|
|
||||||
text = { Text("确定要将合成后的图片保存到相册吗?") },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = {
|
|
||||||
scope.launch {
|
|
||||||
val bitmap = withContext(Dispatchers.Default) {
|
|
||||||
val imageItems = images.map { ImageItem(uri = it, path = it.toString()) }
|
|
||||||
ImageProcessor.mergeImages(
|
|
||||||
imageItems,
|
|
||||||
layoutType,
|
|
||||||
imageQuality
|
|
||||||
).let { mergedBitmap ->
|
|
||||||
if (title.isNotBlank() || content.isNotBlank()) {
|
|
||||||
ImageProcessor.addTextToBitmap(
|
|
||||||
mergedBitmap,
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
titleStyle,
|
|
||||||
contentStyle
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
mergedBitmap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val fileName = ImageProcessor.generateFileName(title.ifBlank { "合成" })
|
|
||||||
val uri = ImageProcessor.saveToGallery(context, bitmap, fileName)
|
|
||||||
uri?.let { onMergeComplete(it) }
|
|
||||||
showSaveDialog = false
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Text("保存")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showSaveDialog = false }) {
|
|
||||||
Text("取消")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LayoutOption(
|
|
||||||
layout: MergeLayoutType,
|
|
||||||
isSelected: Boolean,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
val displayText = when (layout) {
|
|
||||||
MergeLayoutType.Grid2x2 -> "2x2"
|
|
||||||
MergeLayoutType.Grid1x3 -> "1+3"
|
|
||||||
MergeLayoutType.Grid3x1 -> "3+1"
|
|
||||||
MergeLayoutType.Grid1x2 -> "1+2"
|
|
||||||
MergeLayoutType.Grid2x1 -> "2+1"
|
|
||||||
MergeLayoutType.Grid1x1 -> "单图"
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(if (isSelected) Primary else Color.LightGray)
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(12.dp)
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = displayText,
|
|
||||||
color = if (isSelected) Color.White else Color.Black
|
|
||||||
)
|
|
||||||
if (isSelected) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将 Content Uri 拷贝到缓存目录,返回本地文件路径(可被 BitmapFactory.decodeFile 使用)
|
|
||||||
private suspend fun convertUriToPath(context: android.content.Context, uri: android.net.Uri): String? {
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val input = context.contentResolver.openInputStream(uri) ?: return@withContext null
|
|
||||||
val tmpFile = java.io.File(context.cacheDir, "img_${System.nanoTime()}.jpg")
|
|
||||||
java.io.FileOutputStream(tmpFile).use { output ->
|
|
||||||
input.use { inStream -> inStream.copyTo(output) }
|
|
||||||
}
|
|
||||||
tmpFile.absolutePath
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
app/src/main/java/com/inspection/camera/util/PuzzleMerge.kt
Normal file
59
app/src/main/java/com/inspection/camera/util/PuzzleMerge.kt
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package com.inspection.camera.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
object PuzzleMerge {
|
||||||
|
// Merge up to 4 images into a single 2x2 bitmap
|
||||||
|
fun mergeToBitmap(context: Context, imageUris: List<Uri>, targetSize: Int = 1000): Bitmap? {
|
||||||
|
if (imageUris.isEmpty()) return null
|
||||||
|
val size = targetSize
|
||||||
|
val half = size / 2
|
||||||
|
val merged = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(merged)
|
||||||
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
val targets = arrayOf(
|
||||||
|
Rect(0, 0, half, half), // TL
|
||||||
|
Rect(half, 0, size, half), // TR
|
||||||
|
Rect(0, half, half, size), // BL
|
||||||
|
Rect(half, half, size, size) // BR
|
||||||
|
)
|
||||||
|
|
||||||
|
val toUse = imageUris.take(4)
|
||||||
|
for (i in toUse.indices) {
|
||||||
|
val bmp = loadBitmap(context, toUse[i], half, half)
|
||||||
|
if (bmp != null) {
|
||||||
|
val scaled = Bitmap.createScaledBitmap(bmp, half, half, true)
|
||||||
|
canvas.drawBitmap(scaled, null, targets[i], paint)
|
||||||
|
bmp.recycle()
|
||||||
|
scaled.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: fill empty cells with placeholder color if needed
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load bitmap from URI with sampling to fit within maxW x maxH
|
||||||
|
private fun loadBitmap(context: Context, uri: Uri, maxW: Int, maxH: Int): Bitmap? {
|
||||||
|
return try {
|
||||||
|
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
context.contentResolver.openInputStream(uri).use { ins ->
|
||||||
|
BitmapFactory.decodeStream(ins, null, opts)
|
||||||
|
}
|
||||||
|
val inSample = maxOf(1, max(opts.outWidth / maxW, opts.outHeight / maxH))
|
||||||
|
val opts2 = BitmapFactory.Options().apply { inSampleSize = inSample }
|
||||||
|
context.contentResolver.openInputStream(uri).use { ins ->
|
||||||
|
BitmapFactory.decodeStream(ins, null, opts2)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
test/airtest/test_puzzle_merge.py
Normal file
20
test/airtest/test_puzzle_merge.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
AirTest Script: Puzzle Merge - 2x2 large image composition
|
||||||
|
"""
|
||||||
|
|
||||||
|
from airtest.core.api import *
|
||||||
|
auto_setup(__file__)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
start_app("com.inspection.camera")
|
||||||
|
sleep(2)
|
||||||
|
width, height = device().get_current_resolution()
|
||||||
|
# 进入拼图页入口(假设在屏幕右侧)
|
||||||
|
touch((width * 2 / 3, height - 150))
|
||||||
|
sleep(2)
|
||||||
|
snapshot("puzzle_merge_page.png")
|
||||||
|
print("Saved puzzle_merge_page.png")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user