

























package net.staredu import android.Manifest import android.content.pm.PackageManager import android.graphics.BitmapFactory import android.hardware.usb.UsbDevice import android.hardware.usb.UsbManager import android.os.Build import android.os.Bundle import android.os.Environment import android.view.SurfaceHolder import android.view.SurfaceView import android.widget.Button import android.widget.ImageView import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import com.serenegiant.usb.* import java.io.File import java.io.FileOutputStream import java.io.FileWriter import java.io.PrintWriter import java.text.SimpleDateFormat import java.util.* import java.nio.ByteBuffer import android.graphics.YuvImage import java.io.ByteArrayOutputStream class USBCameraTestActivity : AppCompatActivity() { private lateinit var surfaceView: SurfaceView private lateinit var tvStatus: TextView private lateinit var btnStart: Button private lateinit var btnCapture: Button private lateinit var ivCaptured: ImageView private var usbMonitor: USBMonitor? = null private var camera: UVCCamera? = null private var mUsbDevice: UsbDevice? = null private lateinit var logFile: File private var bestResolution = "未找到" private var bestFps = 0 // 状态标志 private var isConnected = false private var isPreviewing = false private val deviceConnectListener = object : USBMonitor.OnDeviceConnectListener { override fun onAttach(device: UsbDevice?) { runOnUiThread { tvStatus.text = "设备已连接: ${device?.deviceName}" Toast.makeText(this@USBCameraTestActivity, "设备已连接", Toast.LENGTH_SHORT).show() log("设备已连接: ${device?.deviceName}") device?.let { usbMonitor?.requestPermission(it) } } } override fun onDeviceOpen(device: UsbDevice?, ctrlBlock: USBMonitor.UsbControlBlock?, createNew: Boolean) { if (ctrlBlock == null) { log("onDeviceOpen: ctrlBlock is null") return } runOnUiThread { tvStatus.text = "设备已打开,正在探测最佳参数..." } try { val param = UVCParam() camera = UVCCamera(param) val result = camera?.open(ctrlBlock) ?: -1 log("UVCCamera.open 返回: $result") if (result != 0) { runOnUiThread { tvStatus.text = "打开摄像头失败,错误码: $result" Toast.makeText(this@USBCameraTestActivity, "打开摄像头失败,错误码: $result", Toast.LENGTH_LONG).show() } log("打开摄像头失败,错误码: $result") return } // 标记已连接 isConnected = true log("设备已连接并打开") // ========== 按优先级探测最佳分辨率和帧率 ========== val resolutionPriority = listOf( Pair(1920, 1080), Pair(1280, 720), Pair(800, 600), Pair(1024, 768), Pair(640, 480), Pair(320, 240) ) val fpsPriority = intArrayOf(60, 50, 40, 30, 25, 20, 15, 10, 5) var found = false for ((width, height) in resolutionPriority) { for (fps in fpsPriority) { try { camera?.setPreviewSize(width, height, fps) log("✅ 成功: ${width}x${height} @ ${fps}fps") bestResolution = "${width}x${height}" bestFps = fps found = true break } catch (e: Exception) { log("❌ 失败: ${width}x${height} @ ${fps}fps, error: ${e.message}") } } if (found) break } if (!found) { log("所有组合均失败") runOnUiThread { tvStatus.text = "无法找到可用的预览参数" Toast.makeText(this@USBCameraTestActivity, "所有分辨率/帧率组合均失败", Toast.LENGTH_LONG).show() } camera?.close() camera = null return } val bestText = "最佳参数: $bestResolution @ ${bestFps}fps" log(bestText) runOnUiThread { tvStatus.text = bestText Toast.makeText(this@USBCameraTestActivity, bestText, Toast.LENGTH_LONG).show() } // 设置预览显示 if (surfaceView.holder.surface != null) { camera?.setPreviewDisplay(surfaceView.holder) camera?.startPreview() isPreviewing = true runOnUiThread { tvStatus.text = "预览中 ($bestResolution @ ${bestFps}fps)" btnStart.isEnabled = false btnCapture.visibility = Button.VISIBLE } log("预览已启动") } } catch (e: Exception) { log("打开摄像头异常: ${e.message}") e.printStackTrace() runOnUiThread { tvStatus.text = "异常: ${e.message}" Toast.makeText(this@USBCameraTestActivity, "异常: ${e.message}", Toast.LENGTH_LONG).show() } } } override fun onDeviceClose(device: UsbDevice?, ctrlBlock: USBMonitor.UsbControlBlock?) { camera?.close() camera = null isConnected = false isPreviewing = false runOnUiThread { tvStatus.text = "设备已关闭" btnStart.isEnabled = true btnCapture.visibility = Button.GONE } log("设备已关闭") } override fun onDetach(device: UsbDevice?) { camera?.close() camera = null isConnected = false isPreviewing = false runOnUiThread { tvStatus.text = "设备已拔出" Toast.makeText(this@USBCameraTestActivity, "设备已拔出", Toast.LENGTH_SHORT).show() btnStart.isEnabled = true btnCapture.visibility = Button.GONE } log("设备已拔出") } override fun onCancel(device: UsbDevice?) { runOnUiThread { tvStatus.text = "USB 权限被拒绝" Toast.makeText(this@USBCameraTestActivity, "USB 权限被拒绝", Toast.LENGTH_LONG).show() } log("USB 权限被拒绝") } } private val surfaceHolderCallback = object : SurfaceHolder.Callback { override fun surfaceCreated(holder: SurfaceHolder) { if (camera != null && isPreviewing) { camera?.setPreviewDisplay(holder) camera?.startPreview() runOnUiThread { tvStatus.text = "预览中 ($bestResolution @ ${bestFps}fps)" } log("Surface 创建,预览恢复") } } override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {} override fun surfaceDestroyed(holder: SurfaceHolder) { camera?.stopPreview() runOnUiThread { tvStatus.text = "预览已停止" } log("Surface 销毁,预览停止") } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_usb_camera_test) initLogFile() surfaceView = findViewById(R.id.surfaceView) tvStatus = findViewById(R.id.tvStatus) btnStart = findViewById(R.id.btnStart) btnCapture = findViewById(R.id.btnCapture) ivCaptured = findViewById(R.id.ivCaptured) surfaceView.holder.addCallback(surfaceHolderCallback) usbMonitor = USBMonitor(this, deviceConnectListener) btnStart.setOnClickListener { startCamera() } btnCapture.setOnClickListener { capturePhoto() } btnCapture.visibility = Button.GONE // 初始隐藏 // 申请权限 val needPerms = mutableListOf<String>() if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { needPerms.add(Manifest.permission.CAMERA) } // Android13+ 外部存储媒体权限 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_MEDIA_IMAGES) != PackageManager.PERMISSION_GRANTED) { needPerms.add(Manifest.permission.READ_MEDIA_IMAGES) } } else { if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { needPerms.add(Manifest.permission.WRITE_EXTERNAL_STORAGE) } } if (needPerms.isNotEmpty()) { ActivityCompat.requestPermissions(this, needPerms.toTypedArray(), 100) } } private fun capturePhoto() { if (camera == null || !isPreviewing) { Toast.makeText(this, "请先启动预览", Toast.LENGTH_SHORT).show() return } val previewSize = camera?.getPreviewSize() if (previewSize == null) { Toast.makeText(this, "无法获取预览尺寸", Toast.LENGTH_SHORT).show() return } val width = previewSize.width val height = previewSize.height log("拍照尺寸: ${width}x${height}") val PIXEL_FORMAT_YUY2 = 0 camera?.setFrameCallback(object : IFrameCallback { override fun onFrame(frame: ByteBuffer) { try { val remaining = frame.remaining() log("帧数据长度: $remaining, 预期 NV21: ${width * height * 3 / 2}, 预期 YUY2: ${width * height * 2}") val bytes = ByteArray(remaining) frame.get(bytes) val jpegData = convertYUY2toJPEG(bytes, width, height) if (jpegData == null || jpegData.isEmpty()) { runOnUiThread { Toast.makeText(this@USBCameraTestActivity, "图片转换失败", Toast.LENGTH_SHORT).show() } return } val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) val fileName = "UVC_${timeStamp}.jpg" val storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES) val file = File(storageDir, fileName) FileOutputStream(file).use { fos -> fos.write(jpegData) } runOnUiThread { val bitmap = BitmapFactory.decodeByteArray(jpegData, 0, jpegData.size) ivCaptured.setImageBitmap(bitmap) Toast.makeText(this@USBCameraTestActivity, "拍照成功: ${file.absolutePath}", Toast.LENGTH_LONG).show() tvStatus.text = "拍照成功" } log("拍照成功: ${file.absolutePath}") } catch (e: Exception) { log("拍照异常: ${e.message}") e.printStackTrace() runOnUiThread { Toast.makeText(this@USBCameraTestActivity, "拍照失败: ${e.message}", Toast.LENGTH_SHORT).show() tvStatus.text = "拍照失败" } } finally { camera?.setFrameCallback(null, 0) } } }, PIXEL_FORMAT_YUY2) } private fun convertYUVtoJPEG(data: ByteArray, width: Int, height: Int, format: Int): ByteArray? { return try { val yuvImage = YuvImage(data, format, width, height, null) val out = ByteArrayOutputStream() yuvImage.compressToJpeg(android.graphics.Rect(0, 0, width, height), 80, out) out.toByteArray() } catch (e: Exception) { log("YUV转换失败: ${e.message}") null } } private fun convertYUY2toJPEG(data: ByteArray, width: Int, height: Int): ByteArray? { try { val nv21 = ByteArray(width * height * 3 / 2) var yIndex = 0 var uvIndex = width * height var i = 0 while (i < data.size) { // YUY2 排列: Y1 U Y2 V val y1 = data[i].toInt() and 0xFF val u = data[i + 1].toInt() and 0xFF val y2 = data[i + 2].toInt() and 0xFF val v = data[i + 3].toInt() and 0xFF // 写入两个 Y 分量 nv21[yIndex++] = y1.toByte() nv21[yIndex++] = y2.toByte() // 计算当前像素对所属的行 val pairIndex = i / 4 val pixelIndex = pairIndex * 2 val row = pixelIndex / width // 只保留偶数行的 UV(垂直下采样) if (row % 2 == 0) { // ✅ 修正:NV21 要求先 V 后 U(V U V U ...) nv21[uvIndex++] = v.toByte() nv21[uvIndex++] = u.toByte() } i += 4 } val yuvImage = YuvImage(nv21, android.graphics.ImageFormat.NV21, width, height, null) val out = ByteArrayOutputStream() yuvImage.compressToJpeg(android.graphics.Rect(0, 0, width, height), 80, out) return out.toByteArray() } catch (e: Exception) { log("YUY2转换失败: ${e.message}") e.printStackTrace() return null } } private fun saveBitmap(bitmap: android.graphics.Bitmap) { try { val dir = getExternalFilesDir(Environment.DIRECTORY_PICTURES) if (dir != null && !dir.exists()) dir.mkdirs() val file = File(dir, "UVC_${System.currentTimeMillis()}.jpg") FileOutputStream(file).use { fos -> bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 90, fos) } Toast.makeText(this, "已保存: ${file.absolutePath}", Toast.LENGTH_LONG).show() } catch (e: Exception) { log("保存图片失败: ${e.message}") } } private fun initLogFile() { try { val dir = getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) ?: filesDir val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) logFile = File(dir, "uvc_log_$timestamp.txt") log("=== UVC 测试日志开始 ===") log("设备信息: ${Build.MANUFACTURER} ${Build.MODEL}, Android ${Build.VERSION.RELEASE}") } catch (e: Exception) { e.printStackTrace() } } private fun log(message: String) { try { FileWriter(logFile, true).use { writer -> PrintWriter(writer).use { pw -> val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()).format(Date()) pw.println("[$timestamp] $message") pw.flush() } } } catch (e: Exception) { } } private fun startCamera() { if (isConnected) { Toast.makeText(this, "摄像头已连接,无需重复启动", Toast.LENGTH_SHORT).show() return } log("启动摄像头") usbMonitor?.register() val usbManager = getSystemService(USB_SERVICE) as UsbManager val deviceList = usbManager.deviceList log("检测到 USB 设备数量: ${deviceList.size}") if (deviceList.isEmpty()) { runOnUiThread { Toast.makeText(this, "未检测到 USB 设备,请连接摄像头", Toast.LENGTH_SHORT).show() tvStatus.text = "未检测到设备" } log("未检测到 USB 设备") return } val device = deviceList.values.firstOrNull() if (device == null) { runOnUiThread { Toast.makeText(this, "未找到 USB 摄像头", Toast.LENGTH_SHORT).show() tvStatus.text = "未找到摄像头" } log("USB 设备列表为空") return } mUsbDevice = device log("请求权限设备: ${device.deviceName}, VID: ${device.vendorId}, PID: ${device.productId}") runOnUiThread { tvStatus.text = "正在请求 USB 权限..." } usbMonitor?.requestPermission(device) } override fun onResume() { super.onResume() usbMonitor?.register() log("onResume") } override fun onPause() { super.onPause() usbMonitor?.unregister() if (isPreviewing) { camera?.stopPreview() } log("onPause") } override fun onDestroy() { super.onDestroy() camera?.stopPreview() camera?.close() camera = null usbMonitor?.destroy() log("onDestroy") log("=== 日志结束 ===") } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == 100) { var allGranted = true grantResults.forEach { if (it != PackageManager.PERMISSION_GRANTED) allGranted = false } if (allGranted) { log("全部权限已授予") } else { log("权限被拒绝,部分功能无法使用") Toast.makeText(this, "缺少必要存储/相机权限", Toast.LENGTH_LONG).show() } } } }
<?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:background="#000000" android:padding="10dp"> <TextView android:id="@+id/tvStatus" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="等待设备..." android:textColor="#FFFFFF" android:textSize="16sp" android:gravity="center" android:padding="8dp"/> <FrameLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:background="#000000"> <!-- 使用 SurfaceView,并固定 16:9 比例(通过 layout_weight + 背景色占位) --> <SurfaceView android:id="@+id/surfaceView" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_gravity="center"/> </FrameLayout> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:layout_marginTop="8dp"> <Button android:id="@+id/btnStart" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:text="启动预览" android:backgroundTint="#2196F3" android:textColor="#FFFFFF"/> <Button android:id="@+id/btnCapture" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:text="拍照" android:backgroundTint="#4CAF50" android:textColor="#FFFFFF" android:visibility="gone"/> </LinearLayout> <ImageView android:id="@+id/ivCaptured" android:layout_width="match_parent" android:layout_height="120dp" android:layout_marginTop="8dp" android:background="#333333" android:scaleType="centerInside"/> </LinearLayout>
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。