Add image composition and text overlay functionality

This commit is contained in:
xiajiid
2026-02-08 12:16:31 +08:00
parent ce8c4f51fd
commit ad950e8468
2 changed files with 298 additions and 6 deletions

View File

@@ -18,9 +18,7 @@ import android.os.Bundle
import android.os.Looper import android.os.Looper
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import android.widget.ImageButton import android.widget.*
import android.widget.LinearLayout
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
@@ -31,11 +29,11 @@ import androidx.camera.view.PreviewView
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.*
import java.util.Locale
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -58,6 +56,11 @@ class CameraActivity : AppCompatActivity() {
private var imageCapture: ImageCapture? = null private var imageCapture: ImageCapture? = null
private lateinit var cameraExecutor: ExecutorService private lateinit var cameraExecutor: ExecutorService
// 存储拍摄的图片URI
private val capturedImageUris = mutableListOf<Uri>()
// 图片合成器
private val compositor = ImageCompositor()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
@@ -140,10 +143,18 @@ class CameraActivity : AppCompatActivity() {
} }
override fun onImageSaved(output: ImageCapture.OutputFileResults) { override fun onImageSaved(output: ImageCapture.OutputFileResults) {
// 添加水印到已保存的照片 // 获取保存的URI
val savedUri = output.savedUri ?: return val savedUri = output.savedUri ?: return
// 添加水印到已保存的照片
addWatermarkToImage(savedUri) addWatermarkToImage(savedUri)
// 添加到捕获的图片列表
capturedImageUris.add(savedUri)
// 更新预览
updatePhotoPreviews()
Toast.makeText( Toast.makeText(
baseContext, baseContext,
"照片已保存", "照片已保存",
@@ -215,6 +226,142 @@ class CameraActivity : AppCompatActivity() {
return watermarkedBitmap return watermarkedBitmap
} }
private fun updatePhotoPreviews() {
// 清空当前预览
photoPreviewLayout.removeAllViews()
// 限制最多显示4张预览图
val displayCount = minOf(capturedImageUris.size, 4)
for (i in 0 until displayCount) {
val imageView = ImageView(this)
val params = LinearLayout.LayoutParams(
200, 200
).apply {
setMargins(8, 8, 8, 8)
}
imageView.layoutParams = params
imageView.scaleType = ImageView.ScaleType.CENTER_CROP
// 加载并设置图片
val uri = capturedImageUris[capturedImageUris.size - displayCount + i] // 显示最新的几张
imageView.setImageURI(uri)
// 添加点击事件以查看大图
imageView.setOnClickListener {
// 可以在这里实现查看大图的功能
}
photoPreviewLayout.addView(imageView)
}
// 如果已有4张图片添加合成按钮
if (capturedImageUris.size >= 4) {
addComposeButton()
}
}
private fun addComposeButton() {
// 添加合成按钮
val composeButton = Button(this)
composeButton.text = "合成图片"
composeButton.setBackgroundColor(ContextCompat.getColor(this, R.color.purple_200))
composeButton.setTextColor(Color.WHITE)
val params = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
setMargins(8, 8, 8, 8)
gravity = android.view.Gravity.CENTER_HORIZONTAL
}
composeButton.layoutParams = params
composeButton.setOnClickListener {
composeLastFourImages()
}
photoPreviewLayout.addView(composeButton)
}
private fun composeLastFourImages() {
// 获取最近的4张图片
val recentUris = if (capturedImageUris.size >= 4) {
capturedImageUris.takeLast(4)
} else {
capturedImageUris
}
// 将URI转换为Bitmap
val bitmaps = mutableListOf<Bitmap>()
for (uri in recentUris) {
try {
val inputStream = contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
bitmaps.add(bitmap)
} catch (e: Exception) {
Log.e(TAG, "加载图片失败: ${e.message}")
}
}
if (bitmaps.isEmpty()) {
Toast.makeText(this, "没有图片可合成", Toast.LENGTH_SHORT).show()
return
}
// 合成图片
val composedBitmap = compositor.composeImagesToGrid(bitmaps, 2000, 2000) // 目标尺寸2000x2000
// 添加标题和内容
val finalBitmap = compositor.addTextOverlay(
composedBitmap,
"巡检报告",
"这是巡检内容的详细描述"
)
// 保存合成后的图片
saveCompositedImage(finalBitmap)
// 回收位图
for (bitmap in bitmaps) {
if (bitmap != finalBitmap) {
bitmap.recycle()
}
}
if (finalBitmap != composedBitmap) {
composedBitmap.recycle()
}
}
private fun saveCompositedImage(bitmap: Bitmap) {
try {
val fileName = compositor.generateFileName()
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
}
}
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
?: throw IOException("无法创建图片URI")
val outputStream = contentResolver.openOutputStream(uri)
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream)
outputStream?.close()
Toast.makeText(this, "合成图片已保存", Toast.LENGTH_SHORT).show()
// 刷新相册
refreshGallery(uri)
} catch (e: Exception) {
Log.e(TAG, "保存合成图片失败: ${e.message}")
Toast.makeText(this, "保存合成图片失败", Toast.LENGTH_SHORT).show()
}
}
private fun refreshGallery(uri: Uri) { private fun refreshGallery(uri: Uri) {
// 通知相册更新 // 通知相册更新
val contentValues = ContentValues().apply { val contentValues = ContentValues().apply {

View File

@@ -0,0 +1,145 @@
package com.example.app
import android.graphics.*
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
class ImageCompositor {
/**
* 将多张图片合成到2x2网格布局中
*/
fun composeImagesToGrid(images: List<Bitmap>, targetWidth: Int, targetHeight: Int): Bitmap {
// 创建目标位图
val composedBitmap = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(composedBitmap)
canvas.drawColor(Color.WHITE) // 白色背景
// 计算每个网格的尺寸
val cellWidth = targetWidth / 2
val cellHeight = targetHeight / 2
// 绘制每张图片到对应网格
for ((index, bitmap) in images.withIndex()) {
if (index >= 4) break // 最多4张图片
val row = index / 2
val col = index % 2
// 计算目标矩形
val dstRect = Rect(col * cellWidth, row * cellHeight,
(col + 1) * cellWidth, (row + 1) * cellHeight)
// 缩放图片以适应网格
val scaledBitmap = Bitmap.createScaledBitmap(bitmap, cellWidth, cellHeight, true)
// 绘制到画布
canvas.drawBitmap(scaledBitmap, null, dstRect, null)
// 回收临时位图
if (scaledBitmap != bitmap) {
scaledBitmap.recycle()
}
}
return composedBitmap
}
/**
* 在图片上添加标题和内容文字
*/
fun addTextOverlay(bitmap: Bitmap, title: String?, content: String?): Bitmap {
val resultBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true)
val canvas = Canvas(resultBitmap)
// 文字样式配置
val titlePaint = Paint().apply {
color = Color.BLACK
textSize = 48f
typeface = Typeface.DEFAULT_BOLD
textAlign = Paint.Align.CENTER
}
val contentPaint = Paint().apply {
color = Color.BLACK
textSize = 36f
typeface = Typeface.DEFAULT
textAlign = Paint.Align.CENTER
}
val bgColor = Color.argb(180, 255, 255, 255) // 半透明白色背景
// 绘制标题(顶部)
if (!title.isNullOrBlank()) {
// 计算标题背景矩形
val titleBgHeight = 100
val titleBgRect = Rect(0, 0, resultBitmap.width, titleBgHeight)
val bgPaint = Paint().apply { color = bgColor }
canvas.drawRect(titleBgRect, bgPaint)
// 绘制标题文字(居中)
val titleX = resultBitmap.width / 2f
val titleY = titleBgHeight / 2f - (titlePaint.fontMetrics.ascent + titlePaint.fontMetrics.descent) / 2
canvas.drawText(title, titleX, titleY, titlePaint)
}
// 绘制内容(底部)
if (!content.isNullOrBlank()) {
// 计算内容背景矩形
val contentBgHeight = 200
val contentBgRect = Rect(0, resultBitmap.height - contentBgHeight,
resultBitmap.width, resultBitmap.height)
val bgPaint = Paint().apply { color = bgColor }
canvas.drawRect(contentBgRect, bgPaint)
// 绘制内容文字(多行处理)
val contentX = resultBitmap.width / 2f
val contentY = resultBitmap.height - contentBgHeight / 2f
drawMultilineText(canvas, content, contentX, contentY, contentPaint, contentBgHeight)
}
return resultBitmap
}
/**
* 绘制多行文字
*/
private fun drawMultilineText(
canvas: Canvas,
text: String,
centerX: Float,
centerY: Float,
paint: Paint,
maxHeight: Int
) {
val staticLayout = StaticLayout.Builder
.obtain(text, 0, text.length,
paint, (canvas.width * 0.9).toInt())
.setMaxLines(5) // 限制最大行数
.setAlignment(Layout.Alignment.ALIGN_CENTER)
.build()
// 计算垂直居中的起始Y位置
val totalHeight = staticLayout.height
val startY = if (totalHeight <= maxHeight) {
centerY - totalHeight / 2
} else {
(canvas.height - maxHeight).toFloat()
}
canvas.save()
canvas.translate(centerX - staticLayout.width / 2, startY)
staticLayout.draw(canvas)
canvas.restore()
}
/**
* 生成带时间戳的文件名
*/
fun generateFileName(subject: String = "巡检报告"): String {
val dateFormat = SimpleDateFormat("yyyyMMddHHmm", Locale.getDefault())
val currentTime = dateFormat.format(Date())
return "${subject}_${currentTime}.jpg"
}
}