垂钓助手-安卓端实战:CameraX推流与OverlayView覆盖层与TTS语音播报

11-安卓端实战:CameraX推流与OverlayView覆盖层与TTS语音播报

服务端会"收"了,浏览器能"看"了。但钓鱼佬真正握在手里的,是手机。这一篇,我们把手机武装成"电子哨兵":摄像头开着、帧推着、识别结果画在屏幕上、鱼一咬钩立刻用嘴喊出来。

大家好,我是黒漂技术佬。上篇我们给 Python 识别核心装了一张嘴(HTTP 服务),现在轮到给手机装"眼睛"和"嗓子"。

回顾一下手机端要干的四件事:

  1. 调起摄像头 ------ 看清水面;
  2. 推流 ------ 把画面送到服务端让 AI 分析;
  3. 画标注 ------ 把 AI 的识别结果(漂在哪、什么事件)画在预览画面上;
  4. 语音播报 ------ 识别到下顿/顶漂/黑漂/点漂时,用嘴喊出来。

技术栈:Kotlin + CameraX + 原生 Canvas + Android TTS。全部是 Android 官方原生能力,一个第三方依赖都不用加(对这个项目来说,YAGNI 精神早已刻进 DNA)。


一、先搞懂三个概念:CameraX、ImageAnalysis、TTS

1.1 CameraX:安卓官方"傻瓜相机"

Android 底层相机 API 叫 Camera2,功能强大到让人头大:光一个"预览"就得处理 CameraDeviceCaptureSessionSurfaceHandlerThread 之间的一大堆状态回调,新手写出来能绕晕三圈。

CameraX 是 Google 在 Camera2 之上封装的 Jetpack 相机库,把复杂性全藏起来,你的代码只需要关心"我用相机干什么":

kotlin 复制代码
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),你只需要:

kotlin 复制代码
tts = TextToSpeech(context) { status ->
    if (status == TextToSpeech.SUCCESS) {
        tts?.language = Locale.CHINESE   // 设置中文
    }
}
// 想说话时:
tts?.speak("下顿", TextToSpeech.QUEUE_FLUSH, null, "event-utterance")

它不联网、不收费、延迟低(几十毫秒就能开口),钓鱼场景下再合适不过。


二、MainActivity 整体流程:一条流水线串起全部

先看全局流程图,然后逐一拆解:

erlang 复制代码
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 标注 + 鱼口语音播报

细节拉满的版本在这,先看核心骨架:

kotlin 复制代码
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 支持,不需要任何三方库。

kotlin 复制代码
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

kotlin 复制代码
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 帧?

  1. 识别跟不上:服务端一帧处理要 30~50ms,就算 10fps 也已经吃满服务端算力了,推 30fps 服务端只会排队积压;
  2. 费流量:30fps × 45KB = 13.5MB/s 的流量,在局域网里不是事,但手机 WiFi 天线会因此发热耗电;
  3. 漂相是慢动作:下顿、顶漂这类动作持续几百毫秒到几秒,10fps 采样绰绰有余,15 帧都能覆盖一个完整的顿口过程。用 120fps 慢动作去拍钓鱼?那是拍电影,不是识别。

一句话:帧率不是越高越好,匹配物理世界的节奏才叫工程

别忘了 image.close()!CameraX 的 ImageProxy 是复用缓冲区的,你不 close 它,相机缓冲区耗尽直接停摆。这是 CameraX 新手必踩的坑,一旦踩了,画面会突然"冻住"。


五、StreamClient:单线程串行推流,绝不并发乱序

推流客户端是整个手机端最容易被写坏的部分。先想清楚两个约束:

  • 顺序:帧必须按拍摄顺序到达服务端,乱序会导致卡尔曼滤波和跟踪器"看到"跳跃的画面;
  • 实时:如果服务端处理不过来,宁可丢帧,也不能积压延迟。

5.1 单线程 Executor 串行发送

如果每帧都开一个线程 POST,网络抖动时后发的帧可能先到,服务端就收到乱序帧。所以所有请求塞进同一个单线程 Executor

kotlin 复制代码
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 直接读字节流,零编码开销:

makefile 复制代码
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 长这样:

json 复制代码
{"status":"ok","frame_id":1287,"event":"下顿","confidence":0.93,
 "float_pos":[0.52,0.31],"fps":9.8}

回调处理逻辑(核心是双保险去重):

kotlin 复制代码
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(相机预览)上面。它不抢相机的画面,只负责在合适的位置"涂鸦":圈出浮漂、飘出事件文字。

布局文件里这么叠:

xml 复制代码
<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),为什么?因为服务端不认识手机屏幕,手机也不关心服务端的处理分辨率------用一个与两端无关的比例坐标系沟通,谁都不需要迁就谁

画的时候转成像素:

kotlin 复制代码
val px = fx * width   // 归一化 x → 屏幕像素 x
val py = fy * height  // 归一化 y → 屏幕像素 y

7.3 onDraw 完整代码

kotlin 复制代码
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
}

三个绘制要点:

  1. invalidate() 是重绘开关 :每次数据更新调用它,系统就会在下一次垂直同步时重新走 onDraw。不调用,画面就永远不会刷新。
  2. Canvas 是"画布"不是"图层" :它直接往 View 上画,画完不留历史。所以每次 onDraw 都要把上一帧的圆圈和文字重新画一遍(这就是为什么叫"覆盖层"------它覆盖在相机画面上,但自己每帧重画)。
  3. 半透明底色提升可读性:户外钓鱼阳光刺眼,纯色文字容易看不清,垫一块半透明黑底 + 阴影,对比度直接拉满。

7.4 cameraExecutor:为什么相机回调必须走独立线程

前面代码里出现了 cameraExecutor,这里专门讲清楚。它是这样的:

kotlin 复制代码
private val cameraExecutor = Executors.newSingleThreadExecutor()

setAnalyzer(cameraExecutor) { ... } 意味着分析器回调跑在这个独立线程上,而不是主线程。为什么必须这样?

  1. 主线程是 UI 线程:它忙着处理点击、动画、绘制。如果把图像转换(YUV→JPEG 要 10~20ms)塞给它,界面会掉帧、卡顿,甚至触发 ANR(Application Not Responding,应用无响应)。
  2. 相机回调天然不在主线程:CameraX 内部也推荐用专用 Executor 跑分析器,这是官方文档明确建议的姿势。
  3. 用单线程而不是线程池 :因为 ImageAnalysis 本身按序回调,且我们的处理链路(转 JPEG → 推流)要求串行,一个线程足够,还省掉线程切换的开销。

7.5 生命周期:相机跟着 Activity 走

bindToLifecycle(this, ...) 传的 this(AppCompatActivity)意味着相机的生命周期完全跟随 Activity

  • 页面退到后台 → CameraX 自动停流、释放相机,省电
  • 回到前台 → 自动恢复推流;
  • 横竖屏切换 → Activity 重建 → 重新走 startCamera() 重新绑定。

这套"托管生命周期"是 CameraX 相对裸 Camera2 最大的福利。如果用 Camera2,你要自己在 onPauseclose() 设备、在 onResume 里重新打开、再重建 Session------一整套状态机代码,写错一步就是黑屏或崩溃。CameraX 把这些全都收了,你要做的只是"绑定一次,忘了它" 。唯一要注意的是:在重建后记得 unbindAll() 再重新绑定(第十节的 startCamera 里已经写了),避免重复绑定导致资源泄漏。


八、TTS 语音播报:让手机开口喊"下顿!"

8.1 初始化

kotlin 复制代码
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 引擎持有系统服务,不释放会泄漏:

kotlin 复制代码
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() 会静默失败------没有任何报错,但手机就是"哑"的。两个预防手段:

  1. 初始化时检查 setLanguage(Locale.CHINESE) 的返回值,失败就 Toast 提示"请下载中文语音包";
  2. 有条件的话,使用厂商自带或第三方离线 TTS 引擎(如讯飞离线版),钓鱼场景下离线优先,因为"开口说话"这种功能不该依赖现场网络。

这套"离线优先 + 语速即默认"的策略,和整个项目"数据不出局域网"的哲学完全一致------能本地解决的,绝不依赖外部条件。

把 TTS 打磨到这个地步,整套体验才算真正闭环,而这正是 Auto-Fishing 的核心特性之一------手机端可跑:安卓 App 调用摄像头,画面实时标注 + 中文语音播报「下顿!提竿!」。识别核心判断出漂相,手机屏幕上圈住浮漂、飘出事件文字,同时开口播报------鱼咬钩的那个瞬间,你的眼睛可以离开浮漂,耳朵替你盯着。从盯漂的死循环里解放出来,这就是这套系统存在的意义。


九、权限与明文 HTTP:两个必踩的坑

9.1 CAMERA 权限

AndroidManifest.xml 里声明 + 运行时动态申请(Android 6+ 必须):

xml 复制代码
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
kotlin 复制代码
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------直接访问会报:

lua 复制代码
java.io.IOException: Cleartext HTTP traffic to 192.168.1.100 not permitted

两个解法,二选一:

方案 A(简单粗暴):manifest 里开全局明文

xml 复制代码
<application
    android:usesCleartextTraffic="true"
    ... >

方案 B(精细控制,推荐):networkSecurityConfig 只放行局域网

xml 复制代码
<!-- 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>
xml 复制代码
<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") 里等于埋雷。

三个实用解法,按推荐程度排序:

  1. 路由器 DHCP 静态绑定:在路由器后台把电脑的 MAC 地址绑定固定 IP,一劳永逸------这是最省心的方案;
  2. IP 放配置文件 :把服务端地址写进 local.properties 或 BuildConfig,换环境改一处配置重编译,而不是满代码搜 IP;
  3. 局域网固定 IP:给电脑网卡手动指定一个静态 IP(如 192.168.1.100),不依赖路由器分配。

实操经验:钓鱼现场往往信号一般,优先保证手机和电脑连的同一个路由器,别一个连 2.4G 一个连 5G 频段(有些路由器会分两个网段,5G 频段的设备访问不到 2.4G 频段的电脑)。这属于"纸上画好了链路、现场卡在频段"的经典翻车点。


十、完整启动流程拼图

把上面所有碎片拼成 MainActivity 的关键启动流程:

kotlin 复制代码
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)。看懂这套链路,你就掌握了大部分"手机摄像头 + 云端/本地推理"类应用的核心套路,换任何场景(看护摄像头、宠物监测、货架巡检)都能复用。


十二、本篇小结

  1. CameraX :Jetpack 相机库,Preview 管显示、ImageAnalysis 管取帧,面向场景编程;
  2. YUV→NV21→JPEG :CameraX 给 YUV_420_888,Android 原生 YuvImage 只认 NV21,最后压成 JPEG 走 HTTP,全程无三方依赖;
  3. 限帧 10fps:漂相是慢动作,30fps 全推浪费流量耗电,取 1/3 足够;
  4. StreamClient :单线程 Executor 串行发送防乱序,sending 标志实现"追新丢旧",二进制 body 省流量;
  5. OverlayView :透明 View 覆盖在 Preview 上,归一化坐标转屏幕像素,Canvas 画圈画字;
  6. TTSQUEUE_FLUSH 抢鲜播报 + 2 秒去重 + 音量调满,户外也能听清;
  7. 权限与明文 :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 咬口信号已接近物理可分极限,该浪况下肉眼同样难以判读;系统优先保证不误报(宁缺毋滥),在中小浪况下表现优异。

三种演示方式:

  1. 零素材演示python main.py demo ------ 自动生成含四种漂相的合成钓鱼视频,识别并输出评估报告,一分钟内跑通全流程;
  2. 实时演示 :电脑跑 python main.py server,手机装 App 后同一 Wi-Fi 连上即可,摄像头对准水面浮漂,语音播报实时响起;
  3. 压力演示python tools/stress_test.py ------ 4 档波浪 × 多场景,直观展示抗风浪能力。

适用场景:

  • 台钓/竞技钓:代替人工盯漂,抓顿口、抓送漂;
  • 教学演示:向新手展示什么是下顿/顶漂/黑漂/点漂;
  • 技术验证:目标检测+跟踪+时序状态机+抗噪的完整示例工程;
  • 产品化起点:识别核心可对接后台 Spring Boot 等,升级 YOLO 模型提升复杂场景鲁棒性。

获取方式:本项目为 demo 版本,源码、文档、安卓工程完整开放(README 快速开始 / 二次开发文档 / 部署文档)。

相关推荐
七牛云行业应用18 分钟前
Codex常用命令速查:CLI、Desktop与任务恢复完整指南
人工智能·agent·ai编程
月疯20 分钟前
Stable Diffusion是如何生成视频
人工智能·stable diffusion
Raas10021 分钟前
MAI Gateway(魔芋企业级AI网关)科普:AI网关解决什么问题?3分钟搞懂AI网关
大数据·人工智能·ai·gateway
星火102423 分钟前
【从 0 到 1 动手造 Agent】04、给 Agent 装上物理手脚——OpenHands PTY 沙盒
人工智能·aigc·agent
yosoar25 分钟前
蔡司工业显微镜扫描电镜软件解决方案与材料应用
人工智能·扫描电子显微镜·昆山友硕·蔡司扫描电镜
Leo.yuan27 分钟前
流批一体数据开发平台选型:除了Flink和Spark,还有哪些选择?
大数据·人工智能
浪兎兎29 分钟前
【机器学习】朴素贝叶斯 贝叶斯回归
人工智能·机器学习·回归
乱世刀疤29 分钟前
Claude Code提高工作效率案例:将视频培训转化为文本资料
人工智能·claude code
xcs1940529 分钟前
新版 IDEA(尤其是 2024/2025/2026)越来越臃肿
前端·人工智能·intellij-idea