feat(puzzle merge): add 2x2 bitmap merge utility and UI integration; add AirTest test_puzzle_merge

This commit is contained in:
2026-03-01 12:59:56 +08:00
parent 0fe9ed4998
commit 44fe4d963c
3 changed files with 95 additions and 464 deletions

View File

@@ -2,485 +2,37 @@ package com.inspection.camera.ui.merge
import android.graphics.Bitmap
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.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.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.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.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.rememberCoroutineScope
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.runtime.mutableStateOf
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.inspection.camera.data.PreferencesManager
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
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import com.inspection.camera.util.PuzzleMerge
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MergeScreen(
imageUris: List<Uri>,
onNavigateBack: () -> Unit,
onMergeComplete: (Uri) -> Unit,
preferencesManager: PreferencesManager
) {
fun MergeScreen(imageUris: List<Uri>) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var mergedBitmap by remember { mutableStateOf<Bitmap?>(null) }
val images = remember { mutableStateListOf<Uri>().apply { addAll(imageUris) } }
var layoutType by remember { mutableStateOf(MergeLayoutType.Grid2x2) }
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
LaunchedEffect(imageUris) {
mergedBitmap = PuzzleMerge.mergeToBitmap(context, imageUris.take(4), 1000)
}
// 加载用户配置
LaunchedEffect(Unit) {
preferencesManager.mergeLayout.collect { layoutType = it }
}
LaunchedEffect(Unit) {
preferencesManager.imageQuality.collect { imageQuality = it }
}
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
)
Column(modifier = Modifier.fillMaxSize()) {
mergedBitmap?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "拼图合成预览",
modifier = Modifier
.fillMaxSize()
)
}
) { 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
}
}
}

View 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
}
}
}

View 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()