# Sullivan_parts **Repository Path**: catcookie/sullivan_parts ## Basic Information - **Project Name**: Sullivan_parts - **Description**: 这是github是我的一个项目的功能部分尝试 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-20 - **Last Updated**: 2026-09-20 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 端侧 YOLO 障碍检测(Android / Java) > 📌 **本仓库是另一个项目 Sullivan 的功能测试项目**——用于验证 Sullivan 所需的端侧能力 > (目标检测、障碍播报策略、IMU 晃动判定),不是 Sullivan 本体。 把项目根目录下 `script.py` 的可行性验证,落地成一个纯 Java 的 Android 应用: 手机摄像头逐帧检测障碍物,实时给出中文方位提示,并可语音播报与振动提醒。 与原型脚本最大的差别是**推理完全在端侧完成**——不联网、不上传画面, 模型由 ONNX Runtime 在手机 CPU 上执行。 > ⚠️ 单目摄像头无法可靠测距,提示里的"较近"只是根据目标在画面中的位置和大小 > 做的估计。本应用不能替代导盲杖、导盲犬或任何专业辅助设备。 ## 快速开始 ### 1. 准备模型 应用需要一个 ultralytics 导出的 YOLO ONNX 模型。用 Python 转一次: ```bash pip install ultralytics yolo export model=yolov8n.pt format=onnx imgsz=640 opset=12 ``` 把生成的 `yolov8n.onnx`(约 12 MB)复制到: ``` app/src/main/assets/yolov8n.onnx ``` `yolov8n` 是最小的检测模型,在手机 CPU 上大约 60–120 ms 一帧,速度优先; 换成 `yolov8s.onnx` / `yolo11s.onnx` 精度更高但会更慢。 也可以不放 assets——装好 App 后点右上角「更换模型」,直接从手机存储里选 `.onnx` 文件,不需要重新编译。 ### 2. 构建运行 ```bash ./gradlew :app:assembleDebug ./gradlew :app:installDebug ``` 首次启动会申请摄像头权限,授权后即可看到实时检测画面。 ## 界面说明 | 区域 | 内容 | | --- | --- | | 左上角 | 当前模型与输入尺寸、推理帧率与耗时 | | 画面叠加层 | 追踪框(近处为亮红)、蓝色框标注的"前方通路"走廊、通路畅通百分比 | | 底部大字 | 中文提示,颜色随危险等级变化(绿 / 黄 / 红) | | 底部开关 | 语音播报、振动提示(**会记住**,重启不丢) | | 底部按钮 | 播报频率:少 / 标准 / 多,点一下换下一档并立刻念出新档位 | | 晃动报警面板 | 判出剧烈晃动时从底部盖上来,只有「我没事」按钮可点 | 静态提示形如「中央行人较近,注意;右侧汽车,请注意」。同方位同类别的目标会合并计数, 所以并排站着两个人时说的是「中央两名行人,注意」,而不是把同一句话念两遍。 动态告警另走一句,文案被刻意压到最短: | 情形 | 文案 | 等级 | | --- | --- | --- | | 剩余碰撞时间 ≤ 1.8 秒,且已排除自身运动 | 「行人接近」 | 危险 | | 剩余碰撞时间 ≤ 3.0 秒 | 「行人正在靠近」 | 注意 | | 横向穿过行进方向 | 「行人横穿」 | 注意 | | 算不出碰撞时间,但刚由远及近(兜底) | 「行人正在靠近」 | 注意 | 叠加层的框标签带轨迹编号与运动标记(`#12 ▼接近` / `▶横穿` / `▲远离`), 真机上肉眼就能验证追踪与测速是否可信:编号频繁重建说明关联门限不对, 标记来回跳说明拟合不稳。这两件事看日志是看不出来的。 ## 播报频率档位 用户抱怨过"上一句还没播完,下一句就开始了"。清理完之后间隔仍然**对用户是硬的**—— 它写在 `AnnounceRate` 里的三组常量里,用户只能接受。所以把它做成界面上可切的档位: | 档位 | 换句间隔 | 同句冷却 | 抢断间隔 | 振动冷却 | 一句话报几簇 | | --- | --- | --- | --- | --- | --- | | 「少」`QUIET` | 2500 ms | 12000 ms | 4000 ms | 2500 ms | 1 | | 「标准」`STANDARD`(默认) | 800 ms | 4000 ms | 2000 ms | 1200 ms | 2 | | 「多」`CHATTY` | 500 ms | 2500 ms | 1500 ms | 800 ms | 2 | 「标准」逐字等于改造前的常量,所以**没动过设置的用户听到的频率零变化**。 两条刻意的约束: - **档位只调"间隔"和"一句话报几簇",不屏蔽任何等级。** 听不到的东西比听腻了更危险: 「少」档下「右侧汽车,请注意」照样会说,只是说得稀。它的代价是把一句话压到只报最近的 一簇——远处那一簇不会被念出来,这是用户自己选的档位,不是默认行为。 - **`STABLE_MS`(350 ms 抖动窗口)不随档位变。** 那是抑制"文案横跳"的,不是频率; 调它等于把"同一件事反复播报"放回来。 档位与两个开关都存进 `SharedPreferences`(`AssistSettings`),重启后保持。 ## 剧烈晃动报警 现在的所有输出都由检测框推导——人摔了、晕了、手机被撞飞了,应用一无所知。判据走**陀螺仪** (`motion/ShakeDetector`):相机只有 10 FPS,按采样定理看不见 3 Hz 的晃动,单目也分不清 "相机晃了"和"场景在动";陀螺仪直接测角速度,与摄像头是否被遮挡无关,不需要任何权限。 用户要的不是"手机动了",而是"出事了"。判据因此分两半——**又猛又乱**: | 判据 | 阈值 | 为什么是这个数 | | --- | --- | --- | | 角速度大小的峰值 | ≥ 2.5 rad/s | 走路手持摆动 0.3–0.8,转身 1.5–3.5,绊倒/撞击 3–8 | | 角速度大小的变化率 | ≥ 20 rad/s² | 峰值挡不住"慢悠悠转一大圈":匀速转体 3 rad/s 轻易越过峰值门限,但角加速度接近零 | | 角速度向量掉头次数 | ≥ 3(1.5 秒内) | 单次转身只有 1 次方向改变,那不是"乱" | | 反转间隔的最大值 ÷ 最小值 | ≥ 1.5 | **"无规则"的量化**:走路、甩手、规律摆动都是整齐的节奏(比值贴近 1) | | 连续采样时长 / 样本数 | ≥ 0.5 s / ≥ 20 | 拿半截数据下结论只会误报 | 反转用**向量点积变号**判定,不用"主轴过零":晃动起来主轴自己在转,挑哪个轴都是赌。 两次反转挨得比 50 ms 还近的不算数——那是过零点附近的噪声,没有这道去抖,**规律**晃动 反而会被当成无规则晃动报出去。 触发之后 6 秒冷却;采样中断超过 150 ms 就作废历史;用户一碰屏幕,之后 2 秒内不做判定 (掏出手机、按按钮、塞回兜里这一串动作完全够得上"剧烈且无规则")。 报警的输出与日常提示**完全分开**(这几条是用户选定的): - **不看**语音/振动开关,**不看**频率档位。用户关掉的是日常唠叨,不是紧急求助; - 报警期间**反过来压住**一切日常提示与日常振动——此刻需要听到的是"出事了", 不是"右侧有自行车"; - 报警重复到用户点掉「我没事」为止;引擎连挂三次则只剩振动,不会一声不吭; - 报警**不随 `onPause` 停止**:用户可能把手机掉在地上,屏幕熄了、应用退到后台。 ## 代码结构 ``` app/src/main/java/com/example/android_try_1/ ├── MainActivity.java 界面、CameraX 绑定、逐帧调度 ├── detect/ —— 从像素到检测框 │ ├── RgbaFrame.java ImageProxy 的独立像素副本(含旋转与曝光时刻) │ ├── Letterbox.java 等比缩放 + 灰边填充的几何参数 │ ├── YoloV8Detector.java ONNX 推理:预处理 / 解码 / NMS │ ├── Detection.java 归一化坐标的检测框 + 框间几何运算 │ ├── CocoLabels.java COCO 类别名、中文翻译、障碍物判定、典型物理高度 │ └── ModelStore.java 模型文件查找与导入 ├── track/ —— 跨帧感知,纯 Java │ ├── ObjectMerger.java 同帧重叠框合并("同一个人被检出两次") │ ├── ObjectTracker.java IoU 关联、生命周期、画面几何变化重置 │ ├── Track.java / TrackWindow.java 轨迹与预分配的观测窗口 │ ├── TtcEstimator.java 最小二乘拟合尺度增长率 → 剩余碰撞时间 │ └── EgoMotionEstimator.java 自运动补偿(走路时相机前进造成的假"靠近") ├── scene/ —— 可序列化的场景描述,纯 Java │ ├── SceneSnapshot.java 一帧的不可变快照 + JSON 日志 / 回放 │ ├── Json.java 手写极简 JSON(不依赖 org.json,单测才能跑) │ └── CorridorCoverage.java 前方走廊的通路占比 ├── motion/ —— 与画面无关的一路:晃没晃 │ ├── ShakeDetector.java 陀螺仪判据:又快又乱才算"剧烈晃动",纯 Java │ └── ShakeSensor.java 传感器注册与专用线程(薄适配层) ├── assist/ —— 决策与输出 │ ├── SceneAnalyzer.java 把上面几层串成一条管线:检测框进,一句话出 │ ├── ObstacleAdvisor.java 静态提示(方位、远近、同方位同类合并计数) │ ├── DynamicAdvisor.java 动态告警(靠近 / 横穿 / 由远及近兜底) │ ├── AnnounceRate.java 播报频率档位:少 / 标准 / 多 │ ├── AnnouncementPolicy.java 播报节流:稳定窗口、说完再播、限定抢断 │ ├── AssistSettings.java 档位与两个开关的持久化 │ └── PromptAnnouncer.java 把上面的决定翻译成 TTS 与振动调用;含报警状态机 └── view/ └── OverlayView.java 叠加绘制追踪框、走廊、通路占比 ``` ### 一帧的处理流程 1. CameraX 以 `KEEP_ONLY_LATEST` 策略回调一帧 RGBA 数据; 2. `RgbaFrame.from()` 把像素复制出来,**立刻归还 `ImageProxy`**,不阻塞相机管线; 传感器曝光时刻也在这一步取出——后面所有速度都建在这个时间戳上; 3. 若上一帧还在推理,这一帧直接丢弃; 4. 推理线程上:letterbox 预处理(采样时一并做旋转和双线性插值)→ ONNX Runtime 推理 → 解码 → 按类别做 NMS; 5. 坐标从模型坐标系还原成相对正立画面的归一化值; 6. `SceneAnalyzer` 接管:合并重叠框 → 跨帧关联成轨迹 → 拟合尺度/横向增长率 → 补偿自身运动 → 得到 `SceneSnapshot`; 7. `ObstacleAdvisor` 给静态提示,`DynamicAdvisor` 给动态告警,两者合成一个 `Decision`(说哪句、什么等级、去重键是什么); 8. 主线程更新叠加层与文字,`PromptAnnouncer` 按 `AnnouncementPolicy` 的裁决决定 这一帧到底说不说。 **感知与决策是分开的**:`track/` 与 `scene/` 只产出测量值(框、增长率、碰撞时间), "较近""正在靠近"这类结论的阈值只存在于 `assist/` 一层。快照带 `toJson()` / `fromJson()`,真机上打开 `MainActivity.LOG_SCENE_JSON` 就能把每帧的真实 数据记下来,回到电脑上回放调阈值。 ## 与 script.py 的对应关系 原脚本用 OneFormer 做全景分割,这里换成 YOLO 目标检测。多数判断逻辑是直接搬过来的: | script.py | 本应用 | 说明 | | --- | --- | --- | | `OBSTACLE_KEYWORDS` | `CocoLabels.OBSTACLE_KEYWORDS` | 关键词表照搬,另补了几个 COCO 类别 | | `LABEL_TRANSLATIONS` | `CocoLabels.TRANSLATIONS` | 同上 | | `_contains_keyword` | `CocoLabels.containsWord` | 改成按整词匹配,否则 `carrot` 会命中 `car` | | `_relevant` | `ObstacleAdvisor.isRelevant` | 阈值完全一致 | | `area_ratio`(掩膜占比) | `Detection.areaRatio()`(框面积占比) | 检测没有掩膜,用框面积近似 | | `_make_message` | `ObstacleAdvisor.advise` | 方位划分、远近判断、取前两个目标都一致 | | `road_coverage`(路面掩膜占比) | `CorridorCoverage.freeRatio` | **替换**:改为统计画面下半部中央走廊被障碍框遮挡的比例 | | `SpeechAnnouncer` | `PromptAnnouncer` + `AnnouncementPolicy` | 冷却与去重语义一致,另加振动、抢断与稳定窗口 | | (无对应物) | `ObjectTracker` | 原脚本每帧独立推理,没有"同一个目标"的概念 | | (无对应物) | `TtcEstimator` | 剩余碰撞时间,需要跨帧观测才能算 | | (无对应物) | `EgoMotionEstimator` | 自运动补偿;原脚本每帧独立、也没有"自身在走"的概念 | | (无对应物) | `DynamicAdvisor` | 动态告警;原脚本不做跨帧比较,给不出"正在靠近" | 需要留意的是最后两行:YOLO 只给框,不给像素级掩膜,所以"道路区域占比"这个指标 在检测范式下没有对应物。这里用"中央走廊的畅通比例"替代,它是一个启发式估计, 含义是**前方行进方向上有没有被挡住**,而不是真实的路面覆盖率。 ## 调参 所有门限都是各类顶部的 `public static final` 常量,改阈值不需要动逻辑。 `YoloV8Detector`(由 `MainActivity` 传入) - `CONFIDENCE_THRESHOLD = 0.35`:漏检多就调低; - `IOU_THRESHOLD = 0.45`:NMS 是**类别内**的,重复框多就调低。 `ObstacleAdvisor` - `TOP_IGNORE_RATIO = 0.38`:画面顶部这个比例以内当作远处背景,不提示; - `NEAR_BOTTOM_RATIO = 0.78` / `NEAR_AREA_RATIO = 0.12`:判定"较近"的门槛; - `BLOCKED_RATIO = 0.35`:走廊遮挡超过这个比例就判为危险等级。 一句话里最多报几**簇**目标不在这里,而在 `AnnounceRate.maxReported()`——它是用户选的档位。 `ObjectMerger` - `MERGE_IOU = 0.25` / `MERGE_CONTAINMENT = 0.55`:两个框重到什么程度算同一个目标。 宁可少合并(多说一句)也不要错合并(漏报一个目标)。 `ObjectTracker` - `IOU_GATE = 0.20`:**不要按直觉调高**。IoU 是尺度无关的量,只取决于目标一帧内走了 多远:横向穿越的行人在 5 FPS 下相邻帧 IoU 只有 0.282,按 0.3 设会让它每帧都丢 ID; - `CROSS_CLASS_IOU_GATE = 0.35`:跨类别关联从严,但不要禁止——YOLO 对骑车的人会在 `person` 与 `bicycle` 之间来回翻转; - `MIN_HITS = 2`:连续匹配两帧才算确认,代价是新目标晚一帧才播报; - `MAX_MISS_MS = 500`:按**时间**老化而不是按帧数,否则轨迹寿命会随帧率变化。 这段宽限期里轨迹仍在(框继续画、滞回状态继续记),但**不再往外报运动量**—— 剩余碰撞时间的公式里含"距拟合质心过去了多久",质心不动而墙钟在走,不清读数的话 一条消失的轨迹的剩余时间会自己往下掉,最后凭空跨过告警门限; - `OCCLUSION_HEIGHT_DROP = 0.25`:单帧高度骤降判为遮挡,不进拟合——否则"被挡住"会被 当成"在远离"。 `TtcEstimator` - `MIN_SAMPLES = 5` / `MIN_SPAN_MS = 800`:样本太少或窗口太短的斜率纯属噪声; - `MAX_RESIDUAL_RMS_LOG = 0.08`:残差 RMS 是免费的可靠性门限,关联错了、目标急转弯 都会让它飙升; - `MIN_SIGNIFICANCE = 1.5`:斜率要达到自身标准误的 1.5 倍,"几乎不动"才不会被算成 "缓慢靠近"; - `MIN_HEIGHT = 0.03`:框太小(约 19 px 高)时一个像素的抖动就能翻转斜率符号。 `EgoMotionEstimator` - `MIN_TRACKS = 3`:轨迹不够就估不出场景运动,于是不给危险等级; - `MAX_RELATIVE_MAD = 0.5`:场景里各目标的自运动不一致(迎面人流)就放弃补偿, 宁可漏报也不误报; - `EGO_CAP = 3.0`:补偿量封顶,**欠补偿优于过补偿**。 `DynamicAdvisor` - `TTC_DANGER_ENTER_S = 1.8` / `TTC_DANGER_EXIT_S = 3.0`:进出门限分开是滞回, 合起来会让告警在门限附近反复闪断; - `TTC_CAUTION_ENTER_S = 3.0` / `TTC_CAUTION_EXIT_S = 4.2`; - `CROSS_ENTER = 0.10` / `CROSS_EXIT = 0.06`:横向速率门限(画面宽度/秒); - `FALLBACK_MIN_AGE_MS = 1500` / `FALLBACK_MIN_HITS = 3`:兜底通道要求目标活得够久, 否则"刚出现就在近处"会被误判成"由远及近"。 `AnnounceRate`(档位表) - 四个间隔都**从上一句播完起算**(这是"上一句还没播完下一句就开始了"的病根); - `maxReported`:「少」档压到 1,即一句话只报最近的一簇。 `AnnouncementPolicy` - `STABLE_MS = 350`:一句话要连续保持这么久才有资格播报,专治框在边界抖动导致的 文案横跳。**不随档位变**——它是抖动抑制,不是频率; - `MIN_PLAYED_BEFORE_INTERRUPT_MS = 700`:抢断前至少要播满这么久,不然字会被咬掉。 `ShakeDetector`(全部要真机实走标定) - `PEAK_RAD_PER_SEC = 2.5`:**只有这一个门限适合先动**。误报多(走路/上楼梯就报)就往上调, 漏报(真摔了没动静)就往下调; - `IRREGULARITY_RATIO = 1.5`:误报里"有节奏的运动"居多就往上调; - `MIN_REVERSAL_INTERVAL_MS = 50`:**不要调小**。它挡的是过零点噪声凑出来的假反转, 调小会让规律晃动被当成无规则晃动; - `MIN_SAMPLES = 20` / `TRIGGER_WINDOW_MS = 500`:设备采样率地板。真遇到 `SENSOR_DELAY_GAME` 实际低于 40 Hz 的机器,改 `ShakeSensor` 里的采样率, 不要改这里; - `TRIGGER_COOLDOWN_MS = 6000`:一次摔倒会在好几秒里持续抖,这个冷却决定"喊一遍" 而不是"每 20 毫秒喊一遍"。 - `MIN_JERK_RAD_PER_SEC2 = 20` 目前是**冗余**的一道门:单测里没有任何用例是只靠它拦下来的 ("乱"那两道门也能挡住同样的动作)。留着是因为它是唯一直接看"角速度变化得多突然"的量, 真机标定时若发现它一次都没起作用,删掉是安全的。 `PromptAnnouncer`(报警) - `MAX_ALARM_REPEATS = 8`:报警语音最多说 8 句(1 句 + 7 次 × 5 秒 ≈ 35 秒)。改成 `Integer.MAX_VALUE` 就是字面意义上的"重复到点掉为止"。封的只是"嘴":面板、报警状态、 日常提示的压制都还在,再来一次晃动也会重新充满配额; - `SPEECH_FAILURE_LIMIT = 3`:引擎连挂三次就只剩振动。 调参的顺序建议:先开 `LOG_SCENE_JSON` 采集几段真实数据,再用 `SceneSnapshot.fromJson` 回放,最后才动 `DynamicAdvisor` 的分档——那是唯一无法靠推理确定、只能靠实走标定的部分。 ## 性能建议 - 默认 `yolov8n` + 640 输入。若帧率不够,可改用 `imgsz=480` 重新导出模型, 代码会自动读取模型声明的输入尺寸,无需改代码。 - 推理被限制为单线程串行,且只处理最新帧,所以低帧率只会降低检测频率, 不会让预览卡顿。 - 画面方向由 CameraX 的 `rotationDegrees` 在预处理采样时一并校正, 竖屏、横屏都能正常检测。 ## 测试 ```bash ./gradlew :app:testDebugUnitTest ``` 141 个用例,覆盖从像素到播报的每一层(`LetterboxTest` 依赖 `RgbaFrame` → `ImageProxy`, 跑不进这套纯 Java 的绕法,走 Gradle): | 测试 | 覆盖 | | --- | --- | | `ObstacleAdvisorTest` | 相关性过滤、由近到远的排序、"左侧/中央/右侧"划分、远近判断、通路占比、文案拼接(**回归基线**:改造前的 13 条逐字未变);`maxReported` 压到 1 时只报最近的一簇、且 0 也不会变成一句话都不说 | | `ObjectMergerTest` | 同一个人的两个框合为一个;行人与自行车重叠合为一个;并排两人**不**合并 | | `ObjectTrackerTest` | 横向穿越的行人在 5 FPS 下不丢 ID;按时间老化;换分辨率 / 转屏触发重置;遮挡帧不被当成"远离";不等帧间隔下速度估计仍正确;**没看到的轨迹不再报运动量** | | `TtcEstimatorTest` | 质心校正(并固化"不校正会恒定高估半个窗口"这个反例);样本不足 / 被裁切 / 斜率不显著都不给结论;野值被抑制;横穿序列在尺度通道上确实不可见 | | `EgoMotionEstimatorTest` | 固化"对增长率取中位数会误报静止目标"的反例;轨迹不足 / 场景不一致时返回 NaN(不补偿) | | `JsonTest` / `SceneSnapshotTest` | 转义往返、`\uXXXX`、科学计数法、NaN → `null` → NaN;快照往返**精确**等价(含 `Float.MIN_VALUE` 与带特殊字符的类别名) | | `AnnouncementPolicyTest` | 未播完不播下一句;抖动不播;同级不打断;高优先级且播满 700 ms 才抢断;抢断限流;同句冷却从播完起算;**档位真的改变说得勤不勤**(同一段时序在「多」下说得出、「标准」「少」下被压住) | | `AnnounceRateTest` | 「标准」逐字等于改造前的常量(字面量搬进枚举后,这是唯一能挡住"默认档被悄悄改掉"的东西);三档的单调性;`next()` 转一圈回到原处;**认不出的存档名字退回默认而不是抛异常**(`valueOf` 会在启动时把应用打掉) | | `ShakeDetectorTest` | **不该报的**:静止、走路(叠了三层误报防线)、慢速转体、单次转身、规律大幅摆动(4 Hz ±3 rad/s,比真晃动还猛,只有"无规则"这道门拦得住它)、规律摆动 + 传感器噪声(没有反转去抖就会被噪声顶成"无规则");**该报的**:又猛又乱的抖动只报一次、连续采样不足 0.5 秒不报、采样中断后不能拼接、冷却期压制、`clear()` 后接受从头开始的时间戳、操作界面期间不判定、时间倒流的样本被丢、10 Hz 的慢设备不可信 | | `DynamicAdvisorTest` | 三档滞回阶梯(1.8 / 3.0 / 4.2 秒);没有自运动估计时危险降级为注意;横穿与兜底通道;消失的轨迹要被忘掉 | | `SceneAnalyzerTest` | **端到端**:重叠框只播一次;并排两人播成「两名行人」;持续放大的行人触发「行人接近」;走近的人走出画面后告警立即停止;静止场景不产生动态告警;换画面几何丢弃全部轨迹;`setRate(QUIET)` 后「中央行人 + 右侧汽车」只报中央(档位确实走到了播报那一层) | | `CocoLabelsTest` | 障碍物关键词匹配与中文翻译,含 `carrot` 不能命中 `car` 的回归用例 | | `LetterboxTest` | 缩放与灰边几何,横屏 / 竖屏旋转 / 正方形 / 换输入尺寸四种情形 | **单测覆盖不到、只能上真机走一遍的**(每次动 `ShakeDetector` 的门限都该重跑): 1. 频率档位:三档各点一遍,确认立刻念出新档位、**杀掉进程重开仍然保持**; 2. 晃动:握住手机**不规则**地用力晃 → 报警;接着故意做**有规律**的 2 Hz 摆动、 正常走路、快速转身、上下楼梯 → **不**报警(这是误报的主要来源,要专门走一遍); 3. 报警期间日常提示全部静音、轻晃不乱触发;点「我没事」→ 出声确认、面板消失; 4. 关掉语音开关再触发报警 → 仍然出声 + 仍然振动; 5. 报警时按 Home 再回来 → **报警照旧**(报警不随 `onPause` 停:用户可能就是把手机 摔出去了,屏幕熄了、应用退到后台,报警必须继续); 6. 没有陀螺仪的模拟器上 → 不崩、不报警、**启动时只说一次**提示(按 Home 回来不会再说)。 > ⚠️ **本机环境的已知问题**:当前用户名是中文(`陈聪`),而 Gradle 主目录默认就在 > `C:\Users\陈聪\.gradle\` 下。当测试 classpath 较长时,Gradle 会改用 `@argfile` > 传参,而 JVM 解析含非 ASCII 路径的 argfile 会失败,于是 `testDebugUnitTest` 报 > `ClassNotFoundException: worker.org.gradle.process.internal.worker.GradleWorkerMain`。 > > 这一点已经定位并验证过:argfile 内容全为 ASCII 时正常,含中文路径时必然失败。 > `assembleDebug` 不受影响(编译在 Gradle daemon 内完成,不 fork worker 进程)。 > > 临时绕开:直接用 `javac` + JUnit 跑纯 Java 的用例(本工程的感知 / 决策层都不依赖 > Android 类,所以能这么跑): > > ```bash > javac -encoding UTF-8 -sourcepath app/src/main/java \ > -cp ";;" -d /tmp/tc-out \ > app/src/test/java/com/example/android_try_1/*Test.java > java -cp "/tmp/tc-out;<同样的 classpath>" org.junit.runner.JUnitCore \ > com.example.android_try_1.SceneAnalyzerTest ... > ``` > > 彻底修复是把 Gradle 主目录和工程都挪到纯 ASCII 路径: > > ```bash > setx GRADLE_USER_HOME C:\gradle-home > ``` > > 首次需要重新下载依赖。工程本身也建议放在如 `C:\AndroidProjects\` 这样的纯 ASCII 目录下。 ## 已知限制 **关于"正在靠近"** - 单目的尺度变化在几何上**无法区分**"他走向你"和"你走向他",只能靠场景内静止目标的 中位数运动去推断自身速度。所以这里只报"正在靠近",绝不声称"迎面/对向"; 而且**拿不到自运动估计时不给危险等级**(降为"注意")——无法证明这个目标比你走路 更快时,喊"要撞上了"是在赌。正面人流密集时中位数会被拉走,此时宁可漏报。 - 剩余碰撞时间是**启发式估计**,不是测距:它依赖类别的典型物理高度、依赖相机内参 恒定、依赖目标匀速。急停、急转、突然加速都会被它误判。 - 新目标要连续匹配 2 帧才确认,所以它晚一帧才可能被播报(15 FPS 下约 67 ms)。 这是"宁可不报,也不报错"的代价:单帧的误检不该触发提示。 **关于剧烈晃动报警** - **没有陀螺仪的设备上,报警功能是关着的。** 启动时用 Toast 和语音各说一次,之后不再提; 这类设备(多数廉价平板和少数低端机)只能靠画面提示。 - **报警状态在内存里,进程被系统回收就没了。** 按 Home 退到后台不回收(正常返回后报警 照旧),但内存吃紧时系统杀掉进程,重新打开就是全新的一次启动:用户得再晃一次才会报警。 写到磁盘上要处理"上次报警还没点掉就重启了"这类状态残留,暂时不做。 - **纯陀螺仪判据看不见"单调的摔"。** 缓慢滑倒、或者手机在兜里随着身体一起倒地 (角速度只有 1–2 rad/s、方向不变),既不"猛"也不"乱",判据不会触发。 要覆盖这一类得加一条加速度计的"失重 + 冲击"分支——那是另一套物理量,没有做。 - 阈值全是**工程估计**,还没有经过真机实走标定。误报(规律性的重复动作)与漏报 (真摔了不报)的平衡点只能靠实走找,标定方法见「调参」一节。 - 报警只到"出声 + 振动 + 界面提示"为止,**不做对外求助**(打电话 / 发消息)。 `ShakeSensor.Listener` 就是之后接求助逻辑的入口。 - 报警最多念 8 句(约 35 秒,见「调参」)。这几条都是刻意的取舍,不是没做完。 **其他** - 只支持 ultralytics 导出的 YOLOv8 / YOLO11 **检测**模型;分割、姿态模型不适用。 - 输出布局支持 `[1, 4+C, N]` 和 `[1, N, 4+C]` 两种;其他布局会抛出明确异常。 - 未接入 NNAPI / GPU 委托,目前是纯 CPU 推理。 - 界面锁定竖屏,依赖后置摄像头。 - 动态告警的阈值(1.8 / 3.0 / 4.2 秒与横向速率)目前是**按走路速度推算**的, 还没有经过真机实走标定。标定方法见「调参」一节的最后一段。