feat: 添加 USB 摄像头连接功能
- 新增 Android USB 摄像头 APP (MJPEG 服务器) - 电脑端支持 ADB 端口转发连接 - 修复 .gitignore 忽略 Android 文件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
26
android-app/app/src/main/AndroidManifest.xml
Normal file
26
android-app/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature android:name="android.hardware.camera.any" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="USB摄像头"
|
||||
android:theme="@style/Theme.AppCompat.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="landscape"
|
||||
android:keepScreenOn="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
159
android-app/app/src/main/java/com/usbwebcam/CameraHelper.kt
Normal file
159
android-app/app/src/main/java/com/usbwebcam/CameraHelper.kt
Normal file
@@ -0,0 +1,159 @@
|
||||
package com.usbwebcam
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageFormat
|
||||
import android.graphics.Rect
|
||||
import android.graphics.YuvImage
|
||||
import android.hardware.camera2.*
|
||||
import android.media.Image
|
||||
import android.media.ImageReader
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Size
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class CameraHelper(
|
||||
private val context: Context,
|
||||
private val onFrame: (ByteArray, Int, Int) -> Unit
|
||||
) {
|
||||
private var cameraDevice: CameraDevice? = null
|
||||
private var captureSession: CameraCaptureSession? = null
|
||||
private var imageReader: ImageReader? = null
|
||||
private var backgroundThread: HandlerThread? = null
|
||||
private var backgroundHandler: Handler? = null
|
||||
|
||||
private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
||||
private val cameraId = cameraManager.cameraIdList.firstOrNull {
|
||||
cameraManager.getCameraCharacteristics(it)
|
||||
.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK
|
||||
} ?: cameraManager.cameraIdList.first()
|
||||
|
||||
fun start() {
|
||||
startBackgroundThread()
|
||||
openCamera()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
closeCamera()
|
||||
stopBackgroundThread()
|
||||
}
|
||||
|
||||
private fun startBackgroundThread() {
|
||||
backgroundThread = HandlerThread("CameraBackground").apply {
|
||||
start()
|
||||
backgroundHandler = Handler(looper)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopBackgroundThread() {
|
||||
backgroundThread?.quitSafely()
|
||||
backgroundThread?.join()
|
||||
backgroundThread = null
|
||||
backgroundHandler = null
|
||||
}
|
||||
|
||||
private fun openCamera() {
|
||||
try {
|
||||
cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() {
|
||||
override fun onOpened(camera: CameraDevice) {
|
||||
cameraDevice = camera
|
||||
createCameraPreviewSession()
|
||||
}
|
||||
|
||||
override fun onDisconnected(camera: CameraDevice) {
|
||||
camera.close()
|
||||
cameraDevice = null
|
||||
}
|
||||
|
||||
override fun onError(camera: CameraDevice, error: Int) {
|
||||
camera.close()
|
||||
cameraDevice = null
|
||||
}
|
||||
}, backgroundHandler!!)
|
||||
} catch (e: SecurityException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createCameraPreviewSession() {
|
||||
try {
|
||||
// 获取支持的尺寸
|
||||
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
|
||||
val map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
|
||||
val previewSize = map?.getOutputSizes(SurfaceHolder::class.java)?.firstOrNull {
|
||||
it.width <= 1280 && it.height <= 720
|
||||
} ?: Size(1280, 720)
|
||||
|
||||
imageReader = ImageReader.newInstance(previewSize.width, previewSize.height, ImageFormat.JPEG, 2)
|
||||
imageReader?.setOnImageAvailableListener(imageAvailableListener, backgroundHandler)
|
||||
|
||||
val captureRequest = cameraDevice?.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)?.apply {
|
||||
addTarget(imageReader!!.surface)
|
||||
set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
|
||||
set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON)
|
||||
}
|
||||
|
||||
cameraDevice?.createCaptureSession(
|
||||
listOf(imageReader!!.surface),
|
||||
object : CameraCaptureSession.StateCallback() {
|
||||
override fun onConfigured(session: CameraCaptureSession) {
|
||||
captureSession = session
|
||||
captureRequest?.let {
|
||||
session.setRepeatingRequest(it.build(), null, backgroundHandler)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConfigureFailed(session: CameraCaptureSession) {}
|
||||
},
|
||||
backgroundHandler
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private val imageAvailableListener = ImageReader.OnImageAvailableListener { reader ->
|
||||
val image = reader.acquireLatestImage() ?: return@OnImageAvailableListener
|
||||
try {
|
||||
val buffer = image.planes[0].buffer
|
||||
val bytes = ByteArray(buffer.remaining())
|
||||
buffer.get(bytes)
|
||||
onFrame(bytes, image.width, image.height)
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun captureAndSave() {
|
||||
// 保存最后一帧
|
||||
imageReader?.acquireLatestImage()?.use { image ->
|
||||
val buffer = image.planes[0].buffer
|
||||
val bytes = ByteArray(buffer.remaining())
|
||||
buffer.get(bytes)
|
||||
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||
val file = File(
|
||||
context.getExternalFilesDir(null),
|
||||
"envelope_$timestamp.jpg"
|
||||
)
|
||||
|
||||
FileOutputStream(file).use { it.write(bytes) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeCamera() {
|
||||
captureSession?.close()
|
||||
captureSession = null
|
||||
imageReader?.close()
|
||||
imageReader = null
|
||||
cameraDevice?.close()
|
||||
cameraDevice = null
|
||||
}
|
||||
}
|
||||
101
android-app/app/src/main/java/com/usbwebcam/MainActivity.kt
Normal file
101
android-app/app/src/main/java/com/usbwebcam/MainActivity.kt
Normal file
@@ -0,0 +1,101 @@
|
||||
package com.usbwebcam
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private var mjpegServer: MjpegServer? = null
|
||||
private var cameraHelper: CameraHelper? = null
|
||||
|
||||
private val requestPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
startCamera()
|
||||
} else {
|
||||
Toast.makeText(this, "需要相机权限", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
val btnStart = findViewById<Button>(R.id.btn_start)
|
||||
val btnStop = findViewById<Button>(R.id.btn_stop)
|
||||
val btnCapture = findViewById<Button>(R.id.btn_capture)
|
||||
val tvStatus = findViewById<TextView>(R.id.tv_status)
|
||||
|
||||
btnStart.setOnClickListener {
|
||||
if (checkPermission()) {
|
||||
startCamera()
|
||||
}
|
||||
}
|
||||
|
||||
btnStop.setOnClickListener {
|
||||
stopCamera()
|
||||
}
|
||||
|
||||
btnCapture.setOnClickListener {
|
||||
cameraHelper?.captureAndSave()
|
||||
Toast.makeText(this, "图片已保存到相册", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
// 自动启动
|
||||
if (checkPermission()) {
|
||||
startCamera()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkPermission(): Boolean {
|
||||
return if (ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.CAMERA
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
true
|
||||
} else {
|
||||
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCamera() {
|
||||
cameraHelper = CameraHelper(this) { frame, _, _ ->
|
||||
mjpegServer?.updateFrame(frame)
|
||||
}
|
||||
|
||||
mjpegServer = MjpegServer(8080)
|
||||
mjpegServer?.start {
|
||||
runOnUiThread {
|
||||
findViewById<TextView>(R.id.tv_status).text =
|
||||
"服务运行中端口: 8080IP: 无需IP (ADB模式)USB连接命令:adb forward tcp:8080 tcp:8080"
|
||||
findViewById<Button>(R.id.btn_start).isEnabled = false
|
||||
findViewById<Button>(R.id.btn_stop).isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
cameraHelper?.start()
|
||||
}
|
||||
|
||||
private fun stopCamera() {
|
||||
cameraHelper?.stop()
|
||||
mjpegServer?.stop()
|
||||
|
||||
findViewById<TextView>(R.id.tv_status).text = "服务已停止"
|
||||
findViewById<Button>(R.id.btn_start).isEnabled = true
|
||||
findViewById<Button>(R.id.btn_stop).isEnabled = false
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
stopCamera()
|
||||
}
|
||||
}
|
||||
150
android-app/app/src/main/java/com/usbwebcam/MjpegServer.kt
Normal file
150
android-app/app/src/main/java/com/usbwebcam/MjpegServer.kt
Normal file
@@ -0,0 +1,150 @@
|
||||
package com.usbwebcam
|
||||
|
||||
import java.io.OutputStream
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class MjpegServer(private val port: Int) {
|
||||
private var serverSocket: ServerSocket? = null
|
||||
private var serverThread: Thread? = null
|
||||
private val clients = CopyOnWriteArrayList<ClientHandler>()
|
||||
private val running = AtomicBoolean(false)
|
||||
private var currentFrame: ByteArray? = null
|
||||
private val frameLock = Any()
|
||||
|
||||
fun start(onStarted: () -> Unit) {
|
||||
serverThread = Thread {
|
||||
try {
|
||||
serverSocket = ServerSocket(port)
|
||||
running.set(true)
|
||||
onStarted()
|
||||
|
||||
while (running.get()) {
|
||||
val client = try {
|
||||
serverSocket?.accept()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} ?: continue
|
||||
|
||||
val handler = ClientHandler(client)
|
||||
clients.add(handler)
|
||||
handler.start()
|
||||
|
||||
// 发送当前帧
|
||||
val frameToSend = synchronized(frameLock) {
|
||||
currentFrame
|
||||
}
|
||||
if (frameToSend != null) {
|
||||
handler.sendFrame(frameToSend)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (running.get()) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
name = "MjpegServerThread"
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFrame(frame: ByteArray) {
|
||||
synchronized(frameLock) {
|
||||
currentFrame = frame
|
||||
}
|
||||
|
||||
// 广播给所有客户端
|
||||
val iterator = clients.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val handler = iterator.next()
|
||||
if (handler.isSocketActive()) {
|
||||
handler.sendFrame(frame)
|
||||
} else {
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!running.getAndSet(false)) return
|
||||
|
||||
try {
|
||||
serverSocket?.close()
|
||||
} catch (e: Exception) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
clients.forEach { it.close() }
|
||||
clients.clear()
|
||||
serverSocket = null
|
||||
}
|
||||
|
||||
private class ClientHandler(private val socket: Socket) : Thread() {
|
||||
private var clientStream: OutputStream? = null
|
||||
@Volatile
|
||||
private var initialized = false
|
||||
|
||||
override fun run() {
|
||||
try {
|
||||
val os = socket.getOutputStream()
|
||||
clientStream = os
|
||||
socket.setSoTimeout(5000)
|
||||
|
||||
// 读取HTTP请求 (清空缓冲区)
|
||||
val buffer = ByteArray(1024)
|
||||
socket.getInputStream().read(buffer)
|
||||
|
||||
// 发送MJPEG流头 - 正确的HTTP格式
|
||||
val header = byteArrayOf(
|
||||
*("HTTP/1.1 200 OK\r\n").toByteArray(),
|
||||
*("Content-Type: multipart/x-mixed-replace; boundary=boundary\r\n").toByteArray(),
|
||||
*("Cache-Control: no-cache\r\n").toByteArray(),
|
||||
*("Connection: close\r\n").toByteArray(),
|
||||
*("\r\n").toByteArray() // 空行表示header结束
|
||||
)
|
||||
|
||||
os.write(header)
|
||||
os.flush()
|
||||
initialized = true
|
||||
} catch (e: Exception) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
fun sendFrame(frame: ByteArray) {
|
||||
if (!initialized || clientStream == null) return
|
||||
|
||||
try {
|
||||
// MJPEG frame header with \r\n
|
||||
val header = byteArrayOf(
|
||||
*("--boundary\r\n").toByteArray(),
|
||||
*("Content-Type: image/jpeg\r\n").toByteArray(),
|
||||
*("Content-Length: ${frame.size}\r\n").toByteArray(),
|
||||
*("\r\n").toByteArray()
|
||||
)
|
||||
|
||||
clientStream?.write(header)
|
||||
clientStream?.write(frame)
|
||||
clientStream?.write(("\r\n").toByteArray())
|
||||
clientStream?.flush()
|
||||
} catch (e: Exception) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun isSocketActive(): Boolean {
|
||||
return !socket.isClosed && socket.isConnected
|
||||
}
|
||||
}
|
||||
}
|
||||
51
android-app/app/src/main/res/layout/activity_main.xml
Normal file
51
android-app/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,51 @@
|
||||
<?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="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="等待启动..."
|
||||
android:textSize="16sp"
|
||||
android:textColor="#333"
|
||||
android:layout_marginBottom="32dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_start"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="60dp"
|
||||
android:text="启动"
|
||||
android:textSize="18sp"
|
||||
android:layout_marginEnd="16dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_stop"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="60dp"
|
||||
android:text="停止"
|
||||
android:textSize="18sp"
|
||||
android:enabled="false" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_capture"
|
||||
android:layout_width="200dp"
|
||||
android:layout_height="70dp"
|
||||
android:text="拍照保存"
|
||||
android:textSize="20sp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:backgroundTint="#4CAF50" />
|
||||
|
||||
</LinearLayout>
|
||||
Reference in New Issue
Block a user