Add image composition and text overlay functionality
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
145
app/src/main/java/com/example/app/ImageCompositor.kt
Normal file
145
app/src/main/java/com/example/app/ImageCompositor.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user