Захват видеопотока с камеры Wi-Fi JieLiAndroid

Форум для тех, кто программирует под Android
Ответить
Anonymous
 Захват видеопотока с камеры Wi-Fi JieLi

Сообщение Anonymous »

Я купил на AliExpress небольшую заднюю камеру с Wi-Fi 12 В. Похоже, что в камере используется чип AC54 или AC51 от JieLi Technology.
Устройство открывает точку доступа Wi-Fi на канале 3 и получает IP-адрес 192.168.1.1.
Вы можете подключиться к Wi-Fi камеры и загрузить файл описания с сайта http://192.168.1.1:8080/mnt/spiflash/res/dev_desc.txt, который имеет вид json:

Код: Выделить всё

{
"app_list": {
"match_android_ver": [
"1.0",
"2.0"
],
"match_ios_ver": [
"1.0",
"2.0"
]
},
"forward_support": [
"0",
"1"
],
"behind_support": [
"0"
],
"forward_record_support": [
"0",
"1"
],
"behind_record_support": [
"0"
],
"rtsp_forward_support": [
"0",
"1"
],
"rtsp_behind_support": [
"0"
],
"device_type": "1",
"net_type": "1",
"rts_type": "0",
"product_type": "AC521x_wifi_car_camera",
"support_projection": "0",
"firmware_version": "1.0.1",
"match_app_type": "DVRunning 2",
"uuid": "xxxxxx"
}
Затем я прослушивал связь между мобильным приложением и камерой. Он использует TCP-канал (порт 3333) для команд и отправляет UDP-пакеты на порт 2224 при активации видеопотока, как я наблюдал в Wireshark.
Затем я написал небольшое тестовое приложение на Kotlin для своего устройства Android для чтения видеопотока. Однако в моем приложении всегда появляются поврежденные изображения. Официальное приложение-компаньон камеры работает нормально, но я хочу использовать свою собственную реализацию. К сожалению, мне не удалось найти загружаемый SDK для этой камеры.
Устройство отправляет поток MJPEG (JFIF JPEG), но его кадры фрагментированы, и я подозреваю, что оно использует собственный формат.
Вот несколько документов для этого типа устройств (в основном на китайском языке, но инструменты перевода делают их в основном читабельными): Забавное примечание: тот же чип используется и в дроне. РЕДАКТИРОВАТЬ:
Я загрузил тестовое приложение в свою учетную запись GitHub:

https://github.com/mightymop/camtest/tree/main
Основная логика, отвечающая за получение и восстановление видеопотока, находится здесь:

https://github.com/mightymop/camtest/bl ... eceiver.kt

Код: Выделить всё

package local.test.camtest.protocol

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.Log
import android.view.SurfaceHolder
import local.test.camtest.R
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.RandomAccessFile
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.SocketTimeoutException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.min

class JFIFMJpegStreamReceiver {

interface StreamListener {
fun onVideoStarted()
fun onVideoStopped()
fun onError(error: String)
fun onFrameDecoded(width: Int, height: Int)
fun onStreamInfo(info: String)
fun onPcapDumpStarted(filePath: String)
fun onPcapDumpStopped(filePath: String, packetCount: Int)
}

private var udpSocket: DatagramSocket? = null
private var isReceiving = false
private var listener: StreamListener? = null
private var surfaceHolder: SurfaceHolder? = null
private var paint: Paint = Paint()

private var pcapDumpEnabled: Boolean = false
private var pcapFile: RandomAccessFile? = null
private var pcapPacketCount = 0

private val frameAssembler = RTPFrameAssembler()

private lateinit var context: Context

companion object {
private const val TAG = "JFIFMJpegStreamReceiver"
}

fun initialize(holder: SurfaceHolder, listener: StreamListener, context: Context) {
this.surfaceHolder = holder
this.listener = listener
this.context = context
paint.isFilterBitmap = true
paint.isAntiAlias = false

Log.d(TAG, "JFIF MJPEG Receiver initialized")
listener.onStreamInfo("JFIF MJPEG Ready")
}

fun startStream(enablePcapDump: Boolean = false) {
pcapDumpEnabled = enablePcapDump

Thread {
try {
udpSocket = DatagramSocket(this.context.resources.getInteger(R.integer.udpport))
udpSocket?.soTimeout = 1000
udpSocket?.receiveBufferSize = 1024 * 1024 * 4
isReceiving = true

if (pcapDumpEnabled) startPcapDump()

Log.d(TAG, "🎥 JFIF MJPEG Stream started - PCAP Dump: $pcapDumpEnabled")
listener?.onVideoStarted()
listener?.onStreamInfo("Receiving UDP stream...  PCAP: $pcapDumpEnabled")

val buffer = ByteArray(65536)
var packetCount = 0
var frameCount = 0

while (isReceiving) {
try {
val packet = DatagramPacket(buffer, buffer.size)
udpSocket?.receive(packet)
val data = packet.data.copyOf(packet.length)

packetCount++
if (pcapDumpEnabled) dumpPacketToPcap(
data,
packet.address.hostAddress!!,
packet.port
)

val frameData = frameAssembler.processPacket(data)
if (frameData != null) {
frameCount++
val bitmap = BitmapFactory.decodeByteArray(frameData, 0, frameData.size)
if (bitmap != null) {
drawToSurface(bitmap)
listener?.onFrameDecoded(bitmap.width, bitmap.height)
if (frameCount % 30 == 0) {
listener?.onStreamInfo("Frames decoded: $frameCount")
}
} else {
Log.w(TAG, "Failed to decode bitmap from frame")
}
}

} catch (e: SocketTimeoutException) {
// Timeout is normal, continue
} catch (e: Exception) {
if (isReceiving) Log.w(TAG, "UDP error: ${e.message}")
}
}

} catch (e: Exception) {
Log.e(TAG, "Stream error: ${e.message}")
listener?.onError("Stream failed: ${e.message}")
} finally {
stopPcapDump()
udpSocket?.close()
Log.d(TAG, "Stream stopped")
}
}.start()
}

private fun drawToSurface(bitmap: Bitmap) {
var canvas: Canvas? = null
try {
canvas = surfaceHolder?.lockCanvas()
canvas?.let {
it.drawColor(Color.BLACK)
val scaled = scaleToSurface(bitmap, it.width, it.height)
val x = (it.width - scaled.width) / 2f
val y = (it.height - scaled.height) / 2f
it.drawBitmap(scaled, x, y, paint)
if (scaled != bitmap) scaled.recycle()
}
} catch (e: Exception) {
Log.e(TAG, "Surface draw error: ${e.message}")
} finally {
canvas?.let {
try {
surfaceHolder?.unlockCanvasAndPost(it)
} catch (_: Exception) {
}
}
}
}

private fun scaleToSurface(bitmap: Bitmap, surfaceWidth: Int, surfaceHeight: Int): Bitmap {
val scale =
min(surfaceWidth.toFloat() / bitmap.width, surfaceHeight.toFloat() / bitmap.height)
if (scale >= 1f) return bitmap
return Bitmap.createScaledBitmap(
bitmap,
(bitmap.width * scale).toInt(),
(bitmap.height * scale).toInt(),
true
)
}

fun stopStream() {
isReceiving = false
udpSocket?.close()
listener?.onVideoStopped()
stopPcapDump()
}

fun release() {
stopStream()
surfaceHolder = null
Log.d(TAG, "Receiver released")
}

// ------------------- PCAP Dump -------------------
private fun startPcapDump() {
try {
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val fileName = "mjpeg_stream_${timestamp}.pcap"
val pcapDir = File(context.getExternalFilesDir(null), "pcap_dumps")
if (!pcapDir.exists()) pcapDir.mkdirs()
val pcapFilePath = File(pcapDir, fileName)
pcapFile = RandomAccessFile(pcapFilePath, "rw")
Log.d(TAG, "📁 PCAP Dump started:  ${pcapFilePath.absolutePath}")
listener?.onPcapDumpStarted(pcapFilePath.absolutePath)
pcapPacketCount = 0
} catch (e: Exception) {
Log.e(TAG, "Failed to start PCAP dump: ${e.message}")
pcapDumpEnabled = false
}
}

private fun dumpPacketToPcap(data: ByteArray, sourceIp: String, sourcePort: Int) {
// Minimal dummy implementation to keep PCAP functionality
pcapPacketCount++
}

private fun stopPcapDump() {
try {
pcapFile?.close()
} catch (_: Exception) {
}
listener?.onPcapDumpStopped(pcapFile?.toString() ?: "unknown", pcapPacketCount)
pcapFile = null
pcapPacketCount = 0
}

// ------------------- Frame Assembler -------------------
class RTPFrameAssembler {

private val activeFrames = mutableMapOf()
private val frameTimeout = 1000L // 1 second timeout

data class FrameKey(val frameId: Int, val timestamp: Int)

data class FrameAssembly(
val fragments: MutableMap = mutableMapOf(),
var lastUpdate: Long = System.currentTimeMillis()
)

fun processPacket(packet: ByteArray): ByteArray? {
val rtpInfo = parseRTPHeader(packet) ?: return null
val (frameId, timestamp, fragmentOffset) = rtpInfo

val payload = packet.copyOfRange(20, packet.size)
val frameKey = FrameKey(frameId, timestamp)

Log.d(
"RTP",
"Packet: frame=${frameId.toString(16)}, ts=${timestamp.toString(16)}, offset=${
fragmentOffset.toString(16)
}"
)

// Clean up old frames
cleanupOldFrames()

// Add fragment to frame (or start new frame)
val frameAssembly = activeFrames.getOrPut(frameKey) { FrameAssembly() }
frameAssembly.fragments[fragmentOffset] = payload
frameAssembly.lastUpdate = System.currentTimeMillis()

Log.d(
"RTP",
"Frame ${frameId.toString(16)} now has ${frameAssembly.fragments.size} fragments"
)

// Check if frame is complete
if (isFrameComplete(frameAssembly)) {
val frameData = assembleFrame(frameAssembly)
activeFrames.remove(frameKey)
return frameData
}

return null
}

private fun isFrameComplete(frame: FrameAssembly): Boolean {
val fragments = frame.fragments

// Check if we have at least one fragment with SOI and one with EOF
val hasSOI = fragments.values.any { hasSOIMarker(it) }
val hasEOF = fragments.values.any { hasEOFMarker(it) }

if (!hasSOI || !hasEOF) {
Log.d("RTP", "Frame incomplete: SOI=$hasSOI, EOF=$hasEOF")
return false
}

// Try to assemble frame and check if it's a valid JPEG
val assembledFrame = tryAssembleFrame(fragments)
return isValidJpegFrame(assembledFrame)
}

private fun tryAssembleFrame(fragments: Map): ByteArray {
val sortedFragments = fragments.entries.sortedBy { it.key }
val buffer = ByteArrayOutputStream()

for ((offset, fragmentData) in sortedFragments) {
buffer.write(fragmentData)
}

return buffer.toByteArray()
}

private fun isValidJpegFrame(data: ByteArray): Boolean {
if (data.size <  100) {
Log.d("JPEG", "Frame too small: ${data.size} bytes")
return false
}

val hasSOI = hasSOIMarker(data)
val hasEOF = hasEOFMarker(data)

if (!hasSOI) {
Log.w("JPEG", "Invalid JPEG: Missing SOI marker")
return false
}

if (!hasEOF) {
Log.w("JPEG", "Invalid JPEG: Missing EOF marker")
return false
}

// Additional check: SOI at start and EOF at end
val soiAtStart = data[0] == 0xFF.toByte() && data[1] == 0xD8.toByte()
val eofAtEnd =
data[data.size - 2] == 0xFF.toByte() && data[data.size - 1] == 0xD9.toByte()

if (!soiAtStart) {
Log.w("JPEG", "Invalid JPEG: SOI not at start")
// Could be repaired, but currently considered invalid
return false
}

Log.d(
"JPEG",
"Valid JPEG: ${data.size} bytes, SOI at start: $soiAtStart, EOF at end: $eofAtEnd"
)
return true
}

private fun hasSOIMarker(data: ByteArray): Boolean {
// Check if SOI marker exists anywhere in the data
for (i in 0 until data.size - 1) {
if (data[i].toInt() and 0xFF == 0xFF && data[i + 1].toInt() and 0xFF == 0xD8) {
return true
}
}
return false
}

private fun hasEOFMarker(data: ByteArray): Boolean {
// Check if EOF marker exists anywhere in the data
for (i in 0 until data.size - 1) {
if (data[i].toInt() and 0xFF == 0xFF && data[i + 1].toInt() and 0xFF == 0xD9) {
return true
}
}
return false
}

private fun assembleFrame(frame: FrameAssembly): ByteArray {
val sortedFragments = frame.fragments.entries.sortedBy { it.key }
val buffer = ByteArrayOutputStream()
var currentPosition = 0

for ((offset, fragmentData) in sortedFragments) {
// Fill gap if necessary
if (offset > currentPosition) {
val gapSize = offset - currentPosition
Log.w(
"RTP",
"Filling gap: ${currentPosition.toString(16)}->${offset.toString(16)} (${gapSize} bytes)"
)
buffer.write(ByteArray(gapSize))
}

buffer.write(fragmentData)
currentPosition = offset + fragmentData.size
}

val frameData = buffer.toByteArray()
Log.d(
"RTP",
"Frame assembled: ${frameData.size} bytes from ${sortedFragments.size} fragments"
)
return frameData
}

private fun cleanupOldFrames() {
val now = System.currentTimeMillis()
val toRemove = activeFrames.filter { (_, frame) ->
now - frame.lastUpdate > frameTimeout
}.keys

toRemove.forEach { key ->
Log.w(
"RTP",
"Frame timeout: ${key.frameId.toString(16)} with ${activeFrames[key]?.fragments?.size} fragments"
)
activeFrames.remove(key)
}
}

private fun parseRTPHeader(data: ByteArray): RTPInfo? {
if (data.size <  20) return null

return try {
// Frame ID (Bytes 4-5)
val frameId = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)

// Timestamp (Bytes 6-11)
val timestamp = ((data[6].toInt() and 0xFF) shl 24) or
((data[7].toInt() and 0xFF) shl 16) or
((data[8].toInt() and 0xFF) shl 8) or
(data[9].toInt() and 0xFF)

// Fragment Offset (Bytes 12-13)
val fragmentOffset =
((data[12].toInt() and 0xFF) shl 8) or (data[13].toInt() and 0xFF)

RTPInfo(frameId, timestamp, fragmentOffset)
} catch (e: Exception) {
Log.e("RTP", "Error parsing RTP header: ${e.message}")
null
}
}
}

data class RTPInfo(val frameId: Int, val timestamp: Int, val fragmentOffset: Int)
}

// These classes are defined outside but kept for reference
data class Fragment(
val offset: Int,
val payload: ByteArray,
val type: FragmentType
)

enum class FragmentType {
START,          // Fragment with FRAG_START offset
START_SOI,      // Fragment with SOI marker (could be middle fragment)
MIDDLE,         // Middle fragment
END,            // Fragment with FRAG_END offset
END_EOF         // Fragment with EOF marker (could be middle fragment)
}
Я также добавил файл PCAP, который содержит короткий сегмент видеопотока и несколько результирующих изображений.
EDIT#2:

Я добавил возможность выгружать пакеты udp в свое приложение.

затем я запустил Pythonscript для полезных данных пакетов. Вот некоторые данные:

Код: Выделить всё

# UDP Payload Header Analysis
# PCAP File: mjpeg_stream.pcap
# Generated: 2025-11-09 03:30:44
# Format: PacketNumber | TotalSize | Magic/Type | Sequence | Timestamp | Fragment | Reserved | HasJPEG
#====================================================================================================
1 |   7360 | 02 00 ac 05 | bc 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES
2 |   7360 | 02 00 ac 05 | bc 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO
3 |   2920 | 02 00 ac 05 | bc 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO
4 |   7360 | 02 00 ac 05 | bd 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES
5 |   7360 | 02 00 ac 05 | bd 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO
6 |   2920 | 02 00 ac 05 | bd 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO
7 |   7360 | 02 00 ac 05 | be 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES
8 |   7360 | 02 00 ac 05 | be 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO
9 |   2920 | 02 00 ac 05 | be 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO
10 |   7360 | 02 00 ac 05 | bf 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES
11 |   7360 | 02 00 ac 05 | bf 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO
12 |   2920 | 02 00 ac 05 | bf 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO
13 |   7360 | 02 00 ac 05 | c0 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES
14 |   7360 | 02 00 ac 05 | c0 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO
15 |   2920 | 02 00 ac 05 | c0 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO
16 |   7360 | 02 00 ac 05 | c1 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES
17 |   7360 | 02 00 ac 05 | c1 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO
18 |   2920 | 02 00 ac 05 | c1 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO
19 |   7360 | 02 00 ac 05 | c2 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES
20 |   7360 | 02 00 ac 05 | c2 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO
21 |   2920 | 02 00 ac 05 | c2 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO
22 |   7360 | 02 00 ac 05 | c3 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES
23 |   7360 | 02 00 ac 05 | c3 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO
24 |   2920 | 02 00 ac 05 | c3 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO
25 |   7360 | 02 00 ac 05 | c4 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES
26 |   7360 | 02 00 ac 05 | c4 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO
27 |   2920 | 02 00 ac 05 | c4 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO
28 |   7360 | 02 00 ac 05 | c5 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES
29 |   7360 | 02 00 ac 05 | c5 5a | 00 00 f8 43 00 00 | 5c 1c | 00 00 00 00 00 00 | NO
30 |   2920 | 02 00 ac 05 | c5 5a | 00 00 f8 43 00 00 | b8 38 | 00 00 00 00 00 00 | NO
31 |   7360 | 02 00 ac 05 | c6 5a | 00 00 f8 43 00 00 | 00 00 | 00 00 00 00 00 00 | YES
Первые 20 байтов каждой полезной нагрузки представляют собой собственный заголовок. Затем я попытался реализовать это в классе RTPFrameAssembler. Фрагменты, отмеченные HasJPEG = YES, содержат маркер SOI (), а фрагменты с наибольшим смещением содержат маркер EOF (), поэтому сами фреймы действительны. Кадры собираются с использованием смещений фрагментов, а затем визуализируются на поверхности. Моя проблема в том, что полученные файлы JPEG повреждены. Я что-то упустил?

Подробнее здесь: https://stackoverflow.com/questions/798 ... deo-stream
Ответить

Быстрый ответ

Изменение регистра текста: 
Смайлики
:) :( :oops: :roll: :wink: :muza: :clever: :sorry: :angel: :read: *x)
Ещё смайлики…
   
К этому ответу прикреплено по крайней мере одно вложение.

Если вы не хотите добавлять вложения, оставьте поля пустыми.

Максимально разрешённый размер вложения: 15 МБ.

Вернуться в «Android»