拍照version
This commit is contained in:
@@ -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()
|
||||
) {
|
||||
/**
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
7
skylink_android_app/app/src/main/res/values/arrays.xml
Normal file
7
skylink_android_app/app/src/main/res/values/arrays.xml
Normal 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>
|
||||
Reference in New Issue
Block a user