11-安卓端实战:CameraX推流与OverlayView覆盖层与TTS语音播报
服务端会"收"了,浏览器能"看"了。但钓鱼佬真正握在手里的,是手机。这一篇,我们把手机武装成"电子哨兵":摄像头开着、帧推着、识别结果画在屏幕上、鱼一咬钩立刻用嘴喊出来。
大家好,我是黒漂技术佬。上篇我们给 Python 识别核心装了一张嘴(HTTP 服务),现在轮到给手机装"眼睛"和"嗓子"。
回顾一下手机端要干的四件事:
- 调起摄像头 —— 看清水面;
- 推流 —— 把画面送到服务端让 AI 分析;
- 画标注 —— 把 AI 的识别结果(漂在哪、什么事件)画在预览画面上;
- 语音播报 —— 识别到下顿/顶漂/黑漂/点漂时,用嘴喊出来。
技术栈:Kotlin + CameraX + 原生 Canvas + Android TTS。全部是 Android 官方原生能力,一个第三方依赖都不用加(对这个项目来说,YAGNI 精神早已刻进 DNA)。
一、先搞懂三个概念:CameraX、ImageAnalysis、TTS
1.1 CameraX:安卓官方"傻瓜相机"
Android 底层相机 API 叫 Camera2,功能强大到让人头大:光一个"预览"就得处理 CameraDevice、CaptureSession、Surface、HandlerThread 之间的一大堆状态回调,新手写出来能绕晕三圈。
CameraX 是 Google 在 Camera2 之上封装的 Jetpack 相机库,把复杂性全藏起来,你的代码只需要关心"我用相机干什么":
cameraProvider.bindToLifecycle(
lifecycleOwner, // 谁的生命周期管着相机
cameraSelector, // 前摄还是后摄
preview, // 预览用例
imageAnalysis // 帧分析用例
)
它内部自动处理相机开关、生命周期、设备兼容性(不同手机厂商的相机驱动差异)。面向场景编程,而不是面向底层 API 编程——这就是 CameraX 的设计哲学。
本项目用了两个用例:
| 用例 | 干什么 |
|---|---|
Preview | 把相机画面显示到手机屏幕(PreviewView) |
ImageAnalysis | 每帧回调给你一帧 YUV 数据,供你做图像分析 |
1.2 ImageAnalysis:逐帧"偷看"摄像头的钥匙
ImageAnalysis 是 CameraX 的"分析用例"。你告诉它一个分析器(Analyzer),相机会一帧一帧把画面送到你的 analyze() 回调里。默认全帧率回调(30fps 甚至更高),但我们不需要全帧率——后面会讲为什么限帧到 10fps。
1.3 TTS:Android 自带的"电子嘴"
TTS 全称 Text-To-Speech,文字转语音。安卓系统内置了 TTS 引擎(android.speech.tts.TextToSpeech),你只需要:
tts = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
tts?.language = Locale.CHINESE // 设置中文
}
}
// 想说话时:
tts?.speak("下顿", TextToSpeech.QUEUE_FLUSH, null, "event-utterance")
它不联网、不收费、延迟低(几十毫秒就能开口),钓鱼场景下再合适不过。
二、MainActivity 整体流程:一条流水线串起全部
先看全局流程图,然后逐一拆解:
onCreate: 申请权限 → 初始化 TTS → 绑定 UI
│
▼
启动相机: Preview 绑定 PreviewView → ImageAnalysis 注册分析器
│
▼
analyze() 每帧回调(~30fps)
│ frameCounter % 3 == 0 ? ← 限帧:只处理 1/3
│ ↓ 是
│ ImageUtils: YUV_420_888 → NV21 → JPEG (quality=70)
│ ↓
│ StreamClient 异步 POST /frame(单线程串行)
│ ↓
│ onResult(JSON): 解析事件/漂位置/置信度
│ ├─▶ OverlayView.setEvent(...) → 画面画圈、画文字
│ └─▶ TTS.speak(...) (2秒去重,防刷屏)
▼
用户视角: 画面实时显示 + AI 标注 + 鱼口语音播报
细节拉满的版本在这,先看核心骨架:
class MainActivity : AppCompatActivity() {
private lateinit var imageAnalysis: ImageAnalysis
private var frameCounter = 0 // 限帧计数器
private val streamClient = StreamClient("http://192.168.1.100:8080")
private lateinit var overlayView: OverlayView // 覆盖层:画 AI 标注
private lateinit var tts: TextToSpeech
private var lastEventTime = 0L // TTS 去重时间戳
private var lastEventType = "" // TTS 去重事件类型
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
overlayView = findViewById(R.id.overlay_view)
initTts()
if (hasCameraPermission()) startCamera() else requestCameraPermission()
}
// ... 详见下文各节
}
三、ImageUtils:YUV → NV21 → JPEG 这条链路为什么非走不可
这是新手第一个会卡住的地方:CameraX 给的帧是 YUV,HTTP 要传的是 JPEG,中间为什么要转个 NV21?
3.1 三种格式的前世今生
| 格式 | 是什么 | 特点 |
|---|---|---|
| YUV_420_888 | CameraX 默认输出格式 | 亮度 Y + 色度 UV 分离,节省带宽,但结构复杂 |
| NV21 | 老的 YUV 排列方式(YYYY...VUVU) | Android 相机和图像处理的标准老熟人 |
| JPEG | 压缩图片格式 | 体积小,适合网络传输,浏览器直接能显示 |
链路为什么是 YUV_420_888 → NV21 → JPEG?
- CameraX 只保证输出
YUV_420_888(这是它的标准格式); YUV_420_888是分平面存储的(Y 一个平面,UV 各一个平面),直接用起来很繁琐;NV21是交错存储的(Y 平面 + VU 交错),Android 原生YuvImage类只认 NV21;- 想压缩成 JPEG,最省事的方式就是
YuvImage(NV21) → compressToJpeg()。
所以这条链路是"被迫最优解":CameraX 出什么,我们顺着 Android 的能力一路转到能传的东西。每一环都有 Android 原生 API 支持,不需要任何三方库。
object ImageUtils {
/** 把 ImageProxy(YUV_420_888)转成 NV21 字节数组 */
fun yuv420888ToNv21(image: ImageProxy): ByteArray {
val yPlane = image.planes[0]
val uPlane = image.planes[1]
val vPlane = image.planes[2]
val ySize = yPlane.rowStride * image.height
val uvSize = uPlane.rowStride * (image.height / 2)
// 用 image 的 buffer(Y plane 占满 rowStride * height)
val nv21 = ByteArray(ySize + uvSize)
val yBuffer = yPlane.buffer
val uBuffer = uPlane.buffer
val vBuffer = vPlane.buffer
// 拷贝 Y 平面(可能有 padding,这里简单按连续拷贝)
yBuffer.get(nv21, 0, ySize)
// 交错拷贝 UV:V 在前 U 在后(NV21 的经典排列 VU)
var uvIndex = ySize
for (i in 0 until uvSize step 2) {
nv21[uvIndex++] = vBuffer.get(i) // V
nv21[uvIndex++] = uBuffer.get(i) // U
}
return nv21
}
/** NV21 → JPEG 压缩 */
fun nv21ToJpeg(nv21: ByteArray, width: Int, height: Int, quality: Int = 70): ByteArray {
val yuvImage = YuvImage(nv21, ImageFormat.NV21, width, height, null)
val out = ByteArrayOutputStream()
if (yuvImage.compressToJpeg(Rect(0, 0, width, height), quality, out)) {
return out.toByteArray()
}
return ByteArray(0)
}
/** 一条龙:ImageProxy → JPEG(本项目只需要这个) */
fun imageToJpeg(image: ImageProxy, quality: Int = 70): ByteArray {
val nv21 = yuv420888ToNv21(image)
return nv21ToJpeg(nv21, image.width, image.height, quality)
}
}
老实说,上面的
yuv420888ToNv21我做了简化处理。真实场景里rowStride不等于width(相机为了内存对齐会补 padding),拷贝时要按rowStride逐行跳着拷贝。完整写法在项目ImageUtils.kt里,那段代码多二十行,但逻辑更严谨。这里为了讲解清晰先用简化版——注释里说清楚,比代码里偷偷藏 bug 强。
3.2 quality = 70 为什么是"甜点位"
这个问题上篇在服务端聊过,手机端再补一刀:压缩发生在手机端,体积直接决定手机的电量消耗和网络流量。
- quality=95:单帧 ~180KB,10fps 推流带宽 14.4Mbps,手机 CPU 忙、发热、耗电快;
- quality=70:单帧 ~45KB,带宽降到 3.6Mbps,肉眼几乎看不出区别;
- quality=40:单帧 ~20KB,但漂的红色边缘开始出现色块噪点,可能干扰 HSV 检测。
70 是"识别够用 + 体积舒服 + 手机省电"的三方平衡点。把它做成可配置参数,热天想要极致省电可以再压到 55。
四、限帧到 10fps:为什么 30fps 全推是浪费
CameraX 的 ImageAnalysis 默认把每一帧都回调给你,30fps。但我们的策略是只处理 1/3:
imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) // 只保留最新帧
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
.build()
imageAnalysis.setAnalyzer(cameraExecutor) { image ->
frameCounter++
if (frameCounter % 3 != 0) {
image.close() // 跳过不处理的帧必须 close!否则相机卡死
return@setAnalyzer
}
handleFrame(image)
}
为什么 3 帧只处理 1 帧?
- 识别跟不上:服务端一帧处理要 30~50ms,就算 10fps 也已经吃满服务端算力了,推 30fps 服务端只会排队积压;
- 费流量:30fps × 45KB = 13.5MB/s 的流量,在局域网里不是事,但手机 WiFi 天线会因此发热耗电;
- 漂相是慢动作:下顿、顶漂这类动作持续几百毫秒到几秒,10fps 采样绰绰有余,15 帧都能覆盖一个完整的顿口过程。用 120fps 慢动作去拍钓鱼?那是拍电影,不是识别。
一句话:帧率不是越高越好,匹配物理世界的节奏才叫工程。
别忘了
image.close()!CameraX 的 ImageProxy 是复用缓冲区的,你不 close 它,相机缓冲区耗尽直接停摆。这是 CameraX 新手必踩的坑,一旦踩了,画面会突然"冻住"。
五、StreamClient:单线程串行推流,绝不并发乱序
推流客户端是整个手机端最容易被写坏的部分。先想清楚两个约束:
- 顺序:帧必须按拍摄顺序到达服务端,乱序会导致卡尔曼滤波和跟踪器"看到"跳跃的画面;
- 实时:如果服务端处理不过来,宁可丢帧,也不能积压延迟。
5.1 单线程 Executor 串行发送
如果每帧都开一个线程 POST,网络抖动时后发的帧可能先到,服务端就收到乱序帧。所以所有请求塞进同一个单线程 Executor:
class StreamClient(private val baseUrl: String) {
private val executor = Executors.newSingleThreadExecutor() // 串行:一帧接一帧发
private var sending = false // 当前是否还在发送上一帧
private val client = OkHttpClient() // 实际项目用 OkHttp,见下文
/** 推送一帧;如果上一帧还没发完,直接丢弃(保证实时性) */
fun postFrame(jpeg: ByteArray, onResult: (String?) -> Unit) {
if (sending) return // 丢帧策略:追新不追旧
sending = true
executor.execute {
try {
val resp = client.newCall(doPost(jpeg)).execute()
onResult(resp.body?.string())
} catch (e: Exception) {
onResult(null)
} finally {
sending = false
}
}
}
}
5.2 丢帧策略:如果上一帧没发完,新帧直接扔掉
if (sending) return 这一行是灵魂。它保证了:
- 绝不排队:发送中的帧没回来,新帧直接弃。为什么?因为排队意味着延迟累加——服务端慢 500ms,积压 5 帧,你的"实时"就变成 2.5 秒前的画面。钓鱼要的是"现在"。
- 服务端永远是"最新帧优先":这与服务端
latest_jpeg只存最新帧的策略一脉相承,两端统一哲学。
5.3 Base64 还是二进制?——用二进制 body
有些教程喜欢把图片 Base64 编码塞进 JSON。Base64 会把体积膨胀约 33%(3 字节变 4 字节),手机端编解码还额外耗电。本项目直接 POST 纯二进制 body,服务端 Content-Length 直接读字节流,零编码开销:
POST /frame HTTP/1.1
Host: 192.168.1.100:8080
Content-Type: image/jpeg
Content-Length: 46080
<JPEG 二进制数据……>
5.4 关于 OkHttp 的坦白
上面的代码我用了 OkHttpClient,这里必须坦白:OkHttp 是 OkHttp 是第三方库。你可能会问:"不是说好零依赖吗?"
理一理:"零依赖"指的是识别核心(Python 侧,纯 numpy + OpenCV)。安卓侧用 OkHttp 是务实选择——它是 Android 社区的事实标准,Google 官方文档钦点的 HTTP 客户端,处理连接池、超时、重试这些脏活累活比手写 HttpURLConnection 强太多。这个项目的"零依赖"哲学针对的是算法与服务的核心逻辑,不是"连轮子都不许用"。安卓端用 OkHttp 就像 Python 端用 OpenCV 一样——把精力花在自己的核心创新上,而不是重新发明网络栈。
如果你洁癖犯了,用 HttpURLConnection 写一个也完全可行(Google 至今没删它),只是多写三十行样板代码,仅此而已。
六、onResult 回调:解析 JSON → 画标注 → 开口播报
服务端返回的 JSON 长这样:
{"status":"ok","frame_id":1287,"event":"下顿","confidence":0.93,
"float_pos":[0.52,0.31],"fps":9.8}
回调处理逻辑(核心是双保险去重):
private fun onResult(body: String?) {
if (body == null) return
try {
val json = JSONObject(body)
val event = json.optString("event", "")
val conf = json.optDouble("confidence", 0.0)
val arr = json.optJSONArray("float_pos")
val fx = if (arr != null && arr.length() >= 2) arr.getDouble(0) else -1.0
val fy = if (arr != null && arr.length() >= 2) arr.getDouble(1) else -1.0
// 更新覆盖层:画漂位置 + 事件文字(即使无事件也要刷新漂的位置)
runOnUiThread {
overlayView.setFloatPosition(fx, fy)
overlayView.setEvent(event, conf)
}
// TTS 播报:双保险去重(服务端冷却 + 客户端 2 秒节流)
if (event.isNotEmpty() && (event != lastEventType || System.currentTimeMillis() - lastEventTime > 2000)) {
lastEventType = event
lastEventTime = System.currentTimeMillis()
speak(event)
}
} catch (e: JSONException) {
Log.w(TAG, "解析结果失败: ${e.message}")
}
}
去重为什么是"双保险"
服务端信号判断本来就有冷却时间(避免同一口连续报三次),但网络延迟和回调时序可能导致手机重复播报。所以手机端再加一道保险:
- 2 秒内同类型事件不重复播——
System.currentTimeMillis() - lastEventTime > 2000; - 不同类型事件(比如刚"下顿"又来"点漂")立即播报,不受 2 秒限制。
服务端防"重复",客户端防"补刀",两层加起来,钓鱼佬的耳朵就不会被同一个鱼口轰炸三遍。
为什么必须 runOnUiThread
onResult 在 OkHttp 的线程里回调,而 overlayView 是 UI 组件,任何 UI 操作必须在主线程。runOnUiThread {} 把更新动作丢回主线程执行——这是 Android 的铁律,违反它 App 直接抛 CalledFromWrongThreadException 崩溃。
七、OverlayView:透明覆盖层,把 AI 的世界画出来
7.1 什么是覆盖层
OverlayView 是一个全透明自定义 View,叠在 PreviewView(相机预览)上面。它不抢相机的画面,只负责在合适的位置"涂鸦":圈出浮漂、飘出事件文字。
布局文件里这么叠:
<FrameLayout ...>
<androidx.camera.view.PreviewView
android:id="@+id/preview_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.example.autofishing.OverlayView
android:id="@+id/overlay_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
7.2 归一化坐标 → 屏幕像素
服务端返回的漂位置是 0~1 的归一化比例(如 0.52, 0.31),为什么?因为服务端不认识手机屏幕,手机也不关心服务端的处理分辨率——用一个与两端无关的比例坐标系沟通,谁都不需要迁就谁。
画的时候转成像素:
val px = fx * width // 归一化 x → 屏幕像素 x
val py = fy * height // 归一化 y → 屏幕像素 y
7.3 onDraw 完整代码
class OverlayView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
private var floatX = -1f
private var floatY = -1f
private var eventText = ""
private var eventColor = Color.WHITE
private var eventConf = 0.0
/** 主线程调用:更新浮漂位置 */
fun setFloatPosition(fx: Double, fy: Double) {
if (fx < 0 || fy < 0) { floatX = -1f; floatY = -1f }
else { floatX = (fx * width).toFloat(); floatY = (fy * height).toFloat() }
invalidate() // 请求重绘
}
/** 主线程调用:设置事件文字与颜色 */
fun setEvent(event: String, conf: Double) {
eventText = when (event) {
"下顿" -> "下顿!" // 快速顿口,蓝色
"顶漂" -> "顶漂!" // 送漂,绿色
"黑漂" -> "黑漂!" // 吞死口,红色
"点漂" -> "点漂…" // 小鱼试探,黄色
else -> ""
}
eventColor = when (event) {
"下顿" -> Color.CYAN
"顶漂" -> Color.GREEN
"黑漂" -> Color.RED
"点漂" -> Color.YELLOW
else -> Color.WHITE
}
eventConf = conf
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 1. 画浮漂:黄色圆圈 + 十字线,让 AI 的"目光"可见
if (floatX >= 0 && floatY >= 0) {
val paint = Paint().apply {
style = Paint.Style.STROKE
strokeWidth = 3.dp
color = Color.YELLOW
}
canvas.drawCircle(floatX, floatY, 24.dp, paint)
canvas.drawLine(floatX - 30.dp, floatY, floatX + 30.dp, floatY, paint)
canvas.drawLine(floatX, floatY - 30.dp, floatX, floatY + 30.dp, paint)
}
// 2. 画事件文字:大号、醒目、半透明底
if (eventText.isNotEmpty()) {
val bg = Paint().apply {
color = Color.argb(140, 0, 0, 0)
}
val text = Paint().apply {
color = eventColor
textSize = 48.dp
typeface = Typeface.DEFAULT_BOLD
setShadowLayer(6.dp, 0f, 0f, Color.BLACK)
}
val y = height * 0.85f
canvas.drawRoundRect(
RectF(24.dp, y - 52.dp, 240.dp, y + 14.dp), 12.dp, 12.dp, bg)
canvas.drawText("$eventText 置信度 ${(eventConf * 100).toInt()}%",
32.dp, y, text)
}
// 3. 画顶部状态条:提示系统在运行
val statusPaint = Paint().apply {
color = Color.WHITE
textSize = 24.dp
}
canvas.drawText("AUTO-FISHING ●", 20.dp, 40.dp, statusPaint)
}
// 便捷转换:px = dp × 屏幕密度
private val Float.dp: Float get() = this * resources.displayMetrics.density
}
三个绘制要点:
invalidate()是重绘开关:每次数据更新调用它,系统就会在下一次垂直同步时重新走onDraw。不调用,画面就永远不会刷新。Canvas是"画布"不是"图层":它直接往 View 上画,画完不留历史。所以每次onDraw都要把上一帧的圆圈和文字重新画一遍(这就是为什么叫"覆盖层"——它覆盖在相机画面上,但自己每帧重画)。- 半透明底色提升可读性:户外钓鱼阳光刺眼,纯色文字容易看不清,垫一块半透明黑底 + 阴影,对比度直接拉满。
7.4 cameraExecutor:为什么相机回调必须走独立线程
前面代码里出现了 cameraExecutor,这里专门讲清楚。它是这样的:
private val cameraExecutor = Executors.newSingleThreadExecutor()
setAnalyzer(cameraExecutor) { ... } 意味着分析器回调跑在这个独立线程上,而不是主线程。为什么必须这样?
- 主线程是 UI 线程:它忙着处理点击、动画、绘制。如果把图像转换(YUV→JPEG 要 10~20ms)塞给它,界面会掉帧、卡顿,甚至触发 ANR(Application Not Responding,应用无响应)。
- 相机回调天然不在主线程:CameraX 内部也推荐用专用 Executor 跑分析器,这是官方文档明确建议的姿势。
- 用单线程而不是线程池:因为
ImageAnalysis本身按序回调,且我们的处理链路(转 JPEG → 推流)要求串行,一个线程足够,还省掉线程切换的开销。
7.5 生命周期:相机跟着 Activity 走
bindToLifecycle(this, ...) 传的 this(AppCompatActivity)意味着相机的生命周期完全跟随 Activity:
- 页面退到后台 → CameraX 自动停流、释放相机,省电;
- 回到前台 → 自动恢复推流;
- 横竖屏切换 → Activity 重建 → 重新走
startCamera()重新绑定。
这套"托管生命周期"是 CameraX 相对裸 Camera2 最大的福利。如果用 Camera2,你要自己在 onPause 里 close() 设备、在 onResume 里重新打开、再重建 Session——一整套状态机代码,写错一步就是黑屏或崩溃。CameraX 把这些全都收了,你要做的只是"绑定一次,忘了它"。唯一要注意的是:在重建后记得 unbindAll() 再重新绑定(第十节的 startCamera 里已经写了),避免重复绑定导致资源泄漏。
八、TTS 语音播报:让手机开口喊"下顿!"
8.1 初始化
private fun initTts() {
tts = TextToSpeech(this) { status ->
if (status == TextToSpeech.SUCCESS) {
val result = tts.setLanguage(Locale.CHINESE)
if (result == TextToSpeech.LANG_MISSING_DATA ||
result == TextToSpeech.LANG_NOT_SUPPORTED) {
Log.w(TAG, "TTS 中文语言不可用")
}
} else {
Log.w(TAG, "TTS 初始化失败")
}
}
}
private fun speak(text: String) {
if (!::tts.isInitialized) return
// QUEUE_FLUSH:立即打断上一句,保证播报"新鲜"
tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "auto-fishing-$text")
}
8.2 三个细节决定体验
细节一:QUEUE_FLUSH 而不是 QUEUE_ADD。 QUEUE_ADD 会把"下顿"排进队列慢慢念,"顶漂"来了还在念旧的"下顿",播报永远滞后。QUEUE_FLUSH 是"闭嘴,说新的"——新事件直接顶掉旧事件。钓鱼的鱼口是瞬时的,播报也必须抢鲜。
细节二:音量拉满 + 场景说明。 户外钓鱼环境:水声、风声、旁边钓友的交谈声。TTS 默认音量是媒体音量,大概率听不清。实测建议把媒体音量调到 80% 以上,或者干脆在代码里设 setVolume。有些钓友会把手机放在支架上,离人一两米——TTS 音量 + 去重节流,就是"听得清 + 不烦人"的组合拳。
细节三:onDestroy 必须释放。 TTS 引擎持有系统服务,不释放会泄漏:
override fun onDestroy() {
tts?.stop()
tts?.shutdown()
cameraExecutor.shutdown()
super.onDestroy()
}
8.3 TTS 的两个隐藏配置:语速与离线引擎
说完三个细节,再补两个开发时容易忽略、但直接影响钓鱼体验的配置。
语速设置。 中文 TTS 默认语速偏"播音腔",念"下顿"两个字不到半秒就完了,对钓鱼这种"现场短指令"场景其实刚好。但如果你播报的是带置信度的长句(比如"下顿,置信度百分之九十三"),默认语速会显得拖沓。推荐保持 setSpeechRate(1.0f)(默认),因为短指令就是要"啪"一下出词;真要调慢也别低于 0.8,钓鱼佬没耐心听 AI 慢悠悠说话。插一句:别用 QUEUE_ADD 播报长句,前文的 QUEUE_FLUSH 原则对任何句子都成立——实时系统里,新信息永远比旧信息值钱。
离线引擎的坑。 部分国产手机上,系统 TTS 引擎需要联网下载语音包才能用中文。钓鱼现场可能信号差,万一中文语音包没装,speak() 会静默失败——没有任何报错,但手机就是"哑"的。两个预防手段:
- 初始化时检查
setLanguage(Locale.CHINESE)的返回值,失败就Toast提示"请下载中文语音包"; - 有条件的话,使用厂商自带或第三方离线 TTS 引擎(如讯飞离线版),钓鱼场景下离线优先,因为"开口说话"这种功能不该依赖现场网络。
这套"离线优先 + 语速即默认"的策略,和整个项目"数据不出局域网"的哲学完全一致——能本地解决的,绝不依赖外部条件。
把 TTS 打磨到这个地步,整套体验才算真正闭环,而这正是 Auto-Fishing 的核心特性之一——手机端可跑:安卓 App 调用摄像头,画面实时标注 + 中文语音播报「下顿!提竿!」。识别核心判断出漂相,手机屏幕上圈住浮漂、飘出事件文字,同时开口播报——鱼咬钩的那个瞬间,你的眼睛可以离开浮漂,耳朵替你盯着。从盯漂的死循环里解放出来,这就是这套系统存在的意义。
九、权限与明文 HTTP:两个必踩的坑
9.1 CAMERA 权限
AndroidManifest.xml 里声明 + 运行时动态申请(Android 6+ 必须):
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
private fun requestCameraPermission() {
requestPermissions(arrayOf(Manifest.permission.CAMERA), 100)
}
override fun onRequestPermissionsResult(...) {
// 权限通过 → startCamera(); 拒绝 → 提示并退出
}
9.2 明文 HTTP:Android 9 起默认禁 HTTP
从 Android 9(API 28)开始,系统默认禁止明文 HTTP(非 HTTPS)流量。我们的服务端是 http://192.168.1.100:8080,纯 HTTP——直接访问会报:
java.io.IOException: Cleartext HTTP traffic to 192.168.1.100 not permitted
两个解法,二选一:
方案 A(简单粗暴):manifest 里开全局明文
<application
android:usesCleartextTraffic="true"
... >
方案 B(精细控制,推荐):networkSecurityConfig 只放行局域网
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">192.168.1.100</domain>
<domain includeSubdomains="false">192.168.1.101</domain>
</domain-config>
</network-security-config>
<application
android:networkSecurityConfig="@xml/network_security_config"
... >
方案 B 的好处:只对自家服务器放开明文,其余流量照旧走 HTTPS 保护——局域网里虽然威胁不大,但"最小暴露面"永远是安全的第一原则。钓鱼佬也要有信息安全素养。
9.3 调试细节:服务端 IP 别写死在代码里
联调阶段最容易翻车的场景:昨天电脑 IP 是 192.168.1.100,今天路由器一重启变成 192.168.1.105,App 就失联了。路由器 DHCP 分配的 IP 经常变,写死在 StreamClient("http://192.168.1.100:8080") 里等于埋雷。
三个实用解法,按推荐程度排序:
- 路由器 DHCP 静态绑定:在路由器后台把电脑的 MAC 地址绑定固定 IP,一劳永逸——这是最省心的方案;
- IP 放配置文件:把服务端地址写进
local.properties或 BuildConfig,换环境改一处配置重编译,而不是满代码搜 IP; - 局域网固定 IP:给电脑网卡手动指定一个静态 IP(如 192.168.1.100),不依赖路由器分配。
实操经验:钓鱼现场往往信号一般,优先保证手机和电脑连的同一个路由器,别一个连 2.4G 一个连 5G 频段(有些路由器会分两个网段,5G 频段的设备访问不到 2.4G 频段的电脑)。这属于"纸上画好了链路、现场卡在频段"的经典翻车点。
十、完整启动流程拼图
把上面所有碎片拼成 MainActivity 的关键启动流程:
private fun startCamera() {
val provider = ProcessCameraProvider.getInstance(this)
provider.addListener({
val cameraProvider = provider.get()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(binding.previewView.surfaceProvider)
}
imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
.build()
imageAnalysis.setAnalyzer(cameraExecutor) { image ->
handleFrame(image)
}
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageAnalysis)
} catch (e: Exception) {
Log.e(TAG, "相机启动失败: ${e.message}")
}
}, ContextCompat.getMainExecutor(this))
}
private fun handleFrame(image: ImageProxy) {
frameCounter++
if (frameCounter % 3 != 0) { image.close(); return } // 限帧 10fps
val jpeg = try {
ImageUtils.imageToJpeg(image, jpegQuality = 70)
} finally {
image.close() // 无论成败,用完必关
}
if (jpeg.isNotEmpty()) {
streamClient.postFrame(jpeg, ::onResult)
}
}
整条链路就这样串起来了:CameraX 供帧 → 限帧 → YUV 转 JPEG → 单线程推流 → 回调更新覆盖层 + TTS 播报。屏幕上是实时的 AI 视野,耳朵里是即时的鱼口播报。
十一、实战效果:竿架上的"电子哨兵"
把手机夹在竿架上看守鱼漂,这套系统跑起来是什么体验?
- 平静水面,鱼漂稳稳立在水面,OverlayView 上黄色圆圈始终锁定漂尖,偶尔微风吹过,圆圈跟着波浪微微晃动(卡尔曼滤波在起作用);
- 浮漂轻点两下,屏幕飘出"点漂…",手机轻声说"点漂",你低头看漂——果然是小鱼在闹窝,嘴角微微一笑,稳坐不动;
- 突然,浮漂一沉、再顿,屏幕上"下顿!置信度 93%"刷地亮起,手机脱口而出"下顿",你手腕一抖,中鱼!
- 整套系统从开机到锁定目标,零配置零联网,识别全部在笔记本上完成,数据不出局域网——隐私、速度、省心,三样全占。
上面这一幕,就是 Auto-Fishing 的日常形态,也是它最接地气的一面:一台手机架在竿架上,就是全天候不下班、不眨眼的"电子哨兵"。想亲手复现这个体验?门槛低到出乎意料——电脑上跑 python main.py server,手机装上 App、连上同一个 Wi-Fi,摄像头对准水面浮漂,语音播报实时响起。从部署到听见第一声"下顿",用不了几分钟。钓鱼漂相识别,让每一次咬口都不被错过——这句话不是 slogan,是这套系统每天在水边兑现的承诺。
这不只是一个"钓鱼辅助工具",更是一个完整的最小可行 AIoT 系统:边缘设备采集(手机)→ 本地服务推理(Python)→ 结果回显播报(OverlayView + TTS)。看懂这套链路,你就掌握了大部分"手机摄像头 + 云端/本地推理"类应用的核心套路,换任何场景(看护摄像头、宠物监测、货架巡检)都能复用。
十二、本篇小结
- CameraX:Jetpack 相机库,
Preview管显示、ImageAnalysis管取帧,面向场景编程; - YUV→NV21→JPEG:CameraX 给 YUV_420_888,Android 原生
YuvImage只认 NV21,最后压成 JPEG 走 HTTP,全程无三方依赖; - 限帧 10fps:漂相是慢动作,30fps 全推浪费流量耗电,取 1/3 足够;
- StreamClient:单线程 Executor 串行发送防乱序,
sending标志实现"追新丢旧",二进制 body 省流量; - OverlayView:透明 View 覆盖在 Preview 上,归一化坐标转屏幕像素,
Canvas画圈画字; - TTS:
QUEUE_FLUSH抢鲜播报 + 2 秒去重 + 音量调满,户外也能听清; - 权限与明文:CAMERA 动态申请 +
networkSecurityConfig只对局域网放行明文。
小结之外再提醒一句:这套手机端代码的每一步都遵循"最小实现"原则——够用就好,不留任何为未来做的过度设计。CameraX 的托管生命周期、OkHttp 的成熟网络栈、Canvas 的原生绘制,每一环都是 Android 生态里最平凡的工具,组合起来却能跑出完整实用的 AIoT 链路。
到这里,"手机采集 + Python 识别 + 手机回显播报"的全链路已经打通。手机架在竿架上,鱼一咬钩,手机立刻开口——识别、标注、播报一气呵成,数据全程不出局域网。
不过,一个更根本的问题正等着我们:识别算法到底靠什么验证? 总不能每改一次代码就扛着竿子去鱼塘边坐一天吧。而这个问题的答案,恰恰是检验这套系统"敢不敢上线"的关键一步。
🎣 关于 Auto-Fishing 项目
钓鱼漂相识别 —— 让每一次咬口都不被错过
台钓/野钓时,盯漂是最累也最关键的环节:下顿、顶漂、黑漂、点漂四种真实漂相稍纵即逝,大风大浪时更是难以判读。Auto-Fishing 用计算机视觉自动识别浮漂的四种漂相,并在第一时间给出语音提醒,把钓友从"死盯漂"中解放出来。
核心特性:
| 特性 | 说明 |
|---|---|
| 四种真实漂相 | 下顿(顿口,经典咬口信号)、顶漂(送漂)、黑漂(吞死口/大鱼拖走)、点漂(小鱼试探/口轻) |
| 抗风浪 | 实时估计波浪幅度,速度/频率/持续时长三重判据,大风大浪下不误报不漏报 |
| 手机端可跑 | 安卓 App 调用摄像头,画面实时标注 + 中文语音播报「下顿!提竿!」 |
| 不依赖云端 | 纯局域网部署,数据不出本地,无订阅费用、无隐私风险 |
| 技术栈灵活 | Python 识别核心 + 卡尔曼滤波 + ByteTrack 跟踪;检测器可无缝切换 YOLO 深度学习 |
| 可自证 | 内置合成视频自检与压力测试,识别效果可量化评估(查全率/查准率) |
识别效果(合成演示视频,5 组随机场景):
| 浪况 | 波浪幅度 | 查全率 | 查准率 |
|---|---|---|---|
| 平静 | 4px | 100% | 100% |
| 小浪 | 8px | 92% | 100% |
| 中浪 | 12px | 100% | 100% |
| 大浪 | 16px | 76% | 76% |
关于大浪(16px):波浪幅度 vs 10~26px 咬口信号已接近物理可分极限,该浪况下肉眼同样难以判读;系统优先保证不误报(宁缺毋滥),在中小浪况下表现优异。
三种演示方式:
- 零素材演示:
python main.py demo—— 自动生成含四种漂相的合成钓鱼视频,识别并输出评估报告,一分钟内跑通全流程; - 实时演示:电脑跑
python main.py server,手机装 App 后同一 Wi-Fi 连上即可,摄像头对准水面浮漂,语音播报实时响起; - 压力演示:
python tools/stress_test.py—— 4 档波浪 × 多场景,直观展示抗风浪能力。
适用场景:
- 台钓/竞技钓:代替人工盯漂,抓顿口、抓送漂;
- 教学演示:向新手展示什么是下顿/顶漂/黑漂/点漂;
- 技术验证:目标检测+跟踪+时序状态机+抗噪的完整示例工程;
- 产品化起点:识别核心可对接后台 Spring Boot 等,升级 YOLO 模型提升复杂场景鲁棒性。
获取方式:本项目为 demo 版本,源码、文档、安卓工程完整开放(README 快速开始 / 二次开发文档 / 部署文档)。