拍照version

This commit is contained in:
flower
2026-02-02 17:06:20 +08:00
parent 47016c00a6
commit d2b7b833b6
111 changed files with 19371 additions and 997 deletions

View File

@@ -165,6 +165,7 @@ data class DecodedFrame(
val lat: Double,
val lon: Double,
val alt: Double,
val photoNumber: Int = 0, // 照片编号从reserve字段提取
val receivedTime: Long = System.currentTimeMillis()
) {
override fun equals(other: Any?): Boolean {
@@ -207,6 +208,7 @@ data class FrameBuffer(
val lat: Double = 0.0,
val lon: Double = 0.0,
val alt: Double = 0.0,
val photoNumber: Int = 0, // 照片编号从reserve字段提取
val receiveTimestamp: Long = System.currentTimeMillis()
) {
/**

View File

@@ -89,7 +89,7 @@ class UdpReceiver(
Log.i(TAG, "UDP 接收缓冲区大小已设置为: ${socket!!.receiveBufferSize}")
}
Log.i(TAG, " UDP 接收器启动,监听端口 $listenPort")
Log.i(TAG, " UDP 接收器启动,监听端口 $listenPort")
startReceiving()
} catch (e: Exception) {
@@ -185,7 +185,8 @@ class UdpReceiver(
timestamp = header.timestamp,
lat = header.lat,
lon = header.lon,
alt = header.alt
alt = header.alt,
photoNumber = header.reserve // 从reserve字段提取照片编号
)
}
@@ -228,7 +229,8 @@ class UdpReceiver(
timestamp = frame.timestamp,
lat = frame.lat,
lon = frame.lon,
alt = frame.alt
alt = frame.alt,
photoNumber = frame.photoNumber // 传递照片编号
)
totalFrames++

View File

@@ -11,6 +11,9 @@
package com.skylink.app.ui
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Color
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -19,6 +22,9 @@ import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import android.widget.EditText
import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.skylink.app.R
@@ -31,6 +37,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.*
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
class HomeFragment : Fragment(), UdpReceiverListener {
@@ -48,6 +58,16 @@ class HomeFragment : Fragment(), UdpReceiverListener {
private var statusTextView: TextView? = null
private var saveButton: Button? = null
private var recordButton: Button? = null
private var triggerCaptureButton: Button? = null
private var triggerCaptureOriginalButton: Button? = null
private var triggerCaptureThumbnailButton: Button? = null
// HTTP客户端用于调用ROS2服务
private val httpClient = OkHttpClient()
// ROS2节点配置
private var ros2NodeIp = "192.168.1.100" // 默认IP需要配置
private var ros2HttpPort = 8080
// ========================================================================
// 业务逻辑
@@ -56,6 +76,7 @@ class HomeFragment : Fragment(), UdpReceiverListener {
private var udpReceiver: UdpReceiver? = null
private var currentFrame: DecodedFrame? = null
private var isRecording = false
private var totalPhotosReceived = 0 // 接收到的总照片数
// ========================================================================
// 生命周期方法
@@ -92,6 +113,9 @@ class HomeFragment : Fragment(), UdpReceiverListener {
statusTextView = view.findViewById(R.id.status_text)
saveButton = view.findViewById(R.id.save_button)
recordButton = view.findViewById(R.id.record_button)
triggerCaptureButton = view.findViewById(R.id.trigger_capture_button)
triggerCaptureOriginalButton = view.findViewById(R.id.trigger_capture_original_button)
triggerCaptureThumbnailButton = view.findViewById(R.id.trigger_capture_thumbnail_button)
// 设置按钮点击监听
saveButton?.setOnClickListener {
@@ -101,6 +125,18 @@ class HomeFragment : Fragment(), UdpReceiverListener {
recordButton?.setOnClickListener {
onRecordToggled()
}
triggerCaptureButton?.setOnClickListener {
showTriggerCaptureDialog()
}
triggerCaptureOriginalButton?.setOnClickListener {
triggerCapture(0) // 原图模式
}
triggerCaptureThumbnailButton?.setOnClickListener {
triggerCapture(1) // 缩略图模式
}
}
// ========================================================================
@@ -126,6 +162,11 @@ class HomeFragment : Fragment(), UdpReceiverListener {
override fun onFrameDecoded(frame: DecodedFrame) {
currentFrame = frame
// 更新照片计数
if (frame.photoNumber > 0) {
totalPhotosReceived++
}
// 移到后台线程执行解码,避免阻塞 UI 线程,进而影响 UDP 接收协程(如果是单核或高负载)
lifecycleScope.launch(Dispatchers.Default) {
@@ -133,7 +174,13 @@ class HomeFragment : Fragment(), UdpReceiverListener {
if (bitmap != null) {
withContext(Dispatchers.Main) {
view?.let {
videoImageView?.setImageBitmap(bitmap)
// 在图像上叠加显示照片编号
val bitmapWithText = if (frame.photoNumber > 0) {
addPhotoNumberToBitmap(bitmap, frame.photoNumber)
} else {
bitmap
}
videoImageView?.setImageBitmap(bitmapWithText)
updateGpsInfo(frame)
}
}
@@ -142,10 +189,12 @@ class HomeFragment : Fragment(), UdpReceiverListener {
}
override fun onStatsUpdated(fps: Int, kbps: Int, packetLoss: Double) {
statsTextView?.text = String.format(
"FPS: %d\nSpeed: %d KB/s\nPacket Loss: %.1f%%",
fps, kbps, packetLoss
)
val statsText = StringBuilder()
statsText.append(String.format("FPS: %d\nSpeed: %d KB/s\nPacket Loss: %.1f%%", fps, kbps, packetLoss))
if (totalPhotosReceived > 0) {
statsText.append(String.format("\nTotal Photos: %d", totalPhotosReceived))
}
statsTextView?.text = statsText.toString()
}
override fun onError(message: String, exception: Exception?) {
@@ -201,4 +250,217 @@ class HomeFragment : Fragment(), UdpReceiverListener {
val formatter = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
return formatter.format(Date(millis))
}
/**
* 在图像上叠加显示照片编号
*/
private fun addPhotoNumberToBitmap(bitmap: Bitmap, photoNumber: Int): Bitmap {
val mutableBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true)
val canvas = Canvas(mutableBitmap)
val text = "Photo #$photoNumber"
val paint = Paint().apply {
color = Color.WHITE
textSize = 48f
isAntiAlias = true
style = Paint.Style.FILL
}
val textPaint = Paint(paint)
val textBounds = android.graphics.Rect()
textPaint.getTextBounds(text, 0, text.length, textBounds)
// 绘制半透明背景
val bgPaint = Paint().apply {
color = Color.argb(180, 0, 0, 0)
style = Paint.Style.FILL
}
val padding = 10
canvas.drawRect(
10f, 10f,
(textBounds.width() + padding * 2).toFloat(),
(textBounds.height() + padding * 2).toFloat(),
bgPaint
)
// 绘制文字
canvas.drawText(text, (10 + padding).toFloat(), (10 + padding + textBounds.height()).toFloat(), textPaint)
return mutableBitmap
}
/**
* 显示触发拍照参数输入对话框
*/
private fun showTriggerCaptureDialog() {
val view = layoutInflater.inflate(R.layout.dialog_trigger_capture, null)
val durationEdit = view.findViewById<EditText>(R.id.duration_edit)
val countEdit = view.findViewById<EditText>(R.id.count_edit)
val intervalEdit = view.findViewById<EditText>(R.id.interval_edit)
val modeSpinner = view.findViewById<android.widget.Spinner>(R.id.mode_spinner)
// 设置Spinner适配器
val modes = arrayOf("原图", "缩略图")
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, modes)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
modeSpinner.adapter = adapter
// 设置默认值
durationEdit.setText("5.0")
countEdit.setText("10")
intervalEdit.setText("0.5")
AlertDialog.Builder(requireContext())
.setTitle("触发拍照")
.setView(view)
.setPositiveButton("确定") { _, _ ->
val duration = durationEdit.text.toString().toDoubleOrNull() ?: 5.0
val count = countEdit.text.toString().toIntOrNull() ?: 10
val interval = intervalEdit.text.toString().toDoubleOrNull() ?: 0.5
val mode = modeSpinner.selectedItemPosition // 0=原图, 1=缩略图
triggerCaptureWithParams(duration, count, interval, mode)
}
.setNegativeButton("取消", null)
.setNeutralButton("设置") { _, _ ->
// 显示IP和端口配置对话框
showConfigDialog()
}
.show()
}
/**
* 显示配置对话框IP和端口
*/
private fun showConfigDialog() {
val view = layoutInflater.inflate(android.R.layout.simple_list_item_2, null)
val ipEdit = EditText(requireContext())
ipEdit.hint = "ROS2节点IP地址"
ipEdit.setText(ros2NodeIp)
val portEdit = EditText(requireContext())
portEdit.hint = "HTTP端口"
portEdit.inputType = android.text.InputType.TYPE_CLASS_NUMBER
portEdit.setText(ros2HttpPort.toString())
val layout = android.widget.LinearLayout(requireContext()).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(50, 40, 50, 10)
addView(ipEdit)
addView(portEdit)
}
AlertDialog.Builder(requireContext())
.setTitle("ROS2节点配置")
.setView(layout)
.setPositiveButton("确定") { _, _ ->
ros2NodeIp = ipEdit.text.toString().ifEmpty { "192.168.1.100" }
ros2HttpPort = portEdit.text.toString().toIntOrNull() ?: 8080
Toast.makeText(context, "配置已保存: $ros2NodeIp:$ros2HttpPort", Toast.LENGTH_SHORT).show()
}
.setNegativeButton("取消", null)
.show()
}
/**
* 快速触发拍照(使用默认参数)
*/
private fun triggerCapture(mode: Int) {
triggerCaptureWithParams(5.0, 10, 0.5, mode)
}
/**
* 使用参数触发拍照
*/
private fun triggerCaptureWithParams(duration: Double, count: Int, interval: Double, mode: Int) {
val modeStr = if (mode == 0) "原图" else "缩略图"
lifecycleScope.launch(Dispatchers.IO) {
try {
// 构建JSON请求
val json = JSONObject().apply {
put("duration", duration)
put("count", count)
put("interval", interval)
put("transmission_mode", mode)
}
val requestBody = json.toString().toRequestBody("application/json".toMediaType())
val url = "http://$ros2NodeIp:$ros2HttpPort/api/v1/trigger_capture"
val request = Request.Builder()
.url(url)
.post(requestBody)
.addHeader("User-Agent", "SkyLink-Android/1.0")
.build()
val response = httpClient.newCall(request).execute()
withContext(Dispatchers.Main) {
if (response.isSuccessful) {
val responseBody = response.body?.string()
if (responseBody != null) {
try {
val responseJson = JSONObject(responseBody)
val success = responseJson.optBoolean("success", false)
val message = responseJson.optString("message", "")
val totalPhotos = responseJson.optInt("total_photos", 0)
if (success) {
Toast.makeText(context,
"拍照任务已启动!\n$message\n总照片数: $totalPhotos",
Toast.LENGTH_LONG).show()
updateStatus("拍照任务已启动 ($modeStr)")
} else {
Toast.makeText(context,
"请求失败: $message",
Toast.LENGTH_LONG).show()
updateStatus("请求失败: $message")
}
} catch (e: Exception) {
Toast.makeText(context,
"响应解析失败: ${e.message}",
Toast.LENGTH_SHORT).show()
updateStatus("响应解析失败")
}
} else {
Toast.makeText(context, "服务器返回空响应", Toast.LENGTH_SHORT).show()
updateStatus("服务器返回空响应")
}
} else {
val errorMsg = when (response.code) {
400 -> "请求参数错误"
404 -> "API端点不存在"
500 -> "服务器内部错误"
else -> "HTTP错误: ${response.code}"
}
Toast.makeText(context, errorMsg, Toast.LENGTH_LONG).show()
updateStatus(errorMsg)
}
}
} catch (e: java.net.SocketTimeoutException) {
withContext(Dispatchers.Main) {
Toast.makeText(context,
"请求超时请检查网络连接和ROS2节点IP地址 ($ros2NodeIp:$ros2HttpPort)",
Toast.LENGTH_LONG).show()
updateStatus("请求超时")
}
} catch (e: java.net.ConnectException) {
withContext(Dispatchers.Main) {
Toast.makeText(context,
"无法连接到ROS2节点 ($ros2NodeIp:$ros2HttpPort)\n请确认HTTP服务器正在运行",
Toast.LENGTH_LONG).show()
updateStatus("连接失败")
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context,
"调用失败: ${e.message}",
Toast.LENGTH_LONG).show()
updateStatus("调用失败: ${e.message}")
}
}
}
}
}

View File

@@ -44,7 +44,7 @@ object BitmapUtils {
// 实际解码
val bitmap = BitmapFactory.decodeByteArray(jpegData, 0, jpegData.size, options)
// Log.i(TAG, " Bitmap 解码成功: ${bitmap?.width}x${bitmap?.height}") // 移除高频日志
// Log.i(TAG, " Bitmap 解码成功: ${bitmap?.width}x${bitmap?.height}") // 移除高频日志
bitmap
} catch (e: Exception) {
Log.e(TAG, "Bitmap 解码失败", e)

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="总时间 (秒, 0=默认):"
android:textSize="14sp"
android:layout_marginBottom="4dp" />
<EditText
android:id="@+id/duration_edit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal"
android:hint="5.0"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="拍照数量 (0=默认):"
android:textSize="14sp"
android:layout_marginBottom="4dp" />
<EditText
android:id="@+id/count_edit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:hint="10"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="时间间隔 (秒, 0=自动):"
android:textSize="14sp"
android:layout_marginBottom="4dp" />
<EditText
android:id="@+id/interval_edit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal"
android:hint="0.5"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="传输模式:"
android:textSize="14sp"
android:layout_marginBottom="4dp" />
<Spinner
android:id="@+id/mode_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:entries="@array/transmission_modes" />
</LinearLayout>

View File

@@ -68,8 +68,44 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:text="⏺ 开始录制" />
<Button
android:id="@+id/trigger_capture_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="📷 触发拍照" />
</LinearLayout>
<!-- 第二行按钮 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_horizontal"
android:layout_marginTop="8dp"
android:baselineAligned="false">
<Button
android:id="@+id/trigger_capture_original_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="📷 原图拍照" />
<Button
android:id="@+id/trigger_capture_thumbnail_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="📷 缩略图拍照" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="transmission_modes">
<item>原图</item>
<item>缩略图</item>
</string-array>
</resources>