Compare commits

..
24 Commits
Author SHA1 Message Date
ZXCLI ee6859877a android+docs: debug 日志默认关闭;操作手册升级为插图版
debug 日志:DebugLog 增加总开关 enabled(默认 false),log() 直接返回、
init() 不装崩溃钩子、startFile() 不建文件。要在设备上排查时改成 true 重新构建
即可,其它代码不动。关闭后崩溃仍按安卓常规方式可见(logcat AndroidRuntime +
系统弹窗),只是不再有落在相册目录旁的 debug_*.log。

真机烟雾测试(小米 22041211AC,相机插着):出流正常、拍照成功(相册 19→20,
新增 MAG160C_20260912_153522.jpg)、崩溃 0、App 自身日志行数 0、无新日志文件。

插图版手册 docs/android_app/app_manual.md + manual_images/:
- 02_markers:标记结构解剖(准星/读数/引线/描边字),真机照片放大 3 倍并标注
- 03/04:横屏 960x720 与竖屏 720x960 的真实照片
- 05/06/07:设置、分析(含底部测量面板)、相册 真机截图
- 01_live_schematic:实时界面改为**自绘示意图**,因为实时 OSD 文字按握持角度预旋转
  (用户要求的可读性设计),手册拍摄时手机横放,截图里文字全是侧躺的无法阅读;
  其余界面文字本就水平,直接用截图。

图由 manual_images/MakeFigures.java 生成(裁剪缩放/放大标注/示意图绘制),
界面改动后重跑即可;原始截图存为 JPEG(PNG→JPEG 省约 8MB)作为可再生输入。
.class 已加入 .gitignore。

README 阅读顺序加入操作手册、结构表更新、里程碑补记根因级修复。105 项测试全绿。
2026-09-12 15:36:34 +08:00
ZXCLI 6ba90f2bb6 docs: 修正操作手册里的标记示意图(四臂+引线+右侧读数,与实际绘制一致) 2026-09-12 15:14:35 +08:00
ZXCLI e3f0729218 docs: 新增 App 操作手册 + README 里程碑/阅读顺序更新
docs/android_app/app_manual.md(新,223 行):
- 安装与首次连接(含"重启后 USB 授权失效需重新确认")
- 界面总览图、实时界面各控件、测温点增删
- 标记怎么读(准星/描边字/MAX-MIN/引线的含义)
- 相册、分析(离线测温)逐项操作
- 设置逐项说明,并标出**是否真正生效**:发射率/报警温度/语言目前仅保存未接入,
  云同步为占位——避免文档承诺 App 做不到的事
- 方向规则(文件跟随握持、预览锁定、录像方向在按下瞬间确定)
- 文件位置与 MDT 容器格式、远程预览双机用法、常见问题、已知限制
  (相册不列录像、红热调色板为近似、PDF/巡检未接入界面)

README.md:阅读顺序加入操作手册;仓库结构表更新;里程碑补记 2026-09-11~12
真机多轮联调的三个根因级修复(launchMode / SET_CONFIGURATION / 软件画布卡顿,
6.5→15.1fps)与同期完成项。

session_state.md 第二十四轮条目补记本轮新增文档。
2026-09-12 15:14:05 +08:00
ZXCLI f465831d8e android: 修正横屏拍摄方向——文件旋转跟随握持角度,不再只跟显示旋转
用户:"切到横屏拍照片和视频方向又不对了"。

根因:上一轮把文件旋转定成显示旋转(锁定 90° + 手动旋转),而这与手机物理姿态无关。
相机模组固定在手机上,手机转 90° 场景就在传感器画面里转 90°,文件必须跟着转,
否则横屏拍出来就是"竖幅文件里装着横躺的场景"。

修法(普通相机做法):文件旋转 = 显示旋转 + 握持角度(PhotoSaver.captureRotation,
握持角取自 DeviceOrientation.deg)。竖直/倒置 → 竖幅 720x960;两个横屏姿态 → 横幅
960x720,且两者相差 180°。录像在开始录制时采样一次(编码尺寸固定)。预览仍保持锁定
(用户此前明确要求),所以横屏文件不是预览截图,而是预览再转一个握持角。
真机复现时设备恰在 270° 姿态(gx=9.88, gy=-1.02 即 deg=270):
修复前 720x960 竖幅;修复后 960x720 横幅,把修复前文件转 -90° 与新文件逐像素比对
平均亮度差 5.06、转 +90° 为 40.94 —— 方向正确且只差这一转。录像 tkhd 960x720
97帧 0 丢弃;分析页读横幅照片 rot=0:MIN/MAX 各一个且与烧录标记重合。

新增 savedFileRotationFollowsTheGrip / landscapeGripsProduceLandscapeFiles。
105 项测试全绿。文档补记教训:文件方向是世界坐标问题、预览方向是屏幕坐标问题,
两者可以不同;这类"方向正确"必须用同一场景在不同姿态下可复现拍摄来验证。
2026-09-12 15:02:03 +08:00
ZXCLI 4c940fe773 android: 照片改为竖屏方向 + 标注风格重做(去白底,准星+描边字)+ 录像 3x 编码
照片方向(推翻上一轮的"传感器朝向"决定,用户明确否掉):
把显示旋转(锁定 90° + 手动旋转)与手动翻转一起烘进 JPEG,竖屏持机拍出来就是
竖屏 720x960,顺序与实时渲染器一致(先镜像再旋转)。温度数据不跟着转——probes/
NUC/extremes 仍在原始传感器空间,旋转只存在于呈现层;BLOCK_RENDER 升到 version 2
多存 rotDeg,旧文件(8B v1 块)解析为 rot=0,符合它们确实是传感器朝向的事实。
分析页映射改为直接复用 PhotoSaver.sensorToPhoto(...,1,1),杜绝两处各算一套。

标注风格(参考 FLIR/Testo 的仪表做法):
- 图标改为细线方形准星 + 四短臂(原为圆环+圆点):方框限定测量区域、四臂指明确切
  像素、中心镂空不遮挡被测点。
- 文字白字+深色描边(先描边后填充),去掉白色底板——白底挡住被测画面且像消费级 App;
  描边让文字在黑冷端和白色热端都读得清。
- 极值改用 MAX/MIN 大写 + 引线把读数连到自己的准星;颜色仍取白色(彩色标点在铁虹
  橙黄区会糊掉,区分靠文字)。

标注尺寸统一:AnnotSpec 的 320 单位是图像长边,实时界面原用 viewport.width()(竖屏
时是短边)去除 320,使实时标点只有照片的 3/4 大——这正是用户早先"照片标点太大"的
由来。现四处统一 max(w,h)/320。

录像清晰度:编码尺寸改为传感器 x3(竖屏 720x960),码率随像素数放大;帧的翻转+旋转
用一个 Matrix 一次 drawBitmap 完成,标点经 sensorToPhoto 落在同一变换下(矩阵复合
结果与 sensorToPhoto 按坐标推导核对一致)。实测 tkhd 720x960 / avc1 / 96帧 0丢失。

分析页"每个标记出现两次":不是坐标错,而是标签避让与绘制顺序有关——拍摄按
probes→MAX→MIN、分析按 MAX→MIN→probes,避让把标签推到不同位置。改为同序后叠加
完全重合(真机裁剪对比确认)。

排查方法:验证"文件里标记位置是否正确"不靠肉眼看截图叠加,直接解析 MDT 算期望像素
再统计该处中性白色像素数——据此一次证伪"旋转没生效":rot=90 处 markerPixels=
204/219/296/719,rot=0 处全为 0。

103 项测试全绿(新增 mirror x rotation 往返、旋转角点、v1→v2 渲染块兼容)。
2026-09-12 14:34:54 +08:00
ZXCLI 9e64c74ffe android: 录像标注日志给出完整判据 + 补充重启后真机验证记录
录像首帧日志原来只打印标记数量,无法判断"少了的标记是为什么少的"。现在一并
打印判据(probes 数量、trace 模式、minPos/maxPos、minC/maxC),录像里缺
max/min 时可直接从日志定位原因(追踪被关、极值尚未算出、重启清空了测点等)。

排查记录(重要):本轮一度以为录像丢了极值标记,实测日志
`annotation: 2 markers ... trace=BOTH minPos=159 maxPos=16269` 说明 probes=0、
2 个标记正好是 max+min ——标记本身是齐的,是我的读法错了(中途重装 APK 清空了
测点,我误把 2 当成"两个测点")。因此保留并强化了这条日志。

补充真机验证(重启冷启动 + 60s 稳定性):
- 重启后 USB 权限被系统清空、重新授权后一次成功,15.1fps、无重枚举
- 录像 99 帧 / 0 丢弃 / 1.6MB,极值标记确认烧入
- 长时间运行 15.1fps 稳定、开增强(8 档)单帧 9.2~9.4ms
- 照片/分析面板/底部面板三方数值完全一致
  (同帧照片 max 36.0 min 20.4 Pt1 21.7 Pt2 26.1 ↔ 面板 36.0/20.4/24.5 + 21.7/26.1)
- 文档记录:判断自绘画面帧率不能用 dumpsys gfxinfo(看不到 lockCanvas 绘制),
  必须用内置 MAG160C/render 日志。99 项测试全绿。
2026-09-12 13:46:08 +08:00
ZXCLI e43e93025e android: 修正增强档位说明(写反了)+ 分析页测温点沿用照片数值
上一提交把档位含义写成了「越高纳入越多弱对比区域」,方向是反的。按公式
amplitude = strength*(center-mean)/divisor, divisor = max(max-mean, mean-min, mapStrength),
mapStrength = strength*gain*2>>8 重新推导:
- 弱对比区域 divisor 被 mapStrength 兜住 → 幅度 = 128/gain,与档位无关;
- 强边缘 divisor 由对比度主导 → 幅度随档位线性增长;
- 门槛 mapStrength <= range*32 → 档位越高弱对比区域被整个排除(正是噪声所在处)。
即高档位是「更锐但更挑」。设置页文案与 DetailEnhance 文档已改正,
并加 theLevelRaisesTheContrastGateAndDropsWeakDetail 锁定
(range=60 窗口:8 档 mapStrength 1500 通过、16 档 3000 被拒)。
真机 HF 实测 0.365(关) / 0.438(6档) / 0.433(8档),与推导一致。

分析页测温点原先前先重新测量 NUC,于是照片上烧录的 Pt2 26.1℃ 与面板 25.9℃
不一致(与极值同一类漂移)。现在优先用文件记录的 tempMc——照片上印的就是它,
仅在记录缺失时才测量。真机确认照片/面板/极值三方数值完全一致。

真机(小米 22041211AC/Android12/MIUI,重启后冷启动):15.1fps 稳定、单帧 6.1~6.6ms、
6/8 档 9.9~10.3ms、拍照 extremes=2、分析页只有一个 min/max 且与照片重合、
底部面板可见(36.0/20.4/24.5)。101 项测试全绿。
2026-09-12 13:36:10 +08:00
ZXCLI f41668d5d1 android: 修正 7x7 细节增强的混叠系数(原实现把增益乘了两遍,画面满噪点)
官方 FilterDetailEnhancement_Simple 把「增益缩放后」的 k = strength*gain*2>>8 只
传给 map(决定阈值与 divisor 下限),混叠系数用的是**原始 strength**。本移植最初
两处都用 k,等于把细节再乘一遍增益:真机 level 2 满屏噪点。改为原始 strength 后
HF 能量与关闭档基本相同(0.47-0.51 vs 0.46-0.47),边缘仍被增强。

细节校正:
- 一个有用的推论:每档的边缘强度其实与档位无关(化简为 128/gain),档位只决定
  哪些窗口通过对比度阈值——所以调高档位是"纳入更多弱对比区域",不是"推得更狠"。
  据此把档位从 0-4 扩到 0/1/2/3/4/6/8(官方 MAG_SetDetailEnhancement 钳 0..32)。
- 官方 App 自身不调用 SetDetailEnhancement(jadx 全量搜索无此符号),SetEX 是数码
  变焦 ROI 与增强无关;故无法据此对齐官方默认档,保持用户可见设置、默认关闭。

测试:
- 新增 blendUsesTheRawStrengthNotTheGainScaledOne 黄金值用例(推导写在注释里),
  把 bug 改回去会立刻失败(已实测验证)。
- 原 gainScalesTheEffect 改为 gainDoesNotAmplifyNoiseOnAFlatField + gainBelowOneIsANoOp,
  因为修正后增益不再缩放混叠幅度。98 项测试全绿。

顺带修复(同一轮真机复现):
- no_device 死锁:首次 connect 撞上相机重枚举时停在「无相机」,且紧随其后的
  ATTACHED 广播被 800ms 防抖丢弃 → 永久无画面。防抖/退避改为延后重试,no_device
  也安排 1.5s 重试(scheduleRetry 单槽位、最新优先)。
- 手机平放时加速度计 x/y≈0,迟滞逻辑保留旧姿态角导致 OSD 文字整体转 90°;
  改为 tilt<4m/s² 判定平放、姿态归零。
- 录像帧缺 max/min 标记:probesAsMarks() 现在一并返回,录像与实时画面一致。

真机(小米 22041211AC/Android12/MIUI):15.1fps 稳定、单帧 6-9ms(开增强 11ms)、
拍照与分析页标记一致且各只有一个 min/max、设置跨冷启动持久化、
屏幕熄灭时渲染停止无空转(CPU 3.7%)。
2026-09-12 05:04:50 +08:00
ZXCLI 4ebdca109a android: 实时画面卡顿根因是软件画布(6.5→15.1fps),标注统一 + 7x7 细节增强
卡顿: 热像走 SurfaceView.lockCanvas 的软件画布, 每帧在 CPU 上做 320x240 ->
~810x1080 双线性放大 + 清整屏, 实测只有 6.5 帧/秒、单帧 134ms。dumpsys gfxinfo
看不到这条路径(只统计 Compose), 故给渲染线程加自测量日志才定位到。改用
lockHardwareCanvas(API29+, 旧系统回退) 后 15.1 帧/秒、单帧 6.4ms。
同时消除每帧垃圾: 复用翻转缓冲/标记列表, 色条改预渲染位图; 温度扫描从主线程
移到 Dispatchers.Default。

标注统一: 新增 AnnotSpec(唯一几何定义) + MarkerPainter(唯一绘制例程), 实时/
分析/照片/视频共用, 极端值标签改用 max/min; 照片按传感器朝向 3x 分辨率保存。
修 placeLabel 不判重叠(min 与 max 印成一团), 现支持让位。

分析对齐: 极端值漏了 sensorToPhoto 映射(落在左上角几十像素内), annotateJpeg
忽略照片镜像(另存时标记镜像到另一侧), 两处已修。

新增 BLOCK_EXTREMES(0x5BB5B562) 存拍摄时的极值位置/温度: 分析页重扫 NUC 不可能
逐位复现 live 结果, 真机出现过两个相距几像素的 min。

分析面板被导航栏遮挡: UiInsets.navPx 改可观察状态并加 padding。

7x7 局部细节增强(官方 FilterDetailEnhancement_Simple 逐行移植), 强度换算
level shl 3 经 MAG_SetDetailEnhancement(钳 0..32)核对一致; 设置页新增
"图像增强" 0-4 级, 默认关闭(无官方参考输出), 不影响逐位基线。

真机验证(小米 22041211AC/Android12/MIUI): 15.1fps、拍照含 2 探针+2 极值、
照片与分析页标记位置/风格一致且各只有一个 min/max、分析数值与照片吻合、
增强 2 级细节明显且不掉帧。测试 96 项全绿。
2026-09-12 04:20:16 +08:00
ZXCLI 47037f3a23 android: unify temperature annotations (one spec, sharp 3x photos), sensor-orientation photos, video annotations, palette cache + render loop, Android 8 API guards 2026-09-12 03:06:27 +08:00
ZXCLI 376b8389ea android: fix launchMode (singleTask) root cause of reconnect storm; drop SET_CONFIGURATION reset; fix analysis temp units, NUC block size, viewer back keys 2026-09-12 02:30:05 +08:00
ZXCLI 4cb6e69ea4 android: fix analysis temperatures (store calibrated NUC data), marker sizing, bottom data panel; split album (zoom/delete) from analysis tab 2026-09-12 00:05:49 +08:00
ZXCLI 15a7127287 android: fix recording crash+save, gallery viewer unreachable, back key, settings persistence; photo orientation+annotations, analyzable MDT probes, trace mode 2026-09-11 23:26:27 +08:00
ZXCLI 3704ee797b android: settings screen scrollable + wire dead rows (language/about) + instant orientation apply 2026-09-11 03:59:55 +08:00
ZXCLI f7bca894ba android: revert image to locked orientation (user decision); keep official-style manual rotate/flip settings 2026-09-11 03:50:56 +08:00
ZXCLI 464cfcb5e8 android: fix real-device defects (temp double-conversion, FFC temp jump, image orientation per grip, remote raw+metadata stream) 2026-09-11 03:01:13 +08:00
ZXCLI 0919f5e186 docs+analysis: independent verification guide for phases A-F (calibrated scanners, self-tested commands) 2026-09-11 01:10:18 +08:00
ZXCLI 8e1312a137 android: phase Z wrap-up (docs, release build, camera feature optional, APK) 2026-09-11 00:57:46 +08:00
ZXCLI 512508e629 android: LAN remote preview (UDP discovery + raw-frame TCP stream, client-side rendering) 2026-09-11 00:54:13 +08:00
ZXCLI f8b3200464 android: visible-light PIP overlay (camera2, draggable, 3 sizes) 2026-09-11 00:31:00 +08:00
ZXCLI c34940e6fd android: cloud module scaffold (retrofit, opt-in, disabled by default) 2026-09-11 00:22:25 +08:00
ZXCLI b7a928e23d android: vendor palette extraction tooling (+exact tables if found) 2026-09-11 00:15:39 +08:00
ZXCLI 656d419f81 android: MDT temperature decode + analyzer probe UI 2026-09-10 23:43:19 +08:00
ZXCLI 06c1f30b13 android: lifetime query + cali consistency check + real-device checklist 2026-09-10 23:34:10 +08:00
93 changed files with 12593 additions and 522 deletions
+3
View File
@@ -36,3 +36,6 @@ __pycache__/
# ZCode 会话目录
.zcode/
# Java build output for the manual figure generator
docs/android_app/manual_images/*.class
+16 -5
View File
@@ -8,10 +8,11 @@ MAG160C 热像仪(160×12015fpsUSB VID `0x833C`)的逆向工程与双
1. `docs/android_app/HANDOFF_DEVELOPMENT.md` — 安卓 App 总交接(必读)
2. `docs/android_app/session_state.md` — 会话心跳与待办(最新进度)
3. `analysis/README.md` — 逆向工程总目录索引(Linux/Windows/Android 三条线
4. `analysis/magcx_official_flow.md`**权威 USB 协议结论**(官方源码逐行核对版
5. `analysis/protocol_spec.md` — 早期 PC 端协议逆向(硬件验证记录
6. `csdk/README.md` — C SDK 文档(构建/API/工具/测试
3. `docs/android_app/app_manual.md`**App 操作手册**(界面/设置/方向/排障
4. `analysis/README.md` — 逆向工程总目录索引(Linux/Windows/Android 三条线
5. `analysis/magcx_official_flow.md`**权威 USB 协议结论**(官方源码逐行核对版
6. `analysis/protocol_spec.md` — 早期 PC 端协议逆向(硬件验证记录
7. `csdk/README.md` — C SDK 文档(构建/API/工具/测试)
## 仓库结构
@@ -23,7 +24,7 @@ MAG160C 热像仪(160×12015fpsUSB VID `0x833C`)的逆向工程与双
| `IR_Camera_SDK-1.0.1/` | 原厂 Windows/Android/Linux SDK 原始包 |
| `app/` | 官方安卓 APK 存档(普通版/专业版/ThermoScope/demo|
| `build-artifacts/` | 构建产物(安卓 APK、PC demo3、DDT 标定数据)|
| `docs/android_app/` | 安卓 App 交接文档(HANDOFF / session_state / 逆向功能总表|
| `docs/android_app/` | 安卓 App 文档:`app_manual.md` 操作手册 / `HANDOFF_DEVELOPMENT.md` 交接 / `session_state.md` 进度心跳 / 逆向功能总表 |
| `vendor-docs/` | 厂商资料 |
| `tools/` | 会话恢复脚本 |
@@ -60,6 +61,16 @@ Start-Process "build-artifacts\mag160c_demo3.exe" -WorkingDirectory "build-artif
USB 层(官方协议 Java 源码逐行复刻)、相机式 UI、拍照/录像/媒体库/分析/PDF
- 2026-09-10:真机联调打通(根因:USB 命令字节序反转,已修复并用单测锁死);
官方 App 全量解包入库(jadx + Ghidra
- 2026-09-11~12:真机多轮联调(无线 adb)。修复根因级的三个问题:
**launchMode 缺失**导致 MIUI 重复广播 ATTACHED、建出多个 Activity 争抢相机
(重连风暴的真凶);`SET_CONFIGURATION` 复位设备导致相机反复重枚举;
**实时画面卡顿**——热像走 `lockCanvas` 软件画布,每帧在 CPU 上放大整屏,
实测只有 6.5 帧/秒、单帧 134ms,改用硬件画布后 **15.1 帧/秒、单帧 6.4ms**
同期完成:官方 7×7 局部细节增强移植(含混叠系数修正与黄金值测试)、
测温点标注风格重做(准星+描边字,去白底)、拍照/录像方向跟随握持角度、
录像 3× 分辨率、分析页与照片标记完全对齐、设置持久化
- 文档:`docs/android_app/app_manual.md`(操作手册)、
`docs/android_app/session_state.md`(第 18~24 轮修复记录与排查方法)
## 仓库说明
Binary file not shown.
@@ -0,0 +1,22 @@
{
"finding": "libcxsdk.so contains NO static palette tables. All 12 vendor palettes are computed at runtime by CFunctions::SetColorPalette (Ghidra FUNC 0x00026c70). A static scan for 256-entry alpha=0xFF runs returns nothing; the tables were recovered by porting the generator instead.",
"static_candidates": [],
"source": {"binary": "analysis/sdk_re/android_app/bin/libcxsdk.so", "sha256_expected_size": 372476, "function": "CFunctions::SetColorPalette", "decompilation": "analysis/sdk_re/android_app/libcxsdk_decomp.txt"},
"published_entry_layout": "byte0=B, byte1=G, byte2=R, byte3=0 (pinned by the iron bow anchor: 256/256)",
"ui_order": ["white_hot", "black_hot", "iron_bow", "rain_bow", "glow_bow", "autumn", "winter", "hot_metal", "jet", "red_saturation", "high_contrast", "red_hot"],
"index_to_case": {"white_hot": 0, "black_hot": 1, "iron_bow": 2, "rain_bow": 3, "glow_bow": 4, "autumn": 5, "winter": 6, "hot_metal": 7, "jet": 8, "red_saturation": 9, "high_contrast": 10, "red_hot": -1},
"unexposed_cases": [12, 13],
"tables": {
"white_hot": [-16777216, -16711423, -16645630, -16579837, -16514044, -16448251, -16382458, -16316665, -16250872, -16185079, -16119286, -16053493, -15987700, -15921907, -15856114, -15790321, -15724528, -15658735, -15592942, -15527149, -15461356, -15395563, -15329770, -15263977, -15198184, -15132391, -15066598, -15000805, -14935012, -14869219, -14803426, -14737633, -14671840, -14606047, -14540254, -14474461, -14408668, -14342875, -14277082, -14211289, -14145496, -14079703, -14013910, -13948117, -13882324, -13816531, -13750738, -13684945, -13619152, -13553359, -13487566, -13421773, -13355980, -13290187, -13224394, -13158601, -13092808, -13027015, -12961222, -12895429, -12829636, -12763843, -12698050, -12632257, -12566464, -12500671, -12434878, -12369085, -12303292, -12237499, -12171706, -12105913, -12040120, -11974327, -11908534, -11842741, -11776948, -11711155, -11645362, -11579569, -11513776, -11447983, -11382190, -11316397, -11250604, -11184811, -11119018, -11053225, -10987432, -10921639, -10855846, -10790053, -10724260, -10658467, -10592674, -10526881, -10461088, -10395295, -10329502, -10263709, -10197916, -10132123, -10066330, -10000537, -9934744, -9868951, -9803158, -9737365, -9671572, -9605779, -9539986, -9474193, -9408400, -9342607, -9276814, -9211021, -9145228, -9079435, -9013642, -8947849, -8882056, -8816263, -8750470, -8684677, -8618884, -8553091, -8487298, -8421505, -8355712, -8289919, -8224126, -8158333, -8092540, -8026747, -7960954, -7895161, -7829368, -7763575, -7697782, -7631989, -7566196, -7500403, -7434610, -7368817, -7303024, -7237231, -7171438, -7105645, -7039852, -6974059, -6908266, -6842473, -6776680, -6710887, -6645094, -6579301, -6513508, -6447715, -6381922, -6316129, -6250336, -6184543, -6118750, -6052957, -5987164, -5921371, -5855578, -5789785, -5723992, -5658199, -5592406, -5526613, -5460820, -5395027, -5329234, -5263441, -5197648, -5131855, -5066062, -5000269, -4934476, -4868683, -4802890, -4737097, -4671304, -4605511, -4539718, -4473925, -4408132, -4342339, -4276546, -4210753, -4144960, -4079167, -4013374, -3947581, -3881788, -3815995, -3750202, -3684409, -3618616, -3552823, -3487030, -3421237, -3355444, -3289651, -3223858, -3158065, -3092272, -3026479, -2960686, -2894893, -2829100, -2763307, -2697514, -2631721, -2565928, -2500135, -2434342, -2368549, -2302756, -2236963, -2171170, -2105377, -2039584, -1973791, -1907998, -1842205, -1776412, -1710619, -1644826, -1579033, -1513240, -1447447, -1381654, -1315861, -1250068, -1184275, -1118482, -1052689, -986896, -921103, -855310, -789517, -723724, -657931, -592138, -526345, -460552, -394759, -328966, -263173, -197380, -131587, -65794, -1],
"black_hot": [-1, -65794, -131587, -197380, -263173, -328966, -394759, -460552, -526345, -592138, -657931, -723724, -789517, -855310, -921103, -986896, -1052689, -1118482, -1184275, -1250068, -1315861, -1381654, -1447447, -1513240, -1579033, -1644826, -1710619, -1776412, -1842205, -1907998, -1973791, -2039584, -2105377, -2171170, -2236963, -2302756, -2368549, -2434342, -2500135, -2565928, -2631721, -2697514, -2763307, -2829100, -2894893, -2960686, -3026479, -3092272, -3158065, -3223858, -3289651, -3355444, -3421237, -3487030, -3552823, -3618616, -3684409, -3750202, -3815995, -3881788, -3947581, -4013374, -4079167, -4144960, -4210753, -4276546, -4342339, -4408132, -4473925, -4539718, -4605511, -4671304, -4737097, -4802890, -4868683, -4934476, -5000269, -5066062, -5131855, -5197648, -5263441, -5329234, -5395027, -5460820, -5526613, -5592406, -5658199, -5723992, -5789785, -5855578, -5921371, -5987164, -6052957, -6118750, -6184543, -6250336, -6316129, -6381922, -6447715, -6513508, -6579301, -6645094, -6710887, -6776680, -6842473, -6908266, -6974059, -7039852, -7105645, -7171438, -7237231, -7303024, -7368817, -7434610, -7500403, -7566196, -7631989, -7697782, -7763575, -7829368, -7895161, -7960954, -8026747, -8092540, -8158333, -8224126, -8289919, -8355712, -8421505, -8487298, -8553091, -8618884, -8684677, -8750470, -8816263, -8882056, -8947849, -9013642, -9079435, -9145228, -9211021, -9276814, -9342607, -9408400, -9474193, -9539986, -9605779, -9671572, -9737365, -9803158, -9868951, -9934744, -10000537, -10066330, -10132123, -10197916, -10263709, -10329502, -10395295, -10461088, -10526881, -10592674, -10658467, -10724260, -10790053, -10855846, -10921639, -10987432, -11053225, -11119018, -11184811, -11250604, -11316397, -11382190, -11447983, -11513776, -11579569, -11645362, -11711155, -11776948, -11842741, -11908534, -11974327, -12040120, -12105913, -12171706, -12237499, -12303292, -12369085, -12434878, -12500671, -12566464, -12632257, -12698050, -12763843, -12829636, -12895429, -12961222, -13027015, -13092808, -13158601, -13224394, -13290187, -13355980, -13421773, -13487566, -13553359, -13619152, -13684945, -13750738, -13816531, -13882324, -13948117, -14013910, -14079703, -14145496, -14211289, -14277082, -14342875, -14408668, -14474461, -14540254, -14606047, -14671840, -14737633, -14803426, -14869219, -14935012, -15000805, -15066598, -15132391, -15198184, -15263977, -15329770, -15395563, -15461356, -15527149, -15592942, -15658735, -15724528, -15790321, -15856114, -15921907, -15987700, -16053493, -16119286, -16185079, -16250872, -16316665, -16382458, -16448251, -16514044, -16579837, -16645630, -16711423, -16777216],
"iron_bow": [-16777216, -16777211, -16777206, -16777201, -16777195, -16777190, -16777185, -16777179, -16777174, -16777169, -16777163, -16777158, -16777153, -16777147, -16777142, -16777137, -16777131, -16777126, -16777121, -16777115, -16777110, -16777105, -16777099, -16646027, -16449418, -16318346, -16121737, -15925129, -15794056, -15597448, -15400839, -15269767, -15073158, -14876550, -14745477, -14548869, -14352260, -14221188, -14024579, -13827971, -13696898, -13500290, -13369217, -13172609, -12976000, -12844928, -12648319, -12451710, -12320638, -12124029, -11927421, -11796348, -11599740, -11403131, -11272059, -11075450, -10878842, -10747769, -10551161, -10420088, -10223480, -10026871, -9895799, -9699190, -9502582, -9371509, -9174901, -8978292, -8847220, -8650611, -8454002, -8322930, -8126321, -7929713, -7798640, -7602032, -7470959, -7274351, -7077742, -6946670, -6750061, -6553453, -6422380, -6225772, -6029163, -5898091, -5701482, -5504874, -5373801, -5177193, -4980584, -4849512, -4652903, -4456294, -4390504, -4324458, -4192876, -4127086, -4061040, -3929458, -3863412, -3797622, -3666040, -3599994, -3534204, -3402622, -3336576, -3270530, -3139204, -3073158, -3007112, -2875530, -2809740, -2743694, -2612112, -2546322, -2480276, -2348694, -2282648, -2216858, -2085276, -2019230, -1887905, -1887397, -1821353, -1755310, -1689522, -1623479, -1557435, -1491392, -1425604, -1359561, -1293517, -1227729, -1161686, -1095642, -1029599, -963811, -963304, -897260, -831473, -765425, -699377, -633329, -567537, -501489, -435441, -369393, -303601, -237553, -171505, -105713, -39665, -39153, -38641, -38385, -37873, -37361, -37105, -36593, -36081, -35569, -35313, -34801, -34289, -33777, -33521, -33009, -32497, -32241, -31729, -31217, -30705, -30449, -29937, -29425, -28913, -28657, -28145, -27633, -27377, -26865, -26353, -25841, -25585, -25073, -24561, -24305, -23793, -23281, -22769, -22513, -22001, -21489, -20977, -20721, -20209, -19697, -19441, -18929, -18417, -17905, -17649, -17137, -16625, -16369, -15857, -15345, -14833, -14577, -14065, -13553, -13041, -12785, -12273, -11761, -11505, -10993, -10481, -9969, -9713, -9201, -8689, -8433, -7914, -7394, -6875, -6611, -6092, -5572, -5053, -4789, -4270, -3750, -3487, -2967, -2448, -1928, -1665, -1145, -626, -106, -99, -91, -84, -76, -69, -61, -54, -46, -39, -31, -24, -16, -9, -1],
"rain_bow": [-16777166, -16776907, -16776392, -16775877, -16775362, -16774846, -16774331, -16773816, -16773301, -16772786, -16772270, -16771755, -16771240, -16770725, -16770210, -16769694, -16769179, -16768664, -16768149, -16767634, -16767118, -16766603, -16766088, -16765573, -16765058, -16764542, -16764027, -16763512, -16762997, -16762482, -16761966, -16761451, -16760936, -16760421, -16759906, -16759390, -16758875, -16758360, -16757845, -16757330, -16756814, -16756299, -16755784, -16755269, -16754754, -16754238, -16753723, -16753208, -16752693, -16752178, -16751662, -16751147, -16750632, -16750117, -16749602, -16749086, -16748571, -16748056, -16747541, -16747026, -16746510, -16745995, -16745480, -16744965, -16744449, -16547332, -16284680, -16022028, -15759376, -15496724, -15234072, -14971420, -14708768, -14446116, -14183464, -13920812, -13658160, -13395508, -13132856, -12870204, -12607552, -12344900, -12082248, -11819596, -11556944, -11294292, -11031640, -10768988, -10506336, -10243684, -9981032, -9718380, -9455728, -9193076, -8930424, -8667772, -8405120, -8142468, -7879816, -7617164, -7354512, -7091860, -6829208, -6566556, -6303904, -6041252, -5778600, -5515948, -5253296, -4990644, -4727992, -4465340, -4202688, -3940036, -3677384, -3414732, -3152080, -2889428, -2626776, -2364124, -2101472, -1838820, -1576168, -1313516, -1050864, -788212, -525560, -262908, -256, -1023, -2045, -3067, -4089, -5111, -6133, -7155, -8177, -9199, -10221, -11243, -12265, -13287, -14309, -15331, -16353, -17375, -18397, -19419, -20441, -21463, -22485, -23507, -24529, -25551, -26573, -27595, -28617, -29639, -30661, -31683, -32705, -33727, -34749, -35771, -36793, -37815, -38837, -39859, -40881, -41903, -42925, -43947, -44969, -45991, -47013, -48035, -49057, -50079, -51101, -52123, -53145, -54167, -55189, -56211, -57233, -58255, -59277, -60299, -61321, -62343, -63365, -64387, -65409, -64639, -63613, -62587, -61561, -60535, -59509, -58483, -57457, -56431, -55405, -54379, -53353, -52327, -51301, -50275, -49249, -48223, -47197, -46171, -45145, -44119, -43093, -42067, -41041, -40015, -38989, -37963, -36937, -35911, -34885, -33859, -32833, -31807, -30781, -29755, -28729, -27703, -26677, -25651, -24625, -23599, -22573, -21547, -20521, -19495, -18469, -17443, -16417, -15391, -14365, -13339, -12313, -11287, -10261, -9235, -8209, -7183, -6157, -5131, -4105, -3079, -2053, -1027],
"glow_bow": [-13500416, -13500160, -13499904, -13499648, -13499392, -13499136, -13498880, -13498624, -13498368, -13498112, -13497856, -13497600, -13497344, -13497088, -13496832, -13496576, -13496320, -13496064, -13495808, -13495552, -13495296, -13495040, -13494784, -13494528, -13494272, -13494016, -13493760, -13493504, -13493248, -13492992, -13492736, -13492480, -13492224, -13491968, -13491712, -13491456, -13491200, -13490944, -13490688, -13490432, -13490176, -13489920, -13489664, -13489408, -13489152, -13488896, -13488640, -13488384, -13488128, -13487872, -13487616, -13421824, -13356032, -13224704, -13158912, -13093120, -12961792, -12896000, -12830208, -12698880, -12633088, -12501760, -12435968, -12370176, -12238848, -12173056, -12107264, -11975936, -11910144, -11844352, -11713024, -11647232, -11515904, -11450112, -11384320, -11252992, -11187200, -11121408, -10990080, -10924288, -10792960, -10727168, -10661376, -10530048, -10464256, -10398464, -10267136, -10201344, -10135552, -10004224, -9938432, -9807104, -9741312, -9675520, -9544192, -9478400, -9412608, -9281280, -9215488, -9149696, -9018368, -8952576, -8821248, -8755456, -8689664, -8558336, -8492544, -8426752, -8295424, -8229632, -8098304, -8032512, -7966720, -7835392, -7769600, -7703808, -7572480, -7506688, -7440896, -7309568, -7243776, -7112448, -7046656, -6980864, -6849536, -6783744, -6717952, -6586624, -6520832, -6455040, -6323712, -6257920, -6126592, -6060800, -5995008, -5863680, -5797888, -5732096, -5600768, -5534976, -5403648, -5337856, -5272064, -5140736, -5074944, -5009152, -4877824, -4812032, -4746240, -4614912, -4549120, -4417792, -4352000, -4286208, -4154880, -4089088, -4023296, -3891968, -3826176, -3760384, -3629056, -3563264, -3431936, -3366144, -3300352, -3169024, -3103232, -3037440, -2906112, -2840320, -2708992, -2643200, -2577408, -2446080, -2380288, -2314496, -2183168, -2117376, -2051584, -1920256, -1854464, -1723136, -1657344, -1591552, -1460224, -1394432, -1328640, -1197312, -1131520, -1065728, -934400, -868608, -737280, -671488, -605696, -474368, -408576, -342784, -211456, -145664, -14336, -14080, -13824, -13568, -13312, -13056, -12800, -12544, -12288, -12032, -11776, -11520, -11264, -11008, -10752, -10496, -10240, -9984, -9728, -9472, -9216, -8960, -8704, -8448, -8192, -7936, -7680, -7424, -7168, -6912, -6656, -6400, -6144, -5888, -5632, -5376, -5120, -4864, -4608, -4352, -4096, -3840, -3584, -3328, -3072, -2816, -2560, -2304, -2048, -1792, -1536, -1280, -1024, -768, -512, -256],
"autumn": [-16777216, -16580608, -16383999, -16187135, -15990526, -15793918, -15531517, -15334909, -15138300, -14941435, -14744827, -14548218, -14285818, -14089209, -13892601, -13695736, -13499127, -13302519, -13040118, -12843510, -12646901, -12450037, -12253428, -12056820, -11794419, -11597810, -11401202, -11204337, -11007729, -10811120, -10548720, -10352111, -10155246, -9958638, -9762029, -9499629, -9303020, -9106412, -8909547, -8712939, -8516330, -8253929, -8057321, -7860712, -7663848, -7467239, -7270631, -7008230, -6811621, -6615013, -6418148, -6221540, -6024931, -5762531, -5565922, -5369314, -5172449, -4975840, -4779232, -4516831, -4320223, -4123614, -3926750, -3730141, -3467740, -3467484, -3401693, -3335901, -3270110, -3204318, -3204063, -3138271, -3072480, -3006689, -2940897, -2940642, -2874850, -2809059, -2743267, -2677476, -2611429, -2611173, -2545382, -2479590, -2413799, -2348007, -2347752, -2281960, -2216169, -2150378, -2084586, -2084331, -2018539, -1952748, -1886956, -1821165, -1755118, -1754862, -1689071, -1623279, -1557488, -1491696, -1491441, -1425649, -1359858, -1294067, -1228275, -1228020, -1162228, -1096437, -1030645, -964854, -898807, -898551, -832760, -766968, -701177, -635385, -635130, -569338, -503547, -437756, -371964, -371709, -305917, -240126, -174334, -108543, -42496, -42240, -41728, -41216, -40704, -40192, -39680, -39168, -38656, -38144, -37632, -37120, -36608, -36096, -35584, -35072, -34560, -34304, -33792, -33280, -32768, -32256, -31744, -31232, -30720, -30208, -29696, -29184, -28672, -28160, -27648, -27136, -26624, -26368, -25856, -25344, -24832, -24320, -23808, -23296, -22784, -22272, -21760, -21248, -20736, -20224, -19712, -19200, -18688, -18432, -17920, -17408, -16896, -16384, -15872, -15360, -14848, -14336, -13824, -13312, -12800, -12288, -11776, -11264, -10752, -10749, -10489, -10485, -10225, -9965, -9961, -9701, -9441, -9437, -9177, -8917, -8913, -8653, -8649, -8389, -8129, -8125, -7865, -7605, -7601, -7341, -7081, -7077, -6817, -6557, -6553, -6293, -6289, -6029, -5769, -5765, -5505, -5245, -5241, -4981, -4721, -4717, -4457, -4453, -4193, -3933, -3929, -3669, -3409, -3405, -3145, -2885, -2881, -2621, -2361, -2357, -2097, -2093, -1833, -1573, -1569, -1309, -1049, -1045, -785, -525, -521, -261],
"winter": [-1, -131330, -328195, -459524, -656389, -787718, -984583, -1115912, -1312777, -1444106, -1640971, -1837836, -1969165, -2166030, -2297359, -2494224, -2625553, -2822418, -2953747, -3150612, -3347477, -3478806, -3675671, -3807000, -4003865, -4135194, -4332059, -4463388, -4660253, -4791582, -4988447, -5185312, -5316641, -5513506, -5644835, -5841700, -5973029, -6169894, -6301223, -6498088, -6694953, -6826282, -7023147, -7154476, -7351341, -7482670, -7679535, -7810864, -8007729, -8139058, -8335923, -8532788, -8664117, -8860982, -8992311, -9189176, -9320505, -9517370, -9648699, -9845564, -10042429, -10173758, -10370623, -10501952, -10698817, -10830146, -11027011, -11158340, -11355205, -11486534, -11683399, -11880264, -12011593, -12208458, -12339787, -12536652, -12667981, -12864846, -12996175, -13193040, -13389905, -13521234, -13718099, -13849428, -14046293, -14177622, -14374487, -14505816, -14702681, -14834010, -15030875, -15227740, -15359069, -15555934, -15687263, -15884128, -16015457, -16212322, -16343651, -16540516, -16737381, -16606566, -16475751, -16279400, -16148585, -15952234, -15821419, -15625068, -15494253, -15363438, -15167087, -15036272, -14839921, -14709106, -14512755, -14381940, -14251125, -14054774, -13923959, -13727608, -13596793, -13400442, -13269627, -13138812, -12942461, -12811646, -12615295, -12484480, -12288129, -12157314, -12026499, -11830148, -11699333, -11502982, -11372167, -11175816, -11045001, -10914186, -10717835, -10587020, -10390669, -10259854, -10063503, -9932688, -9801873, -9605522, -9474707, -9278356, -9147541, -8951190, -8820375, -8689560, -8493209, -8362394, -8166043, -8035228, -7838877, -7708062, -7577247, -7380896, -7250081, -7053730, -6922915, -6726564, -6595749, -6464934, -6268583, -6137768, -5941417, -5810602, -5614251, -5483436, -5352621, -5156270, -5025455, -4829104, -4698289, -4501938, -4371123, -4240308, -4043957, -3913142, -3716791, -3585976, -3389625, -3258810, -3127995, -2931644, -2800829, -2604478, -2473663, -2277312, -2146497, -2015682, -1819331, -1688516, -1492165, -1361350, -1164999, -1034184, -903369, -707018, -576203, -379852, -249037, -52686, -52943, -53200, -53457, -53714, -53971, -54228, -54485, -54742, -54999, -55256, -55513, -55770, -56027, -56284, -56541, -56798, -57055, -57312, -57569, -57826, -58083, -58340, -58597, -58854, -59111, -59368, -59625, -59882, -60139, -60396, -60653, -60910, -61167, -61424, -61681, -61938, -62195, -62452, -62709, -62966, -63223, -63480, -63737, -63994, -64251, -64508, -64765, -65022, -65279, -65536],
"hot_metal": [-16777216, -16646144, -16515072, -16384000, -16252928, -16121856, -15990784, -15859712, -15728640, -15597568, -15466496, -15335424, -15204352, -15073280, -14942208, -14811136, -14680064, -14548992, -14417920, -14286848, -14155776, -14024704, -13893632, -13762560, -13631488, -13500416, -13369344, -13238272, -13107200, -12976128, -12845056, -12713984, -12582912, -12451840, -12320768, -12189696, -12058624, -11927552, -11796480, -11665408, -11534336, -11403264, -11272192, -11141120, -11010048, -10878976, -10747904, -10616832, -10485760, -10354688, -10223616, -10092544, -9961472, -9830400, -9699328, -9568256, -9437184, -9306112, -9175040, -9043968, -8912896, -8781824, -8650752, -8519680, -8388608, -8257536, -8126464, -7995392, -7864320, -7733248, -7602176, -7471104, -7340032, -7208960, -7077888, -6946816, -6815744, -6684672, -6553600, -6422528, -6291456, -6160384, -6029312, -5898240, -5767168, -5636096, -5505024, -5373952, -5242880, -5111808, -4980736, -4849664, -4718592, -4587520, -4456448, -4325376, -4194304, -4063232, -3932160, -3801088, -3670016, -3538944, -3407872, -3276800, -3145728, -3014656, -2883584, -2752512, -2621440, -2490368, -2359296, -2228224, -2097152, -1966080, -1835008, -1703936, -1572864, -1441792, -1310720, -1179648, -1048576, -917504, -786432, -655360, -524288, -393216, -262144, -131072, -65536, -65022, -64508, -63994, -63480, -62966, -62452, -61938, -61424, -60910, -60396, -59882, -59368, -58854, -58340, -57826, -57312, -56798, -56284, -55770, -55256, -54742, -54228, -53714, -53200, -52686, -52172, -51658, -51144, -50630, -50116, -49602, -49088, -48574, -48060, -47546, -47032, -46518, -46004, -45490, -44976, -44462, -43948, -43434, -42920, -42406, -41892, -41378, -40864, -40350, -39836, -39322, -38808, -38294, -37780, -37266, -36752, -36238, -35724, -35210, -34696, -34182, -33668, -33154, -32640, -32126, -31612, -31098, -30584, -30070, -29556, -29042, -28528, -28014, -27500, -26986, -26472, -25958, -25444, -24930, -24416, -23902, -23388, -22874, -22360, -21846, -21332, -20818, -20304, -19790, -19276, -18762, -18248, -17734, -17220, -16706, -16192, -15678, -15164, -14650, -14136, -13622, -13108, -12594, -12080, -11566, -11052, -10538, -10024, -9510, -8996, -8482, -7968, -7454, -6940, -6426, -5912, -5398, -4884, -4370, -3856, -3342, -2828, -2314, -1800, -1286, -772, -1],
"jet": [-16777089, -16777085, -16777081, -16777077, -16777073, -16777069, -16777065, -16777061, -16777057, -16777053, -16777049, -16777045, -16777041, -16777037, -16777033, -16777029, -16777025, -16777021, -16777017, -16777013, -16777009, -16777005, -16777001, -16776997, -16776993, -16776989, -16776985, -16776981, -16776977, -16776973, -16776969, -16776965, -16776961, -16776193, -16775169, -16774145, -16773121, -16772097, -16771073, -16770049, -16769025, -16768001, -16766977, -16765953, -16764929, -16763905, -16762881, -16761857, -16760833, -16759809, -16758785, -16757761, -16756737, -16755713, -16754689, -16753665, -16752641, -16751617, -16750593, -16749569, -16748545, -16747521, -16746497, -16745473, -16744449, -16743425, -16742401, -16741377, -16740353, -16739329, -16738305, -16737281, -16736257, -16735233, -16734209, -16733185, -16732161, -16731137, -16730113, -16729089, -16728065, -16727041, -16726017, -16724993, -16723969, -16722945, -16721921, -16720897, -16719873, -16718849, -16717825, -16716801, -16715777, -16714753, -16713729, -16712705, -16711681, -16515076, -16252936, -15990796, -15728656, -15466516, -15204376, -14942236, -14680096, -14417956, -14155816, -13893676, -13631536, -13369396, -13107256, -12845116, -12582976, -12320836, -12058696, -11796556, -11534416, -11272276, -11010136, -10747996, -10485856, -10223716, -9961576, -9699436, -9437296, -9175156, -8913016, -8650876, -8388736, -8126596, -7864456, -7602316, -7340176, -7078036, -6815896, -6553756, -6291616, -6029476, -5767336, -5505196, -5243056, -4980916, -4718776, -4456636, -4194496, -3932356, -3670216, -3408076, -3145936, -2883796, -2621656, -2359516, -2097376, -1835236, -1573096, -1310956, -1048816, -786676, -524536, -262396, -256, -1024, -2048, -3072, -4096, -5120, -6144, -7168, -8192, -9216, -10240, -11264, -12288, -13312, -14336, -15360, -16384, -17408, -18432, -19456, -20480, -21504, -22528, -23552, -24576, -25600, -26624, -27648, -28672, -29696, -30720, -31744, -32768, -33792, -34816, -35840, -36864, -37888, -38912, -39936, -40960, -41984, -43008, -44032, -45056, -46080, -47104, -48128, -49152, -50176, -51200, -52224, -53248, -54272, -55296, -56320, -57344, -58368, -59392, -60416, -61440, -62464, -63488, -64512, -65536, -327680, -589824, -851968, -1114112, -1376256, -1638400, -1900544, -2162688, -2424832, -2686976, -2949120, -3211264, -3473408, -3735552, -3997696, -4259840, -4521984, -4784128, -5046272, -5308416, -5570560, -5832704, -6094848, -6356992, -6619136, -6881280, -7143424, -7405568, -7667712, -7929856, -8192000],
"red_saturation": [-16777216, -16711423, -16645630, -16579837, -16448251, -16382458, -16316665, -16250872, -16119286, -16053493, -15987700, -15856114, -15790321, -15724528, -15658735, -15527149, -15461356, -15395563, -15329770, -15198184, -15132391, -15066598, -14935012, -14869219, -14803426, -14737633, -14606047, -14540254, -14474461, -14408668, -14277082, -14211289, -14145496, -14013910, -13948117, -13882324, -13816531, -13684945, -13619152, -13553359, -13421773, -13355980, -13290187, -13224394, -13092808, -13027015, -12961222, -12895429, -12763843, -12698050, -12632257, -12500671, -12434878, -12369085, -12303292, -12171706, -12105913, -12040120, -11974327, -11842741, -11776948, -11711155, -11579569, -11513776, -11447983, -11382190, -11250604, -11184811, -11119018, -11053225, -10921639, -10855846, -10790053, -10658467, -10592674, -10526881, -10461088, -10329502, -10263709, -10197916, -10066330, -10000537, -9934744, -9868951, -9737365, -9671572, -9605779, -9539986, -9408400, -9342607, -9276814, -9145228, -9079435, -9013642, -8947849, -8816263, -8750470, -8684677, -8618884, -8487298, -8421505, -8355712, -8224126, -8158333, -8092540, -8026747, -7895161, -7829368, -7763575, -7697782, -7566196, -7500403, -7434610, -7303024, -7237231, -7171438, -7105645, -6974059, -6908266, -6842473, -6710887, -6645094, -6579301, -6513508, -6381922, -6316129, -6250336, -6184543, -6052957, -5987164, -5921371, -5789785, -5723992, -5658199, -5592406, -5460820, -5395027, -5329234, -5263441, -5131855, -5066062, -5000269, -4868683, -4802890, -4737097, -4671304, -4539718, -4473925, -4408132, -4342339, -4210753, -4144960, -4079167, -3947581, -3882045, -3816509, -3750973, -3619901, -3554365, -3488829, -3357757, -3292221, -3226685, -3161149, -3030077, -2964541, -2899005, -2833469, -2702397, -2636861, -2571325, -2440253, -2374717, -2309181, -2243645, -2112573, -2047037, -1981501, -1915965, -1784893, -1719357, -1653821, -1522749, -1457213, -1391677, -1326141, -1195069, -1129533, -1063997, -998461, -867389, -801853, -736317, -605245, -539709, -474173, -408637, -277565, -212029, -146493, -15421, -16192, -17220, -17991, -19019, -19790, -20818, -21589, -22617, -23388, -24416, -25444, -26215, -27243, -28014, -29042, -29813, -30841, -31612, -32640, -33411, -34439, -35467, -36238, -37266, -38037, -39065, -39836, -40864, -41635, -42663, -43434, -44462, -45490, -46261, -47289, -48060, -49088, -49859, -50887, -51658, -52686, -53457, -54485, -55513, -56284, -57312, -58083, -59111, -59882, -60910, -61681, -62709, -63480, -64508, -65536],
"high_contrast": [-16777216, -16318457, -15859698, -15400939, -14942180, -14483421, -14024662, -13565903, -13107144, -12648385, -12189626, -11730867, -11272108, -10813349, -10354590, -9895831, -9437072, -8978313, -8519554, -8060795, -7602036, -7143277, -6684518, -6225759, -5767000, -5308241, -4849482, -4390723, -3931964, -3473205, -3014446, -2555687, -2096928, -1638169, -1179410, -720651, -261892, -720645, -1179399, -1638153, -2096907, -2555660, -3014414, -3473168, -3931922, -4390675, -4849429, -5308183, -5766937, -6225690, -6684444, -7143198, -7601952, -8060705, -8519459, -8978213, -9436967, -9895720, -10354474, -10813228, -11271982, -11730735, -12189489, -12648243, -13106997, -13565750, -14024504, -14483258, -14942012, -15400765, -15859519, -16318273, -16777027, -16775233, -16773439, -16771645, -16769852, -16768058, -16766264, -16764470, -16762677, -16760883, -16759089, -16757295, -16755502, -16753708, -16751914, -16750120, -16748327, -16746533, -16744739, -16742945, -16741152, -16739358, -16737564, -16735770, -16733977, -16732183, -16730389, -16728595, -16726802, -16725008, -16723214, -16721420, -16719627, -16717833, -16716039, -16714245, -16712452, -16712452, -16713227, -16714258, -16715033, -16716064, -16716839, -16717870, -16718645, -16719676, -16720451, -16721482, -16722257, -16723288, -16724063, -16725094, -16725869, -16726900, -16727675, -16728706, -16729481, -16730512, -16731287, -16732318, -16733093, -16734124, -16734899, -16735930, -16736705, -16737736, -16738511, -16739542, -16740317, -16741348, -16742123, -16743154, -16743929, -16744960, -16285184, -15825664, -15365888, -14906368, -14446592, -13987072, -13527296, -13067776, -12608000, -12148480, -11688704, -11229184, -10769408, -10309888, -9850112, -9390592, -8930816, -8471296, -8011520, -7552000, -7092224, -6632704, -6172928, -5713408, -5253632, -4794112, -4334336, -3874816, -3415040, -2955520, -2495744, -2036224, -1576448, -1116928, -657152, -197632, -197632, -264960, -397824, -530688, -663552, -730880, -863744, -996608, -1129472, -1196800, -1329664, -1462528, -1595392, -1662720, -1795584, -1928448, -2061312, -2128640, -2261504, -2394368, -2527232, -2594560, -2727424, -2860288, -2993152, -3060480, -3193344, -3326208, -3459072, -3526400, -3659264, -3792128, -3924992, -3992320, -4125184, -4258048, -4390912, -4390912, -4258041, -4125170, -3992299, -3924964, -3792093, -3659222, -3526351, -3459016, -3326145, -3193274, -3060403, -2993068, -2860197, -2727326, -2594455, -2527120, -2394249, -2261378, -2128507, -2061172, -1928301, -1795430, -1662559, -1595224, -1462353, -1329482, -1196611, -1129276, -996405, -863534, -730663, -663328, -530457, -397586, -264715, -197380]
}
}
Binary file not shown.
@@ -0,0 +1,130 @@
# Phase C —— 厂商 12 调色板精确提取:结论与证据
日期:2026-09-10。目标:从官方 native 库 `libcxsdk.so` 提取 12 个调色板精确表。
## 结论(TL;DR
**`libcxsdk.so` 里没有静态调色板表**。12 个调色板全部由
`CFunctions::SetColorPalette`Ghidra 偏移 `0x00026c70`)在**运行时用算术生成**。
两层证据(2026-09-11 复核时补强,见"否证的最强形式"):
1. **正对照(决定性)**:项目持有的唯一官方表——铁虹——
csdk `src/mag160c_official_palette256.h`,源自厂商运行时内存
`CoreSDKLib dev+0xb18`)在库里**以任何编码都搜不到**
- `B,G,R,0`(官方文档布局)→ 无
- `B,G,R,0xFF`alpha 强制)→ 无
- **忽略第 4 字节的 `B,G,R` 宽松匹配 → 也无**
⚠️ **踩坑记录**:该表在内存里是 `(B, G, R, 0)` —— **第 4 字节是 0**
`OfficialTables.kt` 里的 ARGB int 被生成脚本强制 `alpha=0xFF`
因此"直接拿 Kotlin 表去二进制里搜"**永远搜不到**,会得出假的"未找到"。
正确的针必须从 csdk 头文件(真实内存布局)构造。核查时务必用
`analysis/tools/PalScan.java`(已按此修正)而非手工拼的 ARGB 针。
2. **结构性证据**:Ghidra 反编译显示该函数用算术逐条生成全部 12 张表
`libcxsdk_decomp.txt` 的 case 0..10/12/13),所以静态数据里本就不该有。
> 另:早期"扫描 256 个连续 u32LE、alpha=0xFF"的判据命中 0,这一条仍成立但
> **说服力弱**——它只说明"没有 alpha=0xFF 形式",与布局无关。
> 通用"渐变"启发式(`PalScan.java` 的 [C] 段)在本库上误报约 11% 的位置
> (全是指令/整数数组),**不能作为证据**,工具里已明确如此标注。
因此本阶段改用**移植生成函数**的路线并成功恢复:
**UI 索引 0..10 共 11 个调色板已为官方精确表**(索引 11 见下),
已写入 `android/.../core/VendorPalettes.kt` 并由 `Palettes.kt` 使用。
## 验证链(三重独立验证)
1. **铁虹锚点**:移植的 case 2 输出与 `OfficialTables.PALETTE256_ARGB`
**256/256 逐字节一致**。该表是从厂商运行时(CoreSDKLib `dev+0xb18`)抓取、
并与官方渲染器逐像素比对验证过的,因此它同时锁死了:
- 算术配方(除法取整方式、循环边界、常量)
- 发布字节序 = `{byte0=B, byte1=G, byte2=R, byte3=0}`
2. **完整性**:每个 case 对 256 个槽位**各写入恰好一次**(无空洞、无覆盖),
由模拟器的写地址跟踪断言。
3. **厂商侧预览图**:官方 APK `res/mipmap-hdpi-v4/palette_<name>.png` 是官方用
对应调色板渲染的真实热像帧。每个调色板对其**自身**预览图的颜色覆盖率都是
全体候选中最高,且差距显著:
| 官方调色板 | UI 索引 | 生成 case | 自身覆盖率 | 次优候选 |
|---|---|---|---|---|
| 白热 white_hot | 0 | 0 | 100.0% | 100.0%(灰度图,天然重合) |
| 黑热 black_hot | 1 | 1 | 100.0% | 100.0%(同上) |
| 铁虹 iron_bow | 2 | 2 | 9.0% | 0.1% |
| 彩虹 rain_bow | 3 | 3 | 7.3% | 0.1% |
| 琥珀 glow_bow | 4 | 4 | 30.7% | 2.3% |
| 金秋 autumn | 5 | 5 | 15.4% | 2.5% |
| 寒冬 winter | 6 | 6 | 22.3% | 2.9% |
| 热金属 hot_metal | 7 | 7 | 42.1% | 11.0% |
| 喷射 jet | 8 | 8 | 14.0% | 6.3% |
| 红饱和 red_saturation | 9 | 9 | 26.7% | 22.1% |
| 高对比度 high_contrast | 10 | 10 | 4.0% | 0.2% |
| 红热 red_hot | 11 | **未解决** | — | — |
(预览图是缩放过的照片,存在插值产生的额外颜色,故绝对覆盖率不高;
关键是自身恒为最高。)
## 未解决项:索引 11「红热」
- native 库里另有 case 12、case 13 两个生成体,但两者对**任何**官方预览图的
覆盖率都很低(最高 3–6%,指向 jet),**不能**认定为「红热」。
- 官方 APK 的 `palette_red_hot.png` 是**占位图**11914 个像素中 11777 个与
`palette_white_hot.png` 完全相同(平均绝对差 0.5/255),不携带调色板信息。
- 处理方式:`Palettes.kt` 索引 11 保留原近似曲线,并注明
`// approximated (not found in binary)``VendorPalettes.SOURCE_CASE[11] = -1`
**不猜测**未验证的表。
## 可复现产物
| 路径 | 说明 |
|---|---|
| `analysis/sdk_re/android_app/bin/libcxsdk.so` | 官方 native 库入库(372476 B |
| `analysis/sdk_re/android_app/palette_candidates.json` | 扫描结论(静态候选为空)+ 全部恢复表 |
| `analysis/sdk_re/android_app/palette_candidates.png` | 12 组「官方预览图 + 恢复色条」对照图 |
| `analysis/sdk_re/android_app/palette_match_report.txt` | 覆盖率证据表 |
| `analysis/tools/extract_palettes.py` | 计划要求的纯标准库扫描脚本(本机无 python3,改用下面的 JDK 版) |
| `analysis/tools/PalScan.java` | **JDK 版否证复核器**:正对照(铁虹在 3 种编码下的精确匹配)+ 自校准 + 通用扫描(明确标注不可作证据)。已实测:3 种编码全部 not found |
| `analysis/tools/PalIdentify.java` | 13 个生成体的机械移植(含写地址校验) |
| `analysis/tools/PalExport2.java` | 导出 Kotlin/JSON/PNG/报告(须与 PalIdentify 一起编译) |
| `analysis/tools/CheckStrings.java` | 核对真机清单里的日志串是否真能由源码产生 |
| `android/.../core/VendorPalettes.kt` | 生成的 Kotlin 表(0..10 为精确表) |
重跑(本机无 python3;两个命令都已实测通过):
```bash
# 否证复核:铁虹是否真的不在库里(决定性证据)
java analysis/tools/PalScan.java \
analysis/sdk_re/android_app/bin/libcxsdk.so \
csdk/src/mag160c_official_palette256.h
# 生成物可复现:重新生成后与仓库文件 diff 应为空
mkdir -p /c/Users/zxc/AppData/Local/Temp/regen_build && \
cd /c/Users/zxc/AppData/Local/Temp/regen_build && \
cp "C:\Project\MAG160C\analysis\tools\PalIdentify.java" . && \
cp "C:\Project\MAG160C\analysis\tools\PalExport2.java" . && \
"C:\Tools\jdk-21\bin\javac" -d out PalIdentify.java PalExport2.java && \
"C:\Tools\jdk-21\bin\java" -cp out PalExport2 \
"C:\Project\MAG160C\android\app\src\main\kotlin\com\mag160c\thermal\core\OfficialTables.kt" \
"C:\Users\zxc\AppData\Local\Temp\cxres\res\mipmap-hdpi-v4" \
"C:\Users\zxc\AppData\Local\Temp\regen_core" \
"C:\Users\zxc\AppData\Local\Temp\regen_out"
diff "C:\Users\zxc\AppData\Local\Temp\regen_core\VendorPalettes.kt" \
"C:\Project\MAG160C\android\app\src\main\kotlin\com\mag160c\thermal\core\VendorPalettes.kt"
# 实测:diff 为空;铁虹锚点 256/256
```
有 python3 的机器上(可选,仅证明"无静态 256 项 alpha=0xFF 表"这个弱结论):
```bash
python3 analysis/tools/extract_palettes.py \
analysis/sdk_re/android_app/bin/libcxsdk.so \
android/app/src/main/kotlin/com/mag160c/thermal/core/OfficialTables.kt \
analysis/sdk_re/android_app
```
## 与执行计划的对照
计划 C3 的回退条款为"若扫不到锚点:如实记录未在 libcxsdk 静态数据中找到调色板表,
保留现有一套近似实现,本阶段只交付脚本与 JSON"。实际执行结果**优于**回退:
锚点确实不在静态数据里(按回退如实记录),但通过移植运行时生成函数恢复了
11/12 个精确表,并把它们接入 `Palettes.kt`
@@ -0,0 +1,36 @@
Phase C palette extraction — evidence
=====================================
Conclusion: libcxsdk.so has NO static palette tables. A full-file scan for
256 consecutive u32 LE entries with alpha=0xFF and non-zero RGB finds 0 hits
(also 0 with alpha=0x00, and the BGR/RGB triple forms). CFunctions::
SetColorPalette builds the palettes with arithmetic at runtime.
Validation chain:
1. port reproduces OfficialTables.PALETTE256_ARGB 256/256 (iron bow)
2. every case writes all 256 entries exactly once
3. colour coverage of the official APK preview mipmaps:
palette colors own case best other
white_hot 211 100.0% 100.0%
black_hot 214 100.0% 100.0%
iron_bow 1684 9.0% 0.1%
rain_bow 2005 7.3% 0.1%
glow_bow 596 30.7% 2.2%
autumn 1036 15.4% 2.5%
winter 826 22.3% 2.9%
hot_metal 482 42.1% 11.0%
jet 1251 14.0% 4.7%
red_saturation 660 26.7% 22.1%
high_contrast 3044 4.0% 0.1%
red_hot 337 -1.0% 62.6%
Notes:
- red_hot: the official preview asset is a stub (137 of 11914 pixels differ
from palette_white_hot.png), so it carries no palette information. No
generator body is claimed for UI index 11; Palettes.kt keeps its
approximation with an explicit comment.
- cases 12 and 13 exist in the native library but explain no official
preview (best 3-6% against jet); they are not among the 12 UI palettes.
- the previews are scaled photographic crops, so absolute coverage is low
for some palettes; the correct body always wins by a wide margin.
+75
View File
@@ -0,0 +1,75 @@
import java.nio.file.*;
import java.util.*;
import java.util.regex.*;
import java.util.stream.*;
/**
* Cross-check the quoted DebugLog lines in docs/android_app/real_device_checklist.md
* against the Kotlin sources, so a verifier can confirm the checklist describes
* logs the code can actually produce.
*
* WHY NOT A PLAIN SEARCH: the sources build messages by interpolation
* ("$name write=$n/${packet.size}") while the checklist shows instantiated
* examples ("GetParameter1 write=4/4"). A literal search therefore produces
* false negatives. This tool instead (1) tries the literal with <placeholders>
* removed, then (2) falls back to requiring the remaining identifier-ish words
* to appear somewhere in the sources.
*
* A "needs review" line means the whole message is instance data (the template
* lives in one string literal and the command name is a call-site argument) —
* those must be traced by hand to the call site. Both categories were manually
* resolved for the 2026-09-11 run (see verification_guide.md §3 Phase A).
*
* Usage:
* java CheckStrings.java docs/android_app/real_device_checklist.md android/app/src/main
*/
public class CheckStrings {
public static void main(String[] a) throws Exception {
String md = Files.readString(Paths.get(a[0]), java.nio.charset.StandardCharsets.UTF_8);
StringBuilder src = new StringBuilder();
try (Stream<Path> s = Files.walk(Paths.get(a[1]))) {
List<Path> kt = s.filter(x -> x.toString().endsWith(".kt")).collect(Collectors.toList());
for (Path p : kt) src.append(Files.readString(p, java.nio.charset.StandardCharsets.UTF_8)).append('\n');
}
String all = src.toString();
// only lines that show a DebugLog tag: [usb] [vm] [session] [cmd] [stream] [pip] [remote] [crash]
Matcher m = Pattern.compile("`(\\[(?:usb|vm|session|cmd|stream|pip|remote|crash)\\][^`]*)`").matcher(md);
int exact = 0, skeleton = 0, miss = 0;
Set<String> seen = new LinkedHashSet<>();
List<String> misses = new ArrayList<>();
while (m.find()) {
String line = m.group(1);
if (!seen.add(line)) continue;
// the tag is a separate DebugLog argument in the source
String msg = line.replaceFirst("^\\[[a-z]+\\]\\s*", "");
String literal = msg.replaceAll("<[^>]*>", "").trim();
if (literal.length() >= 6 && all.contains(literal)) { exact++; continue; }
String skel = msg
.replaceAll("<[^>]*>", " ")
.replaceAll("0x[0-9A-Fa-f]+", " ")
.replaceAll("\\b\\d+(\\.\\d+)?\\b", " ")
.replaceAll("[A-Za-z]*\\d[A-Za-z0-9]*", " ")
.replaceAll("[^\\x20-\\x7E\\u4e00-\\u9fff]+", " ");
List<String> keep = new ArrayList<>();
for (String w : skel.trim().split("\\s+")) {
if (w.length() >= 3 && w.matches("[A-Za-z_:.\\-\\[\\]()/]+")) keep.add(w);
}
boolean found = !keep.isEmpty();
for (String w : keep) if (!all.contains(w)) found = false;
if (found) skeleton++;
else { miss++; misses.add(line + " [probe: " + String.join(" ", keep) + "]"); }
}
System.out.println("exact literal match : " + exact);
System.out.println("skeleton match : " + skeleton);
System.out.println("needs human review : " + miss);
if (!misses.isEmpty()) {
System.out.println();
for (String s : misses) System.out.println(" ? " + s);
}
}
}
+244
View File
@@ -0,0 +1,244 @@
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.File;
import java.nio.file.*;
import java.util.*;
import javax.imageio.ImageIO;
/**
* Final Phase-C export.
*
* Produces, from the ported vendor generator (see PalIdentify.java/PalBody):
* 1. android/.../core/VendorPalettes.kt — recovered tables as Kotlin
* 2. analysis/.../palette_candidates.json — provenance + all tables
* 3. analysis/.../palette_candidates.png — strip sheet for eyeballing
* 4. analysis/.../palette_match_report.txt — evidence per palette
*
* Usage: java PalExport2 <OfficialTables.kt> <apkResDir> <androidCoreDir> <analysisOutDir>
*/
public class PalExport2 {
static final String[] NAMES = {"white_hot","black_hot","iron_bow","rain_bow","glow_bow",
"autumn","winter","hot_metal","jet","red_saturation","high_contrast","red_hot"};
static final String[] NAMES_CN = {"白热","黑热","铁虹","彩虹","琥珀","金秋",
"寒冬","热金属","喷射","红饱和","高对比度","红热"};
/** UI index -> libcxsdk generator case; -1 = no body resolved (approximation kept). */
static final int[] CASE_FOR_INDEX = {0,1,2,3,4,5,6,7,8,9,10,-1};
public static void main(String[] args) throws Exception {
String kt = args[0], apkRes = args[1];
Path coreDir = Paths.get(args[2]);
Path outDir = Paths.get(args[3]);
Files.createDirectories(coreDir);
Files.createDirectories(outDir);
int[][] tables = new int[12][];
for (int idx = 0; idx < 12; idx++) {
if (CASE_FOR_INDEX[idx] < 0) continue;
tables[idx] = emit(PalBody.run(CASE_FOR_INDEX[idx]));
}
// ---- 1. Kotlin ----
StringBuilder sb = new StringBuilder();
sb.append("package com.mag160c.thermal.core\n\n");
sb.append("/**\n");
sb.append(" * Vendor display palettes recovered from the official native SDK.\n");
sb.append(" *\n");
sb.append(" * libcxsdk.so contains NO static palette tables. All palettes are built at\n");
sb.append(" * runtime by {@code CFunctions::SetColorPalette}; the tables below are a\n");
sb.append(" * line-by-line port of that routine (Ghidra listing:\n");
sb.append(" * analysis/sdk_re/android_app/libcxsdk_decomp.txt, FUNC 0x00026c70).\n");
sb.append(" *\n");
sb.append(" * Evidence (analysis/sdk_re/android_app/palette_extraction_findings.md):\n");
sb.append(" * - case 2 reproduces [OfficialTables.PALETTE256_ARGB] 256/256 — the table\n");
sb.append(" * captured from the vendor runtime and verified pixel-exact against the\n");
sb.append(" * official renderer. This pins both the arithmetic and the byte order\n");
sb.append(" * (published entry = {byte0=B, byte1=G, byte2=R, byte3=0}).\n");
sb.append(" * - every case writes each of the 256 entries exactly once.\n");
sb.append(" * - each table reproduces the colours of its own preview image shipped in\n");
sb.append(" * the official APK (res/mipmap-hdpi-v4/palette_<name>.png).\n");
sb.append(" *\n");
sb.append(" * GENERATED by analysis/tools/PalExport2.java — do not edit by hand.\n");
sb.append(" */\n");
sb.append("object VendorPalettes {\n");
sb.append(" /** UI index -> source case in CFunctions::SetColorPalette (-1 = unresolved). */\n");
sb.append(" val SOURCE_CASE = intArrayOf(");
for (int i = 0; i < 12; i++) sb.append(i > 0 ? ", " : "").append(CASE_FOR_INDEX[i]);
sb.append(")\n\n");
for (int idx = 0; idx < 12; idx++) {
sb.append(" /** ").append(NAMES_CN[idx]).append(" / ").append(NAMES[idx])
.append(" — UI index ").append(idx);
if (CASE_FOR_INDEX[idx] >= 0) {
sb.append(", libcxsdk case ").append(CASE_FOR_INDEX[idx]).append(" */\n");
} else {
sb.append(", NO generator body resolved (preview asset is a stub) */\n");
}
if (tables[idx] == null) {
sb.append(" val ").append(varName(idx)).append(": IntArray? = null\n\n");
continue;
}
sb.append(" val ").append(varName(idx)).append(" = intArrayOf(\n");
for (int k = 0; k < 256; k += 8) {
sb.append(" ");
for (int j = 0; j < 8 && k + j < 256; j++) {
if (j > 0) sb.append(", ");
sb.append(tables[idx][k + j]);
}
sb.append(",\n");
}
sb.append(" )\n\n");
}
sb.append("}\n");
Files.writeString(coreDir.resolve("VendorPalettes.kt"), sb.toString(), java.nio.charset.StandardCharsets.UTF_8);
System.out.println("wrote " + coreDir.resolve("VendorPalettes.kt"));
// ---- 2. JSON ----
StringBuilder j = new StringBuilder("{\n");
j.append(" \"finding\": \"libcxsdk.so contains NO static palette tables. All 12 vendor palettes are computed at runtime by CFunctions::SetColorPalette (Ghidra FUNC 0x00026c70). A static scan for 256-entry alpha=0xFF runs returns nothing; the tables were recovered by porting the generator instead.\",\n");
j.append(" \"static_candidates\": [],\n");
j.append(" \"source\": {\"binary\": \"analysis/sdk_re/android_app/bin/libcxsdk.so\", \"sha256_expected_size\": 372476, \"function\": \"CFunctions::SetColorPalette\", \"decompilation\": \"analysis/sdk_re/android_app/libcxsdk_decomp.txt\"},\n");
j.append(" \"published_entry_layout\": \"byte0=B, byte1=G, byte2=R, byte3=0 (pinned by the iron bow anchor: 256/256)\",\n");
j.append(" \"ui_order\": [");
for (int i = 0; i < 12; i++) j.append(i > 0 ? ", " : "").append("\"").append(NAMES[i]).append("\"");
j.append("],\n");
j.append(" \"index_to_case\": {");
for (int i = 0; i < 12; i++) j.append(i > 0 ? ", " : "").append("\"").append(NAMES[i]).append("\": ").append(CASE_FOR_INDEX[i]);
j.append("},\n");
j.append(" \"unexposed_cases\": [12, 13],\n");
j.append(" \"tables\": {\n");
List<String> entries = new ArrayList<>();
for (int idx = 0; idx < 12; idx++) {
if (tables[idx] == null) continue;
StringBuilder t = new StringBuilder(" \"").append(NAMES[idx]).append("\": [");
for (int k = 0; k < 256; k++) t.append(k > 0 ? ", " : "").append(tables[idx][k]);
t.append("]");
entries.add(t.toString());
}
j.append(String.join(",\n", entries)).append("\n }\n}\n");
Files.writeString(outDir.resolve("palette_candidates.json"), j.toString(), java.nio.charset.StandardCharsets.UTF_8);
System.out.println("wrote palette_candidates.json");
// ---- 3. PNG strip sheet: official preview | recovered palette ----
int iw = 130, ih = 60, barH = 16, pad = 6, rowsPerCol = 6;
int labelH = 18;
int colW = iw + pad + 256 / 2 + pad * 2;
int rowH = Math.max(ih, barH) + labelH + pad;
BufferedImage sheet = new BufferedImage(colW * 2 + pad, rowH * rowsPerCol + pad,
BufferedImage.TYPE_INT_ARGB);
Graphics2D g = sheet.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setColor(Color.BLACK);
g.fillRect(0, 0, sheet.getWidth(), sheet.getHeight());
g.setFont(new Font("SansSerif", Font.PLAIN, 12));
for (int idx = 0; idx < 12; idx++) {
int col = idx / rowsPerCol, row = idx % rowsPerCol;
int x0 = pad + col * colW, y0 = pad + row * rowH;
int[] t = tables[idx];
if (t == null) continue;
g.setColor(Color.WHITE);
g.drawString(String.format("%d %s (%s) — libcxsdk case %d", idx, NAMES_CN[idx], NAMES[idx], CASE_FOR_INDEX[idx]),
x0, y0 + 12);
// official preview image
File pf = new File(apkRes, "palette_" + NAMES[idx] + ".png");
if (pf.isFile()) {
BufferedImage pv = ImageIO.read(pf);
g.drawImage(pv, x0, y0 + labelH, iw, ih, null);
}
// recovered palette strip
int bx = x0 + iw + pad;
for (int k = 0; k < 128; k++) {
int v = t[k * 2];
g.setColor(new Color((v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF));
g.fillRect(bx + k * 2, y0 + labelH + (ih - barH) / 2, 2, barH);
}
}
g.dispose();
ImageIO.write(sheet, "png", outDir.resolve("palette_candidates.png").toFile());
System.out.println("wrote palette_candidates.png (" + sheet.getWidth() + "x" + sheet.getHeight() + ")");
// ---- 4. evidence report ----
StringBuilder rep = new StringBuilder();
rep.append("Phase C palette extraction — evidence\n");
rep.append("=====================================\n\n");
rep.append("Conclusion: libcxsdk.so has NO static palette tables. A full-file scan for\n");
rep.append("256 consecutive u32 LE entries with alpha=0xFF and non-zero RGB finds 0 hits\n");
rep.append("(also 0 with alpha=0x00, and the BGR/RGB triple forms). CFunctions::\n");
rep.append("SetColorPalette builds the palettes with arithmetic at runtime.\n\n");
rep.append("Validation chain:\n");
rep.append(" 1. port reproduces OfficialTables.PALETTE256_ARGB 256/256 (iron bow)\n");
rep.append(" 2. every case writes all 256 entries exactly once\n");
rep.append(" 3. colour coverage of the official APK preview mipmaps:\n\n");
rep.append(String.format(" %-16s %8s %14s %14s%n", "palette", "colors", "own case", "best other"));
for (int idx = 0; idx < 12; idx++) {
File pf = new File(apkRes, "palette_" + NAMES[idx] + ".png");
if (!pf.isFile()) { rep.append(String.format(" %-16s preview missing%n", NAMES[idx])); continue; }
BufferedImage img = ImageIO.read(pf);
Set<Integer> cols = new HashSet<>();
for (int y = 0; y < img.getHeight(); y++)
for (int x = 0; x < img.getWidth(); x++) {
int p = img.getRGB(x, y);
if ((p >>> 24) == 0xFF) cols.add(p & 0xFFFFFF);
}
double own = -1, other = -1;
for (int k = 0; k < 12; k++) {
int[] cand = tables[k];
if (cand == null) continue;
Set<Integer> set = new HashSet<>();
for (int v : cand) set.add(v & 0xFFFFFF);
int hit = 0;
for (int c : cols) if (set.contains(c)) hit++;
double cov = 100.0 * hit / cols.size();
if (k == idx) own = cov; else other = Math.max(other, cov);
}
rep.append(String.format(" %-16s %8d %13.1f%% %13.1f%%%n", NAMES[idx], cols.size(), own, other));
}
rep.append("\nNotes:\n");
rep.append(" - red_hot: the official preview asset is a stub (137 of 11914 pixels differ\n");
rep.append(" from palette_white_hot.png), so it carries no palette information. No\n");
rep.append(" generator body is claimed for UI index 11; Palettes.kt keeps its\n");
rep.append(" approximation with an explicit comment.\n");
rep.append(" - cases 12 and 13 exist in the native library but explain no official\n");
rep.append(" preview (best 3-6% against jet); they are not among the 12 UI palettes.\n");
rep.append(" - the previews are scaled photographic crops, so absolute coverage is low\n");
rep.append(" for some palettes; the correct body always wins by a wide margin.\n");
Files.writeString(outDir.resolve("palette_match_report.txt"), rep.toString(), java.nio.charset.StandardCharsets.UTF_8);
System.out.println("wrote palette_match_report.txt");
int bad = 0;
int[] anchor = readAnchor(kt);
for (int i = 0; i < 256; i++) if (tables[2][i] != anchor[i]) bad++;
System.out.println("iron_bow vs OfficialTables anchor: " + (256 - bad) + "/256");
}
static String varName(int idx) {
StringBuilder sb = new StringBuilder();
for (char ch : NAMES[idx].toCharArray()) sb.append(ch == '_' ? '_' : Character.toUpperCase(ch));
return sb.toString();
}
static int[] emit(byte[][] buf) {
int[] out = new int[256];
for (int i = 0; i < 256; i++) {
int b0 = buf[i][0] & 0xFF, b1 = buf[i][1] & 0xFF, b2 = buf[i][2] & 0xFF;
out[i] = 0xFF000000 | (b2 << 16) | (b1 << 8) | b0;
}
return out;
}
static int[] readAnchor(String path) throws Exception {
String src = Files.readString(Paths.get(path), java.nio.charset.StandardCharsets.UTF_8);
int k = src.indexOf("val PALETTE256_ARGB = intArrayOf(");
int start = k + "val PALETTE256_ARGB = intArrayOf(".length();
int end = src.indexOf(")", start);
List<Integer> vals = new ArrayList<>();
StringBuilder num = new StringBuilder();
for (char ch : src.substring(start, end).toCharArray()) {
if (ch == '-' || (ch >= '0' && ch <= '9')) num.append(ch);
else if (num.length() > 0) { vals.add(Integer.parseInt(num.toString())); num.setLength(0); }
}
return vals.stream().mapToInt(Integer::intValue).toArray();
}
}
+283
View File
@@ -0,0 +1,283 @@
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.*;
import javax.imageio.ImageIO;
/**
* Resolves which decompiled generator body produces which official palette.
*
* The Ghidra listing shows 13 generator bodies (cases 0..10, 0xc, 0xd) but the
* official app exposes 12 palettes, so the case->index mapping cannot be read
* off the listing directly. This tool identifies each body by matching it
* against the palette preview image the official app ships
* (res/mipmap-hdpi-v4/palette_<name>.png) and reporting the best assignment.
*
* Uses a 32x32x32 quantised lookup so the full 13 x 12 x 2 comparison is fast,
* and reports both byte-order conventions.
*/
public class PalIdentify {
static final int Q = 32; // quantisation levels per channel
static final int STEP = 256 / Q;
public static void main(String[] args) throws Exception {
String apkRes = args[0];
String[] names = {"white_hot","black_hot","iron_bow","rain_bow","glow_bow",
"autumn","winter","hot_metal","jet","red_saturation","high_contrast","red_hot"};
// build all 13 bodies in both conventions
int[][] direct = new int[14][];
int[][] swapped = new int[14][];
for (int c : new int[]{0,1,2,3,4,5,6,7,8,9,10,12,13}) {
byte[][] buf = PalBody.run(c);
direct[c] = emit(buf, false);
swapped[c] = emit(buf, true);
}
// preview histograms
double[][] hist = new double[12][Q * Q * Q];
for (int i = 0; i < 12; i++) {
File f = new File(apkRes, "palette_" + names[i] + ".png");
BufferedImage img = ImageIO.read(f);
int n = 0;
for (int y = 0; y < img.getHeight(); y++)
for (int x = 0; x < img.getWidth(); x++) {
int a = img.getRGB(x, y);
if ((a >>> 24) != 0xFF) continue;
hist[i][qidx(a)]++;
n++;
}
for (int k = 0; k < hist[i].length; k++) hist[i][k] /= n;
}
System.out.println("=== best-matching generator body per official palette ===");
System.out.printf("%-16s %-28s %-28s%n", "palette", "direct", "swapped");
int[] bestBodyD = new int[12], bestBodyS = new int[12];
for (int i = 0; i < 12; i++) {
double[] dd = new double[14], ds = new double[14];
int bd = -1, bs = -1;
for (int c : new int[]{0,1,2,3,4,5,6,7,8,9,10,12,13}) {
dd[c] = dist(hist[i], direct[c]);
ds[c] = dist(hist[i], swapped[c]);
if (bd < 0 || dd[c] < dd[bd]) bd = c;
if (bs < 0 || ds[c] < ds[bs]) bs = c;
}
bestBodyD[i] = bd; bestBodyS[i] = bs;
System.out.printf("%-16s %-28s %-28s%n", names[i],
String.format("case %-4d d=%.2f", bd, dd[bd]),
String.format("case %-4d d=%.2f", bs, ds[bs]));
}
System.out.println("\n=== body -> palettes it best explains (direct) ===");
for (int c : new int[]{0,1,2,3,4,5,6,7,8,9,10,12,13}) {
double best = Double.MAX_VALUE; String who = "-";
for (int i = 0; i < 12; i++) {
double d = dist(hist[i], direct[c]);
if (d < best) { best = d; who = names[i]; }
}
System.out.printf("case %-3d -> %-16s (d=%.2f)%n", c, who, best);
}
}
static int qidx(int argb) {
int r = ((argb >> 16) & 0xFF) / STEP;
int g = ((argb >> 8) & 0xFF) / STEP;
int b = (argb & 0xFF) / STEP;
return (r * Q + g) * Q + b;
}
static double dist(double[] hist, int[] pal) {
double[] lut = new double[Q * Q * Q];
for (int r = 0; r < Q; r++)
for (int g = 0; g < Q; g++)
for (int b = 0; b < Q; b++) {
int rr = r * STEP, gg = g * STEP, bb = b * STEP;
int best = Integer.MAX_VALUE;
for (int v : pal) {
int dr = rr - ((v >> 16) & 0xFF);
int dg = gg - ((v >> 8) & 0xFF);
int db = bb - (v & 0xFF);
int d2 = dr * dr + dg * dg + db * db;
if (d2 < best) best = d2;
}
lut[(r * Q + g) * Q + b] = Math.sqrt(best);
}
double sum = 0;
for (int k = 0; k < hist.length; k++) if (hist[k] != 0) sum += hist[k] * lut[k];
return sum;
}
static int[] emit(byte[][] buf, boolean swap) {
int[] out = new int[256];
for (int i = 0; i < 256; i++) {
int b0 = buf[i][swap ? 2 : 0] & 0xFF;
int b1 = buf[i][1] & 0xFF;
int b2 = buf[i][swap ? 0 : 2] & 0xFF;
out[i] = 0xFF000000 | (b2 << 16) | (b1 << 8) | b0;
}
return out;
}
}
/** Raw generator bodies: returns the 256x4 byte buffer each case produces. */
class PalBody {
static final int BASE = 0x20cc;
static byte[][] buf;
static int[] writes;
static byte[][] run(int c) {
buf = new byte[256][4];
writes = new int[256];
switch (c) {
case 0: {
int v = 0, a = BASE;
do { st(a+1,v); st(a+2,v); st(a,v); st(a+3,0); a+=4; v++; } while (v != 0x100);
break;
}
case 1: {
int v = 0x100, a = BASE;
do { st(a+3,0); v--; st(a+1,v); st(a+2,v); st(a,v); a+=4; } while (v != 0);
break;
}
case 2: {
for (int k = 0; k < 22; k++) { int a=BASE+k*4; st(a+1,0); st(a+2,0); st(a,u(0x75*k,0x16)); st(a+3,0); }
for (int k = 0; k < 71; k++) { int a=BASE+(22+k)*4; st(a+1,0); st(a+3,0); st(a+2,u(0xbc*k,0x47)); st(a,u(u(0x25*k,0x47)+0x75)); }
for (int k = 0; k < 29; k++) { int a=BASE+(93+k)*4; st(a+1,u(0xff*k,0x94)); st(a+3,0); st(a+2,u(u(0x27*k,0x1d)-0x44)); st(a,u(s(-0x3b*k,0x1d)-0x66)); }
for (int k = 0; k < 18; k++) { int a=BASE+(122+k)*4; st(a+1,u(0x1ce3+0xff*k,0x94)); st(a+3,0); st(a+2,u(u(0x1c*k,0x1e)-0x1d)); st(a,u(s(-0x50*k,0x12)+0x5f)); }
for (int k = 0; k < 12; k++) { int a=BASE+(140+k)*4; st(a+1,u(0x2ed1+0xff*k,0x94)); st(a+3,0); st(a+2,u(u(0x1f8+0x1c*k,0x1e)-0x1d)); st(a,0x0f); }
for (int k = 0; k < 71; k++) { int a=BASE+(152+k)*4; st(a+1,u(0x3ac5+0xff*k,0x94)); st(a+3,0); st(a+2,0xff); st(a,0x0f); }
for (int k = 0; k < 18; k++) { int a=BASE+(223+k)*4; st(a+1,u(0x817e+0xff*k,0x94)); st(a+3,0); st(a+2,0xff); st(a,u((0xf0*k>>5)+0x0f)); }
for (int k = 0; k < 15; k++) { int a=BASE+(241+k)*4; st(a+1,0xff); st(a+3,0); st(a+2,0xff); st(a,u(((0xf0e0+0xf0*k)>>5)+0x0f)); }
break;
}
case 3: {
int a=BASE,i,u4=0,u10=0;
for (i=0x40;i!=0;i--){ st(a+2,0); st(a+3,0); st(a+1,u(u10>>7)); st(a,u((u4>>6)+0x32)); u4+=0xcd; u10+=0xff; a+=4; }
a=BASE+0x100; u4=0; int i5=0;
for (i=0x40;i!=0;i--){ st(a+3,0); st(a+2,u(u4>>6)); st(a+1,u((u4+0x3fc0)>>7)); st(a,u(s(i5,0x40)-1)); a+=4; u4+=0xff; i5+=-0xff; }
a=BASE+0x200; i5=0; u4=0;
for (i=0x40;i!=0;i--){ st(a+2,0xff); st(a+3,0); st(a,u(u4>>7)); st(a+1,u(s(i5,0x40)-1)); i5+=-0xff; u4+=0xff; a+=4; }
int t=BASE+0x300; u4=0xbfc0;
for (i=0x40;i!=0;i--){ st(t+2,0xff); st(t+3,0); st(t,u(u4>>7)); st(t+1,u((u4+0x40)>>6)); u4+=0xff; t+=4; }
break;
}
case 4: {
int a=BASE,i;
for (i=-0x32;i!=0;i++){ st(a+2,0x32); st(a,0); st(a+3,0); st(a+1,u(i+0x32)); a+=4; }
a=BASE+0xc8; int i5=-0x96, i11=0;
for (;i5!=0;i5++){ st(a,0); st(a+3,0); st(a+1,u(i5-0x38)); st(a+2,u(u(i11,0x96)+0x32)); a+=4; i11+=0xcd; }
int c2=-0xe0, cv=0xc8;
do { st(c2+0x24cd,cv); st(c2+0x24ce,0xff); st(c2+0x24cc,0); st(c2+0x24cf,0); c2+=4; cv++; } while (c2 != 0);
break;
}
case 5: {
int a=BASE,i,u4=0,u10=0,u13=0;
for (i=0x40;i!=0;i--){ st(a+3,0); st(a+1,u(u10>>6)); st(a+2,u(u13>>6)); st(a,u(u4>>6)); u4+=0x24; u10+=0x16; u13+=0xcb; a+=4; }
a=BASE+0x100; u4=0; u10=0; int i5=0;
for (i=0x40;i!=0;i--){ st(a+3,0); st(a+1,u((u10>>6)+0x16)); st(a+2,u(u(u4>>6)-0x35)); st(a,u(s(i5,0x40)+0x24)); a+=4; u4+=0x34; u10+=0x44; i5+=-0x24; }
a=BASE+0x200; u4=0;
for (i=0x40;i!=0;i--){ st(a+2,0xff); st(a,0); st(a+3,0); st(a+1,u((u4>>6)+0x5a)); u4+=0x7c; a+=4; }
a=BASE+0x300; u4=0; u10=0;
for (i=0x40;i!=0;i--){ st(a+2,0xff); st(a+3,0); st(a,u(u10>>6)); st(a+1,u(u(u4>>6)-0x2a)); u4+=0x29; u10+=0xff; a+=4; }
break;
}
case 6: {
int a=BASE, i5=0, i11=0xff;
do { st(a+1,i11); st(a,i11); st(a+3,0); st(a+2,u(s(i5,100)-1)); a+=4; i5+=-0xff; i11--; } while (i11 != 0x9b);
a=BASE+0x190; i5=0x9b; i11=0;
do { st(a+1,i5); st(a,i5); st(a+3,0); st(a+2,u(u(i11,0x69))); a+=4; i11+=0xff; i5--; } while (i5 != 0x32);
int t=BASE+0x334; i11=0x33;
do { st(t+2,0xff); st(t+3,0); i11--; st(t+1,i11); st(t,i11); t+=4; } while (i11 != 0);
break;
}
case 7: {
int a=BASE,i,cv=0;
for (i=0x80;i!=0;i--){ st(a+1,0); st(a+2,cv); st(a,0); st(a+3,0); a+=4; cv+=2; }
int t=BASE+0x200; int i5=0x80, i11=0;
do { st(t+2,0xff); st(t+3,0); st(t+1,u(u(i11,0x7f))); st(t,u(u(i11,0x7f))); i11+=0xff; t+=4; i5--; } while (i5 != 0);
break;
}
case 8: {
int i11=-0x80;
do { st(i11+0x214d,0); st(i11+0x214e,0); st(i11+0x214c,u(i11-1)); st(i11+0x214f,0); i11+=4; } while (i11 != 0);
int a=BASE+0x80, u4=0, i;
for (i=0x40;i!=0;i--){ st(a+2,0); st(a,0xff); st(a+3,0); st(a+1,u(u4>>6)); u4+=0xff; a+=4; }
a=BASE+0x180; u4=0; int i1=0;
for (i=0x40;i!=0;i--){ st(a+1,0xff); st(a+3,0); st(a+2,u(u4>>6)); st(a,u(s(i1,0x40)-1)); a+=4; u4+=0xff; i1+=-0xff; }
a=BASE+0x280; int i5j=0x40; int j=0;
do { st(a+2,0xff); st(a,0); st(a+3,0); st(a+1,u(s(j,0x40)-1)); j+=-0xff; a+=4; i5j--; } while (i5j != 0);
int k=-0x80, m=0;
do { st(k+0x24cd,0); st(k+0x24cc,0); st(k+0x24cf,0); st(k+0x24ce,u(s(m,0x20)-1)); m+=-0x80; k+=4; } while (k != 0);
break;
}
case 9: {
int a=BASE, i11=0, i5=0x99, cv;
do { st(a+3,0); cv=u(u(i11,200)); st(a+1,cv); st(a+2,cv); st(a,cv); a+=4; i11+=0xff; i5--; } while (i5 != 0);
int t=-0xbc, uu=0x9867;
do { st(t+0x23ed,0xc3); st(t+0x23ee,u(u(uu,200))); st(t+0x23ec,0xc3); st(t+0x23ef,0); uu+=0xff; t+=4; } while (t != 0);
int c2=-0xe0, v=0;
do { st(c2+0x24ce,0xff); st(c2+0x24cf,0); int g=u(s(v,0x37)-0x3d); st(c2+0x24cd,g); st(c2+0x24cc,g); v+=-0xc3; c2+=4; } while (c2 != 0);
break;
}
case 10: {
int a=BASE, i11=0;
do { st(a+1,0); st(a+2,i11); st(a,i11); st(a+3,0); a+=4; i11+=7; } while (i11 != 0xfc);
int cv=0xfc, o=-0x94, u4=0;
do { st(o+0x21f1,0); st(o+0x21f2,cv); st(o+0x21f3,0); st(o+0x21f0,u(-4-(u4>>2))); u4+=7; cv+=-7; o+=4; } while (o != 0);
int p=BASE+0x124; int cv1=0; int u4b=0xf5; boolean more;
do { cv1+=7; st(p+1,cv1); st(p+2,0); st(p+3,0); st(p,u(-4-(u4b>>2))); p+=4; more=u4b!=0; u4b-=7; } while (more);
int q=BASE+0x1b4; int u4c=0; int i11b=0xfc;
do { st(q+2,0); st(q,i11b); st(q+3,0); st(q+1,u(-4-(u4c>>1))); u4c+=7; q+=4; more=i11b!=0; i11b-=7; } while (more);
int r=BASE+0x248; int cvr=0x7; int u4d=0xf5;
do { st(r+2,cvr); st(r,0); st(r+3,0); st(r+1,u(-4-(u4d>>1))); r+=4; cvr+=7; more=u4d!=0; u4d-=7; } while (more);
int u4e=0; int s=-0x94; int cvs=0xfc;
do { st(s+0x2439,cvs); st(s+0x2438,0); st(s+0x243b,0); st(s+0x243a,u(-4-(u4e>>2))); u4e+=7; cvs+=-7; s+=4; } while (s != 0);
int d=BASE+0x36c; int u4f=0xfc; int cv6=0;
do { st(d+1,cv6); st(d,cv6); st(d+3,0); st(d+2,u(-4-(u4f>>2))); d+=4; cv6+=7; more=u4f!=0; u4f-=7; } while (more);
break;
}
case 12: { // case 0xc
int a=BASE, i11=0;
do { st(a+1,0); st(a+2,0); st(a,i11); st(a+3,0); a+=4; i11+=7; } while (i11 != 0xfc);
int o=-0x94, cv=0;
do { st(o+0x21f1,cv); st(o+0x21f2,0); st(o+0x21f0,0xff); st(o+0x21f3,0); cv+=7; o+=4; } while (o != 0);
int p=BASE+0x124; int i11c=0;
do { st(p+1,0xff); st(p+2,0); st(p+3,0); st(p,u(i11c-0xb)); i11c+=-7; p+=4; } while (i11c != -0xfc);
int o2=-0x94, cv2=0;
do { st(o2+0x2315,0xff); st(o2+0x2316,cv2); st(o2+0x2314,0); st(o2+0x2317,0); cv2+=7; o2+=4; } while (o2 != 0);
int p2=BASE+0x248; int i11d=0;
do { st(p2+2,0xff); st(p2,0); st(p2+3,0); st(p2+1,u(i11d-0xb)); i11d+=-7; p2+=4; } while (i11d != -0xfc);
int o3=-0x94, cv3=0;
do { st(o3+0x2439,0); st(o3+0x243a,0xff); st(o3+0x2438,cv3); st(o3+0x243b,0); cv3+=7; o3+=4; } while (o3 != 0);
int o4=-0x94, cv4=0;
do { st(o4+0x24cd,cv4); st(o4+0x24ce,0xff); st(o4+0x24cc,0xff); st(o4+0x24cf,0); cv4+=7; o4+=4; } while (o4 != 0);
break;
}
case 13: { // case 0xd
int a=BASE, i5=0, i;
for (i=0x40;i!=0;i--){ st(a,0xff); st(a+1,u(i5)); st(a+2,0); st(a+3,0); i5+=4; a+=4; }
int b=BASE+0x100; int cv=0xfc;
for (i=0x40;i!=0;i--){ st(b+1,0xff); st(b+2,0); st(b,cv); st(b+3,0); cv-=4; b+=4; }
int c2=BASE+0x200; int cv1=0, cv6=0xfc;
for (i=0x40;i!=0;i--){ st(c2+1,0xff); st(c2+2,cv1); st(c2,0); st(c2+3,0); cv1+=4; c2+=4; }
int d=BASE+0x300;
for (i=0x40;i!=0;i--){ st(d+1,cv6); st(d+2,0xff); st(d,0); st(d+3,0); cv6-=4; d+=4; }
break;
}
}
for (int i = 0; i < 256; i++) {
if (writes[i] != 1) throw new IllegalStateException(
"body " + c + " entry " + i + " written " + writes[i] + " times");
}
return buf;
}
static void st(int addr, int v) {
int rel = addr - BASE, idx = rel / 4, off = rel % 4;
if (idx < 0 || idx > 255) throw new IllegalStateException("oob " + Integer.toHexString(addr));
buf[idx][off] = (byte) v;
if (off == 0) writes[idx]++;
}
static int u(int v) { return v & 0xFF; }
static int u(int a, int b) { return Integer.divideUnsigned(a, b); }
static int s(int a, int b) { return a / b; }
}
+244
View File
@@ -0,0 +1,244 @@
import java.nio.file.*;
import java.util.*;
/**
* Independent check of the Phase C negative claim: "libcxsdk.so contains no
* static palette tables."
*
* The canonical scanner is analysis/tools/extract_palettes.py, but this machine
* has no working python3, so this JDK equivalent lets a verifier re-run the scan.
*
* The tool reports three things and is explicit about how much each one proves.
*
* [A] DETECTOR SANITY -- the known iron-bow table is planted into a synthetic
* buffer and the broad scan must find it. This only proves the scan is not
* blind; it proves nothing about the library.
*
* [B] POSITIVE CONTROL (the decisive part) -- the same table is searched for
* verbatim in the library under the DOCUMENTED byte layout. This matters:
* csdk/src/mag160c_official_palette256.h records the vendor table as
* "256 x 4 bytes (B, G, R, 0)" — the 4th byte is ZERO, while the ARGB ints
* in OfficialTables.kt have alpha forced to 0xFF by the generator, so
* searching the Kotlin ints directly can never match the binary. The tool
* therefore rebuilds the needle from the csdk header (ground truth) and
* additionally tries an RGB-only match that ignores the 4th byte.
* Iron-bow is the one vendor palette the project holds (captured at runtime
* from CoreSDKLib dev+0xb18, verified pixel-exact against the official
* renderer); if this library stored static tables of that family, it would
* be among them. Absence in every encoding is therefore strong evidence
* for the negative claim.
*
* [C] BROAD SCAN (reported, but NOT usable as evidence) -- a generic "256-entry
* colour ramp" search. Measured on this library it flags ~11% of all
* positions, all of them ARM32 code where the 4th byte of each word happens
* to be constant and adjacent words differ little. A generic ramp test
* cannot separate palettes from integer/address arrays, so this count is
* deliberately reported with its false-positive density instead of being
* presented as a verdict.
*
* The negative claim therefore rests on [B] plus the independent structural
* evidence: the Ghidra listing shows CFunctions::SetColorPalette COMPUTING all
* twelve tables with arithmetic (analysis/sdk_re/android_app/libcxsdk_decomp.txt),
* which is why no static data can be found.
*
* Usage:
* java PalScan.java analysis/sdk_re/android_app/bin/libcxsdk.so \
* csdk/src/mag160c_official_palette256.h
*/
public class PalScan {
static final int N = 256;
static final int STEP_LIMIT = 0x200000; // max per-step change
static final int CHANNEL_SPAN = 64; // a colour scale spans a channel
static final int MIN_SPANNING_CHANNELS = 2;
public static void main(String[] args) throws Exception {
String libPath = args[0];
String headerPath = args.length > 1 ? args[1] : null;
byte[] lib = Files.readAllBytes(Paths.get(libPath));
System.out.println("file: " + libPath + " (" + lib.length + " bytes)");
if (headerPath == null) {
System.out.println("(pass csdk/src/mag160c_official_palette256.h as the 2nd argument)");
return;
}
// Ground truth for the in-binary layout: the csdk header holds the table
// exactly as it appears in memory, (B, G, R, 0) per entry.
int[][] rgb = readHeader(headerPath);
if (rgb.length != N) {
System.out.println("header parsed " + rgb.length + " entries, expected " + N + " - aborting");
return;
}
System.out.println("ground-truth table: " + N + " entries (B,G,R,0), first 3 = "
+ rgb[0][0] + "," + rgb[0][1] + "," + rgb[0][2] + " "
+ rgb[1][0] + "," + rgb[1][1] + "," + rgb[1][2] + " "
+ rgb[2][0] + "," + rgb[2][1] + "," + rgb[2][2]);
// ---------- [A] detector sanity ----------
byte[] planted = encodeHeader(rgb, false);
int plantedHits = scan(planted).size();
System.out.println();
System.out.println("[A] detector sanity: planted the known table into a 1024-byte buffer");
System.out.println(" detected: " + plantedHits + " candidate(s)"
+ (plantedHits == 0 ? " <-- SCAN IS BLIND, [C] is meaningless" : " (scan is not blind)"));
// ---------- [B] positive control ----------
System.out.println();
System.out.println("[B] POSITIVE CONTROL - is the known vendor table stored verbatim?");
int exact = indexOf(lib, encodeHeader(rgb, false), 0);
System.out.println(" B,G,R,0 (documented layout) : "
+ (exact < 0 ? "not found" : "*** FOUND at 0x" + Integer.toHexString(exact) + " ***"));
int exactFF = indexOf(lib, encodeHeader(rgb, true), 0);
System.out.println(" B,G,R,0xFF (alpha forced) : "
+ (exactFF < 0 ? "not found" : "*** FOUND at 0x" + Integer.toHexString(exactFF) + " ***"));
int rgbOnly = matchRgbIgnoringFourth(lib, rgb);
System.out.println(" B,G,R ignoring 4th byte : "
+ (rgbOnly < 0 ? "not found" : "*** FOUND at 0x" + Integer.toHexString(rgbOnly) + " ***"));
// ---------- [C] broad scan ----------
System.out.println();
System.out.println("[C] broad scan for any 256-entry colour ramp (supporting only):");
List<Integer> hits = scan(lib);
int positions = (lib.length - N * 4) / 4;
System.out.printf(" candidates: %d of %d positions (%.1f%%)%n",
hits.size(), positions, 100.0 * hits.size() / Math.max(1, positions));
if (!hits.isEmpty()) {
System.out.println(" These are ARM32 code regions: the ramp test cannot tell a colour");
System.out.println(" scale from a run of small integers, so this count is NOT evidence.");
}
// ---------- verdict ----------
System.out.println();
boolean found = exact >= 0 || exactFF >= 0 || rgbOnly >= 0;
if (found) {
System.out.println("VERDICT: a static copy of the vendor table IS present -> the negative claim is WRONG.");
} else {
System.out.println("VERDICT: the known vendor table is absent in every encoding tested.");
System.out.println(" Together with the Ghidra listing (CFunctions::SetColorPalette computes");
System.out.println(" the tables arithmetically) this upholds the negative claim:");
System.out.println(" libcxsdk.so stores no static palette tables.");
}
}
/** Parse csdk/src/mag160c_official_palette256.h into {B,G,R} triples. */
static int[][] readHeader(String path) throws Exception {
List<int[]> rows = new ArrayList<>();
for (String line : Files.readAllLines(Paths.get(path), java.nio.charset.StandardCharsets.UTF_8)) {
if (!line.contains("{") || !line.contains("}")) continue;
String body = line.substring(line.indexOf('{') + 1, line.indexOf('}'));
String[] parts = body.split(",");
if (parts.length < 3) continue;
try {
rows.add(new int[]{
Integer.parseInt(parts[0].trim()),
Integer.parseInt(parts[1].trim()),
Integer.parseInt(parts[2].trim()),
});
} catch (NumberFormatException e) {
// header/trailer lines
}
}
return rows.toArray(new int[0][]);
}
static byte[] encodeHeader(int[][] rgb, boolean alphaFF) {
byte[] out = new byte[rgb.length * 4];
for (int i = 0; i < rgb.length; i++) {
out[i*4] = (byte) rgb[i][0];
out[i*4+1] = (byte) rgb[i][1];
out[i*4+2] = (byte) rgb[i][2];
out[i*4+3] = (byte) (alphaFF ? 0xFF : 0x00);
}
return out;
}
/** Match all 256 (B,G,R) triples at 4-byte stride, ignoring the 4th byte. */
static int matchRgbIgnoringFourth(byte[] data, int[][] rgb) {
for (int o = 0; o + rgb.length * 4 <= data.length; o += 4) {
boolean ok = true;
for (int i = 0; i < rgb.length && ok; i++) {
int p = o + i * 4;
if ((data[p] & 0xFF) != rgb[i][0]
|| (data[p+1] & 0xFF) != rgb[i][1]
|| (data[p+2] & 0xFF) != rgb[i][2]) ok = false;
}
if (ok) return o;
}
return -1;
}
/** All offsets whose 1024-byte window passes the ramp test. */
static List<Integer> scan(byte[] b) {
List<Integer> out = new ArrayList<>();
for (int o = 0; o + N * 4 <= b.length; o += 4) if (isPaletteRun(b, o)) out.add(o);
return out;
}
static boolean isPaletteRun(byte[] b, int o) {
int alpha = b[o + 3] & 0xFF;
int prev = -1, distinct = 0;
int[] mn = {255, 255, 255}, mx = {0, 0, 0};
for (int i = 0; i < N; i++) {
int p = o + i * 4;
if ((b[p + 3] & 0xFF) != alpha) return false; // unused byte constant
int bl = b[p] & 0xFF, g = b[p + 1] & 0xFF, r = b[p + 2] & 0xFF;
int v = bl | (g << 8) | (r << 16);
if (prev >= 0 && Math.abs(v - prev) > STEP_LIMIT) return false;
if (v != prev) distinct++;
if (bl < mn[0]) mn[0] = bl; if (bl > mx[0]) mx[0] = bl;
if (g < mn[1]) mn[1] = g; if (g > mx[1]) mx[1] = g;
if (r < mn[2]) mn[2] = r; if (r > mx[2]) mx[2] = r;
prev = v;
}
if (distinct < 32) return false;
int spanning = 0;
for (int c = 0; c < 3; c++) if (mx[c] - mn[c] >= CHANNEL_SPAN) spanning++;
return spanning >= MIN_SPANNING_CHANNELS;
}
static byte[] encode(int[] anchor, int order) {
byte[] out = new byte[anchor.length * 4];
for (int i = 0; i < anchor.length; i++) {
int v = anchor[i];
int r = (v >> 16) & 0xFF, g = (v >> 8) & 0xFF, bl = v & 0xFF, al = (v >>> 24) & 0xFF;
switch (order) {
case 0: putLE(out, i * 4, v); break;
case 1: putBE(out, i * 4, v); break;
case 2: out[i*4]=(byte) bl; out[i*4+1]=(byte) g; out[i*4+2]=(byte) r; out[i*4+3]=(byte) al; break;
case 3: out[i*4]=(byte) r; out[i*4+1]=(byte) g; out[i*4+2]=(byte) bl; out[i*4+3]=(byte) al; break;
case 4: out[i*4]=0; out[i*4+1]=(byte) bl; out[i*4+2]=(byte) g; out[i*4+3]=(byte) r; break;
}
}
return out;
}
static void putLE(byte[] o, int p, int v) {
o[p]=(byte)(v&0xFF); o[p+1]=(byte)((v>>8)&0xFF); o[p+2]=(byte)((v>>16)&0xFF); o[p+3]=(byte)((v>>>24)&0xFF);
}
static void putBE(byte[] o, int p, int v) {
o[p]=(byte)((v>>>24)&0xFF); o[p+1]=(byte)((v>>16)&0xFF); o[p+2]=(byte)((v>>8)&0xFF); o[p+3]=(byte)(v&0xFF);
}
static int indexOf(byte[] hay, byte[] needle, int from) {
outer:
for (int i = from; i + needle.length <= hay.length; i++) {
for (int j = 0; j < needle.length; j++) if (hay[i + j] != needle[j]) continue outer;
return i;
}
return -1;
}
static int[] readAnchor(String ktPath) throws Exception {
String src = Files.readString(Paths.get(ktPath), java.nio.charset.StandardCharsets.UTF_8);
int k = src.indexOf("val PALETTE256_ARGB = intArrayOf(");
int start = k + "val PALETTE256_ARGB = intArrayOf(".length();
int end = src.indexOf(")", start);
List<Integer> vals = new ArrayList<>();
StringBuilder num = new StringBuilder();
for (char c : src.substring(start, end).toCharArray()) {
if (c == '-' || (c >= '0' && c <= '9')) num.append(c);
else if (num.length() > 0) { vals.add(Integer.parseInt(num.toString())); num.setLength(0); }
}
return vals.stream().mapToInt(Integer::intValue).toArray();
}
}
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Phase C — vendor palette extraction from libcxsdk.so.
Scans the official native library for static 256-entry ARGB palette tables and
compares any candidates against the iron-bow table the project already owns
(OfficialTables.PALETTE256_ARGB), which is the extraction anchor.
RESULT ON THE SHIPPED LIBRARY (recorded in
analysis/sdk_re/android_app/palette_extraction_findings.md): the scan finds NO
static tables — libcxsdk.so builds all 12 palettes at runtime with arithmetic in
CFunctions::SetColorPalette. This script is therefore kept as the reproducible
proof of that negative (and would catch a table if a future vendor build embeds
one). The actual recovery was done by porting that routine; see
analysis/tools/PalIdentify.java + analysis/tools/PalExport2.java.
Pure standard library (struct + zlib + json). Writes UTF-8 explicitly.
Usage:
python3 extract_palettes.py <libcxsdk.so> <OfficialTables.kt> <out_dir>
"""
import json
import os
import re
import struct
import sys
import zlib
ALPHA_RUN = 240 # minimum entries with alpha==0xFF and non-zero RGB
ENTRIES = 256
RUN_BYTES = ENTRIES * 4
def read_anchor(kt_path):
"""Extract PALETTE256_ARGB (256 ARGB ints) from OfficialTables.kt."""
with open(kt_path, "r", encoding="utf-8") as fh:
src = fh.read()
m = re.search(r"val PALETTE256_ARGB = intArrayOf\((.*?)\)", src, re.S)
if not m:
raise SystemExit("PALETTE256_ARGB not found in " + kt_path)
vals = [int(x) for x in re.findall(r"-?\d+", m.group(1))]
if len(vals) != ENTRIES:
raise SystemExit("anchor has %d entries, expected %d" % (len(vals), ENTRIES))
return vals
def u32le(buf, off):
return struct.unpack_from("<I", buf, off)[0]
def scan(data):
"""Return list of offsets where a 256-entry alpha-0xFF run starts."""
hits = []
limit = len(data) - RUN_BYTES
for off in range(0, limit + 1, 4):
ok = 0
for i in range(0, ENTRIES, 16): # sample every 16th entry first
v = u32le(data, off + i * 4)
if (v >> 24) == 0xFF and (v & 0xFFFFFF) != 0:
ok += 1
if ok == ENTRIES // 16: # cheap pass, verify fully
full = 0
for i in range(ENTRIES):
v = u32le(data, off + i * 4)
if (v >> 24) == 0xFF and (v & 0xFFFFFF) != 0:
full += 1
if full >= ALPHA_RUN:
hits.append(off)
return hits
def merge(hits):
"""Merge overlapping runs (a 1024 B table can start at off, off+4, ...)."""
merged = []
for off in hits:
if merged and off <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], off + RUN_BYTES)
else:
merged.append([off, off + RUN_BYTES])
return merged
def write_png(path, rows, row_h, scale):
"""Minimal PNG writer: one horizontal strip per row of 256 colours."""
width = ENTRIES * scale
height = len(rows) * row_h
raw = bytearray()
for row in rows:
line = bytearray()
for x in range(width):
argb = row[x // scale]
line += bytes(((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF))
for _ in range(row_h):
raw.append(0) # filter type 0
raw += line
def chunk(tag, payload):
return (struct.pack(">I", len(payload)) + tag + payload
+ struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
png = b"\x89PNG\r\n\x1a\n"
png += chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
png += chunk(b"IDAT", zlib.compress(bytes(raw), 9))
png += chunk(b"IEND", b"")
with open(path, "wb") as fh:
fh.write(png)
def main():
if len(sys.argv) != 4:
raise SystemExit(__doc__)
so_path, kt_path, out_dir = sys.argv[1:4]
os.makedirs(out_dir, exist_ok=True)
with open(so_path, "rb") as fh:
data = fh.read()
anchor = read_anchor(kt_path)
print("scanned %s: %d bytes; anchor %d entries" % (so_path, len(data), len(anchor)))
hits = scan(data)
print("static 256-entry alpha=0xFF runs: %d" % len(hits))
runs = merge(hits)
candidates = []
anchor_at = None
for start, end in runs:
for off in range(start, end - RUN_BYTES + 1, 4):
if all(u32le(data, off + i * 4) == anchor[i] for i in range(ENTRIES)):
anchor_at = off
break
candidates.append({
"offset": start,
"offsetHex": "0x%X" % start,
"hexPreview": ["0x%08X" % u32le(data, start + i * 4) for i in range(16)],
"length": ENTRIES,
})
if anchor_at is not None:
print("ironbow anchor found at 0x%X" % anchor_at)
else:
print("ironbow anchor NOT found — palettes are not stored as static tables")
report = {
"binary": os.path.basename(so_path),
"binarySize": len(data),
"anchorFound": anchor_at is not None,
"anchorOffset": anchor_at,
"staticCandidates": candidates,
"conclusion": ("no static palette tables: CFunctions::SetColorPalette builds "
"them at runtime" if anchor_at is None else "static tables present"),
}
json_path = os.path.join(out_dir, "palette_candidates.json")
with open(json_path, "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
print("wrote %s" % json_path)
# strip sheet: one row per candidate (empty sheet when none are found)
rows = []
for start, end in runs:
rows.append([u32le(data, start + i * 4) for i in range(ENTRIES)])
if not rows:
rows.append([0xFF000000] * ENTRIES) # placeholder so the file is valid
png_path = os.path.join(out_dir, "palette_candidates.png")
write_png(png_path, rows, row_h=32, scale=8)
print("wrote %s (%d rows)" % (png_path, len(rows)))
if __name__ == "__main__":
main()
+3
View File
@@ -48,6 +48,9 @@ dependencies {
implementation(libs.core.ktx)
implementation(libs.lifecycle.runtime.ktx)
implementation(libs.lifecycle.viewmodel.compose)
// Cloud scaffold (Phase D): opt-in only and disabled by default — see cloud/CloudApi.kt
implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson)
debugImplementation(libs.compose.ui.tooling)
testImplementation(libs.junit)
}
+14
View File
@@ -0,0 +1,14 @@
# MAG160C release (R8) keep rules.
# Cloud scaffold (Phase D): the Retrofit interface + DTOs are only reached via
# reflection (Retrofit proxies, Gson field mapping), so keep them intact.
-keep class com.mag160c.thermal.cloud.** { *; }
-keepattributes Signature
-keepattributes *Annotation*
# Gson's reflective type adapters for the cloud DTOs.
-keep class com.google.gson.** { *; }
-dontwarn com.google.gson.**
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn retrofit2.**
+23 -1
View File
@@ -2,6 +2,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.usb.host" android:required="true" />
<!-- Visible-light PIP overlay (Phase E): optional, requested at runtime.
The explicit camera/camera.any entries keep the CAMERA permission from
implying a REQUIRED camera feature — PIP is optional, so a device
without a camera must still be able to run the thermal app. -->
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- LAN remote preview (Phase F): host broadcasts on UDP 47510, streams on TCP 47511 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
@@ -18,7 +28,19 @@
android:exported="true"
android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:resizeableActivity="true">
android:resizeableActivity="true"
android:launchMode="singleTask">
<!-- launchMode=singleTask is REQUIRED here, not cosmetic.
This activity declares a USB_DEVICE_ATTACHED intent filter, and
MIUI re-broadcasts that attach event continuously while the camera
is plugged in. With the default (standard) mode each broadcast
started ANOTHER MainActivity instance — the device had two live
instances, each with its own ViewModel, IrSession and broadcast
receivers, all fighting over the same camera. That was the real
cause of the endless connect storm, the reconnect loop and the
camera re-enumerating every couple of seconds (device number
climbing 060 -> 093 in one session). The official app uses
launchMode=2 (singleTask) for exactly this reason. -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -1,6 +1,8 @@
package com.mag160c.thermal
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -10,21 +12,54 @@ import com.mag160c.thermal.ui.theme.Mag160cTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// field-debug crash hook + log file (opened at startup so even
// early crashes are captured; logging never throws)
// Field-debug logging is OFF by default (see DebugLog.enabled): a shipping
// build writes no log file and no logcat lines. Flip that flag and rebuild to
// capture a detailed trace on a device — these two calls then behave exactly
// as they used to.
com.mag160c.thermal.media.DebugLog.init(applicationContext)
com.mag160c.thermal.media.DebugLog.startFile(applicationContext)
enableEdgeToEdge()
// immersive: hide the status bar (swipe to reveal)
window.insetsController?.let { c ->
c.hide(android.view.WindowInsets.Type.statusBars())
c.systemBarsBehavior =
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
// enableEdgeToEdge exists since API 21 but reaches for the modern inset
// APIs internally; keep it for API 30+ and fall back below, where the
// window flags are the only supported route.
if (Build.VERSION.SDK_INT >= 30) {
runCatching { enableEdgeToEdge() }
}
hideStatusBar()
setContent {
Mag160cTheme {
AppRoot()
}
}
}
/**
* Immersive status bar (swipe to reveal), on every supported Android version.
*
* `Window.insetsController` and `WindowInsetsController` are API 30+, but this
* app supports API 26+ — the previous unconditional use would have thrown
* NoSuchMethodError on Android 8/9 the moment the app started. API 26-29 uses
* the pre-30 window flags instead; the deprecated flags work through API 29 and
* are still honoured on 30+ as a compatibility path.
*/
private fun hideStatusBar() {
if (Build.VERSION.SDK_INT >= 30) {
window.insetsController?.let { c ->
c.hide(android.view.WindowInsets.Type.statusBars())
c.systemBarsBehavior =
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
// API 26-29: the deprecated system-UI flags are the supported route.
// FLAG_LAYOUT_NO_LIMITS is deliberately NOT set: it would push content
// under the navigation bar as well, which the renderer's inset
// accounting (uiTopPx/uiBottomPx) does not expect.
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
)
}
}
}
@@ -0,0 +1,92 @@
package com.mag160c.thermal.cloud
import okhttp3.RequestBody
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Cloud API scaffold (Phase D) — INTERFACE ONLY, NO NETWORK BEHAVIOUR.
*
* The official professional app talks to cloudapi.magnity.com.cn; this module
* reserves the same shape so the feature can be filled in later without
* reworking the app. Design rules, per the user's decision (2026-09-06):
*
* - disabled by default; enabling is an explicit per-device opt-in
* - nothing here is ever called unless [CloudClient.enabled] is true, and
* [CloudClient.api] refuses to build a Retrofit client otherwise
* - no background sync, no analytics, no silent uploads
*
* Endpoint shapes mirror the vendor service but are placeholders: request and
* response fields will need to be aligned with the real service before use.
*/
interface CloudApi {
@POST("v1/account/login")
fun login(@Body body: LoginReq): Call<LoginResp>
@GET("v1/account/devices")
fun devices(): Call<DeviceListResp>
@POST("v1/account/taskfiles/{taskId}")
fun upload(@Path("taskId") id: String, @Body body: RequestBody): Call<UploadResp>
@GET("v1/account/taskfiles/{taskId}")
fun taskfiles(@Path("taskId") id: String): Call<TaskFilesResp>
}
data class LoginReq(val account: String, val password: String)
data class LoginResp(val code: Int = 0, val token: String? = null, val message: String? = null)
data class DeviceListResp(val code: Int = 0, val devices: List<DeviceInfo> = emptyList())
data class DeviceInfo(
val serial: String = "",
val model: String = "",
val name: String = "",
)
data class UploadResp(val code: Int = 0, val fileId: String? = null, val message: String? = null)
data class TaskFilesResp(val code: Int = 0, val files: List<TaskFile> = emptyList())
data class TaskFile(
val fileId: String = "",
val name: String = "",
val size: Long = 0,
val createdAt: String = "",
)
/**
* Client factory. [enabled] reads the user's opt-in flag; [api] must not be
* called while disabled (it throws rather than silently reaching the network,
* so an accidental call is caught in testing instead of shipping data).
*/
object CloudClient {
const val BASE_URL = "https://cloudapi.magnity.com.cn/"
/** Backed by the settings flag; the caller supplies the value from AppSettings. */
@Volatile
private var optIn = false
val enabled: Boolean get() = optIn
fun setEnabled(value: Boolean) {
optIn = value
}
fun api(): CloudApi {
check(optIn) {
"cloud is disabled: CloudClient.api() must not be called unless the user opted in"
}
return Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(CloudApi::class.java)
}
}
@@ -0,0 +1,136 @@
package com.mag160c.thermal.core
/**
* One definition of how temperature markers look and where their labels sit,
* shared by every surface that draws them: the live screen, the analysis screen,
* saved photos and recorded video.
*
* ## Style (redesigned 2026-09-12, at the user's request)
*
* "工业感" — modelled on the spot meters the big thermal brands use (FLIR, Testo,
* Hikmicro). Those overlays are drawn as thin white line work directly on the
* image, with the readout as outlined text; nothing is filled, because a filled
* plate hides the very pixels the reading is about and reads as a consumer app.
*
* The glyph is a square reticle with four arms: the arms make the exact measured
* pixel unambiguous, and the open centre keeps the measured area visible. The
* label carries a dark outline instead of a light background, which is what keeps
* white text legible over both the black cold end and the white hot end of every
* palette.
*
* Everything below is expressed relative to the RENDERED IMAGE (320x240 buffer
* units), never in screen dp, so a marker has identical proportions on screen, in
* a photo and in a video frame.
*/
object AnnotSpec {
/** Reference width the constants below are calibrated for. */
const val REF_W = 320f
// ---- reticle (multiples of the image scale factor) ----
/** Half side of the square reticle. */
const val SPOT_R = 4.2f
/** Length of each arm beyond the square. */
const val SPOT_ARM = 3.0f
/** Stroke width of the reticle and the leader. */
const val SPOT_W = 1.0f
// ---- label ----
/** Label text height in image units. */
const val TEXT_SIZE = 9.5f
/** Outline half-width around each glyph; this replaces the old white plate. */
const val TEXT_OUTLINE = 1.1f
/** Gap between the reticle arm and the start of the text. */
const val LABEL_GAP = 4f
/**
* Padding around the text used ONLY for the label-collision rect. There is no
* filled plate any more, so this is small — just enough that two labels cannot
* touch glyph to glyph.
*/
const val LABEL_PAD_H = 1.5f
const val LABEL_PAD_V = 1.5f
/** Marker / text colour. Monochrome on purpose: see the class note. */
const val MARK_WHITE = 0xFFFFFFFF.toInt()
/** The dark outline that makes [MARK_WHITE] readable on any palette. */
const val MARK_OUTLINE = 0xFF000000.toInt()
/**
* Colour of the max/min markers. Deliberately the same white as the probes:
* that is what the reference instruments do, and it stays readable over the
* saturated orange/yellow that a coloured marker would disappear into. The
* extremes are told apart by their `MAX` / `MIN` text.
*/
const val EXTREME_TINT = MARK_WHITE
fun scaleFor(imageWidth: Int): Float = imageWidth / REF_W
/** Half extents of a text box in image units, for label placement. */
fun halfExtents(textW: Float, textH: Float): FloatArray =
floatArrayOf(textW / 2f, textH / 2f)
/**
* Default label anchor relative to the marker centre: to the RIGHT of the
* reticle, vertically centred. The same rule everywhere, so a marker that sits
* clear of the image edge on screen also sits clear of it in the photo.
*/
fun labelOffsetX(scale: Float): Float = (SPOT_R + SPOT_ARM + LABEL_GAP) * scale
/**
* Where to put a label so it stays inside [imgW]x[imgH] and does not sit on
* top of an already-placed label.
*
* @param placed boxes already committed, as [x, y, boxW, boxH]; a candidate
* that intersects one of these is nudged downward (and flipped to the left
* of its marker first if that helps). Without this, two extremes that are
* close together in the scene — the usual case for a hot object against a
* cool background — produced one illegible smear of two overlapping
* temperatures.
* @return x of the box's left edge and y of its top edge
*/
fun placeLabel(
cx: Float,
cy: Float,
boxW: Float,
boxH: Float,
imgW: Float,
imgH: Float,
scale: Float,
placed: List<FloatArray> = emptyList(),
): FloatArray {
val margin = 2f * scale
var x = cx + labelOffsetX(scale)
// flip to the left of the marker when it would overflow the right edge
if (x + boxW > imgW - margin) x = cx - labelOffsetX(scale) - boxW
if (x < margin) x = margin
var y = cy - boxH / 2f
if (y < margin) y = margin
if (y + boxH > imgH - margin) y = imgH - margin - boxH
if (placed.isEmpty()) return floatArrayOf(x, y)
// Try the flipped side first when the default side is taken: that keeps the
// label attached to its own marker instead of sliding away from it.
val flippedX = (cx - labelOffsetX(scale) - boxW).coerceAtLeast(margin)
val candidates = if (flippedX != x) floatArrayOf(x, flippedX) else floatArrayOf(x)
for (candidateX in candidates) {
var tryY = y
var guard = 0
while (guard++ < placed.size + 2) {
val hit = placed.firstOrNull {
candidateX < it[0] + it[2] && candidateX + boxW > it[0] &&
tryY < it[1] + it[3] && tryY + boxH > it[1]
} ?: return floatArrayOf(candidateX, tryY)
// step below the box it hit, staying inside the image
tryY = hit[1] + hit[3] + 2f * scale
if (tryY + boxH > imgH - margin) break
}
}
// nothing fit cleanly: keep it inside the frame, on the marker's own side
return floatArrayOf(x, y.coerceIn(margin, (imgH - margin - boxH).coerceAtLeast(margin)))
}
}
@@ -0,0 +1,180 @@
package com.mag160c.thermal.core
/**
* Local 7x7 detail enhancement, ported from the official native SDK
* (`CFunctions::FilterDetailEnhancement_Simple` + `CFunctions::LocalMap7x7_Simple`,
* libcxsdk.so; Ghidra listing in
* analysis/sdk_re/android_app/libcxsdk_decomp.txt).
*
* This is the stage that makes the vendor's live image look crisper: for every
* pixel it measures the local contrast inside a 7x7 window and pushes the pixel
* away from the local mean, so fine texture becomes visible. Our pipeline was
* ported from the C reference in `csdk/`, which stops before this filter — that
* difference is why the official app looked sharper.
*
* ## Reading of the decompilation (all offsets are the vendor's own)
*
* `LocalMap7x7_Simple` samples a 7x7 window at every OTHER pixel (x offsets
* 0,2,4,6 and rows stepped by 2 x width, 4 rows = 16 samples), then:
* mean = Σsamples >> 4
* range = max - min
* if (strength <= range * 32):
* divisor = max(max - mean, mean - min, strength)
* detail = (0x8000 / divisor) * (center - mean)
* else detail = 0
* where `center` is the true middle pixel of the window (row 3, col 3), not one of
* the 16 samples.
*
* The driver walks every window position (rows 0..H-7, cols 0..W-7, both stepped
* by 1 across the two interleaved loops in the original) and stores the detail at
* the window's centre, so each destination pixel receives exactly one value. The
* consumer then applies
* gray = clamp(gray + (strength * detail) / 32768, 0, 255)
* over the rows/cols the earlier stages actually filled (3..H-4).
*
* ## The two strength values are NOT interchangeable
*
* The driver passes a gain-scaled strength to the map
* k = strength * ((dev24 * 1000) >> shift) * 2 >> 8
* and that scaled value is what the map's threshold and divisor floor compare
* against, so it sets how much noise the filter is allowed to amplify. The final
* blend, however, multiplies the detail by the **raw** strength. Using the scaled
* value in both places — as this port first did — amplifies grain by that same
* gain factor (measured: visibly speckled at level 2, where the vendor is
* near-silent on flat areas). The divisor floor is what keeps flat, noisy regions
* quiet while edges still get pushed.
*
* ## What the strength level actually controls
*
* Raising the level does two things at once:
* 1. it RAISES the contrast gate — a window is enhanced only when its 7x7 range
* clears `mapStrength / 32`, so low-contrast areas (where sensor noise lives)
* are excluded entirely at high levels; and
* 2. it RAISES the amplitude on strong edges, where the local contrast itself
* dominates the divisor.
* Where the contrast is small enough that the divisor floor applies, the amplitude
* works out to `strength / mapStrength` = `128 / gain` — independent of the level.
* That is why turning the level up sharpens the picture instead of boiling it.
*
* ## Validation status — READ THIS
*
* The arithmetic above is a faithful transcription, and the structural properties
* are unit-tested (`DetailEnhanceTest`), but this port has **no official reference
* output to diff against**: the byte-exact `RenderPipelineTest` baseline was built
* from the C reference that predates this filter. It is therefore opt-in
* ([RenderPipeline.enhanceStrength], default 0 = off) so the verified path stays
* the default, and it is exposed as a user-visible toggle rather than silently
* changing every image. If it ever looks wrong, turning it off restores the
* byte-exact behaviour exactly.
*/
class DetailEnhance(private val w: Int, private val h: Int) {
private val npix = w * h
private val detail = IntArray(npix)
/**
* Apply the filter to [gray] in place.
*
* @param src16 calibrated 16-bit counts (the pipeline's NUC output)
* @param gray 8-bit gray image that the palette stage consumes
* @param strength vendor strength parameter (0 = no-op); the official app
* derives it from its enhancement setting as `level shl 3`
* @param gain the vendor's `(dev24 * 1000) >> shift` term, clamped to 0xFFFF
* @param srcLimit samples beyond this index are read as 0 (the vendor's frame
* buffer can be larger than the active area)
*/
fun enhance(src16: IntArray, gray: ByteArray, strength: Int, gain: Int, srcLimit: Int = src16.size) {
if (strength <= 0 || gain <= 0) return
require(gray.size >= npix) { "gray buffer smaller than $w x $h" }
// map coefficient (threshold + divisor floor): gain-scaled
val mapStrength = strength * gain * 2 shr 8
if (mapStrength == 0) return
java.util.Arrays.fill(detail, 0)
// --- pass 1: detail map. The vendor walks rows 0..H-7 in two interleaved
// parity loops and stores each window's value at its centre, so every
// destination pixel gets exactly one value. ---
var row = 0
while (row <= h - 7) {
var col = 0
while (col <= w - 7) {
detail[(row + 3) * w + (col + 3)] =
localMap(src16, col, row, mapStrength, srcLimit)
col++
}
row += 2
}
var oddRow = 1
while (oddRow <= h - 7) {
var col = 0
while (col <= w - 7) {
detail[(oddRow + 3) * w + (col + 3)] =
localMap(src16, col, oddRow, mapStrength, srcLimit)
col++
}
oddRow += 2
}
// --- pass 2: apply to the gray image (rows 3..H-4, cols 3..W-4 — exactly
// the region pass 1 filled). The coefficient here is the RAW strength, not
// [mapStrength]: that is what the vendor's blend uses. ---
var y = 3
while (y < h - 3) {
var x = 3
while (x < w - 3) {
val d = detail[y * w + x]
if (d != 0) {
// (strength * detail) / 32768, truncated toward zero like the
// vendor (their `+ ((x >> 31) >> 17)` is the usual
// divide-by-power-of-two correction)
val prod = strength * d
val delta = (prod + (if (prod < 0) 0x7FFF else 0)) shr 15
var v = (gray[y * w + x].toInt() and 0xFF) + delta
if (v < 1) v = 0
if (v > 254) v = 255
gray[y * w + x] = v.toByte()
}
x++
}
y++
}
}
/**
* One 7x7 window centred on ([col]+3, [row]+3). Returns the detail value the
* vendor's LocalMap7x7_Simple produces, or 0 when the local contrast is below
* their `strength <= range * 32` threshold.
*/
private fun localMap(src16: IntArray, col: Int, row: Int, strength: Int, srcLimit: Int): Int {
var mn = Int.MAX_VALUE
var mx = Int.MIN_VALUE
var sum = 0
// rows 0,2,4,6 of the window (stride 2 in y), sampling x at 0,2,4,6: the
// vendor's 4x4 decimation of the 7x7 neighbourhood
var dy = 0
while (dy < 4) {
val base = (row + dy * 2) * w + col
var dx = 0
while (dx < 4) {
val idx = base + dx * 2
val v = if (idx < srcLimit) src16[idx] and 0xFFFF else 0
sum += v
if (v < mn) mn = v
if (v > mx) mx = v
dx++
}
dy++
}
if (strength <= (mx - mn) * 32) {
val mean = sum shr 4
var divisor = (mx - mean).coerceAtLeast(strength)
divisor = (mean - mn).coerceAtLeast(divisor)
if (divisor <= 0) return 0
val gain = 0x8000 / divisor
val ci = (row + 3) * w + (col + 3)
val center = if (ci < srcLimit) src16[ci] and 0xFFFF else 0
return gain * (center - mean)
}
return 0
}
}
@@ -7,8 +7,18 @@ import kotlin.math.sin
import kotlin.math.sqrt
/**
* The 12 display palettes of the official apps. Index 2 (ironbow) uses the
* palette extracted from CoreSDKLib; the rest are standard thermal curves.
* The 12 display palettes of the official apps, in the official UI order
* (DialogFragmentPalette index 0..11).
*
* Eleven of them are now the EXACT vendor tables, recovered from the official
* native SDK: libcxsdk.so stores no static palette data — CFunctions::
* SetColorPalette computes every palette with arithmetic at runtime — so the
* tables were obtained by porting that routine and are validated against the
* iron-bow anchor (see VendorPalettes and
* analysis/sdk_re/android_app/palette_extraction_findings.md).
*
* Index 11 (红热) remains an approximation: no generator body could be matched
* to it (its preview asset in the official APK is a stub copy of 白热).
*/
object Palettes {
val NAMES = listOf(
@@ -21,20 +31,32 @@ object Palettes {
"Winter", "Hot metal", "Jet", "Red saturation", "High contrast", "Red hot",
)
/**
* All twelve palettes, built ONCE.
*
* [buildAll] used to construct the tables on every call, and the renderers call
* it once per frame (for the colour bar) — that means 12 x 256 entries plus the
* trigonometric/gamma math for several curves, 15 times a second, on the render
* thread. That was a measurable part of the stutter reported on Android 12.
*/
val ALL: List<IntArray> by lazy { buildAllInternal() }
/** Build all palettes as ARGB int arrays (256 entries each). */
fun buildAll(): List<IntArray> = listOf(
ramp(255, 255, 255, 0, 0, 0), // 0 white hot
ramp(0, 0, 0, 255, 255, 255), // 1 black hot
officialIronbow(), // 2 ironbow (vendor)
rainbow(), // 3 rainbow
ramp(0, 0, 0, 255, 183, 74), // 4 amber
ramp(0, 0, 0, 255, 220, 120), // 5 autumn
ramp(0, 0, 0, 200, 230, 255), // 6 winter
hotMetal(), // 7 hot metal
jet(), // 8 jet
ramp(0, 0, 0, 255, 0, 0), // 9 red saturation
highContrast(), // 10 high contrast
ramp(0, 0, 0, 128, 0, 0), // 11 red hot
fun buildAll(): List<IntArray> = ALL
private fun buildAllInternal(): List<IntArray> = listOf(
VendorPalettes.WHITE_HOT, // 0 白热 — extracted libcxsdk case 0
VendorPalettes.BLACK_HOT, // 1 黑热 — extracted libcxsdk case 1
officialIronbow(), // 2 铁虹 — official table (anchor-verified)
VendorPalettes.RAIN_BOW, // 3 彩虹 — extracted libcxsdk case 3
VendorPalettes.GLOW_BOW, // 4 琥珀 — extracted libcxsdk case 4
VendorPalettes.AUTUMN, // 5 金秋 — extracted libcxsdk case 5
VendorPalettes.WINTER, // 6 寒冬 — extracted libcxsdk case 6
VendorPalettes.HOT_METAL, // 7 热金属 — extracted libcxsdk case 7
VendorPalettes.JET, // 8 喷射 — extracted libcxsdk case 8
VendorPalettes.RED_SATURATION, // 9 红饱和 — extracted libcxsdk case 9
VendorPalettes.HIGH_CONTRAST, // 10 高对比度 — extracted libcxsdk case 10
approximatedRedHot(), // 11 红热 — approximated (not found in binary)
)
private fun argb(r: Int, g: Int, b: Int): Int =
@@ -55,6 +77,14 @@ object Palettes {
/** Vendor ironbow: reuse the generated official table. */
private fun officialIronbow(): IntArray = OfficialTables.PALETTE256_ARGB
/**
* Index 11 (红热 / red hot): approximation. The native library does define
* two further bodies (cases 12 and 13) but neither reproduces this palette's
* official preview (which is a stub copy of 白热 in the APK), so the
* previous approximation is kept rather than guessing.
*/
private fun approximatedRedHot(): IntArray = ramp(0, 0, 0, 128, 0, 0)
/** Classic ironbow-style fallback used by index 3 rainbow curve. */
private fun rainbow(): IntArray {
val out = IntArray(256)
@@ -24,6 +24,15 @@ class RenderPipeline(
private val force75: Boolean = true,
/** FFC command callback: param 0 = FFC(0), 1 = FFC(1). */
private val onFfc: ((param: Int) -> Unit)? = null,
/**
* Local 7x7 detail enhancement strength (vendor FilterDetailEnhancement_Simple).
* 0 = OFF, which keeps the output byte-identical to the verified C reference —
* that is the default because this stage is ported from a different source than
* the reference baseline and has no official output to diff against (see
* [DetailEnhance]). The app exposes it as the "图像增强" setting; the official
* app derives its value from its enhancement level as `level shl 3`.
*/
private var enhanceStrength: Int = 0,
) {
private val npix = w * h
@@ -56,7 +65,31 @@ class RenderPipeline(
private var refCnt = 0
private var startupFfcDone = false
/** Raw counts scratch used while collecting FFC reference frames. */
private val refScratch = IntArray(npix)
/**
* True once at least one frame completed the full render path. Until then
* [copyNuc]/[probeTemp] must not be trusted: before the first render `nuc`
* still holds zeros, and countsToTempMc(0) is about -161 C, which the UI
* would happily display. Also false while an FFC window is running, so the
* OSD freezes instead of showing the shutter-closed data.
*/
@Volatile
private var renderedOnce = false
/** Last phase returned by the FFC state machine: 0 normal, 1 hidden, 2 reference. */
@Volatile
private var lastPhase = 0
/** True while [frameRemote] is inside the host's reference window. */
private var remoteRefWindow = false
private val ref = IntArray(npix)
/** Local 7x7 detail enhancement (vendor FilterDetailEnhancement_Simple). */
private val detailEnhance = DetailEnhance(w, h)
private val refAcc = IntArray(npix)
private val nuc = IntArray(npix)
private val gray160 = ByteArray(npix)
@@ -454,6 +487,28 @@ class RenderPipeline(
pal = Palettes.buildAll()[index.coerceIn(0, Palettes.NAMES.size - 1)]
}
/**
* Detail enhancement strength; 0 disables it (byte-exact reference path).
*
* The vendor's ceiling: `MAG_SetDetailEnhancement` accepts a level 0..32 and the
* pipeline passes `level << 3`, so the strength tops out at 256.
*/
fun setEnhanceStrength(value: Int) = synchronized(lock) {
enhanceStrength = value.coerceIn(0, 256)
}
fun enhanceStrength(): Int = synchronized(lock) { enhanceStrength }
/**
* The vendor's gain term for the enhancement stage: `(dev24 * 1000) >> shift`,
* clamped to 0xFFFF (their `this+0x5104` is the shift, which matches
* [dev4cShift] in this port).
*/
private fun detailGain(): Int {
val g = (dev24 * 1000) shr dev4cShift
return if (g > 0xFFFE) 0xFFFF else g
}
/** 32-bit unsigned wrap (C unsigned int semantics). */
private fun u32(x: Long): Long = x and 0xFFFFFFFFL
@@ -599,7 +654,10 @@ class RenderPipeline(
*/
fun frame(frame: ByteArray, hasHdr: Boolean, outArgb: IntArray): Boolean = synchronized(lock) {
globalFrames++
if (globalFrames < warmFrames) return false
if (globalFrames < warmFrames) {
lastPhase = 1
return false
}
var shutterValue = shutter
if (hasHdr) {
@@ -611,11 +669,15 @@ class RenderPipeline(
shutter = shutterValue
val st = ffcStep(shutterValue)
lastPhase = st
if (st != 0) {
if (st == 2) {
val off = if (hasHdr) 0x1C else 0
ByteReader.decodeU16(frame, off, npix, nuc)
refPush(nuc)
// collect the reference in a SCRATCH buffer: decoding into `nuc`
// would leave raw (uncompensated) counts there, and the OSD
// samples `nuc` on a timer — that is what made the max/min
// readouts jump to ~150 C for a moment during every FFC.
ByteReader.decodeU16(frame, if (hasHdr) 0x1C else 0, npix, refScratch)
refPush(refScratch)
}
return false
}
@@ -628,6 +690,10 @@ class RenderPipeline(
statsWindow()
lutRebuild()
grayMap()
// vendor order: detail enhancement works on the gray image, before upscale
if (enhanceStrength > 0) {
detailEnhance.enhance(nuc, gray160, enhanceStrength, detailGain())
}
upscale2x()
// palette colorize 320x240 -> ARGB
val pal = this.pal
@@ -636,9 +702,73 @@ class RenderPipeline(
outArgb[i] = pal[gray320[i].toInt() and 0xFF]
i++
}
renderedOnce = true
return true
}
/**
* Process one raw frame on behalf of a REMOTE host (LAN preview client).
*
* The host streams its raw sensor frames plus the metadata its own pipeline
* used, so the client reproduces the host's image instead of running an
* independent (and easily desynchronised) FFC state machine:
* - [phase] is the host's FFC phase for this frame (see [ffcPhase]);
* - [shutterIn] is the frame's camera temperature, required for the NUC
* table interpolation — without it the endpoint tables extrapolate
* wildly and every count saturates.
*
* Reference frames are averaged exactly like the host does, so the client's
* noise reference matches the host's.
*/
fun frameRemote(frame: ByteArray, phase: Int, shutterIn: Int, outArgb: IntArray): Boolean =
synchronized(lock) {
shutter = shutterIn
lastPhase = phase
when (phase) {
1 -> return false // shutter closed / settling: hold the last frame
2 -> {
if (!remoteRefWindow) {
remoteRefWindow = true
refCnt = 0
hasRef = false
renderedOnce = false
rebuildTables(shutterIn)
}
ByteReader.decodeU16(frame, 0, npix, refScratch)
refPush(refScratch)
return false
}
else -> {
remoteRefWindow = false
if (!hasRef) return false
ByteReader.decodeU16(frame, 0, npix, nuc)
nucAndBlind(nuc, nuc)
statsWindow()
lutRebuild()
grayMap()
upscale2x()
val pal = this.pal
var i = 0
while (i < npix * 4) {
outArgb[i] = pal[gray320[i].toInt() and 0xFF]
i++
}
renderedOnce = true
return true
}
}
}
/** FFC phase of the frame just processed: 0 normal, 1 hidden, 2 reference. */
fun ffcPhase(): Int = synchronized(lock) { lastPhase }
/** Camera temperature of the frame just processed (raw sensor units). */
fun lastShutter(): Int = synchronized(lock) { shutter }
/** True once a frame completed the full render path (temps are meaningful). */
fun tempsReady(): Boolean = synchronized(lock) { renderedOnce }
fun setShutter(value: Int) {
synchronized(lock) { shutter = value }
}
@@ -33,6 +33,21 @@ object TempMath {
return temp.toInt()
}
/**
* Decode a raw measurement frame (19200 x u16 LE = 38400 B, as stored in
* the MDT BLOCK_FRAME) into a millidegree-C map, one entry per pixel.
*/
fun tempMapFromPixels(pixels: ByteArray, w: Int = 160, h: Int = 120): IntArray {
val n = minOf(w * h, pixels.size / 2)
val out = IntArray(n)
for (i in 0 until n) {
val lo = pixels[i * 2].toInt() and 0xFF
val hi = pixels[i * 2 + 1].toInt() and 0xFF
out[i] = countsToTempMc(lo or (hi shl 8))
}
return out
}
/**
* T2E piecewise-linear evaluation with Q13 band selection
* (vendor ReviseTemperature/CorrectTemperature core, 274-entry curve).
@@ -0,0 +1,425 @@
package com.mag160c.thermal.core
/**
* Vendor display palettes recovered from the official native SDK.
*
* libcxsdk.so contains NO static palette tables. All palettes are built at
* runtime by {@code CFunctions::SetColorPalette}; the tables below are a
* line-by-line port of that routine (Ghidra listing:
* analysis/sdk_re/android_app/libcxsdk_decomp.txt, FUNC 0x00026c70).
*
* Evidence (analysis/sdk_re/android_app/palette_extraction_findings.md):
* - case 2 reproduces [OfficialTables.PALETTE256_ARGB] 256/256 — the table
* captured from the vendor runtime and verified pixel-exact against the
* official renderer. This pins both the arithmetic and the byte order
* (published entry = {byte0=B, byte1=G, byte2=R, byte3=0}).
* - every case writes each of the 256 entries exactly once.
* - each table reproduces the colours of its own preview image shipped in
* the official APK (res/mipmap-hdpi-v4/palette_<name>.png).
*
* GENERATED by analysis/tools/PalExport2.java — do not edit by hand.
*/
object VendorPalettes {
/** UI index -> source case in CFunctions::SetColorPalette (-1 = unresolved). */
val SOURCE_CASE = intArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, -1)
/** 白热 / white_hot — UI index 0, libcxsdk case 0 */
val WHITE_HOT = intArrayOf(
-16777216, -16711423, -16645630, -16579837, -16514044, -16448251, -16382458, -16316665,
-16250872, -16185079, -16119286, -16053493, -15987700, -15921907, -15856114, -15790321,
-15724528, -15658735, -15592942, -15527149, -15461356, -15395563, -15329770, -15263977,
-15198184, -15132391, -15066598, -15000805, -14935012, -14869219, -14803426, -14737633,
-14671840, -14606047, -14540254, -14474461, -14408668, -14342875, -14277082, -14211289,
-14145496, -14079703, -14013910, -13948117, -13882324, -13816531, -13750738, -13684945,
-13619152, -13553359, -13487566, -13421773, -13355980, -13290187, -13224394, -13158601,
-13092808, -13027015, -12961222, -12895429, -12829636, -12763843, -12698050, -12632257,
-12566464, -12500671, -12434878, -12369085, -12303292, -12237499, -12171706, -12105913,
-12040120, -11974327, -11908534, -11842741, -11776948, -11711155, -11645362, -11579569,
-11513776, -11447983, -11382190, -11316397, -11250604, -11184811, -11119018, -11053225,
-10987432, -10921639, -10855846, -10790053, -10724260, -10658467, -10592674, -10526881,
-10461088, -10395295, -10329502, -10263709, -10197916, -10132123, -10066330, -10000537,
-9934744, -9868951, -9803158, -9737365, -9671572, -9605779, -9539986, -9474193,
-9408400, -9342607, -9276814, -9211021, -9145228, -9079435, -9013642, -8947849,
-8882056, -8816263, -8750470, -8684677, -8618884, -8553091, -8487298, -8421505,
-8355712, -8289919, -8224126, -8158333, -8092540, -8026747, -7960954, -7895161,
-7829368, -7763575, -7697782, -7631989, -7566196, -7500403, -7434610, -7368817,
-7303024, -7237231, -7171438, -7105645, -7039852, -6974059, -6908266, -6842473,
-6776680, -6710887, -6645094, -6579301, -6513508, -6447715, -6381922, -6316129,
-6250336, -6184543, -6118750, -6052957, -5987164, -5921371, -5855578, -5789785,
-5723992, -5658199, -5592406, -5526613, -5460820, -5395027, -5329234, -5263441,
-5197648, -5131855, -5066062, -5000269, -4934476, -4868683, -4802890, -4737097,
-4671304, -4605511, -4539718, -4473925, -4408132, -4342339, -4276546, -4210753,
-4144960, -4079167, -4013374, -3947581, -3881788, -3815995, -3750202, -3684409,
-3618616, -3552823, -3487030, -3421237, -3355444, -3289651, -3223858, -3158065,
-3092272, -3026479, -2960686, -2894893, -2829100, -2763307, -2697514, -2631721,
-2565928, -2500135, -2434342, -2368549, -2302756, -2236963, -2171170, -2105377,
-2039584, -1973791, -1907998, -1842205, -1776412, -1710619, -1644826, -1579033,
-1513240, -1447447, -1381654, -1315861, -1250068, -1184275, -1118482, -1052689,
-986896, -921103, -855310, -789517, -723724, -657931, -592138, -526345,
-460552, -394759, -328966, -263173, -197380, -131587, -65794, -1,
)
/** 黑热 / black_hot — UI index 1, libcxsdk case 1 */
val BLACK_HOT = intArrayOf(
-1, -65794, -131587, -197380, -263173, -328966, -394759, -460552,
-526345, -592138, -657931, -723724, -789517, -855310, -921103, -986896,
-1052689, -1118482, -1184275, -1250068, -1315861, -1381654, -1447447, -1513240,
-1579033, -1644826, -1710619, -1776412, -1842205, -1907998, -1973791, -2039584,
-2105377, -2171170, -2236963, -2302756, -2368549, -2434342, -2500135, -2565928,
-2631721, -2697514, -2763307, -2829100, -2894893, -2960686, -3026479, -3092272,
-3158065, -3223858, -3289651, -3355444, -3421237, -3487030, -3552823, -3618616,
-3684409, -3750202, -3815995, -3881788, -3947581, -4013374, -4079167, -4144960,
-4210753, -4276546, -4342339, -4408132, -4473925, -4539718, -4605511, -4671304,
-4737097, -4802890, -4868683, -4934476, -5000269, -5066062, -5131855, -5197648,
-5263441, -5329234, -5395027, -5460820, -5526613, -5592406, -5658199, -5723992,
-5789785, -5855578, -5921371, -5987164, -6052957, -6118750, -6184543, -6250336,
-6316129, -6381922, -6447715, -6513508, -6579301, -6645094, -6710887, -6776680,
-6842473, -6908266, -6974059, -7039852, -7105645, -7171438, -7237231, -7303024,
-7368817, -7434610, -7500403, -7566196, -7631989, -7697782, -7763575, -7829368,
-7895161, -7960954, -8026747, -8092540, -8158333, -8224126, -8289919, -8355712,
-8421505, -8487298, -8553091, -8618884, -8684677, -8750470, -8816263, -8882056,
-8947849, -9013642, -9079435, -9145228, -9211021, -9276814, -9342607, -9408400,
-9474193, -9539986, -9605779, -9671572, -9737365, -9803158, -9868951, -9934744,
-10000537, -10066330, -10132123, -10197916, -10263709, -10329502, -10395295, -10461088,
-10526881, -10592674, -10658467, -10724260, -10790053, -10855846, -10921639, -10987432,
-11053225, -11119018, -11184811, -11250604, -11316397, -11382190, -11447983, -11513776,
-11579569, -11645362, -11711155, -11776948, -11842741, -11908534, -11974327, -12040120,
-12105913, -12171706, -12237499, -12303292, -12369085, -12434878, -12500671, -12566464,
-12632257, -12698050, -12763843, -12829636, -12895429, -12961222, -13027015, -13092808,
-13158601, -13224394, -13290187, -13355980, -13421773, -13487566, -13553359, -13619152,
-13684945, -13750738, -13816531, -13882324, -13948117, -14013910, -14079703, -14145496,
-14211289, -14277082, -14342875, -14408668, -14474461, -14540254, -14606047, -14671840,
-14737633, -14803426, -14869219, -14935012, -15000805, -15066598, -15132391, -15198184,
-15263977, -15329770, -15395563, -15461356, -15527149, -15592942, -15658735, -15724528,
-15790321, -15856114, -15921907, -15987700, -16053493, -16119286, -16185079, -16250872,
-16316665, -16382458, -16448251, -16514044, -16579837, -16645630, -16711423, -16777216,
)
/** 铁虹 / iron_bow — UI index 2, libcxsdk case 2 */
val IRON_BOW = intArrayOf(
-16777216, -16777211, -16777206, -16777201, -16777195, -16777190, -16777185, -16777179,
-16777174, -16777169, -16777163, -16777158, -16777153, -16777147, -16777142, -16777137,
-16777131, -16777126, -16777121, -16777115, -16777110, -16777105, -16777099, -16646027,
-16449418, -16318346, -16121737, -15925129, -15794056, -15597448, -15400839, -15269767,
-15073158, -14876550, -14745477, -14548869, -14352260, -14221188, -14024579, -13827971,
-13696898, -13500290, -13369217, -13172609, -12976000, -12844928, -12648319, -12451710,
-12320638, -12124029, -11927421, -11796348, -11599740, -11403131, -11272059, -11075450,
-10878842, -10747769, -10551161, -10420088, -10223480, -10026871, -9895799, -9699190,
-9502582, -9371509, -9174901, -8978292, -8847220, -8650611, -8454002, -8322930,
-8126321, -7929713, -7798640, -7602032, -7470959, -7274351, -7077742, -6946670,
-6750061, -6553453, -6422380, -6225772, -6029163, -5898091, -5701482, -5504874,
-5373801, -5177193, -4980584, -4849512, -4652903, -4456294, -4390504, -4324458,
-4192876, -4127086, -4061040, -3929458, -3863412, -3797622, -3666040, -3599994,
-3534204, -3402622, -3336576, -3270530, -3139204, -3073158, -3007112, -2875530,
-2809740, -2743694, -2612112, -2546322, -2480276, -2348694, -2282648, -2216858,
-2085276, -2019230, -1887905, -1887397, -1821353, -1755310, -1689522, -1623479,
-1557435, -1491392, -1425604, -1359561, -1293517, -1227729, -1161686, -1095642,
-1029599, -963811, -963304, -897260, -831473, -765425, -699377, -633329,
-567537, -501489, -435441, -369393, -303601, -237553, -171505, -105713,
-39665, -39153, -38641, -38385, -37873, -37361, -37105, -36593,
-36081, -35569, -35313, -34801, -34289, -33777, -33521, -33009,
-32497, -32241, -31729, -31217, -30705, -30449, -29937, -29425,
-28913, -28657, -28145, -27633, -27377, -26865, -26353, -25841,
-25585, -25073, -24561, -24305, -23793, -23281, -22769, -22513,
-22001, -21489, -20977, -20721, -20209, -19697, -19441, -18929,
-18417, -17905, -17649, -17137, -16625, -16369, -15857, -15345,
-14833, -14577, -14065, -13553, -13041, -12785, -12273, -11761,
-11505, -10993, -10481, -9969, -9713, -9201, -8689, -8433,
-7914, -7394, -6875, -6611, -6092, -5572, -5053, -4789,
-4270, -3750, -3487, -2967, -2448, -1928, -1665, -1145,
-626, -106, -99, -91, -84, -76, -69, -61,
-54, -46, -39, -31, -24, -16, -9, -1,
)
/** 彩虹 / rain_bow — UI index 3, libcxsdk case 3 */
val RAIN_BOW = intArrayOf(
-16777166, -16776907, -16776392, -16775877, -16775362, -16774846, -16774331, -16773816,
-16773301, -16772786, -16772270, -16771755, -16771240, -16770725, -16770210, -16769694,
-16769179, -16768664, -16768149, -16767634, -16767118, -16766603, -16766088, -16765573,
-16765058, -16764542, -16764027, -16763512, -16762997, -16762482, -16761966, -16761451,
-16760936, -16760421, -16759906, -16759390, -16758875, -16758360, -16757845, -16757330,
-16756814, -16756299, -16755784, -16755269, -16754754, -16754238, -16753723, -16753208,
-16752693, -16752178, -16751662, -16751147, -16750632, -16750117, -16749602, -16749086,
-16748571, -16748056, -16747541, -16747026, -16746510, -16745995, -16745480, -16744965,
-16744449, -16547332, -16284680, -16022028, -15759376, -15496724, -15234072, -14971420,
-14708768, -14446116, -14183464, -13920812, -13658160, -13395508, -13132856, -12870204,
-12607552, -12344900, -12082248, -11819596, -11556944, -11294292, -11031640, -10768988,
-10506336, -10243684, -9981032, -9718380, -9455728, -9193076, -8930424, -8667772,
-8405120, -8142468, -7879816, -7617164, -7354512, -7091860, -6829208, -6566556,
-6303904, -6041252, -5778600, -5515948, -5253296, -4990644, -4727992, -4465340,
-4202688, -3940036, -3677384, -3414732, -3152080, -2889428, -2626776, -2364124,
-2101472, -1838820, -1576168, -1313516, -1050864, -788212, -525560, -262908,
-256, -1023, -2045, -3067, -4089, -5111, -6133, -7155,
-8177, -9199, -10221, -11243, -12265, -13287, -14309, -15331,
-16353, -17375, -18397, -19419, -20441, -21463, -22485, -23507,
-24529, -25551, -26573, -27595, -28617, -29639, -30661, -31683,
-32705, -33727, -34749, -35771, -36793, -37815, -38837, -39859,
-40881, -41903, -42925, -43947, -44969, -45991, -47013, -48035,
-49057, -50079, -51101, -52123, -53145, -54167, -55189, -56211,
-57233, -58255, -59277, -60299, -61321, -62343, -63365, -64387,
-65409, -64639, -63613, -62587, -61561, -60535, -59509, -58483,
-57457, -56431, -55405, -54379, -53353, -52327, -51301, -50275,
-49249, -48223, -47197, -46171, -45145, -44119, -43093, -42067,
-41041, -40015, -38989, -37963, -36937, -35911, -34885, -33859,
-32833, -31807, -30781, -29755, -28729, -27703, -26677, -25651,
-24625, -23599, -22573, -21547, -20521, -19495, -18469, -17443,
-16417, -15391, -14365, -13339, -12313, -11287, -10261, -9235,
-8209, -7183, -6157, -5131, -4105, -3079, -2053, -1027,
)
/** 琥珀 / glow_bow — UI index 4, libcxsdk case 4 */
val GLOW_BOW = intArrayOf(
-13500416, -13500160, -13499904, -13499648, -13499392, -13499136, -13498880, -13498624,
-13498368, -13498112, -13497856, -13497600, -13497344, -13497088, -13496832, -13496576,
-13496320, -13496064, -13495808, -13495552, -13495296, -13495040, -13494784, -13494528,
-13494272, -13494016, -13493760, -13493504, -13493248, -13492992, -13492736, -13492480,
-13492224, -13491968, -13491712, -13491456, -13491200, -13490944, -13490688, -13490432,
-13490176, -13489920, -13489664, -13489408, -13489152, -13488896, -13488640, -13488384,
-13488128, -13487872, -13487616, -13421824, -13356032, -13224704, -13158912, -13093120,
-12961792, -12896000, -12830208, -12698880, -12633088, -12501760, -12435968, -12370176,
-12238848, -12173056, -12107264, -11975936, -11910144, -11844352, -11713024, -11647232,
-11515904, -11450112, -11384320, -11252992, -11187200, -11121408, -10990080, -10924288,
-10792960, -10727168, -10661376, -10530048, -10464256, -10398464, -10267136, -10201344,
-10135552, -10004224, -9938432, -9807104, -9741312, -9675520, -9544192, -9478400,
-9412608, -9281280, -9215488, -9149696, -9018368, -8952576, -8821248, -8755456,
-8689664, -8558336, -8492544, -8426752, -8295424, -8229632, -8098304, -8032512,
-7966720, -7835392, -7769600, -7703808, -7572480, -7506688, -7440896, -7309568,
-7243776, -7112448, -7046656, -6980864, -6849536, -6783744, -6717952, -6586624,
-6520832, -6455040, -6323712, -6257920, -6126592, -6060800, -5995008, -5863680,
-5797888, -5732096, -5600768, -5534976, -5403648, -5337856, -5272064, -5140736,
-5074944, -5009152, -4877824, -4812032, -4746240, -4614912, -4549120, -4417792,
-4352000, -4286208, -4154880, -4089088, -4023296, -3891968, -3826176, -3760384,
-3629056, -3563264, -3431936, -3366144, -3300352, -3169024, -3103232, -3037440,
-2906112, -2840320, -2708992, -2643200, -2577408, -2446080, -2380288, -2314496,
-2183168, -2117376, -2051584, -1920256, -1854464, -1723136, -1657344, -1591552,
-1460224, -1394432, -1328640, -1197312, -1131520, -1065728, -934400, -868608,
-737280, -671488, -605696, -474368, -408576, -342784, -211456, -145664,
-14336, -14080, -13824, -13568, -13312, -13056, -12800, -12544,
-12288, -12032, -11776, -11520, -11264, -11008, -10752, -10496,
-10240, -9984, -9728, -9472, -9216, -8960, -8704, -8448,
-8192, -7936, -7680, -7424, -7168, -6912, -6656, -6400,
-6144, -5888, -5632, -5376, -5120, -4864, -4608, -4352,
-4096, -3840, -3584, -3328, -3072, -2816, -2560, -2304,
-2048, -1792, -1536, -1280, -1024, -768, -512, -256,
)
/** 金秋 / autumn — UI index 5, libcxsdk case 5 */
val AUTUMN = intArrayOf(
-16777216, -16580608, -16383999, -16187135, -15990526, -15793918, -15531517, -15334909,
-15138300, -14941435, -14744827, -14548218, -14285818, -14089209, -13892601, -13695736,
-13499127, -13302519, -13040118, -12843510, -12646901, -12450037, -12253428, -12056820,
-11794419, -11597810, -11401202, -11204337, -11007729, -10811120, -10548720, -10352111,
-10155246, -9958638, -9762029, -9499629, -9303020, -9106412, -8909547, -8712939,
-8516330, -8253929, -8057321, -7860712, -7663848, -7467239, -7270631, -7008230,
-6811621, -6615013, -6418148, -6221540, -6024931, -5762531, -5565922, -5369314,
-5172449, -4975840, -4779232, -4516831, -4320223, -4123614, -3926750, -3730141,
-3467740, -3467484, -3401693, -3335901, -3270110, -3204318, -3204063, -3138271,
-3072480, -3006689, -2940897, -2940642, -2874850, -2809059, -2743267, -2677476,
-2611429, -2611173, -2545382, -2479590, -2413799, -2348007, -2347752, -2281960,
-2216169, -2150378, -2084586, -2084331, -2018539, -1952748, -1886956, -1821165,
-1755118, -1754862, -1689071, -1623279, -1557488, -1491696, -1491441, -1425649,
-1359858, -1294067, -1228275, -1228020, -1162228, -1096437, -1030645, -964854,
-898807, -898551, -832760, -766968, -701177, -635385, -635130, -569338,
-503547, -437756, -371964, -371709, -305917, -240126, -174334, -108543,
-42496, -42240, -41728, -41216, -40704, -40192, -39680, -39168,
-38656, -38144, -37632, -37120, -36608, -36096, -35584, -35072,
-34560, -34304, -33792, -33280, -32768, -32256, -31744, -31232,
-30720, -30208, -29696, -29184, -28672, -28160, -27648, -27136,
-26624, -26368, -25856, -25344, -24832, -24320, -23808, -23296,
-22784, -22272, -21760, -21248, -20736, -20224, -19712, -19200,
-18688, -18432, -17920, -17408, -16896, -16384, -15872, -15360,
-14848, -14336, -13824, -13312, -12800, -12288, -11776, -11264,
-10752, -10749, -10489, -10485, -10225, -9965, -9961, -9701,
-9441, -9437, -9177, -8917, -8913, -8653, -8649, -8389,
-8129, -8125, -7865, -7605, -7601, -7341, -7081, -7077,
-6817, -6557, -6553, -6293, -6289, -6029, -5769, -5765,
-5505, -5245, -5241, -4981, -4721, -4717, -4457, -4453,
-4193, -3933, -3929, -3669, -3409, -3405, -3145, -2885,
-2881, -2621, -2361, -2357, -2097, -2093, -1833, -1573,
-1569, -1309, -1049, -1045, -785, -525, -521, -261,
)
/** 寒冬 / winter — UI index 6, libcxsdk case 6 */
val WINTER = intArrayOf(
-1, -131330, -328195, -459524, -656389, -787718, -984583, -1115912,
-1312777, -1444106, -1640971, -1837836, -1969165, -2166030, -2297359, -2494224,
-2625553, -2822418, -2953747, -3150612, -3347477, -3478806, -3675671, -3807000,
-4003865, -4135194, -4332059, -4463388, -4660253, -4791582, -4988447, -5185312,
-5316641, -5513506, -5644835, -5841700, -5973029, -6169894, -6301223, -6498088,
-6694953, -6826282, -7023147, -7154476, -7351341, -7482670, -7679535, -7810864,
-8007729, -8139058, -8335923, -8532788, -8664117, -8860982, -8992311, -9189176,
-9320505, -9517370, -9648699, -9845564, -10042429, -10173758, -10370623, -10501952,
-10698817, -10830146, -11027011, -11158340, -11355205, -11486534, -11683399, -11880264,
-12011593, -12208458, -12339787, -12536652, -12667981, -12864846, -12996175, -13193040,
-13389905, -13521234, -13718099, -13849428, -14046293, -14177622, -14374487, -14505816,
-14702681, -14834010, -15030875, -15227740, -15359069, -15555934, -15687263, -15884128,
-16015457, -16212322, -16343651, -16540516, -16737381, -16606566, -16475751, -16279400,
-16148585, -15952234, -15821419, -15625068, -15494253, -15363438, -15167087, -15036272,
-14839921, -14709106, -14512755, -14381940, -14251125, -14054774, -13923959, -13727608,
-13596793, -13400442, -13269627, -13138812, -12942461, -12811646, -12615295, -12484480,
-12288129, -12157314, -12026499, -11830148, -11699333, -11502982, -11372167, -11175816,
-11045001, -10914186, -10717835, -10587020, -10390669, -10259854, -10063503, -9932688,
-9801873, -9605522, -9474707, -9278356, -9147541, -8951190, -8820375, -8689560,
-8493209, -8362394, -8166043, -8035228, -7838877, -7708062, -7577247, -7380896,
-7250081, -7053730, -6922915, -6726564, -6595749, -6464934, -6268583, -6137768,
-5941417, -5810602, -5614251, -5483436, -5352621, -5156270, -5025455, -4829104,
-4698289, -4501938, -4371123, -4240308, -4043957, -3913142, -3716791, -3585976,
-3389625, -3258810, -3127995, -2931644, -2800829, -2604478, -2473663, -2277312,
-2146497, -2015682, -1819331, -1688516, -1492165, -1361350, -1164999, -1034184,
-903369, -707018, -576203, -379852, -249037, -52686, -52943, -53200,
-53457, -53714, -53971, -54228, -54485, -54742, -54999, -55256,
-55513, -55770, -56027, -56284, -56541, -56798, -57055, -57312,
-57569, -57826, -58083, -58340, -58597, -58854, -59111, -59368,
-59625, -59882, -60139, -60396, -60653, -60910, -61167, -61424,
-61681, -61938, -62195, -62452, -62709, -62966, -63223, -63480,
-63737, -63994, -64251, -64508, -64765, -65022, -65279, -65536,
)
/** 热金属 / hot_metal — UI index 7, libcxsdk case 7 */
val HOT_METAL = intArrayOf(
-16777216, -16646144, -16515072, -16384000, -16252928, -16121856, -15990784, -15859712,
-15728640, -15597568, -15466496, -15335424, -15204352, -15073280, -14942208, -14811136,
-14680064, -14548992, -14417920, -14286848, -14155776, -14024704, -13893632, -13762560,
-13631488, -13500416, -13369344, -13238272, -13107200, -12976128, -12845056, -12713984,
-12582912, -12451840, -12320768, -12189696, -12058624, -11927552, -11796480, -11665408,
-11534336, -11403264, -11272192, -11141120, -11010048, -10878976, -10747904, -10616832,
-10485760, -10354688, -10223616, -10092544, -9961472, -9830400, -9699328, -9568256,
-9437184, -9306112, -9175040, -9043968, -8912896, -8781824, -8650752, -8519680,
-8388608, -8257536, -8126464, -7995392, -7864320, -7733248, -7602176, -7471104,
-7340032, -7208960, -7077888, -6946816, -6815744, -6684672, -6553600, -6422528,
-6291456, -6160384, -6029312, -5898240, -5767168, -5636096, -5505024, -5373952,
-5242880, -5111808, -4980736, -4849664, -4718592, -4587520, -4456448, -4325376,
-4194304, -4063232, -3932160, -3801088, -3670016, -3538944, -3407872, -3276800,
-3145728, -3014656, -2883584, -2752512, -2621440, -2490368, -2359296, -2228224,
-2097152, -1966080, -1835008, -1703936, -1572864, -1441792, -1310720, -1179648,
-1048576, -917504, -786432, -655360, -524288, -393216, -262144, -131072,
-65536, -65022, -64508, -63994, -63480, -62966, -62452, -61938,
-61424, -60910, -60396, -59882, -59368, -58854, -58340, -57826,
-57312, -56798, -56284, -55770, -55256, -54742, -54228, -53714,
-53200, -52686, -52172, -51658, -51144, -50630, -50116, -49602,
-49088, -48574, -48060, -47546, -47032, -46518, -46004, -45490,
-44976, -44462, -43948, -43434, -42920, -42406, -41892, -41378,
-40864, -40350, -39836, -39322, -38808, -38294, -37780, -37266,
-36752, -36238, -35724, -35210, -34696, -34182, -33668, -33154,
-32640, -32126, -31612, -31098, -30584, -30070, -29556, -29042,
-28528, -28014, -27500, -26986, -26472, -25958, -25444, -24930,
-24416, -23902, -23388, -22874, -22360, -21846, -21332, -20818,
-20304, -19790, -19276, -18762, -18248, -17734, -17220, -16706,
-16192, -15678, -15164, -14650, -14136, -13622, -13108, -12594,
-12080, -11566, -11052, -10538, -10024, -9510, -8996, -8482,
-7968, -7454, -6940, -6426, -5912, -5398, -4884, -4370,
-3856, -3342, -2828, -2314, -1800, -1286, -772, -1,
)
/** 喷射 / jet — UI index 8, libcxsdk case 8 */
val JET = intArrayOf(
-16777089, -16777085, -16777081, -16777077, -16777073, -16777069, -16777065, -16777061,
-16777057, -16777053, -16777049, -16777045, -16777041, -16777037, -16777033, -16777029,
-16777025, -16777021, -16777017, -16777013, -16777009, -16777005, -16777001, -16776997,
-16776993, -16776989, -16776985, -16776981, -16776977, -16776973, -16776969, -16776965,
-16776961, -16776193, -16775169, -16774145, -16773121, -16772097, -16771073, -16770049,
-16769025, -16768001, -16766977, -16765953, -16764929, -16763905, -16762881, -16761857,
-16760833, -16759809, -16758785, -16757761, -16756737, -16755713, -16754689, -16753665,
-16752641, -16751617, -16750593, -16749569, -16748545, -16747521, -16746497, -16745473,
-16744449, -16743425, -16742401, -16741377, -16740353, -16739329, -16738305, -16737281,
-16736257, -16735233, -16734209, -16733185, -16732161, -16731137, -16730113, -16729089,
-16728065, -16727041, -16726017, -16724993, -16723969, -16722945, -16721921, -16720897,
-16719873, -16718849, -16717825, -16716801, -16715777, -16714753, -16713729, -16712705,
-16711681, -16515076, -16252936, -15990796, -15728656, -15466516, -15204376, -14942236,
-14680096, -14417956, -14155816, -13893676, -13631536, -13369396, -13107256, -12845116,
-12582976, -12320836, -12058696, -11796556, -11534416, -11272276, -11010136, -10747996,
-10485856, -10223716, -9961576, -9699436, -9437296, -9175156, -8913016, -8650876,
-8388736, -8126596, -7864456, -7602316, -7340176, -7078036, -6815896, -6553756,
-6291616, -6029476, -5767336, -5505196, -5243056, -4980916, -4718776, -4456636,
-4194496, -3932356, -3670216, -3408076, -3145936, -2883796, -2621656, -2359516,
-2097376, -1835236, -1573096, -1310956, -1048816, -786676, -524536, -262396,
-256, -1024, -2048, -3072, -4096, -5120, -6144, -7168,
-8192, -9216, -10240, -11264, -12288, -13312, -14336, -15360,
-16384, -17408, -18432, -19456, -20480, -21504, -22528, -23552,
-24576, -25600, -26624, -27648, -28672, -29696, -30720, -31744,
-32768, -33792, -34816, -35840, -36864, -37888, -38912, -39936,
-40960, -41984, -43008, -44032, -45056, -46080, -47104, -48128,
-49152, -50176, -51200, -52224, -53248, -54272, -55296, -56320,
-57344, -58368, -59392, -60416, -61440, -62464, -63488, -64512,
-65536, -327680, -589824, -851968, -1114112, -1376256, -1638400, -1900544,
-2162688, -2424832, -2686976, -2949120, -3211264, -3473408, -3735552, -3997696,
-4259840, -4521984, -4784128, -5046272, -5308416, -5570560, -5832704, -6094848,
-6356992, -6619136, -6881280, -7143424, -7405568, -7667712, -7929856, -8192000,
)
/** 红饱和 / red_saturation — UI index 9, libcxsdk case 9 */
val RED_SATURATION = intArrayOf(
-16777216, -16711423, -16645630, -16579837, -16448251, -16382458, -16316665, -16250872,
-16119286, -16053493, -15987700, -15856114, -15790321, -15724528, -15658735, -15527149,
-15461356, -15395563, -15329770, -15198184, -15132391, -15066598, -14935012, -14869219,
-14803426, -14737633, -14606047, -14540254, -14474461, -14408668, -14277082, -14211289,
-14145496, -14013910, -13948117, -13882324, -13816531, -13684945, -13619152, -13553359,
-13421773, -13355980, -13290187, -13224394, -13092808, -13027015, -12961222, -12895429,
-12763843, -12698050, -12632257, -12500671, -12434878, -12369085, -12303292, -12171706,
-12105913, -12040120, -11974327, -11842741, -11776948, -11711155, -11579569, -11513776,
-11447983, -11382190, -11250604, -11184811, -11119018, -11053225, -10921639, -10855846,
-10790053, -10658467, -10592674, -10526881, -10461088, -10329502, -10263709, -10197916,
-10066330, -10000537, -9934744, -9868951, -9737365, -9671572, -9605779, -9539986,
-9408400, -9342607, -9276814, -9145228, -9079435, -9013642, -8947849, -8816263,
-8750470, -8684677, -8618884, -8487298, -8421505, -8355712, -8224126, -8158333,
-8092540, -8026747, -7895161, -7829368, -7763575, -7697782, -7566196, -7500403,
-7434610, -7303024, -7237231, -7171438, -7105645, -6974059, -6908266, -6842473,
-6710887, -6645094, -6579301, -6513508, -6381922, -6316129, -6250336, -6184543,
-6052957, -5987164, -5921371, -5789785, -5723992, -5658199, -5592406, -5460820,
-5395027, -5329234, -5263441, -5131855, -5066062, -5000269, -4868683, -4802890,
-4737097, -4671304, -4539718, -4473925, -4408132, -4342339, -4210753, -4144960,
-4079167, -3947581, -3882045, -3816509, -3750973, -3619901, -3554365, -3488829,
-3357757, -3292221, -3226685, -3161149, -3030077, -2964541, -2899005, -2833469,
-2702397, -2636861, -2571325, -2440253, -2374717, -2309181, -2243645, -2112573,
-2047037, -1981501, -1915965, -1784893, -1719357, -1653821, -1522749, -1457213,
-1391677, -1326141, -1195069, -1129533, -1063997, -998461, -867389, -801853,
-736317, -605245, -539709, -474173, -408637, -277565, -212029, -146493,
-15421, -16192, -17220, -17991, -19019, -19790, -20818, -21589,
-22617, -23388, -24416, -25444, -26215, -27243, -28014, -29042,
-29813, -30841, -31612, -32640, -33411, -34439, -35467, -36238,
-37266, -38037, -39065, -39836, -40864, -41635, -42663, -43434,
-44462, -45490, -46261, -47289, -48060, -49088, -49859, -50887,
-51658, -52686, -53457, -54485, -55513, -56284, -57312, -58083,
-59111, -59882, -60910, -61681, -62709, -63480, -64508, -65536,
)
/** 高对比度 / high_contrast — UI index 10, libcxsdk case 10 */
val HIGH_CONTRAST = intArrayOf(
-16777216, -16318457, -15859698, -15400939, -14942180, -14483421, -14024662, -13565903,
-13107144, -12648385, -12189626, -11730867, -11272108, -10813349, -10354590, -9895831,
-9437072, -8978313, -8519554, -8060795, -7602036, -7143277, -6684518, -6225759,
-5767000, -5308241, -4849482, -4390723, -3931964, -3473205, -3014446, -2555687,
-2096928, -1638169, -1179410, -720651, -261892, -720645, -1179399, -1638153,
-2096907, -2555660, -3014414, -3473168, -3931922, -4390675, -4849429, -5308183,
-5766937, -6225690, -6684444, -7143198, -7601952, -8060705, -8519459, -8978213,
-9436967, -9895720, -10354474, -10813228, -11271982, -11730735, -12189489, -12648243,
-13106997, -13565750, -14024504, -14483258, -14942012, -15400765, -15859519, -16318273,
-16777027, -16775233, -16773439, -16771645, -16769852, -16768058, -16766264, -16764470,
-16762677, -16760883, -16759089, -16757295, -16755502, -16753708, -16751914, -16750120,
-16748327, -16746533, -16744739, -16742945, -16741152, -16739358, -16737564, -16735770,
-16733977, -16732183, -16730389, -16728595, -16726802, -16725008, -16723214, -16721420,
-16719627, -16717833, -16716039, -16714245, -16712452, -16712452, -16713227, -16714258,
-16715033, -16716064, -16716839, -16717870, -16718645, -16719676, -16720451, -16721482,
-16722257, -16723288, -16724063, -16725094, -16725869, -16726900, -16727675, -16728706,
-16729481, -16730512, -16731287, -16732318, -16733093, -16734124, -16734899, -16735930,
-16736705, -16737736, -16738511, -16739542, -16740317, -16741348, -16742123, -16743154,
-16743929, -16744960, -16285184, -15825664, -15365888, -14906368, -14446592, -13987072,
-13527296, -13067776, -12608000, -12148480, -11688704, -11229184, -10769408, -10309888,
-9850112, -9390592, -8930816, -8471296, -8011520, -7552000, -7092224, -6632704,
-6172928, -5713408, -5253632, -4794112, -4334336, -3874816, -3415040, -2955520,
-2495744, -2036224, -1576448, -1116928, -657152, -197632, -197632, -264960,
-397824, -530688, -663552, -730880, -863744, -996608, -1129472, -1196800,
-1329664, -1462528, -1595392, -1662720, -1795584, -1928448, -2061312, -2128640,
-2261504, -2394368, -2527232, -2594560, -2727424, -2860288, -2993152, -3060480,
-3193344, -3326208, -3459072, -3526400, -3659264, -3792128, -3924992, -3992320,
-4125184, -4258048, -4390912, -4390912, -4258041, -4125170, -3992299, -3924964,
-3792093, -3659222, -3526351, -3459016, -3326145, -3193274, -3060403, -2993068,
-2860197, -2727326, -2594455, -2527120, -2394249, -2261378, -2128507, -2061172,
-1928301, -1795430, -1662559, -1595224, -1462353, -1329482, -1196611, -1129276,
-996405, -863534, -730663, -663328, -530457, -397586, -264715, -197380,
)
/** 红热 / red_hot — UI index 11, NO generator body resolved (preview asset is a stub) */
val RED_HOT: IntArray? = null
}
@@ -16,8 +16,20 @@ import java.util.Locale
/**
* Field-debug logger: mirrors every line to logcat AND appends it to a
* timestamped text file next to the photos (DCIM/MAG160C), so the user can
* hand over the file without adb (real-device bring-up, round 11).
* timestamped text file next to the photos (DCIM/MAG160C), so a problem on the
* user's phone can be diagnosed without adb.
*
* ## It is OFF by default (user decision, 2026-09-12)
*
* A shipping build has no business writing a log file beside the user's photos or
* filling logcat at 15 frames per second, so [enabled] defaults to false and every
* entry point returns immediately. To investigate something on a device, flip
* [enabled] to true and rebuild — that is the whole procedure; the file then
* appears exactly as it used to.
*
* With logging off, crashes are still reported the normal Android way (logcat
* `AndroidRuntime` plus the system's "app keeps stopping" dialog); only the
* convenient on-device copy is gone.
*
* Logging must NEVER crash the app: every sink operation is guarded, and
* writes are capped. Sink fallback chain (scoped storage only allows media
@@ -28,6 +40,13 @@ import java.util.Locale
* The header line records where the file actually ended up.
*/
object DebugLog {
/**
* Master switch. OFF in shipping builds: writes nothing, to no sink.
* Set to true (and rebuild) to capture a detailed trace on a device.
*/
@Volatile
var enabled: Boolean = false
private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.ENGLISH)
private val fileFmt = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH)
private val lock = Any()
@@ -38,6 +57,7 @@ object DebugLog {
/** Install once from MainActivity.onCreate: crash hook + app context. */
fun init(context: Context) {
if (!enabled) return
synchronized(lock) {
if (previousHandler != null) return
previousHandler = Thread.getDefaultUncaughtExceptionHandler()
@@ -51,6 +71,7 @@ object DebugLog {
/** Log one line (also to logcat as MAG160C/<tag>). Never throws. */
fun log(tag: String, msg: String) {
if (!enabled) return
try {
Log.d("MAG160C/$tag", msg)
} catch (_: Exception) {
@@ -80,9 +101,10 @@ object DebugLog {
/**
* Open a fresh debug_*.log. Called once at app start (so even startup
* crashes are captured); never throws. Returns the display path for
* logging. Closes any previous file.
* logging. Closes any previous file. Does nothing when [enabled] is false.
*/
fun startFile(context: Context): String {
if (!enabled) return "(debug log disabled)"
val name = "debug_${fileFmt.format(Date())}.log"
closeFile()
synchronized(lock) {
@@ -0,0 +1,147 @@
package com.mag160c.thermal.media
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface
import com.mag160c.thermal.core.AnnotSpec
/**
* Draws temperature markers onto a canvas with the SAME geometry every surface
* uses (live screen, analysis screen, saved photos, recorded video), at whatever
* resolution the caller is rendering.
*
* The look is the instrument convention: a thin square reticle with four arms and
* an outlined readout beside it, drawn straight onto the image with nothing
* filled. See [AnnotSpec] for why.
*
* Text crispness: the saved photo used to be written at the sensor's 320x240 and
* then displayed scaled up on a phone screen, which is why the labels looked
* blurry. Rendering at a higher factor ([AnnotSpec.TEXT_SIZE] multiplied by that
* factor) keeps the characters sharp at the size the user actually views.
*/
object MarkerPainter {
/** A probe to draw, in the coordinate space of the target bitmap. */
data class Mark(
val x: Float,
val y: Float,
val label: String,
val tempC: Float,
val tint: Int? = null,
)
/**
* @param canvas target
* @param marks marks in the SAME pixel space as [imgW]/[imgH]
* @param imgW/imgH target image size
* @param imageUnitsToPixels conversion from AnnotSpec units (based on a 320-wide
* reference) to target pixels; pass the render scale for a photo or video
* rendered at N x the sensor size, or the on-screen scale for a display.
* @param textRotationDeg rotate each label about its marker by this angle. The
* live screen passes the negative grip angle so labels stay upright while the
* image is drawn rotated; photos and analysis pass 0, which keeps the text
* horizontal in the saved frame. The reticle itself is never rotated.
*/
fun draw(
canvas: Canvas,
marks: List<Mark>,
imgW: Float,
imgH: Float,
imageUnitsToPixels: Float,
textRotationDeg: Float = 0f,
) {
if (marks.isEmpty()) return
val k = imageUnitsToPixels
val typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
// Thin white line work, no fills: the reticle, the arms and the leader.
val line = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = AnnotSpec.SPOT_W * k
strokeCap = Paint.Cap.BUTT
color = AnnotSpec.MARK_WHITE
}
// The readout is white text inside a dark outline. That is what keeps it
// readable over the black cold end AND the white hot end of every palette —
// the reason there is no filled plate behind it.
val textFill = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = AnnotSpec.MARK_WHITE
textSize = AnnotSpec.TEXT_SIZE * k
this.typeface = typeface
}
val textOutline = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = AnnotSpec.TEXT_OUTLINE * k
strokeJoin = Paint.Join.ROUND
color = AnnotSpec.MARK_OUTLINE
textSize = AnnotSpec.TEXT_SIZE * k
this.typeface = typeface
}
val padH = AnnotSpec.LABEL_PAD_H * k
val padV = AnnotSpec.LABEL_PAD_V * k
// Label rects committed so far: passed to placeLabel so a second label never
// lands on the first one. Extremes (max/min) are usually near each other in
// the scene, so without this their readouts merged into an unreadable overlap.
val placed = ArrayList<FloatArray>(marks.size)
for (m in marks) {
val color = m.tint ?: AnnotSpec.MARK_WHITE
line.color = color
val r = AnnotSpec.SPOT_R * k
val arm = AnnotSpec.SPOT_ARM * k
// square reticle, then one arm out of each side: the arms pin down which
// pixel is measured, the open centre keeps that pixel visible
canvas.drawRect(m.x - r, m.y - r, m.x + r, m.y + r, line)
canvas.drawLine(m.x - r - arm, m.y, m.x - r, m.y, line)
canvas.drawLine(m.x + r, m.y, m.x + r + arm, m.y, line)
canvas.drawLine(m.x, m.y - r - arm, m.x, m.y - r, line)
canvas.drawLine(m.x, m.y + r, m.x, m.y + r + arm, line)
val full = (if (m.label.isNotEmpty()) "${m.label} " else "") +
"%.1f℃".format(m.tempC)
val tw = textFill.measureText(full)
val fm = textFill.fontMetrics
val boxW = tw + padH * 2
val boxH = (fm.descent - fm.ascent) + padV * 2
// same placement rule as everywhere else (right of the reticle, flipped
// when it would overflow, pushed down when it would collide)
val pos = com.mag160c.thermal.core.AnnotSpec.placeLabel(
cx = m.x, cy = m.y, boxW = boxW, boxH = boxH,
imgW = imgW, imgH = imgH, scale = k, placed = placed,
)
placed.add(floatArrayOf(pos[0], pos[1], boxW, boxH))
canvas.save()
if (textRotationDeg != 0f) {
// rotate the LABEL about its marker, keeping it attached: the live
// view draws the image rotated, so unrotated text would run down
// the screen. The reticle glyph itself is never rotated.
canvas.rotate(textRotationDeg, m.x, m.y)
}
// leader: ties the readout to its own reticle when several are on screen
val labelY = pos[1] + boxH / 2f
if (pos[0] > m.x) {
canvas.drawLine(m.x + r + arm, m.y, pos[0], labelY, line)
} else {
canvas.drawLine(m.x - r - arm, m.y, pos[0] + boxW, labelY, line)
}
val baseline = pos[1] + padV - fm.ascent
// outline first, fill second: half the stroke lands inside the glyph and
// is covered, so the visible result is a dark halo around white text
textOutline.color = AnnotSpec.MARK_OUTLINE
canvas.drawText(full, pos[0] + padH, baseline, textOutline)
textFill.color = color
canvas.drawText(full, pos[0] + padH, baseline, textFill)
canvas.restore()
}
}
/** Convenience: draw the given marks over an existing bitmap. */
fun drawOn(
canvas: Canvas,
marks: List<Mark>,
bitmapW: Int,
bitmapH: Int,
renderScale: Float,
) = draw(canvas, marks, bitmapW.toFloat(), bitmapH.toFloat(), renderScale)
}
@@ -13,8 +13,25 @@ import java.io.ByteArrayOutputStream
* DDT body = typed blocks {u32 magic, u32 len, data padded to 4}:
* 0x5BB5B55B camera info (0x38B from command 66b)
* 0x5BB5B55C second info block (0x38B from 66c, optional)
* 0x5BB5B55D raw measurement frame (19200 x uint16 LE)
* 0x5BB5B55D raw measurement frame (19200 x uint16 LE) — RAW sensor response
* 0x5BB5B55E text note (UTF-8, optional)
* 0x5BB5B55F probe points (UTF-8 lines "x,y,label,tempMc", optional)
* 0x5BB5B560 NUC counts on the sensor grid (19200 x uint16 LE, optional)
* 0x5BB5B561 render params {u32 version, u32 mirror flags} (optional)
* 0x5BB5B562 traced extremes "minPos,maxPos,minMc,maxMc" (optional)
*
* PROBES/NUC/EXTREMES ARE ALL IN SENSOR SPACE. The JPEG is whatever the user's
* mirror settings produced, so a viewer needs [RenderParams] to line a marker up
* with a feature in the image; storing markers in photo space instead was the bug
* that made analysis markers land mirrored against the burned-in ones.
*
* WHY THE NUC BLOCK EXISTS (2026-09-11): the raw frame is the sensor response
* BEFORE non-uniformity correction, so converting it directly yields nonsense
* (the analysis panel showed 145 C max / -161 C min for a 30 C scene). The live
* readouts use the pipeline's NUC output (counts), which is what the calibration
* tables are valid for. The photo therefore carries that corrected data, already
* transformed into the saved photo's own pixel order, so an offline temperature
* lookup is a plain index into it and gives the same numbers the live view showed.
*/
object Mdt {
const val SECTION_DDT = 0x5BB5B55B
@@ -25,6 +42,156 @@ object Mdt {
const val BLOCK_FRAME = 0x5BB5B55D
const val BLOCK_TXT = 0x5BB5B55E
/**
* Probe points captured with the photo, in SENSOR pixel coordinates (0..159,
* 0..119). Sensor space, not photo space: the photo carries the user's mirror
* corrections while the probes must stay in the frame the temperature data
* lives in, so a viewer applies [RenderParams] to place them (see [BLOCK_NUC]).
* Stored as UTF-8 text ("x,y,label,tempMc" per line) so the values stay
* inspectable with any hex editor — the same reasoning the vendor layout uses
* for its TXT section.
*/
const val BLOCK_PROBES = 0x5BB5B55F
/**
* NUC (calibrated) counts on the 160x120 SENSOR grid (19200 x uint16 LE), the
* same space as [BLOCK_PROBES]; see the header note. Because the sensor grid is
* 160 wide and a sensor pixel is exactly two AnnotSpec units, the offline
* temperature lookup is a single index — no resampling.
*/
const val BLOCK_NUC = 0x5BB5B560
/**
* How the JPEG was rendered: {u32 version, u32 flags[, u32 rotDeg]}.
*
* Flags: bit0 = mirrored horizontally, bit1 = mirrored vertically (the user's
* sensor-mount corrections), applied BEFORE the rotation, exactly as the live
* renderer does. Version 2 adds the clockwise rotation the capture baked in
* (0/90/180/270) — photos are saved in the orientation the user was looking at,
* so a reader that assumes an unrotated frame draws every marker in the wrong
* place. An 8-byte block (version 1, older files) means no rotation.
*
* All of this is needed because probes and the NUC grid are in raw SENSOR space:
* a viewer must apply mirror-then-rotation to line them up with the image.
*/
const val BLOCK_RENDER = 0x5BB5B561
/** Current version of the [BLOCK_RENDER] payload. */
const val RENDER_VERSION = 2
/**
* The max/min the capture recorded, in SENSOR coordinates:
* "minX,minY,minMc,maxX,maxY,maxMc" (UTF-8 text, like the probe block).
*
* Stored because the analysis screen otherwise re-derives the extremes from
* the NUC block, and it cannot get the same answer: the live scan and the
* offline scan disagree by a temperature step and several pixels whenever the
* sensor drifts between the capture and the reload (the argmin of a noisy flat
* region moves easily). On device this showed as TWO min markers a few pixels
* apart with 22.0 and 22.1 C — the one burned into the JPEG and the one the
* analysis had just recomputed. With this block the analysis draws exactly the
* marker the photo already carries.
*/
const val BLOCK_EXTREMES = 0x5BB5B562
const val RENDER_FLAG_FLIP_H = 1
const val RENDER_FLAG_FLIP_V = 2
/** One probe carried in an MDT file. */
data class Probe(val x: Int, val y: Int, val label: String, val tempMc: Int)
/** Render parameters recorded with the photo. */
data class RenderParams(
val flipH: Boolean,
val flipV: Boolean,
/** Clockwise rotation baked into the JPEG (0/90/180/270). */
val rotDeg: Int = 0,
) {
val flags: Int
get() = (if (flipH) RENDER_FLAG_FLIP_H else 0) or
(if (flipV) RENDER_FLAG_FLIP_V else 0)
companion object {
val NONE = RenderParams(false, false, 0)
fun fromFlags(flags: Int, rotDeg: Int = 0): RenderParams =
RenderParams(
flags and RENDER_FLAG_FLIP_H != 0,
flags and RENDER_FLAG_FLIP_V != 0,
rotDeg,
)
}
}
fun encodeRenderParams(p: RenderParams): ByteArray {
val out = ByteArray(12)
put32(out, 0, RENDER_VERSION)
put32(out, 4, p.flags)
put32(out, 8, p.rotDeg)
return out
}
/**
* Parse the render block. A version-1 block (8 bytes) carries no rotation, which
* is correct for the photos written before rotation was baked in — they really
* are in sensor orientation.
*/
fun parseRenderParams(bytes: ByteArray?): RenderParams {
if (bytes == null || bytes.size < 8) return RenderParams.NONE
val flags = u32(bytes, 4)
val rot = if (bytes.size >= 12) u32(bytes, 8) else 0
return RenderParams.fromFlags(flags, rot)
}
/**
* The capture's max/min in sensor coordinates. [minPos]/[maxPos] are sensor
* indices (y*160+x), matching what the live view published.
*/
data class Extremes(
val minPos: Int,
val maxPos: Int,
val minMc: Int,
val maxMc: Int,
) {
companion object {
/** Sentinel for "the capture did not trace any extreme". */
val NONE = Extremes(-1, -1, 0, 0)
val hasAny: (Extremes) -> Boolean = { it.minPos >= 0 || it.maxPos >= 0 }
}
}
fun encodeExtremes(e: Extremes): ByteArray =
"${e.minPos},${e.maxPos},${e.minMc},${e.maxMc}".toByteArray(Charsets.UTF_8)
fun parseExtremes(bytes: ByteArray?): Extremes {
if (bytes == null || bytes.isEmpty()) return Extremes.NONE
val parts = String(bytes, Charsets.UTF_8).trimEnd('\u0000').split(',')
if (parts.size < 4) return Extremes.NONE
val mn = parts[0].trim().toIntOrNull() ?: return Extremes.NONE
val mx = parts[1].trim().toIntOrNull() ?: return Extremes.NONE
val mnMc = parts[2].trim().toIntOrNull() ?: return Extremes.NONE
val mxMc = parts[3].trim().toIntOrNull() ?: return Extremes.NONE
return Extremes(mn, mx, mnMc, mxMc)
}
fun encodeProbes(probes: List<Probe>): ByteArray =
probes.joinToString("\n") { "${it.x},${it.y},${it.label},${it.tempMc}" }
.toByteArray(Charsets.UTF_8)
/** Parse the probe block; malformed lines are skipped rather than failing. */
fun parseProbes(bytes: ByteArray?): List<Probe> {
if (bytes == null || bytes.isEmpty()) return emptyList()
val text = String(bytes, Charsets.UTF_8).trimEnd('\u0000')
return text.lineSequence().mapNotNull { line ->
val parts = line.split(',')
if (parts.size < 4) return@mapNotNull null
val x = parts[0].trim().toIntOrNull() ?: return@mapNotNull null
val y = parts[1].trim().toIntOrNull() ?: return@mapNotNull null
val temp = parts[3].trim().toIntOrNull() ?: return@mapNotNull null
Probe(x, y, parts[2], temp)
}.toList()
}
private fun align4(n: Int): Int = (n + 3) / 4 * 4
fun u32(b: ByteArray, off: Int): Int =
@@ -54,6 +221,10 @@ object Mdt {
info1: ByteArray?,
framePixels: ByteArray?,
text: ByteArray? = null,
probes: ByteArray? = null,
nucPixels: ByteArray? = null,
renderParams: ByteArray? = null,
extremes: ByteArray? = null,
): ByteArray {
val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320)
out.write(jpg, 0, jpg.size)
@@ -76,6 +247,16 @@ object Mdt {
emit(BLOCK_FRAME, framePixels)
}
text?.let { emit(BLOCK_TXT, it) }
probes?.let { if (it.isNotEmpty()) emit(BLOCK_PROBES, it) }
// The NUC block is 1:1 with the photo, so its size follows the photo
// dimensions (240x320 = 153600 bytes for the standard rotation) — the
// first version only accepted exactly 38400 and silently DROPPED the
// block, which is why the analysis screen had no data to measure.
nucPixels?.let {
if (it.size >= 38400 && it.size % 2 == 0) emit(BLOCK_NUC, it)
}
renderParams?.let { emit(BLOCK_RENDER, it) }
extremes?.let { if (it.isNotEmpty()) emit(BLOCK_EXTREMES, it) }
val bodyBytes = body.toByteArray()
val header = ByteArray(0x88)
@@ -94,7 +275,7 @@ object Mdt {
/** Parse an MDT file produced by [compose] (or any file whose last 152
* bytes carry a valid tail). Returns the sections or null. */
fun parse(bytes: ByteArray): Parsed? {
fun parse(bytes: ByteArray): MdtFile? {
if (bytes.size < 152) return null
val tail = bytes.copyOfRange(bytes.size - 152, bytes.size)
if (u32(tail, 0) != SECTION_TAIL) return null
@@ -103,7 +284,7 @@ object Mdt {
if (u32(bytes, ddtOffset) != SECTION_DDT) return null
val bodySize = u32(bytes, ddtOffset + 4)
val bodyStart = ddtOffset + 0x88
if (bodyStart + bodySize > bytes.size - 152) return null
if (bodySize < 0 || bodyStart + bodySize > bytes.size - 152) return null
val blocks = HashMap<Int, ByteArray>()
var p = bodyStart
val end = bodyStart + bodySize
@@ -114,20 +295,68 @@ object Mdt {
blocks[magic] = bytes.copyOfRange(p + 8, p + 8 + len)
p += 8 + len
}
return Parsed(
jpg = bytes.copyOfRange(0, ddtOffset),
return MdtFile(
jpg = extractJpg(bytes, ddtOffset),
info0 = blocks[BLOCK_INFO0],
info1 = blocks[BLOCK_INFO1],
frame = blocks[BLOCK_FRAME],
text = blocks[BLOCK_TXT],
framePixels = blocks[BLOCK_FRAME],
// block payloads are padded to 4; strip the NUL padding
text = blocks[BLOCK_TXT]?.let { String(it, Charsets.UTF_8).trimEnd('\u0000') },
probes = parseProbes(blocks[BLOCK_PROBES]),
nucPixels = blocks[BLOCK_NUC],
render = parseRenderParams(blocks[BLOCK_RENDER]),
extremes = parseExtremes(blocks[BLOCK_EXTREMES]),
)
}
class Parsed(
/**
* The JPEG section is padded to 4 bytes before the DDT header, so cut it
* back to the last EOI marker (FF D9) — the padding bytes are zeros and
* cannot contain one.
*/
private fun extractJpg(bytes: ByteArray, ddtOffset: Int): ByteArray {
var i = ddtOffset - 2
while (i >= 0) {
if ((bytes[i].toInt() and 0xFF) == 0xFF && (bytes[i + 1].toInt() and 0xFF) == 0xD9) {
return bytes.copyOfRange(0, i + 2)
}
i--
}
return bytes.copyOfRange(0, ddtOffset)
}
/** Decoded MDT container (mirror of [compose]). */
class MdtFile(
val jpg: ByteArray,
val info0: ByteArray?,
val info1: ByteArray?,
val frame: ByteArray?,
val text: ByteArray?,
)
/**
* Raw 38400 B sensor response (19200 x u16 LE). NOTE: this is PRE-NUC
* data — do not convert it to temperature directly (see [nucPixels]).
*/
val framePixels: ByteArray?,
val text: String?,
/** Probe points in the saved photo's pixel coordinates (empty when none). */
val probes: List<Probe> = emptyList(),
/**
* Calibrated NUC counts in the saved photo's pixel order (19200 x u16 LE),
* valid input for TempMath.countsToTempMc. Null for photos taken before
* 2026-09-11 — callers must then refuse to show temperatures rather than
* computing wrong ones.
*/
val nucPixels: ByteArray? = null,
/**
* How the JPEG was mirrored. Markers and the NUC grid are in raw SENSOR
* space, so a viewer must apply this to line them up with the image.
*/
val render: RenderParams = RenderParams.NONE,
/**
* The max/min the capture recorded, when it traced any. Preferred over
* re-scanning [nucPixels]: see [BLOCK_EXTREMES].
*/
val extremes: Extremes = Extremes.NONE,
) {
/** True when this photo can be measured offline. */
val hasTemperatureData: Boolean get() = nucPixels != null
}
}
@@ -0,0 +1,21 @@
package com.mag160c.thermal.media
import android.content.Context
import android.net.Uri
/**
* Cheap "can this photo be measured offline?" check for the analysis list.
*
* A measurable photo carries the NUC block (see [Mdt.BLOCK_NUC]). Only the tail
* and the DDT block table are read — never the whole file — so filtering a large
* album stays fast.
*/
object MdtProbe {
fun isMeasurable(context: Context, uri: Uri): Boolean = runCatching {
context.contentResolver.openInputStream(uri)?.use { input ->
val all = input.readBytes()
val parsed = Mdt.parse(all) ?: return@use false
parsed.hasTemperatureData
} ?: false
}.getOrDefault(false)
}
@@ -2,7 +2,6 @@ package com.mag160c.thermal.media
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.media.MediaCodec
import android.media.MediaCodecInfo
@@ -12,14 +11,39 @@ import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
/**
* MP4 (H.264) recorder for the live 320x240 stream, replacing the vendor
* .mgs / FFmpeg recording paths. Uses a Surface-fed encoder so the codec
* handles color conversion; frames arrive as ARGB bitmaps.
* MP4 (H.264) recorder for the live stream, replacing the vendor .mgs / FFmpeg
* recording paths. Uses a Surface-fed encoder so the codec handles color
* conversion; frames arrive as ARGB bitmaps.
*
* ENCODED SIZE: the caller passes the output size (see
* [com.mag160c.thermal.ui.live.LiveViewModel.RECORD_SCALE]). It is deliberately
* larger than the 320x240 sensor frame — the image gains nothing, but the burned-in
* temperature readouts are only as sharp as the frame they are drawn into, and at
* 320x240 they were blurry when the video was watched full screen.
*
* ORIENTATION: [mirror] and [rotDeg] are applied to every frame through one matrix,
* exactly the order the still-photo path uses (flip first, then rotate), so a video
* and a photo taken at the same moment show the same scene the same way up.
*
* THREADING (2026-09-11 fix): frames arrive on the USB reader thread while
* start/stop run on the UI thread. The first version read [inputSurface] and
* then called lockCanvas on it, so a stop() in between released the Surface and
* lockCanvas threw on the reader thread — an uncaught exception that killed the
* app the moment recording stopped. Every surface/encoder access is now under
* one lock, and [offerFrame] additionally swallows codec-level errors: a dropped
* frame is always preferable to a crash.
*/
class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
private val fps = 15
private val bitRate = 2_000_000
/** Scale the bitrate with the pixel count so a bigger frame keeps its quality. */
private val bitRate = (width.toLong() * height * 10).toInt().coerceIn(2_000_000, 20_000_000)
/** Source frame the pipeline produces (sensor render). */
private val srcW = 320
private val srcH = 240
private val lock = Any()
private var encoder: MediaCodec? = null
private var inputSurface: android.view.Surface? = null
private var muxer: MediaMuxer? = null
@@ -28,12 +52,39 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
private val active = AtomicBoolean(false)
private val canvas = Canvas()
private val paint = Paint()
private val frameMatrix = android.graphics.Matrix()
/** Frames accepted since start (diagnostics). */
@Volatile
var frameCount: Int = 0
private set
/**
* Markers burned into every recorded frame (sensor coordinates). Set by the
* view model while recording so the video carries the same temperature
* annotations the user sees, not a bare image.
*/
@Volatile
var marks: List<MarkerPainter.Mark> = emptyList()
/** Mirror corrections applied to recorded frames (sensor-mount semantics). */
@Volatile
var mirror: PhotoSaver.Mirror = PhotoSaver.Mirror(false, false)
/** Clockwise rotation baked into recorded frames (display orientation). */
@Volatile
var rotDeg: Int = 0
/** Frames dropped because the encoder was busy or gone (diagnostics). */
@Volatile
var droppedCount: Int = 0
private set
@Volatile
var outPath: File? = null
private set
fun start(): Boolean {
fun start(): Boolean = synchronized(lock) {
if (active.get()) return true
return try {
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
@@ -54,50 +105,134 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
val tmp = File.createTempFile("mag160c", ".mp4")
muxer = MediaMuxer(tmp.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
outPath = tmp
frameCount = 0
droppedCount = 0
active.set(true)
true
} catch (e: Exception) {
releaseAll()
DebugLog.log("rec", "start failed: ${e.javaClass.simpleName}: ${e.message}")
releaseAllLocked()
false
}
}
fun isRecording(): Boolean = active.get()
/** Push one frame bitmap (called from the frame callback thread). */
/**
* Push one frame bitmap (called from the frame callback thread).
* Never throws: encoding errors drop the frame and are logged once.
*/
fun offerFrame(bmp: Bitmap) {
val surface = inputSurface ?: return
if (!active.get()) return
val c = surface.lockCanvas(null) ?: return
try {
c.drawBitmap(bmp, null, android.graphics.RectF(0f, 0f, width.toFloat(), height.toFloat()), paint)
} finally {
surface.unlockCanvasAndPost(c)
synchronized(lock) {
if (!active.get()) return // stop() won the race
val surface = inputSurface ?: return
val enc = encoder ?: return
val c = surface.lockCanvas(null) ?: run {
droppedCount++
return
}
try {
// One matrix does flip-then-rotate and fits the source into the
// encoded frame, so the reader thread does a single draw call
// instead of transforming bitmaps.
val m = mirror
val rot = PhotoSaver.normalizeDeg(rotDeg)
val swapped = rot == 90 || rot == 270
val scale = width.toFloat() / if (swapped) srcH else srcW
frameMatrix.reset()
frameMatrix.postTranslate(-srcW / 2f, -srcH / 2f)
if (m.flipH) frameMatrix.postScale(-1f, 1f)
if (m.flipV) frameMatrix.postScale(1f, -1f)
frameMatrix.postRotate(rot.toFloat())
frameMatrix.postScale(scale, scale)
frameMatrix.postTranslate(width / 2f, height / 2f)
c.drawBitmap(bmp, frameMatrix, paint)
// Burn the temperature annotations into the recorded frame so
// the video shows the same readouts as the live screen.
val mks = marks
if (mks.isNotEmpty()) {
val converted = mks.map { mk ->
val p = PhotoSaver.sensorToPhoto(
mk.x.toInt().coerceIn(0, 159),
mk.y.toInt().coerceIn(0, 119),
m, rot, width, height,
)
MarkerPainter.Mark(p[0], p[1], mk.label, mk.tempC, mk.tint)
}
MarkerPainter.draw(
canvas = c,
marks = converted,
imgW = width.toFloat(),
imgH = height.toFloat(),
// AnnotSpec units measure the IMAGE, whose long side is
// 320 units whatever the rotation — using the frame's
// width instead made markers shrink in portrait mode.
imageUnitsToPixels = maxOf(width, height) / 320f,
)
}
} finally {
surface.unlockCanvasAndPost(c)
}
drainLocked(enc, false)
frameCount++
}
} catch (e: Exception) {
// a released surface / stopped codec must not take down the reader thread
droppedCount++
if (droppedCount == 1 || droppedCount % 60 == 0) {
DebugLog.log(
"rec",
"frame dropped (${e.javaClass.simpleName}: ${e.message}) " +
"dropped=$droppedCount frames=$frameCount",
)
}
}
drain(false)
}
/** Stop recording and finalize. Returns the output file. */
/** Stop recording and finalize. Returns the output file, or null. */
fun stop(): File? {
if (!active.getAndSet(false)) return null
val enc = encoder ?: return null
// drain with EOS
val idx = enc.dequeueInputBuffer(10_000)
if (idx >= 0) enc.queueInputBuffer(idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
drain(true)
val f = outPath
runCatching { muxer?.stop() }
runCatching { muxer?.release() }
muxer = null
runCatching { enc.stop() }
runCatching { enc.release() }
encoder = null
inputSurface?.release()
inputSurface = null
return f
synchronized(lock) {
val enc = encoder
val f = outPath
var drainError: String? = null
try {
if (enc != null) {
val idx = enc.dequeueInputBuffer(10_000)
if (idx >= 0) {
enc.queueInputBuffer(idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
}
drainLocked(enc, true)
}
} catch (e: Exception) {
// The codec can already be in an error state by the time recording
// stops (e.g. a surface frame lost during an FFC). Keeping the log
// explicit lets a later reader tell "the tail is truncated" from
// "the file is fine": the muxer below still finalises whatever was
// written, so the MP4 stays playable.
drainError = "${e.javaClass.simpleName}: ${e.message}"
DebugLog.log("rec", "drain on stop failed (file kept): $drainError")
}
runCatching { muxer?.stop() }
runCatching { muxer?.release() }
muxer = null
runCatching { encoder?.stop() }
runCatching { encoder?.release() }
encoder = null
runCatching { inputSurface?.release() }
inputSurface = null
DebugLog.log(
"rec",
"stopped: frames=$frameCount dropped=$droppedCount file=${f?.length()}" +
(drainError?.let { " (drain error, tail may be short)" } ?: ""),
)
return f
}
}
private fun releaseAll() {
private fun releaseAllLocked() {
runCatching { encoder?.stop() }
runCatching { encoder?.release() }
encoder = null
@@ -108,8 +243,8 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
muxer = null
}
private fun drain(end: Boolean) {
val enc = encoder ?: return
/** Caller must hold [lock]; reads the muxer/encoder fields directly. */
private fun drainLocked(enc: MediaCodec, end: Boolean) {
val mux = muxer ?: return
val info = MediaCodec.BufferInfo()
while (true) {
@@ -135,4 +270,14 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
}
}
}
/**
* Copy a finished recording into the system gallery (DCIM/MAG160C).
* Returns the created name, or null when the copy failed.
*/
fun publishToGallery(context: android.content.Context, file: File): String? {
val name = "MAG160C_V_${PhotoSaver.fileName().removePrefix("MAG160C_").removeSuffix(".jpg")}.mp4"
val uri = PhotoSaver.saveVideo(context, file, name)
return if (uri != null) name else null
}
}
@@ -3,26 +3,70 @@ package com.mag160c.thermal.media
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Typeface
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import java.io.ByteArrayOutputStream
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import com.mag160c.thermal.core.AnnotSpec
/**
* Save captured photos into MediaStore under DCIM/MAG160C (system gallery
* visible, no rogue folders). The stored file is a self-contained MDT
* container (JPG + temperature frame + note) named by capture time.
*
* ## Orientation policy (user decision, 2026-09-12 — REVISED)
*
* The saved photo is written in the SAME orientation the user is looking at: the
* display rotation (90 deg for the portrait-locked live view) plus the user's
* manual rotate correction are baked in, so a photo taken while holding the phone
* upright comes out upright (3:4 portrait), not as the sensor's 4:3 landscape
* frame. The user reported the landscape file as a bug: "竖屏拍照的时候,出来的
* 照片不是竖屏的啊".
*
* The user's manual flips (水平翻转/竖直翻转) are applied BEFORE the rotation,
* exactly as the live renderer does it, so the file shows what the screen showed.
*
* The temperature data does NOT follow this rotation: probes, the NUC grid and the
* extremes stay in raw SENSOR space, and [Mdt.RenderParams] records the mirror and
* the rotation so any reader can map them onto the pixels. Keeping the data in one
* fixed space is what stops the photo and the measurement from drifting apart.
*
* ## Resolution policy
*
* Rendered at [RENDER_SCALE]x the sensor size (3x -> 960x720, or 720x960 when the
* rotation makes it portrait). Text drawn at the sensor's 320x240 was legible but
* visibly soft once the photo was viewed at full screen; the same layout at 3x is
* sharp.
*/
object PhotoSaver {
private val TIME_FMT = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH)
/** Sensor frame size used by the pipeline. */
const val SENSOR_W = 320
const val SENSOR_H = 240
/** Saved photo is this many times the sensor frame (crisp text). */
const val RENDER_SCALE = 3
fun fileName(now: Date = Date()): String = "MAG160C_${TIME_FMT.format(now)}.jpg"
/** Probe annotation burned into a saved photo (SENSOR coordinates). */
data class ProbeMark(val x: Int, val y: Int, val label: String, val tempC: Float)
/** The user's manual mirror corrections (sensor-mount semantics). */
data class Mirror(val flipH: Boolean, val flipV: Boolean)
/** Encode a rendered ARGB frame to JPEG bytes. */
fun encodeJpeg(frame: IntArray, w: Int = 320, h: Int = 240, quality: Int = 92): ByteArray {
fun encodeJpeg(frame: IntArray, w: Int = SENSOR_W, h: Int = SENSOR_H, quality: Int = 92): ByteArray {
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
bmp.setPixels(frame, 0, w, 0, 0, w, h)
return encodeJpeg(bmp, quality)
@@ -35,9 +79,268 @@ object PhotoSaver {
return out.toByteArray()
}
/**
* Render the sensor frame for saving: mirrored and rotated per the user's
* settings, scaled by [RENDER_SCALE], with the markers burned in.
*
* @param frame 320x240 ARGB render of the sensor image
* @param mirror manual flip corrections (sensor mounting), applied first
* @param rotDeg clockwise rotation baked in afterwards — the live display
* rotation plus the user's manual correction, so the file matches the screen
* @param probes probes in SENSOR coordinates (0..159, 0..119)
* @param extremes optional max/min markers, also sensor coordinates
*/
fun encodeRendered(
frame: IntArray,
mirror: Mirror = Mirror(false, false),
rotDeg: Int = 0,
probes: List<ProbeMark> = emptyList(),
extremes: List<MarkerPainter.Mark> = emptyList(),
w: Int = SENSOR_W,
h: Int = SENSOR_H,
quality: Int = 95,
): ByteArray {
val src = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
src.setPixels(frame, 0, w, 0, 0, w, h)
// 1) mirror (sensor-mount correction), 2) rotate (display orientation).
// This is the same order the live renderer applies, so the file matches
// what the user was looking at when they pressed the shutter.
var work = src
if (mirror.flipH || mirror.flipV) {
val m = Matrix().apply {
setScale(
if (mirror.flipH) -1f else 1f,
if (mirror.flipV) -1f else 1f,
w / 2f, h / 2f,
)
}
work = Bitmap.createBitmap(src, 0, 0, w, h, m, true)
}
val rot = normalizeDeg(rotDeg)
if (rot != 0) {
work = rotateBitmap(work, rot)
}
val outW = work.width * RENDER_SCALE
val outH = work.height * RENDER_SCALE
val out = if (work.width == outW && work.height == outH) {
work.copy(Bitmap.Config.ARGB_8888, true)
} else {
Bitmap.createScaledBitmap(work, outW, outH, true)
}
val marks = ArrayList<MarkerPainter.Mark>(probes.size + extremes.size)
for (p in probes) {
val pos = sensorToPhoto(p.x, p.y, mirror, rot, outW, outH)
marks.add(MarkerPainter.Mark(pos[0], pos[1], p.label, p.tempC))
}
// The extremes arrive in SENSOR coordinates (same space as the probes) and
// must go through the same mapping. Adding them raw put both markers in the
// photo's top-left corner: sensor x/y are 0..159/0..119, so on a 960x720
// photo they landed within a few dozen pixels of the origin instead of over
// the hot/cold spots they name.
for (e in extremes) {
val pos = sensorToPhoto(e.x.toInt(), e.y.toInt(), mirror, rot, outW, outH)
marks.add(MarkerPainter.Mark(pos[0], pos[1], e.label, e.tempC, e.tint))
}
if (marks.isNotEmpty()) {
// AnnotSpec units measure the IMAGE: its long side is 320 units whichever
// way it is rotated, so the scale comes from the long side. Using the
// frame width would make a portrait photo's markers smaller than a
// landscape one's for the same picture.
MarkerPainter.draw(
canvas = Canvas(out),
marks = marks,
imgW = outW.toFloat(),
imgH = outH.toFloat(),
imageUnitsToPixels = maxOf(outW, outH) / AnnotSpec.REF_W,
)
}
return encodeJpeg(out, quality)
}
/** Rotation normalized to 0/90/180/270. */
fun normalizeDeg(deg: Int): Int {
val d = ((deg % 360) + 360) % 360
return when {
d < 45 || d >= 315 -> 0
d < 135 -> 90
d < 225 -> 180
else -> 270
}
}
/**
* Rotation baked into a saved photo or video, so the FILE always shows the scene
* the way the user saw it.
*
* `displayRot` is what the live view draws the image with (the locked 90 plus the
* user's manual correction); `gripDeg` is how far the device is physically turned
* ([com.mag160c.thermal.ui.DeviceOrientation.deg]: 0 upright, 90 turned clockwise,
* ...). Adding the grip is what a normal camera app does, and it is required
* because the sensor is bolted to the phone: turn the phone 90 deg and the scene
* rotates 90 deg inside the sensor frame, so the saved frame has to turn with it.
*
* Without this, a photo taken in a landscape grip came out as a portrait file
* holding a sideways scene — reported on device ("切到横屏拍照片和视频方向又不对了").
* Measured against a portrait-grip photo of the same room, the file was exactly
* 90 deg out.
*
* Consequences worth knowing: upright and upside-down grips give a portrait file
* (720x960), the two landscape grips give a landscape file (960x720), and the two
* landscape grips differ by 180 deg from each other — the same as any camera.
*/
fun captureRotation(displayRot: Int, gripDeg: Int): Int =
normalizeDeg(displayRot + gripDeg)
/**
* Rotate [src] by [rot] degrees CLOCKWISE, returning a bitmap of the rotated
* size. The pivot+translate pair is what keeps the result inside the new
* bitmap's bounds: rotating about the centre moves the content to
* [-w/2..w/2]x[-h/2..h/2]-ish, so it is shifted by half the size difference.
*/
private fun rotateBitmap(src: Bitmap, rot: Int): Bitmap {
val w = src.width
val h = src.height
val swapped = rot == 90 || rot == 270
val w2 = if (swapped) h else w
val h2 = if (swapped) w else h
val m = Matrix().apply {
setRotate(rot.toFloat(), w / 2f, h / 2f)
postTranslate((w2 - w) / 2f, (h2 - h) / 2f)
}
return Bitmap.createBitmap(src, 0, 0, w, h, m, true)
}
/**
* Sensor pixel -> saved-photo pixel, through the mirror and then the rotation.
*
* Both are affine and order-sensitive (a flip followed by a rotation is not the
* same as the rotation followed by the flip), so this mirrors the bitmap path
* exactly: normalize in sensor space, flip, then rotate clockwise.
*/
fun sensorToPhoto(
sx: Int,
sy: Int,
mirror: Mirror,
rotDeg: Int,
photoW: Int,
photoH: Int,
): FloatArray {
var u = (sx + 0.5f) / 160f
var v = (sy + 0.5f) / 120f
if (mirror.flipH) u = 1f - u
if (mirror.flipV) v = 1f - v
val r = normalizeDeg(rotDeg)
val ru: Float
val rv: Float
when (r) {
90 -> { ru = 1f - v; rv = u }
180 -> { ru = 1f - u; rv = 1f - v }
270 -> { ru = v; rv = 1f - u }
else -> { ru = u; rv = v }
}
return floatArrayOf(ru * photoW, rv * photoH)
}
/** Inverse of [sensorToPhoto]: photo pixel -> sensor pixel. */
fun photoToSensor(
px: Int,
py: Int,
mirror: Mirror,
rotDeg: Int,
photoW: Int,
photoH: Int,
): Pair<Int, Int> {
val ru = (px + 0.5f) / photoW
val rv = (py + 0.5f) / photoH
val r = normalizeDeg(rotDeg)
var u: Float
var v: Float
when (r) {
90 -> { u = rv; v = 1f - ru }
180 -> { u = 1f - ru; v = 1f - rv }
270 -> { u = 1f - rv; v = ru }
else -> { u = ru; v = rv }
}
if (mirror.flipH) u = 1f - u
if (mirror.flipV) v = 1f - v
val sx = (u * 160f).toInt().coerceIn(0, 159)
val sy = (v * 120f).toInt().coerceIn(0, 119)
return sx to sy
}
/**
* Pack the NUC (calibrated) counts for offline measurement.
*
* Stored on the SENSOR grid (160x120 -> 38400 bytes): the photo has no
* rotation relative to the sensor, so the analysis lookup is a uniform scale
* (photo pixel / (photoW/160)) and no per-pixel rotation math is involved. An
* earlier version stored one sample per photo pixel — 4x the size and it
* silently vanished whenever the size check did not match the actual rotation.
*/
fun packNucForPhoto(nuc160: IntArray): ByteArray {
require(nuc160.size >= 160 * 120) { "expected 19200 NUC samples, got ${nuc160.size}" }
val out = ByteArray(160 * 120 * 2)
for (i in 0 until 160 * 120) {
val v = nuc160[i].coerceIn(0, 0xFFFF)
out[i * 2] = (v and 0xFF).toByte()
out[i * 2 + 1] = ((v shr 8) and 0xFF).toByte()
}
return out
}
/** Unpack the NUC block (160x120 counts). */
fun unpackNuc(bytes: ByteArray): IntArray {
val n = minOf(160 * 120, bytes.size / 2)
val out = IntArray(n)
for (i in 0 until n) {
out[i] = (bytes[i * 2].toInt() and 0xFF) or ((bytes[i * 2 + 1].toInt() and 0xFF) shl 8)
}
return out
}
/**
* Burn markers onto an ALREADY-RENDERED JPEG (analysis "save as new").
*
* Drawn with [MarkerPainter], i.e. the same geometry as the live screen, at
* the resolution of the file being saved — so labels stay sharp instead of
* being upscaled from a smaller bitmap (the reported blurriness).
*/
fun annotateJpeg(
jpg: ByteArray,
marks: List<MarkerPainter.Mark>,
mirror: Mirror = Mirror(false, false),
rotDeg: Int = 0,
): ByteArray {
if (marks.isEmpty()) return jpg
val bmp = android.graphics.BitmapFactory.decodeByteArray(jpg, 0, jpg.size) ?: return jpg
val out = bmp.copy(Bitmap.Config.ARGB_8888, true) ?: return jpg
// marks arrive in SENSOR coordinates; the bitmap is the saved photo, which
// carries the capture's mirror AND rotation. Converting through sensorToPhoto
// keeps them on the same features the photo shows — without it, saving from
// the analysis screen put every marker on the mirrored/rotated wrong side.
val scaled = marks.map {
val pos = sensorToPhoto(
it.x.toInt().coerceIn(0, 159), it.y.toInt().coerceIn(0, 119),
mirror, rotDeg, out.width, out.height,
)
MarkerPainter.Mark(pos[0], pos[1], it.label, it.tempC, it.tint)
}
MarkerPainter.draw(
canvas = Canvas(out),
marks = scaled,
imgW = out.width.toFloat(),
imgH = out.height.toFloat(),
// AnnotSpec units measure the IMAGE; its long side is 320 units (see encodeRendered)
imageUnitsToPixels = maxOf(out.width, out.height) / AnnotSpec.REF_W,
)
return encodeJpeg(out)
}
/** Save an MDT container into MediaStore. Returns the media uri string. */
fun saveMdt(
context: Context,
fun saveMdt( context: Context,
mdt: ByteArray,
displayName: String,
): String? {
@@ -67,6 +370,40 @@ object PhotoSaver {
return uri.toString()
}
/**
* Copy a finished recording into DCIM/MAG160C as a playable video.
* Returns the created MediaStore uri, or null when it could not be stored.
*/
fun saveVideo(context: Context, src: File, displayName: String): String? {
if (!src.isFile || src.length() == 0L) return null
val resolver = context.contentResolver
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4")
if (Build.VERSION.SDK_INT >= 29) {
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/MAG160C")
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
}
val uri = resolver.insert(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values,
) ?: return null
return try {
resolver.openOutputStream(uri)?.use { out ->
src.inputStream().use { input -> input.copyTo(out) }
}
if (Build.VERSION.SDK_INT >= 29) {
val done = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }
resolver.update(uri, done, null, null)
}
uri.toString()
} catch (e: Exception) {
DebugLog.log("rec", "video publish failed: $e")
resolver.delete(uri, null, null)
null
}
}
/** Save a plain JPEG (no MDT wrapper). */
fun saveJpeg(context: Context, jpg: ByteArray, displayName: String): String? {
val values = ContentValues().apply {
@@ -92,5 +429,4 @@ object PhotoSaver {
}
return uri.toString()
}
}
@@ -0,0 +1,303 @@
package com.mag160c.thermal.net
import com.mag160c.thermal.media.DebugLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketTimeoutException
import java.util.concurrent.atomic.AtomicBoolean
/**
* Client side of the LAN remote preview (Phase F).
*
* [discover] listens for host beacons on UDP 47510 and emits each host once per
* 3 s window. [connect] opens the TCP control channel and returns a session.
*
* Protocol phasing (keeps one TCP stream unambiguous):
* - BEFORE start: the stream carries newline-delimited JSON lines only
* (welcome, ok, errors);
* - AFTER start: the stream carries fixed 38412-byte frame records only.
* The host's keepalive pings sent between frames are harmless because the
* frame reader resynchronises on the magic; the client's liveness timer is
* driven by received bytes, so a ping also counts as "link alive".
*/
object RemoteClient {
/** Hosts seen on the LAN, de-duplicated within a 3 s window. */
fun discover(scope: CoroutineScope): Flow<RemoteContract.HostInfo> {
val out = MutableSharedFlow<RemoteContract.HostInfo>(
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
scope.launch(Dispatchers.IO) {
val socket = try {
DatagramSocket(null).apply {
reuseAddress = true
broadcast = true
soTimeout = 1000
bind(InetSocketAddress(RemoteContract.DISCOVERY_PORT))
}
} catch (e: Exception) {
DebugLog.log("remote", "discovery bind failed: $e")
return@launch
}
val buf = ByteArray(RemoteContract.MAX_LINE)
val lastSeen = HashMap<String, Long>()
try {
while (isActive) {
val packet = DatagramPacket(buf, buf.size)
try {
socket.receive(packet)
} catch (e: SocketTimeoutException) {
continue
} catch (e: Exception) {
if (!isActive) break
continue
}
val text = String(buf, 0, packet.length, Charsets.UTF_8)
val from = packet.address?.hostAddress ?: continue
val info = RemoteContract.parseBeacon(text, from) ?: continue
val now = System.currentTimeMillis()
val key = "${info.address}:${info.tcpPort}"
val prev = lastSeen[key]
if (prev == null || now - prev >= 3000) {
lastSeen[key] = now
out.tryEmit(info)
}
}
} finally {
runCatching { socket.close() }
}
}
return out.asSharedFlow()
}
suspend fun connect(
host: String,
port: Int = RemoteContract.CONTROL_PORT,
scope: CoroutineScope,
): RemoteSession? = withContext(Dispatchers.IO) {
try {
val socket = Socket()
socket.connect(InetSocketAddress(host, port), 4000)
socket.tcpNoDelay = true
socket.soTimeout = 100
val session = RemoteSession(host, port, socket, scope)
session.startReader()
DebugLog.log("remote", "connected to $host:$port")
session
} catch (e: Exception) {
DebugLog.log(
"remote",
"connect $host:$port failed: ${e.javaClass.simpleName}: ${e.message}",
)
null
}
}
}
/** One live connection to a remote host. */
class RemoteSession(
val host: String,
val port: Int,
private val socket: Socket,
private val scope: CoroutineScope,
) {
// Control lines are few and the consumer may subscribe a moment late
// (connect() returns before the UI starts collecting), so keep a small
// replay window instead of dropping welcome / stream-start.
private val _lines = MutableSharedFlow<String>(
replay = 8,
extraBufferCapacity = 32,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Control lines from the host; only meaningful before the stream starts. */
val lines: Flow<String> = _lines.asSharedFlow()
private val _frames = MutableSharedFlow<RemoteContract.FramePacket>(
extraBufferCapacity = 8,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Frame records (raw counts + the host's FFC metadata), ready to render. */
val frames: Flow<RemoteContract.FramePacket> = _frames.asSharedFlow()
private val closed = AtomicBoolean(false)
/**
* Command queue + single writer coroutine.
*
* Commands MUST be written by one coroutine: launching a writer per command
* (the first implementation) let two coroutines interleave on the same
* socket stream, so a `hello` immediately followed by `start` could reach the
* host in the opposite order — the host then answered `stream-start` first
* and the late `welcome` line arrived after the reader had switched to frame
* mode, where it was discarded as garbage (~6.7% of runs).
*/
private val outQueue = kotlinx.coroutines.channels.Channel<String>(
capacity = kotlinx.coroutines.channels.Channel.UNLIMITED,
)
private val writerStarted = AtomicBoolean(false)
@Volatile
private var streaming = false
@Volatile
var frameCount: Int = 0
private set
@Volatile
var lastDataMs: Long = System.currentTimeMillis()
private set
/** Set when the reader exits (link lost); the UI shows a snackbar and leaves. */
private val _closedFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val closedFlow: Flow<Unit> = _closedFlow.asSharedFlow()
val isOpen: Boolean get() = !closed.get() && !socket.isClosed
internal fun startReader() {
scope.launch(Dispatchers.IO) {
val reader = socket.getInputStream()
val framer = RemoteContract.FramePacketReader()
val chunk = ByteArray(64 * 1024)
val lineBuf = StringBuilder()
try {
while (!closed.get() && isActive) {
val n = try {
reader.read(chunk)
} catch (e: SocketTimeoutException) {
0 // no data this tick (soTimeout) — NOT end of stream
}
if (n < 0) {
DebugLog.log("remote", "host closed the connection")
break
}
if (n > 0) {
lastDataMs = System.currentTimeMillis()
if (streaming) {
// frame records; keepalive pings in between are skipped
// by the framer's magic resynchronisation
for (frame in framer.feed(chunk.copyOfRange(0, n))) {
frameCount++
_frames.tryEmit(frame)
}
} else {
// line mode: scan for newline-terminated JSON. The host
// sends stream-start and then switches to raw frames, so a
// chunk can contain the line AND the first frame: the
// remainder after the newline must go to the framer.
var i = 0
while (i < n) {
val c = chunk[i].toInt().toChar()
if (c == '\n') {
val text = lineBuf.toString()
lineBuf.setLength(0)
if (text.isNotBlank()) {
if (RemoteContract.typeOf(text) == "stream-start") {
streaming = true
_lines.tryEmit(text)
for (frame in framer.feed(chunk.copyOfRange(i + 1, n))) {
frameCount++
_frames.tryEmit(frame)
}
break
}
_lines.tryEmit(text)
}
} else if (c != '\r') {
if (lineBuf.length < RemoteContract.MAX_LINE) lineBuf.append(c)
}
i++
}
}
continue
}
if (System.currentTimeMillis() - lastDataMs > RemoteContract.DEAD_AFTER_MS) {
DebugLog.log(
"remote",
"no data for ${RemoteContract.DEAD_AFTER_MS} ms, closing",
)
break
}
delay(10)
}
} catch (e: Exception) {
if (!closed.get()) {
DebugLog.log(
"remote",
"session error: ${e.javaClass.simpleName}: ${e.message}",
)
}
} finally {
val wasOpen = !closed.get()
close()
if (wasOpen) _closedFlow.tryEmit(Unit)
}
}
}
/**
* Ask the host to start streaming. The reader stays in line mode until the
* host's stream-start line arrives, then switches to frame mode (so the
* confirmation is observable and no frame byte is ever parsed as text).
*/
fun startStream() {
if (closed.get()) return
send(RemoteContract.cmd("start"))
}
fun stopStream() {
streaming = false
send(RemoteContract.cmd("stop"))
}
fun requestFfc() = send(RemoteContract.cmd("ffc"))
fun hello(name: String) = send(RemoteContract.hello(name))
private fun send(cmd: String) {
if (closed.get()) return
ensureWriter()
// trySend from the CALLER's thread keeps enqueue order == call order
outQueue.trySend(cmd)
}
/** Start the single writer coroutine lazily, on first command. */
private fun ensureWriter() {
if (!writerStarted.compareAndSet(false, true)) return
scope.launch(Dispatchers.IO) {
try {
val out = socket.getOutputStream()
for (line in outQueue) {
if (closed.get()) break
out.write((line + "\n").toByteArray(Charsets.UTF_8))
out.flush()
}
} catch (e: Exception) {
if (!closed.get()) {
DebugLog.log("remote", "writer ended: ${e.javaClass.simpleName}: ${e.message}")
}
}
}
}
fun close() {
if (!closed.getAndSet(true)) {
runCatching { outQueue.close() }
runCatching { socket.close() }
}
}
}
@@ -0,0 +1,313 @@
package com.mag160c.thermal.net
/**
* Wire contract for the LAN remote preview (Phase F).
*
* Design (frozen — do not change the wire parameters):
* - discovery: UDP broadcast on port 47510, one UTF-8 JSON line per second:
* {"app":"mag160c-remote","role":"host","name":"<model>","tcp":47511,"serial":...}
* - control: TCP 47511, newline-delimited UTF-8 JSON, one line <= 4 KB
* - frames: after {"cmd":"start"} the host writes fixed frame records:
* [u32 LE magic 0x1BB1B11B][u32 LE counter][u32 LE 38400]
* [u32 LE flags][u32 LE ffcPhase][i32 LE shutter][38400 B u16 LE pixels]
* Header = 24 bytes, record = 38424 bytes.
* flags bit 0 = "renders" (a usable image frame); ffcPhase mirrors the HOST's
* FFC state machine (0 normal / 1 shutter closed / 2 reference) and shutter is
* the frame's camera temperature. The client needs both to reproduce the
* host's image: the NUC tables interpolate on shutter, and the reference
* frames must be averaged exactly as the host averages them.
* - the client renders locally with its own RenderPipeline + bundled DDT, so
* palette / zoom never cross the wire
* - keepalive: host sends {"type":"ping"} after 3 s without frames; the
* client treats 10 s of silence as a dead link
*
* Deliberately FREE OF ANDROID TYPES (including org.json, which is a stub in
* JVM unit tests): the JSON used here is a flat object of strings and numbers,
* handled by the tiny helpers below so the whole contract is unit-testable.
*/
object RemoteContract {
const val DISCOVERY_PORT = 47510
const val CONTROL_PORT = 47511
const val APP_TAG = "mag160c-remote"
const val FRAME_MAGIC = 0x1BB1B11B
const val FRAME_PIXELS = 38400
const val FRAME_HEADER = 24
const val FRAME_TOTAL = FRAME_HEADER + FRAME_PIXELS // 38424
const val PING_AFTER_MS = 3000L
const val DEAD_AFTER_MS = 10000L
const val MAX_LINE = 4096
/** flags bit 0: the host produced an image for this frame. */
const val FLAG_RENDERS = 1
/** FFC phase values mirrored from the host pipeline. */
const val PHASE_NORMAL = 0
const val PHASE_HIDDEN = 1
const val PHASE_REFERENCE = 2
/** One discovery beacon / host descriptor. */
data class HostInfo(
val name: String,
val address: String,
val tcpPort: Int = CONTROL_PORT,
val serial: Long = 0L,
)
/** Stream-start banner from the host. */
data class Welcome(val w: Int, val h: Int, val fps: Int, val serial: Long)
// ---- flat JSON helpers (string + number values only, no nesting) ----
/** Extract a raw value token for `"key"`; null when absent. */
private fun rawValue(json: String, key: String): String? {
val needle = "\"$key\""
var from = 0
while (true) {
val at = json.indexOf(needle, from)
if (at < 0) return null
var i = at + needle.length
while (i < json.length && json[i].isWhitespace()) i++
if (i >= json.length || json[i] != ':') { from = at + 1; continue }
i++
while (i < json.length && json[i].isWhitespace()) i++
if (i >= json.length) return null
if (json[i] == '"') {
val sb = StringBuilder()
var j = i + 1
while (j < json.length) {
val c = json[j]
when {
c == '\\' && j + 1 < json.length -> {
when (val e = json[j + 1]) {
'"' -> sb.append('"')
'\\' -> sb.append('\\')
'n' -> sb.append('\n')
'r' -> sb.append('\r')
't' -> sb.append('\t')
'u' -> {
if (j + 5 < json.length) {
val cp = json.substring(j + 2, j + 6).toIntOrNull(16)
if (cp != null) sb.append(cp.toChar())
j += 4
}
}
else -> sb.append(e)
}
j += 2
}
c == '"' -> return sb.toString()
else -> {
sb.append(c)
j++
}
}
}
return null
}
var j = i
while (j < json.length && json[j] != ',' && json[j] != '}' && !json[j].isWhitespace()) j++
return json.substring(i, j)
}
}
private fun str(json: String, key: String, def: String = ""): String =
rawValue(json, key) ?: def
private fun num(json: String, key: String, def: Long): Long =
rawValue(json, key)?.toLongOrNull() ?: def
private fun escape(s: String): String {
val sb = StringBuilder(s.length + 8)
for (c in s) {
when (c) {
'"' -> sb.append("\\\"")
'\\' -> sb.append("\\\\")
'\n' -> sb.append("\\n")
'\r' -> sb.append("\\r")
'\t' -> sb.append("\\t")
else -> if (c < ' ') sb.append(String.format("\\u%04x", c.code)) else sb.append(c)
}
}
return sb.toString()
}
fun hostBeacon(name: String, serial: Long): String =
"{\"app\":\"$APP_TAG\",\"role\":\"host\",\"name\":\"${escape(name)}\"," +
"\"tcp\":$CONTROL_PORT,\"serial\":$serial}"
fun hello(name: String): String = "{\"cmd\":\"hello\",\"name\":\"${escape(name)}\"}"
fun cmd(name: String): String = "{\"cmd\":\"$name\"}"
fun okLine(): String = "{\"type\":\"ok\"}"
/** Sent to a second client while another one is already streaming. */
fun busyLine(): String = "{\"type\":\"busy\"}"
fun errorLine(message: String): String = "{\"type\":\"error\",\"message\":\"${escape(message)}\"}"
fun welcomeLine(w: Int, h: Int, fps: Int, serial: Long): String =
"{\"type\":\"welcome\",\"w\":$w,\"h\":$h,\"fps\":$fps,\"serial\":$serial}"
fun streamStartLine(): String = "{\"type\":\"stream-start\"}"
fun streamStopLine(): String = "{\"type\":\"stream-stop\"}"
fun pingLine(): String = "{\"type\":\"ping\"}"
/** "cmd" field of a client->host control line ("" when absent). */
fun commandOf(text: String): String = str(text.trim(), "cmd")
/** "type" field of a host->client line ("" when absent). */
fun typeOf(text: String): String = str(text.trim(), "type")
fun nameOf(text: String): String = str(text.trim(), "name")
fun isBusyLine(text: String): Boolean = typeOf(text) == "busy"
/** Parse a discovery datagram; null when it is not one of ours. */
fun parseBeacon(text: String, fromAddress: String): HostInfo? {
val t = text.trim()
if (str(t, "app") != APP_TAG || str(t, "role") != "host") return null
return HostInfo(
name = str(t, "name", fromAddress),
address = fromAddress,
tcpPort = num(t, "tcp", CONTROL_PORT.toLong()).toInt(),
serial = num(t, "serial", 0L),
)
}
fun parseWelcome(text: String): Welcome? {
val t = text.trim()
if (typeOf(t) != "welcome") return null
return Welcome(
w = num(t, "w", 160).toInt(),
h = num(t, "h", 120).toInt(),
fps = num(t, "fps", 15).toInt(),
serial = num(t, "serial", 0L),
)
}
fun isKeepalive(text: String): Boolean = typeOf(text) == "ping"
/** One decoded frame record. */
class FramePacket(
val counter: Int,
val flags: Int,
val ffcPhase: Int,
val shutter: Int,
val pixels: ByteArray,
) {
val renders: Boolean get() = flags and FLAG_RENDERS != 0
}
/** Build one frame record for the wire. */
fun encodeFramePacket(
raw: ByteArray,
counter: Int,
flags: Int = FLAG_RENDERS,
ffcPhase: Int = PHASE_NORMAL,
shutter: Int = 0,
): ByteArray {
require(raw.size == FRAME_PIXELS) {
"frame payload must be $FRAME_PIXELS bytes, got ${raw.size}"
}
val out = ByteArray(FRAME_TOTAL)
putU32(out, 0, FRAME_MAGIC)
putU32(out, 4, counter)
putU32(out, 8, FRAME_PIXELS)
putU32(out, 12, flags)
putU32(out, 16, ffcPhase)
putU32(out, 20, shutter)
System.arraycopy(raw, 0, out, FRAME_HEADER, FRAME_PIXELS)
return out
}
fun u32(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or
((b[off + 1].toInt() and 0xFF) shl 8) or
((b[off + 2].toInt() and 0xFF) shl 16) or
((b[off + 3].toInt() and 0xFF) shl 24)
fun putU32(dst: ByteArray, off: Int, v: Int) {
dst[off] = (v and 0xFF).toByte()
dst[off + 1] = ((v shr 8) and 0xFF).toByte()
dst[off + 2] = ((v shr 16) and 0xFF).toByte()
dst[off + 3] = ((v ushr 24) and 0xFF).toByte()
}
/**
* Incremental reassembly of the TCP byte stream into frame records.
* Handles partial records and several records in one read (both occur with
* raw socket reads). Malformed data resynchronises on the next magic
* instead of dropping the connection.
*/
class FramePacketReader {
private var buffer = ByteArray(FRAME_TOTAL * 4)
private var size = 0
/** Feed bytes, get back every complete frame available. */
fun feed(bytes: ByteArray): List<FramePacket> {
append(bytes)
val out = ArrayList<FramePacket>(2)
while (true) {
if (size < FRAME_HEADER) break
var magicAt = -1
for (i in 0..size - 4) {
if (u32(buffer, i) == FRAME_MAGIC) {
magicAt = i
break
}
}
if (magicAt < 0) {
// keep the tail in case a magic straddles this read
dropBefore(maxOf(0, size - 3))
break
}
if (magicAt > 0) dropBefore(magicAt)
if (size < FRAME_HEADER) break
if (u32(buffer, 8) != FRAME_PIXELS) { // bogus header
dropBefore(1)
continue
}
if (size < FRAME_TOTAL) break
out.add(
FramePacket(
counter = u32(buffer, 4),
flags = u32(buffer, 12),
ffcPhase = u32(buffer, 16),
shutter = u32(buffer, 20),
pixels = buffer.copyOfRange(FRAME_HEADER, FRAME_TOTAL),
),
)
dropBefore(FRAME_TOTAL)
}
return out
}
/** Bytes buffered but not yet forming a complete frame. */
fun pending(): Int = size
private fun append(bytes: ByteArray) {
if (size + bytes.size > buffer.size) {
buffer = buffer.copyOf(maxOf(buffer.size, size + bytes.size))
}
System.arraycopy(bytes, 0, buffer, size, bytes.size)
size += bytes.size
}
private fun dropBefore(n: Int) {
if (n <= 0) return
if (n >= size) {
size = 0
return
}
System.arraycopy(buffer, n, buffer, 0, size - n)
size -= n
}
fun reset() {
size = 0
}
}
}
@@ -0,0 +1,331 @@
package com.mag160c.thermal.net
import com.mag160c.thermal.media.DebugLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.BufferedOutputStream
import java.io.InputStream
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketException
import java.net.SocketTimeoutException
import java.util.concurrent.atomic.AtomicBoolean
/**
* Host side of the LAN remote preview (Phase F).
*
* Broadcasts a discovery beacon on UDP 47510 once a second and serves exactly
* one preview client on TCP 47511 (a second client is told `busy` and closed).
* Frames arrive as raw 38400-byte sensor payloads plus the host pipeline's
* metadata for that frame (FFC phase, camera temperature); the client needs both
* to reproduce the host's image, because the NUC tables interpolate on the
* camera temperature and the FFC reference frames must be averaged identically.
*
* Concurrency: ONE coroutine owns the client socket — it reads control lines
* and writes both control replies and frame records. A second coroutine only
* feeds a bounded queue, so a frame can never interleave into the middle of a
* write (which would desynchronise the client's frame reader).
*
* Any error tears the client down and returns to "waiting"; the thermal session
* is never affected by a network problem.
*/
class RemoteHost(
private val deviceName: String,
private val serial: Long,
) {
enum class State { STOPPED, WAITING, STREAMING }
@Volatile
var state: State = State.STOPPED
private set
@Volatile
var clientAddress: String? = null
private set
/** Callback for {"cmd":"ffc"} — wired to the live session's triggerFfc. */
@Volatile
var onFfcRequest: (() -> Unit)? = null
/**
* Supplies the pipeline metadata for the frame just pushed via [offerFrame].
* Set by LiveViewModel: it must describe the SAME frame, which is why the
* session invokes rawHook after the pipeline processed it.
*/
@Volatile
var frameMeta: (() -> FrameMeta)? = null
/** FFC phase + camera temperature of one raw frame. */
data class FrameMeta(val phase: Int, val shutter: Int, val renders: Boolean)
private var scope: CoroutineScope? = null
private var serverSocket: ServerSocket? = null
private var discoverySocket: DatagramSocket? = null
private val running = AtomicBoolean(false)
/**
* Only one client is served at a time. The accept loop must NOT block inside
* serve(), otherwise a second connection would wait in the kernel backlog and
* never be told `busy` — so each client is served in its own coroutine and the
* flag rejects newcomers immediately.
*/
private val serving = AtomicBoolean(false)
/** Bounded queue; a slow client drops the oldest frame instead of stalling. */
private var frameQueueRef: Channel<RemoteHost.OutFrame>? = null
private class OutFrame(val pixels: ByteArray, val meta: FrameMeta)
val isRunning: Boolean get() = running.get()
fun start() {
if (!running.compareAndSet(false, true)) return
frameQueueRef = Channel(capacity = 8, onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST)
val s = CoroutineScope(SupervisorJob() + Dispatchers.IO)
scope = s
state = State.WAITING
s.launch { discoveryLoop() }
s.launch { acceptLoop() }
DebugLog.log("remote", "host started (name=$deviceName serial=$serial)")
}
fun stop() {
if (!running.getAndSet(false)) return
state = State.STOPPED
clientAddress = null
runCatching { discoverySocket?.close() }
discoverySocket = null
runCatching { serverSocket?.close() }
serverSocket = null
frameQueueRef?.close()
frameQueueRef = null
scope?.cancel()
scope = null
DebugLog.log("remote", "host stopped")
}
/** Push one raw sensor frame from the live session together with its metadata. */
fun offerFrame(raw: ByteArray) {
if (!running.get()) return
if (raw.size != RemoteContract.FRAME_PIXELS) return
val meta = frameMeta?.invoke() ?: FrameMeta(RemoteContract.PHASE_NORMAL, 0, true)
frameQueueRef?.trySend(OutFrame(raw, meta))
}
/** One UDP beacon per second on the broadcast address. */
private suspend fun discoveryLoop() {
val bytes = RemoteContract.hostBeacon(deviceName, serial).toByteArray(Charsets.UTF_8)
val socket = try {
DatagramSocket().apply {
broadcast = true
soTimeout = 1000
discoverySocket = this
}
} catch (e: Exception) {
DebugLog.log("remote", "discovery socket failed: $e")
return
}
val target = try {
InetAddress.getByName("255.255.255.255")
} catch (e: Exception) {
DebugLog.log("remote", "broadcast address failed: $e")
return
}
while (running.get()) {
try {
socket.send(DatagramPacket(bytes, bytes.size, target, RemoteContract.DISCOVERY_PORT))
} catch (e: Exception) {
if (!running.get()) break
DebugLog.log("remote", "beacon send failed: $e")
}
kotlinx.coroutines.delay(1000)
}
runCatching { socket.close() }
}
private suspend fun acceptLoop() {
val server = try {
ServerSocket(RemoteContract.CONTROL_PORT).apply { serverSocket = this }
} catch (e: Exception) {
DebugLog.log("remote", "cannot listen on ${RemoteContract.CONTROL_PORT}: $e")
state = State.STOPPED
running.set(false)
return
}
while (running.get()) {
val socket = try {
server.accept()
} catch (e: SocketException) {
break // closed by stop()
} catch (e: Exception) {
DebugLog.log("remote", "accept failed: $e")
break
}
// single client: tell later arrivals we are busy instead of silently
// queueing them in the kernel backlog (plan F2 requires the busy line)
if (!serving.compareAndSet(false, true)) {
val addr = socket.inetAddress?.hostAddress ?: "?"
DebugLog.log("remote", "rejecting $addr: already serving a client")
runCatching {
val o = socket.getOutputStream()
o.write((RemoteContract.busyLine() + "\n").toByteArray(Charsets.UTF_8))
o.flush()
}
runCatching { socket.close() }
continue
}
// serve OFF the accept loop so the loop can keep accepting (and
// rejecting) while this client is connected
scope?.launch {
try {
serve(socket)
} finally {
serving.set(false)
}
}
}
runCatching { server.close() }
}
/**
* Serve one client to completion. Single-threaded by design: every write to
* the socket happens here, so control replies and frame records stay framed.
*
* While streaming, a command reply must NOT be written: the client is in
* frame mode and would see the JSON line as stray bytes inside a frame
* record. Commands are therefore acknowledged only before the stream starts;
* `ffc` is executed and logged without a reply while streaming.
*/
private fun serve(socket: Socket) {
val addr = socket.inetAddress?.hostAddress ?: "?"
DebugLog.log("remote", "client connected from $addr")
clientAddress = addr
try {
socket.tcpNoDelay = true
socket.soTimeout = 100
} catch (_: Exception) {
}
val out = BufferedOutputStream(socket.getOutputStream(), 128 * 1024)
val input = socket.getInputStream()
var streaming = false
var counter = 0
var sentFrames = 0
var lastWrite = System.currentTimeMillis()
val lineBuf = StringBuilder()
try {
while (running.get()) {
// 1) control lines available this tick
val lines = drainLines(input, lineBuf)
for (raw in lines) {
val text = raw.trim()
if (text.isEmpty()) continue
when (RemoteContract.commandOf(text)) {
"hello" ->
if (!streaming) writeLine(out, RemoteContract.welcomeLine(160, 120, 15, serial))
"start" -> if (!streaming) {
streaming = true
state = State.STREAMING
writeLine(out, RemoteContract.streamStartLine())
DebugLog.log("remote", "streaming to $addr")
}
"stop" -> if (streaming) {
streaming = false
state = State.WAITING
writeLine(out, RemoteContract.streamStopLine())
}
"ffc" -> {
onFfcRequest?.invoke()
// no reply while streaming (see the note above)
if (!streaming) writeLine(out, RemoteContract.okLine())
}
else -> if (RemoteContract.typeOf(text).isEmpty() && !streaming) {
writeLine(out, RemoteContract.errorLine("unknown command"))
}
}
}
// 2) frames (only while streaming)
if (streaming) {
var wrote = false
while (true) {
val f = frameQueueRef?.tryReceive()?.getOrNull() ?: break
out.write(
RemoteContract.encodeFramePacket(
raw = f.pixels,
counter = counter++,
flags = if (f.meta.renders) RemoteContract.FLAG_RENDERS else 0,
ffcPhase = f.meta.phase,
shutter = f.meta.shutter,
),
)
sentFrames++
wrote = true
}
if (wrote) {
out.flush()
lastWrite = System.currentTimeMillis()
} else if (System.currentTimeMillis() - lastWrite >= RemoteContract.PING_AFTER_MS) {
writeLine(out, RemoteContract.pingLine())
lastWrite = System.currentTimeMillis()
}
if (wrote && sentFrames % 300 == 0) {
DebugLog.log("remote", "frames=$sentFrames to $addr")
}
}
// blocking sleep is correct here: this method runs on its own IO
// dispatcher thread and must not yield the socket to another writer
Thread.sleep(5)
}
} catch (e: Exception) {
DebugLog.log("remote", "client $addr error: ${e.javaClass.simpleName}: ${e.message}")
} finally {
if (streaming) state = State.WAITING
clientAddress = null
runCatching { socket.close() }
DebugLog.log("remote", "client $addr disconnected (frames=$sentFrames)")
}
}
/** Read whatever complete newline-terminated lines are available right now. */
private fun drainLines(input: InputStream, buf: StringBuilder): List<String> {
val out = ArrayList<String>(2)
val chunk = ByteArray(1024)
while (true) {
val n = try {
input.read(chunk)
} catch (e: SocketTimeoutException) {
break
} catch (e: Exception) {
if (running.get()) throw e else break
}
if (n < 0) throw SocketException("client closed the control connection")
if (n == 0) break
var i = 0
while (i < n) {
val b = chunk[i]
if (b == '\n'.code.toByte()) {
out.add(buf.toString())
buf.setLength(0)
} else if (b != '\r'.code.toByte()) {
if (buf.length < RemoteContract.MAX_LINE) buf.append(b.toInt().toChar())
}
i++
}
}
return out
}
private fun writeLine(out: BufferedOutputStream, line: String) {
out.write(line.toByteArray(Charsets.UTF_8))
out.write('\n'.code)
out.flush()
}
}
@@ -11,11 +11,13 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -25,9 +27,13 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.mag160c.thermal.R
import com.mag160c.thermal.ui.gallery.GalleryScreen
import com.mag160c.thermal.ui.live.LiveScreen
import com.mag160c.thermal.ui.live.LiveViewModel
import com.mag160c.thermal.ui.remote.RemoteClientListScreen
import com.mag160c.thermal.ui.remote.RemoteViewerScreen
import com.mag160c.thermal.ui.settings.SettingsScreen
private data class Tab(val label: String, val icon: Int)
@@ -45,33 +51,89 @@ private val TABS = listOf(
* position never moves, however the phone is physically held). Bar content
* (icons/text) is pre-rotated by the physical device orientation so it stays
* readable in any grip. Tab content lives in a stable slot.
*
* Phase F adds two full-screen destinations on top of the tabs: the remote
* host list and the remote viewer (which keeps the same bottom navigation).
*/
@Composable
fun AppRoot() {
var tab by rememberSaveable { mutableStateOf(0) }
val phi by DeviceOrientation.deg.collectAsState()
val ctx = LocalContext.current
val liveVm: LiveViewModel = viewModel()
// remote preview navigation (Phase F)
var showRemoteList by rememberSaveable { mutableStateOf(false) }
var remoteHost by rememberSaveable { mutableStateOf<String?>(null) }
var remotePort by rememberSaveable { mutableStateOf(0) }
var notice by remember { mutableStateOf<String?>(null) }
DisposableEffect(Unit) {
DeviceOrientation.start(ctx)
onDispose { DeviceOrientation.stop() }
}
// Album taps open a plain photo viewer (zoom/delete); analysis-tab taps open
// the measurement viewer. Keeping them separate is what makes the two tabs
// different (the user reported they looked identical).
var viewerItem by remember { mutableStateOf<com.mag160c.thermal.ui.gallery.GalleryViewModel.Item?>(null) }
var analyzeItem by remember { mutableStateOf<com.mag160c.thermal.ui.gallery.GalleryViewModel.Item?>(null) }
// the same view model instance the grids use, so selection/refresh stay in sync
val galleryVm: com.mag160c.thermal.ui.gallery.GalleryViewModel =
androidx.lifecycle.viewmodel.compose.viewModel()
Box(modifier = Modifier.fillMaxSize()) {
when (tab) {
0 -> LiveScreen(onOpenGallery = { tab = 1 })
1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
GalleryScreen()
when {
remoteHost != null -> RemoteViewerScreen(
host = remoteHost!!,
port = remotePort,
onDisconnected = { reason ->
notice = reason
remoteHost = null
remotePort = 0
},
)
showRemoteList -> RemoteClientListScreen(
onBack = { showRemoteList = false },
onConnect = { host, port ->
showRemoteList = false
remoteHost = host
remotePort = port
},
)
viewerItem != null -> com.mag160c.thermal.ui.gallery.PhotoViewerScreen(
item = viewerItem!!,
vm = galleryVm,
onClose = { viewerItem = null },
)
analyzeItem != null -> com.mag160c.thermal.ui.analyze.AnalyzeViewer(
item = analyzeItem!!,
galleryVm = galleryVm,
onClose = { analyzeItem = null },
density = androidx.compose.ui.platform.LocalDensity.current.density,
)
tab == 0 -> LiveScreen(vm = liveVm, onOpenGallery = { tab = 1 })
// album = browse/zoom/delete; analysis = measure a measurable photo
tab == 1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
GalleryScreen(vm = galleryVm, onOpen = { viewerItem = it })
}
2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
GalleryScreen()
tab == 2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
com.mag160c.thermal.ui.analyze.AnalyzeScreen(
vm = galleryVm,
onOpen = { analyzeItem = it },
)
}
else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
SettingsScreen()
SettingsScreen(
onOpenRemoteClient = { showRemoteList = true },
remoteHostRunning = liveVm.isRemoteHostRunning(),
onToggleRemoteHost = { on -> liveVm.setRemoteHostEnabled(on) },
)
}
}
// bottom navigation overlay (glued to the portrait bottom edge)
// bottom navigation overlay (glued to the portrait bottom edge).
// The remote viewer keeps it, matching the plan's "底导航不变".
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
@@ -82,7 +144,12 @@ fun AppRoot() {
TABS.forEachIndexed { i, t ->
NavigationBarItem(
selected = tab == i,
onClick = { tab = i },
onClick = {
tab = i
// leaving the remote screens returns to the tabs
remoteHost = null
showRemoteList = false
},
// pre-rotate so the item is upright in the current grip
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
icon = { Icon(painterResource(t.icon), null) },
@@ -91,5 +158,23 @@ fun AppRoot() {
}
}
}
// "连接已断开" notice after returning from the remote viewer
notice?.let { msg ->
Surface(
color = MaterialTheme.colorScheme.inverseSurface,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp),
) {
androidx.compose.foundation.layout.Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(msg, color = MaterialTheme.colorScheme.inverseOnSurface)
TextButton(onClick = { notice = null }) { Text("") }
}
}
}
}
}
}
@@ -52,6 +52,16 @@ object DeviceOrientation : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
val gx = event.values[0]
val gy = event.values[1]
// Lying (nearly) flat: gx and gy are both ~0, so the grip angle is
// undefined and the hysteresis below would keep whatever pose was seen
// last. That is how the live screen ended up with all OSD text rotated 90
// deg while the phone sat flat on a desk. Flat means "read it as drawn":
// the composition is glued to the portrait frame, so 0 is the honest value.
// 4.0 m/s^2 ~= within 24 deg of horizontal.
if (kotlin.math.hypot(gx, gy) < FLAT_TILT_MS2) {
if (_deg.value != 0) _deg.value = 0
return
}
// world-up in device coords: (0,+g)=0 (-g,0)=90 (0,-g)=180 (+g,0)=270.
// hysteresis: only switch pose when the dominant axis clearly wins,
// so ~45 deg in-between holds keep the previous reading
@@ -64,4 +74,6 @@ object DeviceOrientation : SensorEventListener {
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
private const val FLAT_TILT_MS2 = 4.0f
}
@@ -1,8 +1,19 @@
package com.mag160c.thermal.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/** Shared UI inset state (px), written by AppRoot's nav overlay. */
object UiInsets {
/** Bottom navigation overlay height in px (portrait-locked app: constant). */
@Volatile
var navPx: Int = 0
/**
* Bottom navigation overlay height in px (portrait-locked app: constant).
*
* Observable, not a plain var: the full-screen overlays (analysis viewer,
* album photo viewer) place their own bottom panel above this bar, and a
* non-observable value read during composition would stay at whatever it was
* on the first frame — which is 0, leaving the analysis readouts behind the
* navigation bar where the user could not see them.
*/
var navPx: Int by mutableStateOf(0)
}
@@ -0,0 +1,126 @@
package com.mag160c.thermal.ui.analyze
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import com.mag160c.thermal.ui.gallery.GalleryViewModel
/**
* Analysis tab.
*
* Previously this tab rendered the SAME photo grid as the album tab, which is why
* the two looked identical (user report). It now has its own purpose: pick a photo
* to measure, with a short explanation of what the analysis offers, and it only
* lists photos that actually carry temperature data.
*/
@Composable
fun AnalyzeScreen(
vm: GalleryViewModel,
onOpen: (GalleryViewModel.Item) -> Unit,
) {
val items by vm.items.collectAsState()
val context = LocalContext.current
var analyzed by remember { mutableStateOf<List<GalleryViewModel.Item>>(emptyList()) }
val perm = if (android.os.Build.VERSION.SDK_INT >= 33)
android.Manifest.permission.READ_MEDIA_IMAGES
else android.Manifest.permission.READ_EXTERNAL_STORAGE
val launcher = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { vm.refresh() }
LaunchedEffect(Unit) {
val granted = androidx.core.content.ContextCompat.checkSelfPermission(context, perm) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
if (granted) vm.refresh() else launcher.launch(perm)
}
// only photos with temperature data can be measured
LaunchedEffect(items) {
analyzed = items.filter { com.mag160c.thermal.media.MdtProbe.isMeasurable(context, it.uri) }
}
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("离线分析 (${analyzed.size})", style = MaterialTheme.typography.titleMedium)
Button(onClick = { vm.refresh() }) { Text("刷新") }
}
Text(
"选择一张照片进入分析:查看测温点、点击图像增删测温点、保存为新照片。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
)
if (analyzed.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
"暂无可分析的照片\n(需要先在实时页拍照)",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = Modifier.padding(top = 4.dp)) {
items(analyzed.size) { idx ->
val item = analyzed[idx]
var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(item.name) { vm.thumbnail(item) { b -> bmp = b } }
Box(
modifier = Modifier
.aspectRatio(3f / 4f)
.padding(1.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { onOpen(item) },
) {
val b = bmp
if (b != null) {
Image(
bitmap = b.asImageBitmap(),
contentDescription = item.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
Text(
item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(3.dp)
.background(Color(0x99000000)),
)
}
}
}
}
}
}
@@ -3,95 +3,302 @@ package com.mag160c.thermal.ui.analyze
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.mag160c.thermal.core.TempMath
import com.mag160c.thermal.media.Mdt
import com.mag160c.thermal.media.MarkerPainter
import com.mag160c.thermal.media.PhotoSaver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
/**
* Offline MDT analysis state: loaded container, palette re-render, probes.
* Offline MDT analysis (sensor-space, 2026-09-12).
*
* The saved photo keeps the SENSOR's orientation and the NUC block is stored on
* the sensor grid, so everything here works in sensor coordinates:
* - a photo pixel maps to a sensor pixel by a uniform scale;
* - a probe is stored and edited in sensor coordinates;
* - the displayed image is the saved JPEG, so what the user measures is exactly
* what the file contains.
*
* Temperatures come from the NUC block — the CALIBRATED counts the live screen
* measures. Photos without it (older files) report "no temperature data" rather
* than inventing numbers.
*/
class AnalyzeViewModel(
app: Application,
private val containerBytes: ByteArray,
private val fileUri: android.net.Uri,
) : AndroidViewModel(app) {
val parsed: Mdt.Parsed? = Mdt.parse(containerBytes)
/** A probe point in SENSOR coordinates (0..159, 0..119). */
data class Probe(val x: Int, val y: Int, val label: String, val tempC: Float)
/** Raw measurement frame (19200 uint16) if present. */
val rawFrame: IntArray? by lazy {
parsed?.frame?.let { raw ->
val out = IntArray(19200)
for (i in out.indices) {
out[i] = (raw[i * 2].toInt() and 0xFF) or ((raw[i * 2 + 1].toInt() and 0xFF) shl 8)
}
out
}
val parsed: Mdt.MdtFile? = Mdt.parse(containerBytes)
/** Calibrated counts on the 160x120 sensor grid (null for older photos). */
private val nucCounts: IntArray? by lazy {
parsed?.nucPixels?.let { PhotoSaver.unpackNuc(it) }
}
val hasTemperatureData: Boolean get() = nucCounts != null && parsed?.hasTemperatureData == true
private val _render = MutableStateFlow<Bitmap?>(null)
/** The image to display: the saved JPEG, as-is. */
val render: StateFlow<Bitmap?> = _render
private val _paletteIndex = MutableStateFlow(2)
val paletteIndex: StateFlow<Int> = _paletteIndex
/** Re-render the raw frame with the given palette + auto window. */
fun render(paletteIdx: Int) {
val raw = rawFrame ?: return
_paletteIndex.value = paletteIdx
/** Editable probe list (snapshot state so the canvas redraws on change). */
val probes = mutableStateListOf<Probe>()
private val _minTempC = mutableStateOf<Float?>(null)
val minTempC: Float? get() = _minTempC.value
private val _maxTempC = mutableStateOf<Float?>(null)
val maxTempC: Float? get() = _maxTempC.value
private val _centerTempC = mutableStateOf<Float?>(null)
val centerTempC: Float? get() = _centerTempC.value
/** Min/max position in SENSOR coordinates, for the on-image markers. */
private val _minPos = mutableStateOf<Int>(-1)
val minPos: Int get() = _minPos.value
private val _maxPos = mutableStateOf<Int>(-1)
val maxPos: Int get() = _maxPos.value
private val _imageW = mutableStateOf(PhotoSaver.SENSOR_W)
val imageW: Int get() = _imageW.value
private val _imageH = mutableStateOf(PhotoSaver.SENSOR_H)
val imageH: Int get() = _imageH.value
fun load() {
viewModelScope.launch(Dispatchers.Default) {
val jpg = parsed?.jpg
val bmp = jpg?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
if (bmp != null) {
_imageW.value = bmp.width
_imageH.value = bmp.height
}
val counts = nucCounts
val recorded = parsed?.extremes ?: com.mag160c.thermal.media.Mdt.Extremes.NONE
var mn = Int.MAX_VALUE
var mx = -1
for (v in raw) {
if (v < mn) mn = v
if (v > mx) mx = v
var mx = Int.MIN_VALUE
var mnPos = -1
var mxPos = -1
counts?.forEachIndexed { i, v ->
if (v < mn) { mn = v; mnPos = i }
if (v > mx) { mx = v; mxPos = i }
}
if (mx <= mn) mx = mn + 1
val pal = com.mag160c.thermal.core.Palettes.buildAll()[paletteIdx.coerceIn(0, 11)]
val argb = IntArray(19200)
val scale = (255 shl 12) / (mx - mn)
for (i in argb.indices) {
var g = ((raw[i] - mn) * scale) shr 8
if (g < 0) g = 0 else if (g > 255) g = 255
argb[i] = pal[g]
// Prefer the extremes the CAPTURE recorded. Re-scanning the NUC block
// gives a slightly different answer (a different pixel and a step of
// temperature, because the sensor drifts between capture and reload),
// which showed up on device as two "min" markers a few pixels apart.
if (recorded.minPos >= 0) mnPos = recorded.minPos
if (recorded.maxPos >= 0) mxPos = recorded.maxPos
// Each probe keeps the temperature the CAPTURE recorded, because that is
// the number burned into the photo next to the marker. Re-measuring the
// NUC block gives a slightly different value (the sensor drifts between
// capture and reload), which showed on device as the photo saying
// "Pt2 26.1" while the panel said 25.9 for the same point. Only probes
// that somehow lack a recorded value are measured.
val loaded = (parsed?.probes.orEmpty()).map { p ->
Probe(p.x, p.y, p.label, (p.tempMc / 1000f).takeIf { p.tempMc != 0 }
?: measureSensor(p.x, p.y) ?: 0f)
}
withContext(Dispatchers.Main) {
_render.value = bmp
probes.clear()
probes.addAll(loaded)
if (counts != null) {
// mn/mx are NUC counts and must go through the temperature
// curve; dividing them directly showed 9 C for a 23 C scene.
// A recorded extreme is already a temperature (millidegrees),
// so it is used as-is — the two units must not be mixed.
_minTempC.value = if (recorded.minPos >= 0 && recorded.minMc != 0) {
recorded.minMc / 1000f
} else if (mn != Int.MAX_VALUE) {
TempMath.countsToTempMc(mn) / 1000f
} else null
_maxTempC.value = if (recorded.maxPos >= 0 && recorded.maxMc != 0) {
recorded.maxMc / 1000f
} else if (mx != Int.MIN_VALUE) {
TempMath.countsToTempMc(mx) / 1000f
} else null
_minPos.value = mnPos
_maxPos.value = mxPos
_centerTempC.value = measureSensor(80, 60)
}
com.mag160c.thermal.media.DebugLog.log(
"analyze",
"loaded ${bmp?.width}x${bmp?.height} measurable=$hasTemperatureData " +
"min=${_minTempC.value} max=${_maxTempC.value} " +
"center=${_centerTempC.value} probes=${loaded.size}",
)
}
val bmp = Bitmap.createBitmap(160, 120, Bitmap.Config.ARGB_8888)
bmp.setPixels(argb, 0, 160, 0, 0, 160, 120)
withContext(Dispatchers.Main) { _render.value = bmp }
}
}
fun decodeNote(): String? {
val t = parsed?.text ?: return null
return String(t, Charsets.UTF_8)
/** Mirror applied to the saved JPEG (from the container). */
val photoMirror: PhotoSaver.Mirror
get() {
val r = parsed?.render ?: Mdt.RenderParams.NONE
return PhotoSaver.Mirror(r.flipH, r.flipV)
}
/**
* Clockwise rotation baked into the saved JPEG (0/90/180/270), from the
* container. Photos are written in the orientation the user was looking at, so
* this must be applied when mapping sensor-space measurement data onto the
* pixels. Version-1 files carry no rotation and report 0, which is correct for
* them.
*/
val photoRotDeg: Int
get() = (parsed?.render ?: Mdt.RenderParams.NONE).rotDeg
/**
* Sensor pixel -> photo pixel for the DISPLAYED image.
*
* The JPEG carries the user's mirror corrections AND the capture rotation while
* probes and the NUC grid stay in raw sensor space, so this conversion is what
* keeps markers sitting on the same spot the live screen showed. Ignoring it was
* the reported misalignment.
*/
private fun sensorToPhoto(px: Int, py: Int): Pair<Int, Int> {
val w = _imageW.value
val h = _imageH.value
val p = PhotoSaver.sensorToPhoto(px, py, photoMirror, photoRotDeg, w, h)
return (p[0].toInt().coerceIn(0, w - 1)) to (p[1].toInt().coerceIn(0, h - 1))
}
/** Probe temperature approximation at a raw pixel (millidegrees C). */
fun probeTemp(x: Int, y: Int): Int? {
val raw = rawFrame ?: return null
if (x < 0 || y < 0 || x >= 160 || y >= 120) return null
return com.mag160c.thermal.core.TempMath.countsToTempMc(raw[y * 160 + x])
/** Inverse of [sensorToPhoto]: a photo pixel back to sensor coordinates. */
private fun photoToSensor(px: Int, py: Int): Pair<Int, Int> =
PhotoSaver.photoToSensor(px, py, photoMirror, photoRotDeg, _imageW.value, _imageH.value)
/** Temperature (C) at a SENSOR pixel, from the stored NUC counts. */
fun measureSensor(sx: Int, sy: Int): Float? {
val counts = nucCounts ?: return null
if (sx < 0 || sy < 0 || sx >= 160 || sy >= 120) return null
val idx = sy * 160 + sx
if (idx >= counts.size) return null
return TempMath.countsToTempMc(counts[idx]) / 1000f
}
/** Save note: rewrite the container in place (jpg = current render). */
fun saveNote(note: String, onDone: (Boolean) -> Unit) {
/** Temperature (C) at a pixel of the DISPLAYED photo. */
fun measure(px: Int, py: Int): Float? {
val s = photoToSensor(px, py)
return measureSensor(s.first, s.second)
}
/** Tap in canvas space -> SENSOR pixel; toggles a probe there. */
fun toggleProbeAt(
pos: androidx.compose.ui.geometry.Offset,
rect: androidx.compose.ui.geometry.Rect,
) {
if (rect.width <= 0f || rect.height <= 0f) return
if (pos.x < rect.left || pos.x > rect.right || pos.y < rect.top || pos.y > rect.bottom) return
val photoX = ((pos.x - rect.left) / rect.width * _imageW.value).toInt()
.coerceIn(0, _imageW.value - 1)
val photoY = ((pos.y - rect.top) / rect.height * _imageH.value).toInt()
.coerceIn(0, _imageH.value - 1)
val (sx, sy) = photoToSensor(photoX, photoY)
// markers are ~6 sensor pixels across on screen; match that when hit-testing
val thr = 6f
val hit = probes.indexOfFirst { p ->
val dx = (p.x - sx).toFloat()
val dy = (p.y - sy).toFloat()
dx * dx + dy * dy < thr * thr
}
if (hit >= 0) {
probes.removeAt(hit)
return
}
probes.add(Probe(sx, sy, "Pt${probes.size + 1}", measureSensor(sx, sy) ?: 0f))
}
/** Current probes as markers (sensor space), for burning into a saved copy. */
fun probesAsMarks(): List<MarkerPainter.Mark> =
probes.map { MarkerPainter.Mark(it.x.toFloat(), it.y.toFloat(), it.label, it.tempC) }
fun setPaletteIndex(idx: Int) {
_paletteIndex.value = idx
}
fun decodeNote(): String? = parsed?.text
/**
* Save as a NEW photo: markers burned in at the photo's own resolution (so the
* labels stay sharp) and the probes stored in the container.
*/
fun saveAsNew(
context: android.content.Context,
notes: String,
density: Float,
onDone: (Boolean) -> Unit,
) {
val bmp = _render.value
if (bmp == null) {
onDone(false)
return
}
viewModelScope.launch(Dispatchers.IO) {
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(bmp)
val jpg = PhotoSaver.encodeJpeg(bmp, quality = 92)
val annotated = PhotoSaver.annotateJpeg(jpg, probesAsMarks(), photoMirror, photoRotDeg)
val mdt = Mdt.compose(
jpg = annotated,
info0 = parsed?.info0,
info1 = parsed?.info1,
framePixels = parsed?.framePixels,
text = notes.takeIf { it.isNotEmpty() }?.toByteArray(Charsets.UTF_8),
probes = Mdt.encodeProbes(
probes.map { Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) },
),
// carry the temperature data forward so the edited photo stays measurable
nucPixels = parsed?.nucPixels,
renderParams = com.mag160c.thermal.media.Mdt.encodeRenderParams(
com.mag160c.thermal.media.Mdt.RenderParams(photoMirror.flipH, photoMirror.flipV, photoRotDeg),
),
// the extremes are burned into the pixels already; keep the block so
// the next reader still knows where they were
extremes = parsed?.extremes?.takeIf {
com.mag160c.thermal.media.Mdt.Extremes.hasAny(it)
}?.let { com.mag160c.thermal.media.Mdt.encodeExtremes(it) },
)
val name = "MAG160C_${java.text.SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
.format(java.util.Date())}_edit.jpg"
val ok = PhotoSaver.saveMdt(context, mdt, name) != null
withContext(Dispatchers.Main) { onDone(ok) }
}
}
/** Save the note into the ORIGINAL container. */
fun saveNote(note: String, onDone: (Boolean) -> Unit) {
val bmp = _render.value ?: run { onDone(false); return }
viewModelScope.launch(Dispatchers.IO) {
val jpg = PhotoSaver.encodeJpeg(bmp)
val mdt = Mdt.compose(
jpg = jpg,
info0 = parsed?.info0,
info1 = parsed?.info1,
framePixels = parsed?.frame,
framePixels = parsed?.framePixels,
text = note.toByteArray(Charsets.UTF_8),
probes = Mdt.encodeProbes(
probes.map { Mdt.Probe(it.x, it.y, it.label, (it.tempC * 1000).toInt()) },
),
nucPixels = parsed?.nucPixels,
renderParams = com.mag160c.thermal.media.Mdt.encodeRenderParams(
com.mag160c.thermal.media.Mdt.RenderParams(photoMirror.flipH, photoMirror.flipV, photoRotDeg),
),
extremes = parsed?.extremes?.takeIf {
com.mag160c.thermal.media.Mdt.Extremes.hasAny(it)
}?.let { com.mag160c.thermal.media.Mdt.encodeExtremes(it) },
)
val ok = runCatching {
val ctx = getApplication<Application>()
@@ -1,24 +1,26 @@
package com.mag160c.thermal.ui.analyze
import android.graphics.Bitmap
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -31,23 +33,41 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.mag160c.thermal.core.Palettes
import com.mag160c.thermal.media.PhotoSaver
import com.mag160c.thermal.ui.gallery.GalleryViewModel
/**
* Single-file MDT analysis viewer: pinch zoom/pan, palette re-render,
* text note editing.
*/
import java.util.Locale
/**
* Offline MDT analysis (layout revised 2026-09-11 per user feedback).
*
* Layout: the photo occupies the top area, the measurements are a compact panel
* along the BOTTOM (the side column wasted most of a portrait screen and looked
* unbalanced), with the actions in a slim title row.
*
* The image shown is the saved photo as-is; tapping adds/removes a probe. Marker
* sizes scale with the IMAGE, so they look the same as on the live screen instead
* of covering the photo.
*/
@Composable
fun AnalyzeViewer(item: GalleryViewModel.Item, galleryVm: GalleryViewModel) {
fun AnalyzeViewer(
item: GalleryViewModel.Item,
galleryVm: GalleryViewModel,
onClose: () -> Unit = {},
density: Float = 2f,
) {
val context = LocalContext.current
val vm = remember(item.name) {
val bytes = runCatching {
@@ -56,115 +76,332 @@ fun AnalyzeViewer(item: GalleryViewModel.Item, galleryVm: GalleryViewModel) {
AnalyzeViewModel(context.applicationContext as android.app.Application, bytes, item.uri)
}
val render by vm.render.collectAsState()
// collected (not read via .value inside composition) so recomposition is
// driven by state, and lint's StateFlowValueCalledInComposition stays clean
val paletteIdx by vm.paletteIndex.collectAsState()
var zoom by remember { mutableStateOf(1f) }
var pan by remember { mutableStateOf(Offset.Zero) }
var showNote by remember { mutableStateOf(false) }
var reportName by remember { mutableStateOf<String?>(null) }
var note by remember { mutableStateOf(vm.decodeNote() ?: "") }
var showNote by remember { mutableStateOf(false) }
var showPalette by remember { mutableStateOf(false) }
var toast by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { vm.render(2) }
LaunchedEffect(Unit) { vm.load() }
Box(
// System back leaves the viewer instead of exiting the app. The viewer is a
// full-screen overlay, so without this the user could not get out with the
// back gesture (reported on device).
androidx.activity.compose.BackHandler(enabled = true) { onClose() }
// The viewer is a full-screen overlay ABOVE the navigation bar, so it must
// reserve the bar's height itself — otherwise the measurement panel ends up
// behind the bar and the readouts are invisible (reported on device).
val navPad = with(androidx.compose.ui.platform.LocalDensity.current) {
com.mag160c.thermal.ui.UiInsets.navPx.toDp()
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
.background(Color(0xFF101014))
.padding(bottom = navPad),
) {
Canvas(
modifier = Modifier
.aspectRatio(4f / 3f)
.align(Alignment.Center)
.pointerInput(Unit) {
detectTransformGestures { _, gesturePan, gestureZoom, _ ->
zoom = (zoom * gestureZoom).coerceIn(1f, 4f)
pan += gesturePan
if (zoom <= 1.01f) {
zoom = 1f
pan = Offset.Zero
// ---- slim title row ----
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onClose) { Text("返回") }
Text(
item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
color = Color.White,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f),
maxLines = 1,
)
TextButton(onClick = { showPalette = true }) { Text(Palettes.NAMES[paletteIdx]) }
TextButton(onClick = { showNote = true }) { Text("备注") }
TextButton(
onClick = {
vm.saveAsNew(context, notes = note, density = density) { ok ->
toast = if (ok) "已保存为新照片" else "保存失败"
if (ok) galleryVm.refresh()
}
},
) { Text("保存") }
}
HorizontalDivider(color = Color(0x33FFFFFF))
// ---- image ----
val bmp = render
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
if (bmp == null) {
Text(
if (vm.hasTemperatureData) "载入中…" else "无温度数据(旧照片)",
color = Color.Gray,
)
} else {
val img = bmp.asImageBitmap()
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { _, gesturePan, gestureZoom, _ ->
zoom = (zoom * gestureZoom).coerceIn(1f, 6f)
pan += gesturePan
if (zoom <= 1.01f) {
zoom = 1f
pan = Offset.Zero
}
}
}
.pointerInput(Unit) {
detectTapGestures { pos ->
vm.toggleProbeAt(pos, imageRect(currentSize(), zoom, pan, bmp))
}
},
) {
val rect = imageRect(size, zoom, pan, bmp)
drawImage(
image = img,
dstOffset = androidx.compose.ui.unit.IntOffset(
rect.left.toInt(), rect.top.toInt(),
),
dstSize = IntSize(rect.width.toInt(), rect.height.toInt()),
)
// extremes and probes go into ONE painter call: the painter
// avoids putting a label on top of an earlier one, and that only
// works if it sees every marker at once (drawing the extremes in
// separate calls let "max" and "min" land on each other here).
// Probes FIRST, then MAX, then MIN — the exact order the capture
// used when it burned the same markers into the JPEG. Label
// collision avoidance is order-dependent, so a different order
// placed the live labels beside the burned-in ones and the screen
// showed every marker twice with two different offsets. In the
// capture's order the overlay retraces the burned-in markers.
val marks = ArrayList<com.mag160c.thermal.media.MarkerPainter.Mark>(4)
marks.addAll(probeMarks(vm.probes, rect, vm.photoMirror, vm.photoRotDeg))
if (vm.hasTemperatureData) {
sensorMark(vm.maxPos, rect, vm.photoMirror, vm.photoRotDeg, "MAX", vm.maxTempC)
?.let { marks.add(it) }
sensorMark(vm.minPos, rect, vm.photoMirror, vm.photoRotDeg, "MIN", vm.minTempC)
?.let { marks.add(it) }
}
if (marks.isNotEmpty()) {
// AnnotSpec units measure the IMAGE, whose long side is 320
// units whichever way it is rotated — the same rule the live,
// photo and video paths use. Deriving it from rect.width (the
// short side in portrait) shrank the markers on a portrait photo.
val k = maxOf(rect.width, rect.height) / com.mag160c.thermal.core.AnnotSpec.REF_W
drawIntoCanvas { c ->
com.mag160c.thermal.media.MarkerPainter.draw(
canvas = c.nativeCanvas,
marks = marks,
imgW = size.width,
imgH = size.height,
imageUnitsToPixels = k,
)
}
}
},
) {
val img = render?.asImageBitmap()
if (img != null) {
val w = size.width * zoom
val h = size.height * zoom
val left = (size.width - w) / 2 + pan.x
val top = (size.height - h) / 2 + pan.y
drawImage(
image = img,
dstOffset = androidx.compose.ui.unit.IntOffset(left.toInt(), top.toInt()),
dstSize = IntSize(w.toInt(), h.toInt()),
)
}
}
}
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(Color(0x66000000))
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
// ---- bottom measurement panel ----
Surface(
color = Color(0xFF1B1B20),
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Palettes.NAMES.forEachIndexed { idx, name ->
Text(
name,
color = if (paletteIdx == idx) MaterialTheme.colorScheme.primary else Color.White,
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Stat("最高", vm.maxTempC)
Stat("最低", vm.minTempC)
Stat("中心", vm.centerTempC)
}
HorizontalDivider(Modifier.padding(vertical = 6.dp), color = Color(0x33FFFFFF))
Row(
modifier = Modifier
.clickable { vm.render(idx) }
.padding(horizontal = 10.dp, vertical = 8.dp),
)
}
}
Row(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(12.dp),
) {
Button(onClick = { showNote = true }) { Text("备注") }
Button(
onClick = {
val bmp = render
if (bmp != null) {
reportName = com.mag160c.thermal.media.PdfReport.generate(
context, bmp,
com.mag160c.thermal.media.PdfReport.ReportData(
date = java.text.SimpleDateFormat("yyyy/MM/dd", Locale.getDefault())
.format(java.util.Date()),
time = java.text.SimpleDateFormat("HH:mm:ss", Locale.getDefault())
.format(java.util.Date()),
),
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"测温点 ${vm.probes.size}",
color = Color.White,
style = MaterialTheme.typography.labelMedium,
)
Text(
if (vm.hasTemperatureData) " 点击图像添加/删除" else " 无温度数据,无法测温",
color = Color.Gray,
style = MaterialTheme.typography.labelSmall,
)
vm.probes.forEach { p ->
Text(
" ${p.label} ${"%.1f℃".format(Locale.US, p.tempC)}",
color = Color(0xFFFFD54F),
style = MaterialTheme.typography.labelMedium,
)
}
},
modifier = Modifier.padding(start = 8.dp),
) { Text("报告") }
}
if (reportName != null) {
Text(
"已生成: $reportName",
color = Color.White,
modifier = Modifier
.align(Alignment.TopStart)
.padding(12.dp),
)
}
if (showNote) {
NoteEditor(
initial = note,
onSave = {
note = it
vm.saveNote(it) { }
showNote = false
},
onDismiss = { showNote = false },
)
}
toast?.let {
Text(
it,
color = Color(0xFF80FF80),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 4.dp),
)
LaunchedEffect(it) {
kotlinx.coroutines.delay(2500)
toast = null
}
}
}
}
}
if (showNote) {
NoteEditor(
initial = note,
onSave = {
note = it
vm.saveNote(it) { ok -> toast = if (ok) "备注已保存" else "备注保存失败" }
showNote = false
},
onDismiss = { showNote = false },
)
}
if (showPalette) {
AlertDialog(
onDismissRequest = { showPalette = false },
title = { Text("调色板") },
text = {
Text(
"仅作为显示参考记录;离线分析不改动已保存的照片。",
style = MaterialTheme.typography.bodySmall,
)
},
confirmButton = {
TextButton(onClick = { showPalette = false }) { Text("") }
},
)
}
}
private fun IntOffsetCompat(x: Float, y: Float): Offset = Offset(x, y)
/** One number with its caption, for the bottom panel. */
@Composable
private fun Stat(label: String, value: Float?) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(label, color = Color.Gray, style = MaterialTheme.typography.labelSmall)
Text(
value?.let { "%.1f℃".format(Locale.US, it) } ?: "--",
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
}
}
private fun androidx.compose.ui.unit.IntSize.toSize(): Size =
Size(width.toFloat(), height.toFloat())
private fun imageRect(
size: Size,
zoom: Float,
pan: Offset,
bmp: android.graphics.Bitmap,
): androidx.compose.ui.geometry.Rect {
val scale = minOf(size.width / bmp.width, size.height / bmp.height) * zoom
val w = bmp.width * scale
val h = bmp.height * scale
val left = (size.width - w) / 2 + pan.x
val top = (size.height - h) / 2 + pan.y
return androidx.compose.ui.geometry.Rect(left, top, left + w, top + h)
}
/**
* Markers for the analysis view.
*
* Delegates to [com.mag160c.thermal.media.MarkerPainter] — the very same painter
* that burns markers into saved photos and recorded video frames — so a probe is
* rendered identically everywhere. Hand-drawing here is what made the on-screen
* marker differ from the one in the file.
*
* Positions are converted from sensor space through the photo's mirror so the
* marker sits on the same feature the photo shows. The marks are returned rather
* than drawn so the caller can hand the painter ALL of them at once, which is
* what lets it keep their labels from overlapping.
*/
private fun probeMarks(
probes: List<AnalyzeViewModel.Probe>,
rect: androidx.compose.ui.geometry.Rect,
mirror: PhotoSaver.Mirror,
rotDeg: Int,
): List<com.mag160c.thermal.media.MarkerPainter.Mark> =
probes.map { p ->
val (u, v) = sensorToDisplay(p.x, p.y, mirror, rotDeg)
com.mag160c.thermal.media.MarkerPainter.Mark(
x = rect.left + u * rect.width,
y = rect.top + v * rect.height,
label = p.label,
tempC = p.tempC,
)
}
/**
* Overall max/min marker: the same reticle as a probe, labelled `MAX` / `MIN`
* (the user asked for these exact names rather than the previous 高/低 wording).
*/
private fun sensorMark(
pos: Int,
rect: androidx.compose.ui.geometry.Rect,
mirror: PhotoSaver.Mirror,
rotDeg: Int,
label: String,
tempC: Float?,
): com.mag160c.thermal.media.MarkerPainter.Mark? {
if (pos < 0 || tempC == null) return null
val sx = pos % 160
val sy = pos / 160
if (sy >= 120) return null
val (u, v) = sensorToDisplay(sx, sy, mirror, rotDeg)
return com.mag160c.thermal.media.MarkerPainter.Mark(
x = rect.left + u * rect.width,
y = rect.top + v * rect.height,
label = label,
tempC = tempC,
tint = com.mag160c.thermal.core.AnnotSpec.EXTREME_TINT,
)
}
/**
* Sensor pixel -> normalised (0..1) position in the DISPLAYED photo.
*
* Delegates to [PhotoSaver.sensorToPhoto] with a 1x1 target so the analysis screen
* and the file it is displaying cannot disagree — the mirror AND the capture
* rotation are applied in the one place that also produced the JPEG. The pixel
* CENTRE is used (sx + 0.5), so a marker lands on the middle of the sensor pixel
* rather than its corner.
*/
private fun sensorToDisplay(
sx: Int,
sy: Int,
mirror: PhotoSaver.Mirror,
rotDeg: Int,
): Pair<Float, Float> {
val u = PhotoSaver.sensorToPhoto(sx, sy, mirror, rotDeg, 1, 1)
return u[0] to u[1]
}
/** Current canvas size, captured for the tap handler. */
private fun androidx.compose.ui.input.pointer.PointerInputScope.currentSize(): Size {
// PointerInputScope exposes `size` as IntSize
return Size(size.width.toFloat(), size.height.toFloat())
}
@Composable
private fun NoteEditor(initial: String, onSave: (String) -> Unit, onDismiss: () -> Unit) {
@@ -172,14 +409,8 @@ private fun NoteEditor(initial: String, onSave: (String) -> Unit, onDismiss: ()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("文字备注") },
text = {
OutlinedTextField(value = text, onValueChange = { text = it })
},
confirmButton = {
TextButton(onClick = { onSave(text) }) { Text("保存") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("取消") }
},
text = { OutlinedTextField(value = text, onValueChange = { text = it }) },
confirmButton = { TextButton(onClick = { onSave(text) }) { Text("保存") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
)
}
@@ -1,9 +1,6 @@
package com.mag160c.thermal.ui.gallery
import android.content.Intent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -14,9 +11,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -31,23 +30,29 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.material3.Button
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.lifecycle.viewmodel.compose.viewModel
import com.mag160c.thermal.ui.analyze.AnalyzeViewer
/**
* Photo grid (album tab). Behaves like a normal gallery: tap opens a full-screen
* viewer with pinch-zoom and delete. Temperature tools live in the analysis tab,
* which is why this screen no longer embeds the measurement UI.
*/
@Composable
fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
fun GalleryScreen(
vm: GalleryViewModel = viewModel(),
onOpen: (GalleryViewModel.Item) -> Unit = {},
) {
val items by vm.items.collectAsState()
val context = LocalContext.current
var showViewer by remember { mutableStateOf(false) }
// runtime media permission (API 33+: READ_MEDIA_IMAGES, else READ_EXTERNAL_STORAGE)
val perm = if (android.os.Build.VERSION.SDK_INT >= 33)
android.Manifest.permission.READ_MEDIA_IMAGES
else android.Manifest.permission.READ_EXTERNAL_STORAGE
val launcher = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { granted -> vm.refresh() }
) { vm.refresh() }
LaunchedEffect(Unit) {
val granted = androidx.core.content.ContextCompat.checkSelfPermission(context, perm) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
@@ -72,12 +77,10 @@ fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
}
Box(
modifier = Modifier
.aspectRatio(4f / 3f)
.aspectRatio(3f / 4f)
.padding(1.dp)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable {
vm.select(item)
showViewer = true
},
.clickable { onOpen(item) },
) {
val b = bmp
if (b != null) {
@@ -88,24 +91,19 @@ fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
modifier = Modifier.fillMaxSize(),
)
}
// delete lives in the viewer (with confirmation), matching how
// normal gallery apps behave; no clutter on the grid itself
Text(
item.name.removePrefix("MAG160C_"),
item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
style = MaterialTheme.typography.labelSmall,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(4.dp)
.background(Color(0x88000000)),
.padding(3.dp)
.background(Color(0x99000000)),
)
}
}
}
}
if (showViewer) {
val sel = vm.selected.value
if (sel != null) {
AnalyzeViewer(item = sel, galleryVm = vm)
}
}
}
@@ -108,6 +108,33 @@ class GalleryViewModel(app: Application) : AndroidViewModel(app) {
}
}
/**
* Full-resolution image for the album viewer. Reads the container's embedded
* JPEG (the MDT file is not a plain JPEG, so the system decoder cannot be
* handed the file directly).
*/
fun fullImage(item: Item, onReady: (Bitmap) -> Unit) {
full[item.name]?.let { onReady(it); return }
viewModelScope.launch(Dispatchers.IO) {
val ctx = getApplication<Application>()
val bmp = runCatching {
ctx.contentResolver.openInputStream(item.uri)?.use { s ->
val all = s.readBytes()
val parsed = Mdt.parse(all)
if (parsed != null) {
BitmapFactory.decodeByteArray(parsed.jpg, 0, parsed.jpg.size)
} else null
}
}.getOrNull()
if (bmp != null) {
full[item.name] = bmp
withContext(Dispatchers.Main) { onReady(bmp) }
}
}
}
private val full = ConcurrentHashMap<String, Bitmap>()
fun select(item: Item?) {
_selected.value = item
}
@@ -117,6 +144,7 @@ class GalleryViewModel(app: Application) : AndroidViewModel(app) {
val ctx = getApplication<Application>()
runCatching { ctx.contentResolver.delete(item.uri, null, null) }
thumbs.remove(item.name)
full.remove(item.name)
refresh()
}
}
@@ -0,0 +1,147 @@
package com.mag160c.thermal.ui.gallery
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.ui.layout.ContentScale
import java.io.ByteArrayOutputStream
/**
* Plain full-screen photo viewer for the album tab: pinch to zoom, drag to pan,
* double-tap to reset, and delete. Deliberately free of measurement tools — that
* is what the analysis tab is for (the user asked the album to behave like a
* normal gallery app).
*/
@Composable
fun PhotoViewerScreen(
item: GalleryViewModel.Item,
vm: GalleryViewModel,
onClose: () -> Unit,
) {
val context = LocalContext.current
var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) }
var zoom by remember { mutableStateOf(1f) }
var pan by remember { mutableStateOf(Offset.Zero) }
var askDelete by remember { mutableStateOf(false) }
LaunchedEffect(item.name) {
vm.fullImage(item) { b -> bmp = b }
}
// System back closes the viewer rather than leaving the app
androidx.activity.compose.BackHandler(enabled = true) { onClose() }
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
val b = bmp
if (b != null) {
Image(
bitmap = b.asImageBitmap(),
contentDescription = item.name,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = zoom,
scaleY = zoom,
translationX = pan.x,
translationY = pan.y,
)
.pointerInput(item.name) {
detectTransformGestures { _, gesturePan, gestureZoom, _ ->
zoom = (zoom * gestureZoom).coerceIn(1f, 8f)
pan += gesturePan
}
}
.pointerInput(item.name) {
detectTapGestures(
onDoubleTap = {
// reset, or zoom to 2x if already at rest
if (zoom > 1.05f) {
zoom = 1f; pan = Offset.Zero
} else {
zoom = 2f
}
},
)
},
)
} else {
Text("载入中…", color = Color.White, modifier = Modifier.align(Alignment.Center))
}
// top bar
Surface(
color = Color(0x99000000),
modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onClose) { Text("返回") }
Text(
item.name.removePrefix("MAG160C_").removeSuffix(".jpg"),
color = Color.White,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { askDelete = true }) {
Text("删除", color = Color(0xFFFF8A80))
}
}
}
Text(
"双指缩放 · 拖动平移 · 双击复位",
color = Color(0x99FFFFFF),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(12.dp),
)
}
if (askDelete) {
AlertDialog(
onDismissRequest = { askDelete = false },
title = { Text("删除照片") },
text = { Text("删除后无法恢复:${item.name}") },
confirmButton = {
TextButton(onClick = {
vm.delete(item)
askDelete = false
onClose()
}) { Text("删除") }
},
dismissButton = {
TextButton(onClick = { askDelete = false }) { Text("取消") }
},
)
}
}
@@ -0,0 +1,165 @@
package com.mag160c.thermal.ui.live
/**
* Pure geometry for the live image: the rotation/flip transform, the letterbox
* fit rect, and the sensor<->screen mapping shared by the renderer (markers,
* colour bar) and the tap handler. Deliberately free of Android types so it can
* be unit tested on the JVM — a mismatch between the drawn image and the marker
* mapping is exactly the class of bug this file exists to prevent.
*
* ## The image IS locked (user decision, 2026-09-11)
*
* The composition (top bar / image area / bottom bar) and the IMAGE CONTENT are
* both glued to the phone's portrait frame: rotating the phone never changes the
* image's rotation. Because the thermal sensor is physically attached to the
* phone, it rotates with it, so a locked image keeps the scene aligned with the
* world automatically — this is also what the official app ends up showing
* (its window auto-rotates, so its panel-level image rotation is the constant 90
* that we draw directly).
*
* An earlier revision made the image counter-rotate with the accelerometer grip
* (`rot = 90 - grip`). That was wrong and was reverted: it double-compensated,
* since the locked image already accounts for the sensor turning with the phone.
*
* Adapting to a differently mounted sensor is done with the three manual
* corrections the official app also offers (settings screen):
* [userRotateDeg] "旋转USB画面" 0/90/180/270 added to the locked 90
* flipH / flipV "水平翻转" / "竖直翻转", applied to the SENSOR frame
* before the rotation (same order as the official app, which
* passes the flip to the native renderer and rotates the
* result on the display matrix)
*
* NOTE (verified by ImageTransformOrientationTest): rotating 90 with flipV is
* mathematically identical to rotating 270 with flipH. So a vertical flip and a
* 270 rotation differ only by a horizontal mirror — worth knowing when choosing
* between them on a device.
*/
object ImageTransform {
const val SENSOR_W = 160
const val SENSOR_H = 120
/** Rotation the image is always drawn with: 90 deg CW, i.e. 3:4 portrait. */
const val LOCKED_ROT_DEG = 90
/**
* @param rotDeg clockwise rotation applied to the image content, in buffer space
* @param flipH mirror the source horizontally (before rotation)
* @param flipV mirror the source vertically (before rotation)
*/
data class Params(
val rotDeg: Int,
val flipH: Boolean,
val flipV: Boolean,
)
/**
* The locked rotation plus the user's manual correction. Deliberately takes
* NO grip angle: the image must not rotate with the phone.
*/
fun params(
userRotateDeg: Int = 0,
flipH: Boolean = false,
flipV: Boolean = false,
): Params = Params(
(((LOCKED_ROT_DEG + userRotateDeg) % 360) + 360) % 360,
flipH,
flipV,
)
/** True when the drawn image is taller than wide on screen (rot 90/270). */
fun swapped(rotDeg: Int): Boolean = rotDeg % 180 != 0
/** Letterboxed rect for the image inside the available area. */
class Fit(val left: Float, val top: Float, val width: Float, val height: Float) {
val cx: Float get() = left + width / 2f
val cy: Float get() = top + height / 2f
val right: Float get() = left + width
val bottom: Float get() = top + height
}
/** On-screen aspect of the drawn image: the source is 4:3, sideways 3:4. */
fun screenAspect(rotDeg: Int): Float = if (swapped(rotDeg)) 3f / 4f else 4f / 3f
fun fit(availLeft: Float, availTop: Float, availW: Float, availH: Float, rotDeg: Int): Fit {
val aspect = screenAspect(rotDeg)
var w = availW
var h = availW / aspect
if (h > availH) {
h = availH
w = availH * aspect
}
return Fit(availLeft + (availW - w) / 2f, availTop + (availH - h) / 2f, w, h)
}
/** Digital-zoom crop in normalised source units; 0,0,1,1 = no zoom. */
fun cropForZoom(zoom: Int): FloatArray {
if (zoom <= 1) return floatArrayOf(0f, 0f, 1f, 1f)
val inset = (1f - 1f / zoom) / 2f
return floatArrayOf(inset, inset, 1f - inset, 1f - inset)
}
/** Pre-rotation draw size for a fit rect (the rotated footprint). */
private fun preRotationSize(fit: Fit, rotDeg: Int): FloatArray =
if (swapped(rotDeg)) floatArrayOf(fit.height, fit.width)
else floatArrayOf(fit.width, fit.height)
/**
* Sensor pixel -> buffer point. Sampled at pixel centres so markers land on
* the middle of the pixel they describe.
*/
fun sensorToScreen(sx: Float, sy: Float, p: Params, fit: Fit, crop: FloatArray): FloatArray {
var u = (sx + 0.5f) / SENSOR_W
var v = (sy + 0.5f) / SENSOR_H
if (p.flipH) u = 1f - u
if (p.flipV) v = 1f - v
val size = preRotationSize(fit, p.rotDeg)
val lx = (u - crop[0]) / (crop[2] - crop[0])
val ly = (v - crop[1]) / (crop[3] - crop[1])
val px = fit.cx - size[0] / 2f + lx * size[0]
val py = fit.cy - size[1] / 2f + ly * size[1]
return rotate(px, py, fit.cx, fit.cy, p.rotDeg.toFloat())
}
/**
* Buffer point -> sensor pixel (inverse of [sensorToScreen]).
* Returns null when the point falls outside the drawn image.
*/
fun screenToSensor(x: Float, y: Float, p: Params, fit: Fit, crop: FloatArray): Pair<Int, Int>? {
val q = rotate(x, y, fit.cx, fit.cy, -p.rotDeg.toFloat())
val size = preRotationSize(fit, p.rotDeg)
val lx = (q[0] - (fit.cx - size[0] / 2f)) / size[0]
val ly = (q[1] - (fit.cy - size[1] / 2f)) / size[1]
if (lx < 0f || lx > 1f || ly < 0f || ly > 1f) return null
var u = crop[0] + lx * (crop[2] - crop[0])
var v = crop[1] + ly * (crop[3] - crop[1])
if (p.flipH) u = 1f - u
if (p.flipV) v = 1f - v
val sx = (u * SENSOR_W).toInt()
val sy = (v * SENSOR_H).toInt()
if (sx < 0 || sy < 0 || sx >= SENSOR_W || sy >= SENSOR_H) return null
return sx to sy
}
/** Clockwise rotation of a point about a pivot, in buffer (y-down) coords. */
private fun rotate(x: Float, y: Float, cx: Float, cy: Float, deg: Float): FloatArray {
val r = Math.toRadians(deg.toDouble())
val c = kotlin.math.cos(r).toFloat()
val s = kotlin.math.sin(r).toFloat()
val dx = x - cx
val dy = y - cy
return floatArrayOf(cx + dx * c - dy * s, cy + dx * s + dy * c)
}
/**
* Half extents of a text box drawn with a clockwise [textRotDeg] rotation.
* Used to place labels flush against their target (e.g. the colour-bar
* ends) whatever the grip: the box is positioned by its bounding box, not
* by a baseline anchor, so it stays aligned when the text is turned 90 deg.
*/
fun rotatedBoxHalfExtents(textW: Float, textH: Float, textRotDeg: Float): FloatArray {
val r = Math.toRadians(textRotDeg.toDouble())
val c = kotlin.math.abs(kotlin.math.cos(r)).toFloat()
val s = kotlin.math.abs(kotlin.math.sin(r)).toFloat()
return floatArrayOf((textW * c + textH * s) / 2f, (textW * s + textH * c) / 2f)
}
}
@@ -5,8 +5,11 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import android.os.SystemClock
import android.view.SurfaceHolder
import android.view.SurfaceView
import com.mag160c.thermal.core.AnnotSpec
import java.util.Locale
/**
* Software canvas renderer for the live IR stream.
@@ -27,6 +30,31 @@ class LiveRenderer(
) : SurfaceHolder.Callback, Runnable {
private var thread: Thread? = null
private val running = java.util.concurrent.atomic.AtomicBoolean(false)
/**
* Hardware canvas when the platform has one, software otherwise.
*
* This is THE stutter fix. `SurfaceHolder.lockCanvas()` hands out a software
* canvas, so every frame upscaled the 320x240 image to the fitted rect
* (~810x1080) with a CPU bilinear filter and cleared a ~10 MB buffer by hand —
* measured on the device: 6.5 paints/second at 134 ms each, i.e. the render
* thread threw away more than half of the camera's 15 fps before the user ever
* saw them. A hardware canvas does the same scale-up on the GPU (and the
* surface clear for free), leaving the CPU with only the 76800-pixel copy.
*
* `lockHardwareCanvas` exists from API 29; on older devices the software path
* is kept, which is correct, just slower.
*/
private val useHardwareCanvas = android.os.Build.VERSION.SDK_INT >= 29
private fun lockCanvas(holder: SurfaceHolder): Canvas? {
if (useHardwareCanvas) {
val hw = runCatching { holder.lockHardwareCanvas() }.getOrNull()
if (hw != null) return hw
}
return runCatching { holder.lockCanvas() }.getOrNull()
}
private val density = surfaceView.resources.displayMetrics.density
private val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888)
private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
@@ -42,6 +70,26 @@ class LiveRenderer(
}
private val viewport = android.graphics.RectF()
/**
* Scratch buffers owned by the render thread.
*
* Per-frame allocation is what makes a stream feel "卡": at 15 fps a fresh
* 320x240 IntArray is 4.6 MB/s of garbage, and the collector's pauses land
* exactly on the frames the user is watching. The flipped frame, the marker
* list and the colour-bar segments are all recycled instead.
*/
private val flipped = IntArray(320 * 240)
private val marks = ArrayList<com.mag160c.thermal.media.MarkerPainter.Mark>(8)
private val barPaint = Paint()
private val srcRect = android.graphics.Rect()
private val dstRect = android.graphics.RectF()
private var barBitmap: Bitmap? = null
private var barPaletteIdx = -1
private companion object {
const val BAR_SEGMENTS = 96
}
/** OSD text compensation: pre-rotation so labels are upright in the current grip. */
private val textRot: Float
get() = -com.mag160c.thermal.ui.DeviceOrientation.deg.value.toFloat()
@@ -70,17 +118,60 @@ class LiveRenderer(
override fun run() {
val holder = surfaceView.holder
var lastPainted = 0L
var lastPaintMs = 0L
var paints = 0
var paintT0 = SystemClock.elapsedRealtime()
var paintMsTotal = 0L
var worstPaintMs = 0L
while (running.get()) {
val canvas = holder.lockCanvas() ?: continue
// Skip frames when nothing new arrived: the camera runs at 15 fps and
// the loop used to paint at 30 fps regardless, doubling the canvas work
// for no benefit (a source of the reported stutter). A repaint is still
// forced periodically so OSD text (which changes on its own timer) and
// a resize settle.
val newest = vm.framesRcvd.toLong()
if (newest == lastPainted && SystemClock.elapsedRealtime() - lastPaintMs < 300) {
Thread.sleep(8)
continue
}
lastPainted = newest
lastPaintMs = SystemClock.elapsedRealtime()
val canvas = lockCanvas(holder)
if (canvas == null) {
// surface not ready (or being resized): do NOT spin on it
Thread.sleep(16)
continue
}
val t0 = SystemClock.elapsedRealtime()
try {
drawFrame(canvas)
} finally {
holder.unlockCanvasAndPost(canvas)
}
try {
Thread.sleep(33)
} catch (_: InterruptedException) {
val dt = SystemClock.elapsedRealtime() - t0
paints++
paintMsTotal += dt
if (dt > worstPaintMs) worstPaintMs = dt
val now = SystemClock.elapsedRealtime()
if (now - paintT0 >= 5000) {
// The render thread is where stutter is visible; without this the
// only signal was dumpsys gfxinfo, which does not see lockCanvas
// paints at all (it counted 28 frames while the stream ran at 15 fps).
com.mag160c.thermal.media.DebugLog.log(
"render",
"paints=${paints} in ${now - paintT0} ms " +
"avg=%.1f ms worst=$worstPaintMs ms".format(Locale.US, paintMsTotal.toFloat() / paints),
)
paints = 0
paintMsTotal = 0
worstPaintMs = 0
paintT0 = now
}
// No extra sleep here: the gate at the top of the loop already parks
// the thread until a new frame arrives, so sleeping again only added
// latency between the camera producing a frame and it reaching the
// screen.
}
}
@@ -89,7 +180,7 @@ class LiveRenderer(
val h = canvas.height.toFloat()
canvas.drawColor(Color.BLACK)
val frame = vm.latestFrame ?: return
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
setFramePixels(frame)
// available area (minus UI overlays)
val top = vm.uiTopPx.toFloat()
@@ -98,97 +189,185 @@ class LiveRenderer(
val availH = bottom - top
if (availH <= 0) return
// fit the 3:4 (rotated) image into the available rect (fixed orientation)
var dstW = availW
var dstH = availW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 3f / 4f
}
val left = (availW - dstW) / 2f
val vpTop = top + (availH - dstH) / 2f
viewport.set(left, vpTop, left + dstW, vpTop + dstH)
// Grip compensation applies to the IMAGE CONTENT only (the composition
// stays glued to the portrait frame, and the image content is locked to
// it too — see ImageTransform). The user's manual corrections are the
// only rotation adjustments; the grip angle is used for OSD text only.
val params = vm.imageParams()
val fit = ImageTransform.fit(0f, top, availW, availH, params.rotDeg)
viewport.set(fit.left, fit.top, fit.right, fit.bottom)
// draw the source bitmap rotated 90 deg CW around the viewport center
val cx = viewport.centerX()
val cy = viewport.centerY()
val zoom = vm.state.value.zoom
val srcRect = if (zoom > 1) {
val cw = 320 / zoom
val ch = 240 / zoom
android.graphics.Rect(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2)
val crop = ImageTransform.cropForZoom(zoom)
val src: android.graphics.Rect? = if (zoom > 1) {
val cw = (320 * (crop[2] - crop[0])).toInt()
val ch = (240 * (crop[3] - crop[1])).toInt()
srcRect.set(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2)
srcRect
} else null
val size = if (ImageTransform.swapped(params.rotDeg)) {
floatArrayOf(fit.height, fit.width)
} else {
floatArrayOf(fit.width, fit.height)
}
canvas.save()
canvas.rotate(90f, cx, cy)
// pre-rotation draw rect (source aspect 4:3 inside the rotated 3:4 viewport)
val w0 = dstH
val h0 = dstW
val dst = android.graphics.RectF(cx - w0 / 2f, cy - h0 / 2f, cx + w0 / 2f, cy + h0 / 2f)
if (srcRect != null) canvas.drawBitmap(bitmap, srcRect, dst, paint)
else canvas.drawBitmap(bitmap, null, dst, paint)
canvas.rotate(params.rotDeg.toFloat(), fit.cx, fit.cy)
dstRect.set(
fit.cx - size[0] / 2f, fit.cy - size[1] / 2f,
fit.cx + size[0] / 2f, fit.cy + size[1] / 2f,
)
if (src != null) canvas.drawBitmap(bitmap, src, dstRect, paint)
else canvas.drawBitmap(bitmap, null, dstRect, paint)
canvas.restore()
drawOsd(canvas, vm.state.value)
}
private fun drawTempMarker(canvas: Canvas, sx: Int, sy: Int, tempC: Float?, label: String?) {
if (sx < 0 || sy < 0 || tempC == null) return
val p = vm.probeToScreen(sx, sy)
val cx = p[0]
val cy = p[1]
val dotR = 4.5f * density
markerPaint.style = Paint.Style.FILL
canvas.drawCircle(cx, cy, dotR, markerPaint)
markerPaint.style = Paint.Style.STROKE
markerPaint.strokeWidth = 2.5f * density
canvas.drawCircle(cx, cy, dotR + 5f * density, markerPaint)
markerPaint.style = Paint.Style.FILL
/** Grip angle in 0/90/180/270; used for OSD TEXT readability only. */
private val orientationDeg: Int
get() = com.mag160c.thermal.ui.DeviceOrientation.deg.value
val text = (label?.let { "$it " } ?: "") + "%.1f℃".format(tempC)
/**
* Copy the rendered frame into the draw bitmap, applying the user's
* mirror settings. Doing the flip on the pixel copy (instead of a negative
* scale in the draw matrix) keeps the buffer-space mapping in
* [ImageTransform] exact and testable.
*/
private fun setFramePixels(frame: IntArray) {
val p = vm.imageParams()
if (!p.flipH && !p.flipV) {
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
return
}
for (y in 0 until 240) {
val sy = if (p.flipV) 239 - y else y
val srcRow = sy * 320
val dstRow = y * 320
if (!p.flipH) {
System.arraycopy(frame, srcRow, flipped, dstRow, 320)
} else {
for (x in 0 until 320) flipped[dstRow + x] = frame[srcRow + (319 - x)]
}
}
bitmap.setPixels(flipped, 0, 320, 0, 0, 320, 240)
}
/**
* Draw every marker through the shared [MarkerPainter], the same one that
* burns markers into saved photos and video. Using it here (rather than
* hand-drawing) is what makes a saved photo look like a screenshot of the live
* view — same dot, same ring, same label box, same placement rule.
*
* The labels are rotated by the grip angle so they stay upright on the rotated
* screen; the glyphs are not rotated.
*/
private fun drawMarkers(canvas: Canvas, state: LiveViewModel.LiveState) {
marks.clear()
for (p in state.probes) {
val t = p.tempC ?: continue
val scr = vm.probeToScreen(p.x, p.y)
marks.add(
com.mag160c.thermal.media.MarkerPainter.Mark(
scr[0], scr[1], p.label, t,
),
)
}
if (state.traceMode.showsMax && state.maxPos >= 0 && state.maxTempC != null) {
val scr = vm.probeToScreen(state.maxPos % 160, state.maxPos / 160)
marks.add(
com.mag160c.thermal.media.MarkerPainter.Mark(
scr[0], scr[1], "MAX", state.maxTempC, AnnotSpec.EXTREME_TINT,
),
)
}
if (state.traceMode.showsMin && state.minPos >= 0 && state.minTempC != null) {
val scr = vm.probeToScreen(state.minPos % 160, state.minPos / 160)
marks.add(
com.mag160c.thermal.media.MarkerPainter.Mark(
scr[0], scr[1], "MIN", state.minTempC, AnnotSpec.EXTREME_TINT,
),
)
}
if (marks.isEmpty()) return
com.mag160c.thermal.media.MarkerPainter.draw(
canvas = canvas,
marks = marks,
imgW = canvas.width.toFloat(),
imgH = canvas.height.toFloat(),
// AnnotSpec units measure the IMAGE, whose long side is 320 units however
// it is rotated — the same rule the photo and video paths use, so a marker
// is the same size relative to the picture everywhere. Using the viewport
// WIDTH (the short side in portrait) made live markers 3/4 the size of the
// ones burned into a photo of the same scene.
imageUnitsToPixels = maxOf(viewport.width(), viewport.height()) / AnnotSpec.REF_W,
textRotationDeg = textRot,
)
}
/** Rotate a text-local offset into buffer space and add it to an anchor. */
private fun offsetInTextFrame(ax: Float, ay: Float, offX: Float, offY: Float): FloatArray {
val r = Math.toRadians(textRot.toDouble())
val c = kotlin.math.cos(r).toFloat()
val s = kotlin.math.sin(r).toFloat()
return floatArrayOf(ax + offX * c - offY * s, ay + offX * s + offY * c)
}
/** Draw OSD text pre-rotated by the grip angle, centred on a buffer point. */
private fun drawGripText(canvas: Canvas, text: String, centerX: Float, centerY: Float) {
val tw = textPaint.measureText(text)
val pad = 6f * density
var tx = cx + 14f * density
var ty = cy + textPaint.textSize
if (tx + tw + pad > viewport.right) tx = cx - 14f * density - tw
if (ty > viewport.bottom - 4f * density) ty = cy - 10f * density
if (ty < viewport.top + textPaint.textSize) ty = cy + textPaint.textSize + 4f * density
// pivot around the marker anchor: the label stays attached while upright
val fm = textPaint.fontMetrics
canvas.save()
canvas.rotate(textRot, cx, cy)
canvas.drawText(text, tx, ty, textPaint)
canvas.rotate(textRot, centerX, centerY)
val baseline = centerY - (fm.ascent + fm.descent) / 2f
canvas.drawText(text, centerX - tw / 2f, baseline, textPaint)
canvas.restore()
}
/** Half extents of the drawn text box in buffer space. */
private fun gripTextHalf(text: String): FloatArray {
val fm = textPaint.fontMetrics
return ImageTransform.rotatedBoxHalfExtents(
textPaint.measureText(text), fm.descent - fm.ascent, textRot,
)
}
private fun drawColorBar(canvas: Canvas, state: LiveViewModel.LiveState) {
if (state.maxTempC == null || state.minTempC == null) return
val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex]
val barW = 20f * density
val barH = viewport.height() * 0.8f
val x = viewport.right - barW - 12f * density
val y0 = viewport.top + (viewport.height() - barH) / 2f
val seg = Paint()
val n = 96
for (i in 0 until n) {
val c = pal[255 - i * 255 / (n - 1)]
val sy = y0 + barH * i / n
val ey = y0 + barH * (i + 1) / n
seg.color = c
canvas.drawRect(x, sy, x + barW, ey + 0.5f, seg)
// The strip is a 1x96 bitmap stretched to the bar rect, rebuilt only when
// the palette changes. Drawing 96 rects every frame was pure overhead on
// the render thread.
if (barPaletteIdx != state.paletteIndex || barBitmap == null) {
val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex]
val bmp = Bitmap.createBitmap(1, BAR_SEGMENTS, Bitmap.Config.ARGB_8888)
val px = IntArray(BAR_SEGMENTS)
for (i in 0 until BAR_SEGMENTS) {
px[i] = pal[255 - i * 255 / (BAR_SEGMENTS - 1)]
}
bmp.setPixels(px, 0, 1, 0, 0, 1, BAR_SEGMENTS)
barBitmap = bmp
barPaletteIdx = state.paletteIndex
}
barBitmap?.let {
srcRect.set(0, 0, 1, BAR_SEGMENTS)
canvas.drawBitmap(it, srcRect, android.graphics.RectF(x, y0, x + barW, y0 + barH), paint)
}
textPaint.color = Color.WHITE
val maxT = "%.1f".format(state.maxTempC)
val minT = "%.1f".format(state.minTempC)
val labelX = x + barW / 2f - textPaint.measureText(maxT) / 2
val labelMaxX = x + barW / 2f - textPaint.measureText(minT) / 2
canvas.save()
canvas.rotate(textRot, x + barW / 2f, y0 - 8f * density)
canvas.drawText(maxT, labelX, y0 - 8f * density, textPaint)
canvas.restore()
canvas.save()
canvas.rotate(textRot, x + barW / 2f, y0 + barH + textPaint.textSize)
canvas.drawText(minT, labelMaxX, y0 + barH + textPaint.textSize, textPaint)
canvas.restore()
val cx = x + barW / 2f
val gap = 8f * density
// Position by the ROTATED bounding box: with a 90 deg grip the text runs
// along the bar, so centring a baseline anchor (the old code) made the
// numbers overlap the bar instead of sitting beside its ends.
val halfMax = gripTextHalf(maxT)
val halfMin = gripTextHalf(minT)
drawGripText(canvas, maxT, cx, y0 - gap - halfMax[1])
drawGripText(canvas, minT, cx, y0 + barH + gap + halfMin[1])
}
private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) {
@@ -196,18 +375,16 @@ class LiveRenderer(
val ox = viewport.left + 12f * density
val oy = viewport.top + textPaint.textSize + 10f * density
state.centerTempC?.let {
canvas.save()
canvas.rotate(textRot, ox, oy)
canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
canvas.restore()
}
if (state.maxTraceOn) {
drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "")
}
drawTempMarker(canvas, state.minPos % 160, state.minPos / 160, state.minTempC, null)
for (p in state.probes) {
drawTempMarker(canvas, p.x, p.y, p.tempC, p.label)
val text = "中心 %.1f℃".format(it)
val half = gripTextHalf(text)
// keep the readout inside the image rect whatever the grip
val cx = (ox + half[0]).coerceAtMost(viewport.right - half[0] - 4f * density)
val cy = (oy - textPaint.textSize / 2f).coerceAtLeast(viewport.top + half[1] + 4f * density)
drawGripText(canvas, text, cx, cy)
}
// probes and the max/min extremes, all through the shared painter so the
// saved photo matches this screen exactly
drawMarkers(canvas, state)
drawColorBar(canvas, state)
}
}
@@ -1,9 +1,13 @@
package com.mag160c.thermal.ui.live
import android.view.SurfaceView
import android.view.TextureView
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -31,6 +35,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -39,6 +44,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
@@ -48,6 +54,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.mag160c.thermal.R
import com.mag160c.thermal.core.Palettes
@@ -72,25 +79,80 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
var showPalette by remember { mutableStateOf(false) }
var navPx by remember { mutableStateOf(UiInsets.navPx) }
var shutterPx by remember { mutableStateOf(0) }
// visible-light PIP (Phase E)
var pipEngine by remember { mutableStateOf<PipCameraEngine?>(null) }
var viewportSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) }
val cameraPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
vm.togglePip()
} else {
com.mag160c.thermal.media.DebugLog.log("pip", "camera permission denied by user")
}
}
LaunchedEffect(Unit) {
vm.connect()
vm.uiBottomPx = navPx + shutterPx
// Seed the renderer with the persisted settings once; from then on
// AppSettings publishes changes, so the settings tab applies immediately.
com.mag160c.thermal.ui.settings.ImageOrientationSettings.publishFrom(context)
// Temperature/OSD updates run on a background dispatcher inside the view
// model: doing that work here (main thread) every 400 ms was the visible
// stutter on the device (7.3% janky frames, 60-700 ms tail).
vm.startTemperatureLoop()
while (true) {
kotlinx.coroutines.delay(400)
navPx = UiInsets.navPx
vm.uiBottomPx = navPx + shutterPx
vm.refreshTemps()
}
}
// Apply orientation / palette / trace-mode changes the moment they happen
// (including changes made on the settings tab), and on first launch.
val settings by com.mag160c.thermal.ui.settings.ImageOrientationSettings.state
.collectAsState()
LaunchedEffect(settings) {
vm.userRotateDeg = settings.rotateDeg
vm.flipH = settings.flipH
vm.flipV = settings.flipV
vm.setTraceMode(settings.traceMode)
vm.applyEnhanceLevel(settings.enhanceLevel)
// palette: only re-apply when the user picks a different default, so a
// temporary change from the live control bar is not stomped every frame
if (vm.state.value.paletteIndex != settings.paletteIndex) {
vm.setPalette(settings.paletteIndex)
}
}
Box(modifier = Modifier.fillMaxSize()) {
// The PIP overlay owns the camera: it releases on any of these exits —
// PIP switched off (overlay leaves composition), live screen left, or the
// activity going to the background (ON_STOP).
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_STOP) {
pipEngine?.release()
pipEngine = null
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Box(
modifier = Modifier
.fillMaxSize()
.onSizeChanged { viewportSize = it },
) {
AndroidSurface(vm)
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures { offset ->
// the image is locked to the portrait frame, so the tap
// mapping does not depend on the grip angle
vm.tapImage(
offset.x, offset.y,
size.width.toFloat(), size.height.toFloat(),
@@ -109,6 +171,33 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
)
}
// Transient confirmation for actions taken WHILE streaming (photo saved,
// recording finished, save failure). These statuses are not shown by the
// block above, which only covers the disconnected case — without this the
// user got no feedback at all for拍照/录像.
if (state.connected && state.status in LIVE_ACTION_STATUSES) {
Surface(
color = MaterialTheme.colorScheme.inverseSurface,
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = (navPx / density).dp + 96.dp)
.graphicsLayer { rotationZ = -phi.toFloat() },
) {
Text(
statusText(state),
color = MaterialTheme.colorScheme.inverseOnSurface,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
// clear the transient message so it does not linger
LaunchedEffect(state.status) {
kotlinx.coroutines.delay(2500)
vm.clearTransientStatus()
}
}
// ---- control TOP bar (glued to the portrait top edge) ----
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
@@ -165,16 +254,21 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable { vm.toggleMaxTrace() }.padding(4.dp),
) {
val on = state.maxTraceOn
val tracing = state.traceMode != LiveViewModel.TraceMode.NONE
Icon(
painterResource(R.drawable.ic_target), "最高温追踪",
tint = if (on) MaterialTheme.colorScheme.primary
painterResource(R.drawable.ic_target), "追踪",
tint = if (tracing) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
if (on) "追踪·开" else "追踪",
when (state.traceMode) {
LiveViewModel.TraceMode.MAX -> "追高"
LiveViewModel.TraceMode.MIN -> "追低"
LiveViewModel.TraceMode.BOTH -> "追高·低"
LiveViewModel.TraceMode.NONE -> "追踪"
},
style = MaterialTheme.typography.labelSmall,
color = if (on) MaterialTheme.colorScheme.primary
color = if (tracing) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -194,9 +288,54 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
)
}
}
Box(
modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() },
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clickable {
if (state.pipOn) {
vm.togglePip()
} else if (ContextCompat.checkSelfPermission(
context, android.Manifest.permission.CAMERA,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) {
vm.togglePip()
} else {
cameraPermission.launch(android.Manifest.permission.CAMERA)
}
}
.padding(4.dp),
) {
val pipOn = state.pipOn
Icon(
painterResource(R.drawable.ic_pip), "画中画",
tint = if (pipOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
if (pipOn) "画中画·开" else "画中画",
style = MaterialTheme.typography.labelSmall,
color = if (pipOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
// ---- visible-light PIP overlay (before the shutter row so the row draws on top) ----
if (state.pipOn) {
PipOverlay(
state = state,
vm = vm,
viewport = viewportSize,
onEngine = { pipEngine = it },
)
}
// ---- camera shutter row above the bottom navigation (live tab) ----
val recording = state.status == "recording"
Row(
@@ -289,6 +428,83 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
}
}
/**
* Draggable visible-light PIP overlay (Phase E).
*
* Position is expressed relative to the thermal viewport (the same rect the
* renderer draws into): left = viewport.left + xf * free width, with the
* color-bar footprint kept clear at the right edge. Drag updates the stored
* fractions; tap cycles 96/128/160 dp; double tap closes.
*/
@Composable
private fun PipOverlay(
state: LiveViewModel.LiveState,
vm: LiveViewModel,
viewport: androidx.compose.ui.unit.IntSize,
onEngine: (PipCameraEngine) -> Unit,
) {
val density = LocalDensity.current
val context = LocalContext.current
val engine = remember { PipCameraEngine(context.applicationContext) }
LaunchedEffect(Unit) { onEngine(engine) }
// switching PIP off / leaving the screen removes this overlay: release here
DisposableEffect(engine) {
onDispose { engine.release() }
}
val wDp = LiveViewModel.PIP_WIDTHS_DP[state.pipSizeIndex.coerceIn(0, 2)]
val hDp = wDp * 3 / 4
with(density) {
val top = vm.uiTopPx.toFloat()
val bottom = (viewport.height - vm.uiBottomPx).toFloat().coerceAtLeast(top + 1f)
val freeW = (viewport.width - wDp * density.density).coerceAtLeast(1f)
val freeH = (bottom - top - hDp * density.density).coerceAtLeast(1f)
// xf = 1 parks the PIP at the right edge minus the color-bar margin
val rightMargin = LiveViewModel.PIP_RIGHT_MARGIN_DP * density.density
val left = vm.state.value.pipXf * (freeW - rightMargin)
val topPx = top + vm.state.value.pipYf * freeH
Box(
modifier = Modifier
.offset(x = (left / density.density).dp, y = (topPx / density.density).dp)
.size(wDp.dp, hDp.dp)
.border(2.dp, Color.White, RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp))
.pointerInput(state.pipSizeIndex) {
detectDragGestures { change, drag ->
change.consume()
val s = vm.state.value
val nx = (s.pipXf + drag.x / (freeW - rightMargin).coerceAtLeast(1f)).coerceIn(0f, 1f)
val ny = (s.pipYf + drag.y / freeH).coerceIn(0f, 1f)
vm.setPipPos(nx, ny)
}
}
.pointerInput(state.pipSizeIndex) {
detectTapGestures(
onTap = { vm.cyclePipSize() },
onDoubleTap = { vm.togglePip() },
)
},
) {
AndroidView(
factory = { ctx ->
TextureView(ctx).also { tv ->
tv.surfaceTextureListener = engine
engine.attach(tv)
}
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
/** Statuses that are a one-shot confirmation of a user action while streaming. */
private val LIVE_ACTION_STATUSES = setOf(
"saved", "save_fail", "rec_done", "rec_fail", "rec_save_fail",
)
private fun statusText(state: LiveViewModel.LiveState): String = when (state.status) {
"no_device" -> "未检测到热像仪,请插入MAG160C"
"no_permission" -> "USB权限未授予"
@@ -298,6 +514,12 @@ private fun statusText(state: LiveViewModel.LiveState): String = when (state.sta
"connect_fail" -> "连接流程异常(查看调试日志)"
"no_handshake" -> "相机无应答,请拔插热像仪重试"
"no_stream_data" -> "已连接但无数据流(10秒),日志已记录"
"saved" -> "已保存到相册(DCIM/MAG160C"
"save_fail" -> "照片保存失败(查看调试日志)"
"recording" -> "录像中…"
"rec_done" -> "录像已保存到相册(DCIM/MAG160C"
"rec_fail" -> "录像启动失败(查看调试日志)"
"rec_save_fail" -> "录像保存失败(查看调试日志)"
else -> {
val prefix = if (state.status.startsWith("exception:")) {
"异常:" + state.status.removePrefix("exception:")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,195 @@
package com.mag160c.thermal.ui.live
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.SurfaceTexture
import android.hardware.camera2.CameraCaptureSession
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CaptureRequest
import android.os.Handler
import android.os.HandlerThread
import android.view.Surface
import android.view.TextureView
import com.mag160c.thermal.media.DebugLog
/**
* Minimal Camera2 preview engine for the visible-light PIP overlay (Phase E).
*
* Design rules:
* - open the first BACK-facing camera, preview-only (TEMPLATE_PREVIEW), no
* capture requests, no recording, no image reader;
* - every failure path logs under tag "pip" and tears the camera down —
* a camera problem must never crash or block the thermal live view;
* - [release] is idempotent and safe to call from any state.
*/
class PipCameraEngine(private val context: Context) : TextureView.SurfaceTextureListener {
private var textureView: TextureView? = null
private var camera: CameraDevice? = null
private var session: CameraCaptureSession? = null
private var thread: HandlerThread? = null
private var handler: Handler? = null
private var surface: Surface? = null
@Volatile
private var released = false
/** True once a repeating preview request has been submitted. */
@Volatile
var previewing = false
private set
/** Attach to a TextureView and start the preview as soon as it has a surface. */
fun attach(view: TextureView) {
try {
textureView = view
view.surfaceTextureListener = this
if (view.isAvailable) onSurfaceTextureAvailable(view.surfaceTexture!!, view.width, view.height)
} catch (e: Exception) {
DebugLog.log("pip", "attach failed: $e")
}
}
override fun onSurfaceTextureAvailable(st: SurfaceTexture, width: Int, height: Int) {
try {
startThread()
surface = Surface(st)
openCamera()
} catch (e: Exception) {
DebugLog.log("pip", "surface available handling failed: $e")
release()
}
}
override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, width: Int, height: Int) = Unit
override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean {
release()
return true
}
override fun onSurfaceTextureUpdated(st: SurfaceTexture) = Unit
private fun startThread() {
if (thread == null) {
val t = HandlerThread("pip-camera")
t.start()
thread = t
handler = Handler(t.looper)
}
}
private fun openCamera() {
val manager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
val id = try {
manager.cameraIdList.firstOrNull { cid ->
val facing = manager.getCameraCharacteristics(cid)
.get(CameraCharacteristics.LENS_FACING)
facing == CameraCharacteristics.LENS_FACING_BACK
} ?: manager.cameraIdList.firstOrNull()
} catch (e: Exception) {
DebugLog.log("pip", "no camera listed: $e")
null
}
if (id == null) {
DebugLog.log("pip", "no camera available")
return
}
if (context.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
DebugLog.log("pip", "camera permission not granted")
return
}
try {
manager.openCamera(id, object : CameraDevice.StateCallback() {
override fun onOpened(device: CameraDevice) {
camera = device
DebugLog.log("pip", "camera opened id=$id")
createSession(device)
}
override fun onDisconnected(device: CameraDevice) {
DebugLog.log("pip", "camera disconnected")
release()
}
override fun onError(device: CameraDevice, error: Int) {
DebugLog.log("pip", "camera error=$error")
release()
}
}, handler)
} catch (e: Exception) {
DebugLog.log("pip", "openCamera failed: $e")
release()
}
}
private fun createSession(device: CameraDevice) {
val target = surface ?: run {
DebugLog.log("pip", "no surface for session")
return
}
try {
@Suppress("DEPRECATION")
device.createCaptureSession(listOf(target), object : CameraCaptureSession.StateCallback() {
override fun onConfigured(s: CameraCaptureSession) {
session = s
startPreview(device, s, target)
}
override fun onConfigureFailed(s: CameraCaptureSession) {
DebugLog.log("pip", "capture session configure failed")
release()
}
}, handler)
} catch (e: Exception) {
DebugLog.log("pip", "createCaptureSession failed: $e")
release()
}
}
private fun startPreview(device: CameraDevice, s: CameraCaptureSession, target: Surface) {
try {
val request = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply {
addTarget(target)
set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO)
}
s.setRepeatingRequest(request.build(), null, handler)
previewing = true
DebugLog.log("pip", "repeating preview started")
} catch (e: Exception) {
DebugLog.log("pip", "startPreview failed: $e")
release()
}
}
/** Idempotent teardown: session -> camera -> surface -> thread. */
fun release() {
if (released && camera == null && session == null) return
released = true
previewing = false
try {
session?.close()
} catch (e: Exception) {
DebugLog.log("pip", "session close failed: $e")
}
session = null
try {
camera?.close()
} catch (e: Exception) {
DebugLog.log("pip", "camera close failed: $e")
}
camera = null
try {
surface?.release()
} catch (_: Exception) {
}
surface = null
thread?.quitSafely()
thread = null
handler = null
DebugLog.log("pip", "released")
}
}
@@ -0,0 +1,199 @@
package com.mag160c.thermal.ui.remote
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.mag160c.thermal.R
import com.mag160c.thermal.net.RemoteClient
import com.mag160c.thermal.net.RemoteContract
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Remote-preview host list (Phase F).
*
* Layout is fixed by the execution plan: 56dp title bar with a back arrow,
* 64dp host cards (24dp icon, host name 16sp, "IP:47511" 12sp grey, trailing
* "连接" button), a full-width 48dp "重新扫描" button, then a "手动添加" row with
* an IP field, and a centred empty state while scanning.
*/
@Composable
fun RemoteClientListScreen(
onBack: () -> Unit,
onConnect: (String, Int) -> Unit,
) {
val context = LocalContext.current
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
var hosts by remember { mutableStateOf<List<RemoteContract.HostInfo>>(emptyList()) }
var scanning by remember { mutableStateOf(true) }
var manualIp by remember { mutableStateOf("") }
var scanGeneration by remember { mutableStateOf(0) }
DisposableEffect(Unit) {
onDispose { scope.cancel() }
}
// System back returns to the settings screen instead of leaving the app
androidx.activity.compose.BackHandler(enabled = true) { onBack() }
LaunchedEffect(scanGeneration) {
hosts = emptyList()
scanning = true
val job = scope.launch {
RemoteClient.discover(scope).collect { info ->
// newest first, de-duplicated by address
hosts = (listOf(info) + hosts.filter { it.address != info.address }).take(12)
}
}
// stop showing the empty state after the 10 s discovery window
kotlinx.coroutines.delay(10_000)
scanning = false
job.cancel()
}
Column(modifier = Modifier.fillMaxSize()) {
// ---- title bar (56dp) ----
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painterResource(R.drawable.ic_back_arrow), "返回",
modifier = Modifier
.size(24.dp)
.clickable { onBack() },
)
Spacer(Modifier.width(12.dp))
Text("远程预览", style = MaterialTheme.typography.titleMedium)
}
HorizontalDivider()
LazyColumn(modifier = Modifier.weight(1f)) {
items(hosts, key = { it.address }) { h ->
HostCard(h) { onConnect(h.address, h.tcpPort) }
}
item {
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { scanGeneration++ },
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.padding(horizontal = 16.dp),
) { Text("重新扫描") }
Spacer(Modifier.height(8.dp))
HorizontalDivider()
Text(
"手动添加",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = manualIp,
onValueChange = { manualIp = it },
placeholder = { Text("例如 192.168.1.23") },
singleLine = true,
modifier = Modifier.weight(1f),
)
TextButton(
onClick = {
val ip = manualIp.trim()
if (ip.isNotEmpty()) onConnect(ip, RemoteContract.CONTROL_PORT)
},
) { Text("连接") }
}
Spacer(Modifier.height(16.dp))
}
}
if (hosts.isEmpty()) {
Box(modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
Text(
if (scanning) "正在扫描局域网主机…" else "未发现主机",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun HostCard(info: RemoteContract.HostInfo, onConnect: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.clickable { onConnect() }
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painterResource(R.drawable.ic_pip), null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
info.name,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
"${info.address}:${info.tcpPort}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onConnect) { Text("连接") }
}
HorizontalDivider(color = Color(0x1F000000))
}
@@ -0,0 +1,233 @@
package com.mag160c.thermal.ui.remote
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import android.view.SurfaceHolder
import android.view.SurfaceView
import com.mag160c.thermal.ui.DeviceOrientation
/**
* Software renderer for the remote preview (Phase F).
*
* Composition is intentionally IDENTICAL to LiveRenderer (which is frozen and
* must not be edited): the thermal frame is drawn rotated 90 deg CW into a
* fitted 3:4 rect inside the area between the top bar and the bottom nav, so a
* remote and a local view of the same camera look the same. Only the data
* source differs — this one reads RemoteViewerViewModel.
*/
class RemoteRendererHost(
private val surfaceView: SurfaceView,
private val vm: RemoteViewerViewModel,
) : SurfaceHolder.Callback, Runnable {
private var thread: Thread? = null
private val running = java.util.concurrent.atomic.AtomicBoolean(false)
private val density = surfaceView.resources.displayMetrics.density
private val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888)
private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
typeface = Typeface.SANS_SERIF
textSize = 15f * density
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
}
private val viewport = android.graphics.RectF()
private val textRot: Float
get() = -DeviceOrientation.deg.value.toFloat()
fun attach() {
surfaceView.holder.addCallback(this)
}
override fun surfaceCreated(holder: SurfaceHolder) {
running.set(true)
thread = Thread(this, "remote-render").also { it.start() }
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
running.set(false)
thread?.join(200)
thread = null
}
override fun run() {
val holder = surfaceView.holder
while (running.get()) {
val canvas = holder.lockCanvas() ?: continue
try {
drawFrame(canvas)
} finally {
holder.unlockCanvasAndPost(canvas)
}
try {
Thread.sleep(33)
} catch (_: InterruptedException) {
}
}
}
private fun drawFrame(canvas: Canvas) {
val w = canvas.width.toFloat()
val h = canvas.height.toFloat()
canvas.drawColor(Color.BLACK)
val frame = vm.latestFrame ?: return
val top = vm.uiTopPx.toFloat()
val bottom = h - vm.uiBottomPx.toFloat()
val availW = w
val availH = bottom - top
if (availH <= 0) return
// Same orientation pipeline as the live view: the image is LOCKED to the
// portrait frame and only the user's manual corrections apply.
val params = vm.imageParams()
setFramePixels(frame, params)
val fit = com.mag160c.thermal.ui.live.ImageTransform.fit(0f, top, availW, availH, params.rotDeg)
viewport.set(fit.left, fit.top, fit.right, fit.bottom)
val zoom = vm.state.value.zoom
val crop = com.mag160c.thermal.ui.live.ImageTransform.cropForZoom(zoom)
val srcRect = if (zoom > 1) {
val cw = (320 * (crop[2] - crop[0])).toInt()
val ch = (240 * (crop[3] - crop[1])).toInt()
android.graphics.Rect(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2)
} else null
val size = if (com.mag160c.thermal.ui.live.ImageTransform.swapped(params.rotDeg)) {
floatArrayOf(fit.height, fit.width)
} else {
floatArrayOf(fit.width, fit.height)
}
canvas.save()
canvas.rotate(params.rotDeg.toFloat(), fit.cx, fit.cy)
val dst = android.graphics.RectF(
fit.cx - size[0] / 2f, fit.cy - size[1] / 2f,
fit.cx + size[0] / 2f, fit.cy + size[1] / 2f,
)
if (srcRect != null) canvas.drawBitmap(bitmap, srcRect, dst, paint)
else canvas.drawBitmap(bitmap, null, dst, paint)
canvas.restore()
drawColorBar(canvas, vm.state.value)
drawOsd(canvas, vm.state.value)
}
/** Copy the frame into the draw bitmap, honouring the user's mirror settings. */
private fun setFramePixels(
frame: IntArray,
p: com.mag160c.thermal.ui.live.ImageTransform.Params,
) {
if (!p.flipH && !p.flipV) {
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
return
}
val flipped = IntArray(320 * 240)
for (y in 0 until 240) {
val sy = if (p.flipV) 239 - y else y
val srcRow = sy * 320
val dstRow = y * 320
if (!p.flipH) {
System.arraycopy(frame, srcRow, flipped, dstRow, 320)
} else {
for (x in 0 until 320) flipped[dstRow + x] = frame[srcRow + (319 - x)]
}
}
bitmap.setPixels(flipped, 0, 320, 0, 0, 320, 240)
}
private fun drawColorBar(canvas: Canvas, state: RemoteViewerViewModel.State) {
if (state.maxTempC == null || state.minTempC == null) return
val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex]
val barW = 20f * density
val barH = viewport.height() * 0.8f
val x = viewport.right - barW - 12f * density
val y0 = viewport.top + (viewport.height() - barH) / 2f
val seg = Paint()
val n = 96
for (i in 0 until n) {
val c = pal[255 - i * 255 / (n - 1)]
seg.color = c
canvas.drawRect(x, y0 + barH * i / n, x + barW, y0 + barH * (i + 1) / n + 0.5f, seg)
}
textPaint.color = Color.WHITE
val maxT = "%.1f".format(state.maxTempC)
val minT = "%.1f".format(state.minTempC)
val cx = x + barW / 2f
val gap = 8f * density
// position by the ROTATED bounding box so the numbers stay beside the bar
// ends whatever the grip (baseline anchoring drifted with rotation)
val halfMax = gripTextHalf(maxT)
val halfMin = gripTextHalf(minT)
drawGripText(canvas, maxT, cx, y0 - gap - halfMax[1])
drawGripText(canvas, minT, cx, y0 + barH + gap + halfMin[1])
}
private fun drawOsd(canvas: Canvas, state: RemoteViewerViewModel.State) {
textPaint.color = Color.WHITE
val ox = viewport.left + 12f * density
val oy = viewport.top + textPaint.textSize + 10f * density
state.centerTempC?.let {
val text = "中心 %.1f℃".format(it)
val half = gripTextHalf(text)
val cx = (ox + half[0]).coerceAtMost(viewport.right - half[0] - 4f * density)
val cy = (oy - textPaint.textSize / 2f).coerceAtLeast(viewport.top + half[1] + 4f * density)
drawGripText(canvas, text, cx, cy)
}
val markerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
}
// the trace toggle governs both extremes, as on the live screen
if (state.maxTraceOn && state.maxPos >= 0 && state.maxTempC != null) {
val p = probeToScreen(state.maxPos % 160, state.maxPos / 160)
markerPaint.style = Paint.Style.STROKE
markerPaint.strokeWidth = 2.5f * density
canvas.drawCircle(p[0], p[1], 9f * density, markerPaint)
markerPaint.style = Paint.Style.FILL
canvas.drawCircle(p[0], p[1], 4.5f * density, markerPaint)
}
if (state.maxTraceOn && state.minPos >= 0 && state.minTempC != null) {
val p = probeToScreen(state.minPos % 160, state.minPos / 160)
markerPaint.style = Paint.Style.STROKE
markerPaint.strokeWidth = 2.5f * density
canvas.drawCircle(p[0], p[1], 9f * density, markerPaint)
markerPaint.style = Paint.Style.FILL
canvas.drawCircle(p[0], p[1], 4.5f * density, markerPaint)
}
}
/** Draw OSD text pre-rotated by the grip angle, centred on a buffer point. */
private fun drawGripText(canvas: Canvas, text: String, centerX: Float, centerY: Float) {
val tw = textPaint.measureText(text)
val fm = textPaint.fontMetrics
canvas.save()
canvas.rotate(textRot, centerX, centerY)
val baseline = centerY - (fm.ascent + fm.descent) / 2f
canvas.drawText(text, centerX - tw / 2f, baseline, textPaint)
canvas.restore()
}
private fun gripTextHalf(text: String): FloatArray {
val fm = textPaint.fontMetrics
return com.mag160c.thermal.ui.live.ImageTransform.rotatedBoxHalfExtents(
textPaint.measureText(text), fm.descent - fm.ascent, textRot,
)
}
/** Sensor pixel -> screen position, sharing the live view's geometry. */
private fun probeToScreen(sx: Int, sy: Int): FloatArray {
val viewW = vm.uiViewW.toFloat().coerceAtLeast(1f)
val availH = (vm.uiViewH - vm.uiTopPx - vm.uiBottomPx).toFloat().coerceAtLeast(1f)
val params = vm.imageParams()
val fit = com.mag160c.thermal.ui.live.ImageTransform.fit(0f, vm.uiTopPx.toFloat(), viewW, availH, params.rotDeg)
val crop = com.mag160c.thermal.ui.live.ImageTransform.cropForZoom(vm.state.value.zoom)
return com.mag160c.thermal.ui.live.ImageTransform.sensorToScreen(
sx.toFloat(), sy.toFloat(), params, fit, crop,
)
}
}
@@ -0,0 +1,247 @@
package com.mag160c.thermal.ui.remote
import android.view.SurfaceView
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewmodel.compose.viewModel
import com.mag160c.thermal.R
import com.mag160c.thermal.core.Palettes
import com.mag160c.thermal.ui.DeviceOrientation
/**
* Remote preview viewer (Phase F): same skeleton as the live screen — surface
* renderer, 4-item control top bar, colour bar, OSD — but the frame source is
* the LAN session and the shutter row is replaced by a single red "断开" button.
*
* FFC goes back to the host; zoom / max-trace / palette are local pipeline
* operations, so no round trip is needed for them.
*/
@Composable
fun RemoteViewerScreen(
host: String,
port: Int,
onDisconnected: (String) -> Unit,
vm: RemoteViewerViewModel = viewModel(),
) {
val state by vm.state.collectAsState()
val phi by DeviceOrientation.deg.collectAsState()
val density = LocalDensity.current.density
val context = androidx.compose.ui.platform.LocalContext.current
var showPalette by remember { mutableStateOf(false) }
var navPx by remember { mutableStateOf(com.mag160c.thermal.ui.UiInsets.navPx) }
var shutterPx by remember { mutableStateOf(0) }
LaunchedEffect(host, port) { vm.connect(host, port) }
LaunchedEffect(Unit) {
while (true) {
kotlinx.coroutines.delay(400)
navPx = com.mag160c.thermal.ui.UiInsets.navPx
vm.uiBottomPx = navPx + shutterPx
}
}
// The remote image uses the same locked orientation + manual corrections as
// the live view, applied the moment they change.
val orientation by com.mag160c.thermal.ui.settings.ImageOrientationSettings.state
.collectAsState()
LaunchedEffect(orientation) {
vm.userRotateDeg = orientation.rotateDeg
vm.flipH = orientation.flipH
vm.flipV = orientation.flipV
}
LaunchedEffect(Unit) {
vm.disconnected.collect { reason -> onDisconnected(reason) }
}
// System back disconnects and returns to the host list, instead of exiting
androidx.activity.compose.BackHandler(enabled = true) {
vm.disconnect()
onDisconnected("已断开")
}
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
factory = { ctx ->
SurfaceView(ctx).also { sv ->
val renderer = RemoteRendererHost(sv, vm)
renderer.attach()
}
},
modifier = Modifier
.fillMaxSize()
.onSizeChanged {
vm.uiViewW = it.width
vm.uiViewH = it.height
},
)
if (!state.connected) {
Text(
text = when (state.status) {
"connect_fail" -> "无法连接主机"
"disconnected" -> "连接已断开"
"ddt_fail" -> "本地标定文件加载失败"
else -> "连接中…"
},
modifier = Modifier
.align(Alignment.Center)
.graphicsLayer { rotationZ = -phi.toFloat() }
.padding(16.dp),
)
}
// ---- control TOP bar (4 items, same as live) ----
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.onSizeChanged { vm.uiTopPx = it.height }
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RemoteTopItem(phi, "FFC") { vm.requestFfc() }
RemoteTopItem(phi, "${state.zoom}×") { vm.setZoom(state.zoom % 4 + 1) }
RemoteTopItem(
phi,
if (state.maxTraceOn) "追踪·开" else "追踪",
icon = R.drawable.ic_target,
highlight = state.maxTraceOn,
) { vm.toggleMaxTrace() }
RemoteTopItem(
phi,
Palettes.NAMES[state.paletteIndex],
icon = R.drawable.ic_palette,
) { showPalette = true }
}
}
// ---- single red disconnect button in the shutter row slot ----
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.graphicsLayer { translationY = -navPx.toFloat() }
.onSizeChanged {
shutterPx = it.height
vm.uiBottomPx = navPx + shutterPx
}
.fillMaxWidth()
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
) {
Box(
modifier = Modifier
.size(60.dp)
.border(4.dp, Color(0xFFFF5252), CircleShape)
.clickable {
vm.disconnect()
onDisconnected("已断开")
},
)
Text(
"断开", color = Color.White,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
if (showPalette) {
AlertDialog(
onDismissRequest = { showPalette = false },
title = { Text("调色板") },
text = {
LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = Modifier.height(320.dp)) {
items((0..11).toList()) { idx ->
Text(
Palettes.NAMES[idx],
color = if (idx == state.paletteIndex) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clickable { vm.setPalette(idx); showPalette = false }
.padding(14.dp),
)
}
}
},
confirmButton = {},
)
}
}
@Composable
private fun androidx.compose.foundation.layout.RowScope.RemoteTopItem(
phi: Int,
label: String,
icon: Int = R.drawable.ic_ffc,
highlight: Boolean = false,
onClick: () -> Unit,
) {
Box(
modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() },
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable(onClick = onClick).padding(4.dp),
) {
Icon(
painterResource(icon), null,
tint = if (highlight) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = if (highlight) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,225 @@
package com.mag160c.thermal.ui.remote
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import com.mag160c.thermal.core.RenderPipeline
import com.mag160c.thermal.media.DebugLog
import com.mag160c.thermal.net.RemoteClient
import com.mag160c.thermal.net.RemoteContract
import com.mag160c.thermal.net.RemoteSession
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Client-side remote preview (Phase F).
*
* The session sends raw sensor frames; this view model renders them with the
* LOCAL pipeline (bundled DDT) so palette and zoom changes are instant and
* never round-trip. Temperature readout mirrors the live screen's local
* branch (probe at sensor pixel 80,60).
*/
class RemoteViewerViewModel(app: Application) : AndroidViewModel(app) {
data class State(
val connected: Boolean = false,
val host: String = "",
val frames: Int = 0,
val centerTempC: Float? = null,
val maxTempC: Float? = null,
val minTempC: Float? = null,
val maxPos: Int = -1,
val minPos: Int = -1,
val paletteIndex: Int = 2,
val zoom: Int = 1,
val maxTraceOn: Boolean = true,
val status: String = "",
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state
/** Latest rendered 320x240 ARGB frame for the renderer. */
@Volatile
var latestFrame: IntArray? = null
private set
/** UI insets (px) reported from Compose, consumed by the renderer. */
@Volatile
var uiTopPx: Int = 0
@Volatile
var uiBottomPx: Int = 0
@Volatile
var uiViewW: Int = 1080
@Volatile
var uiViewH: Int = 2280
/**
* Manual orientation corrections, persisted like the live screen's. The
* remote view must use the SAME settings as the host's live view, otherwise
* the two screens disagree about which way is up.
*/
@Volatile
var userRotateDeg: Int = 0
@Volatile
var flipH: Boolean = false
@Volatile
var flipV: Boolean = false
/**
* Image orientation: LOCKED base rotation plus the user's manual
* corrections, exactly as on the live screen (no grip dependence).
*/
fun imageParams(): com.mag160c.thermal.ui.live.ImageTransform.Params =
com.mag160c.thermal.ui.live.ImageTransform.params(userRotateDeg, flipH, flipV)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var session: RemoteSession? = null
private var pipeline: RenderPipeline? = null
private var renderJob: Job? = null
/** Emits once when the link drops (UI shows a snackbar and returns). */
private val _disconnected = MutableSharedFlow<String>(extraBufferCapacity = 1)
val disconnected = _disconnected
/** Connect, then immediately start the stream. */
fun connect(host: String, port: Int) {
scope.launch {
val s = RemoteClient.connect(host, port, scope)
if (s == null) {
_state.value = _state.value.copy(connected = false, status = "connect_fail")
_disconnected.tryEmit("无法连接主机")
return@launch
}
session = s
_state.value = _state.value.copy(connected = true, host = "$host:$port")
DebugLog.log("remote", "client connected to $host:$port")
// local render pipeline with the bundled DDT
val ddt = runCatching {
getApplication<Application>().assets.open("mag160c.ddt").readBytes()
}.getOrDefault(ByteArray(0))
val pipe = RenderPipeline(w = 160, h = 120)
if (!pipe.loadDdt(ddt)) {
DebugLog.log("remote", "ddt load failed -> cannot render remote frames")
_state.value = _state.value.copy(status = "ddt_fail")
return@launch
}
pipe.setPalette(_state.value.paletteIndex)
pipeline = pipe
// control lines (welcome / busy / stream-start) for logging + UX
launch {
s.lines.collect { line ->
DebugLog.log("remote", "line: $line")
if (RemoteContract.isBusyLine(line)) {
// the host is already serving someone else
_state.value = _state.value.copy(status = "busy")
_disconnected.tryEmit("主机正忙(已有客户端连接)")
}
}
}
// frames -> local pipeline -> latestFrame.
// The host sends the raw counts plus the metadata ITS pipeline used
// (FFC phase + camera temperature). Running an independent FFC state
// machine on the client produced a wrong image (the NUC tables
// interpolate on the camera temperature, which was missing, so the
// counts saturated and the readout showed ~-161 C).
val out = IntArray(320 * 240)
renderJob = launch {
s.frames.collect { pkt ->
val n = _state.value.frames + 1
if (n == 1) DebugLog.log("remote", "first remote frame")
_state.value = _state.value.copy(frames = n)
if (pipe.frameRemote(pkt.pixels, pkt.ffcPhase, pkt.shutter, out)) {
latestFrame = out.copyOf()
}
}
}
launch {
s.closedFlow.collect {
_state.value = _state.value.copy(connected = false, status = "disconnected")
_disconnected.tryEmit("连接已断开")
}
}
s.hello("mag160c-client")
s.startStream()
// temperature refresh on a slow timer
while (true) {
kotlinx.coroutines.delay(400)
refreshTemps()
}
}
}
private fun refreshTemps() {
val pipe = pipeline ?: return
// gate on a completed render: before the first one the buffers hold
// zeros and countsToTempMc(0) reads about -161 C
if (!pipe.tempsReady()) return
// pipeline.probeTemp already returns millidegrees C
val centerMc = pipe.probeTemp(80, 60)
val nuc = IntArray(19200)
pipe.copyNuc(nuc)
var mn = Int.MAX_VALUE
var mx = -1
var mnPos = -1
var mxPos = -1
for (i in nuc.indices) {
val v = nuc[i]
if (v < mn) { mn = v; mnPos = i }
if (v > mx) { mx = v; mxPos = i }
}
_state.value = _state.value.copy(
centerTempC = centerMc / 1000f,
maxTempC = if (mx >= 0) com.mag160c.thermal.core.TempMath.countsToTempMc(mx) / 1000f else null,
minTempC = if (mn <= Int.MAX_VALUE) com.mag160c.thermal.core.TempMath.countsToTempMc(mn) / 1000f else null,
maxPos = mxPos,
minPos = mnPos,
)
}
fun setPalette(index: Int) {
pipeline?.setPalette(index)
_state.value = _state.value.copy(paletteIndex = index)
}
fun setZoom(z: Int) {
_state.value = _state.value.copy(zoom = z.coerceIn(1, 4))
}
fun toggleMaxTrace() {
_state.value = _state.value.copy(maxTraceOn = !_state.value.maxTraceOn)
}
fun requestFfc() {
session?.requestFfc()
}
/** Disconnect and return; also called from onCleared. */
fun disconnect() {
session?.stopStream()
session?.close()
session = null
renderJob?.cancel()
renderJob = null
_state.value = _state.value.copy(connected = false)
}
override fun onCleared() {
disconnect()
scope.cancel()
super.onCleared()
}
}
@@ -1,6 +1,55 @@
package com.mag160c.thermal.ui.settings
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Process-wide observable copy of the settings the LIVE/REMOTE screens must
* apply immediately (image orientation, palette, trace mode).
*
* The settings screen writes [AppSettings] and the renderers need the new values
* at once. Before this existed, the renderers polled AppSettings every 400 ms
* from the live screen's loop — which only runs while the LIVE tab is composed,
* so a change made on the settings tab was applied late, or not until the app
* was restarted (the user reported "settings revert to defaults after a
* restart", which was really "they were never applied").
*/
object ImageOrientationSettings {
data class State(
val rotateDeg: Int = 0,
val flipH: Boolean = false,
val flipV: Boolean = false,
val paletteIndex: Int = 2,
val traceMode: com.mag160c.thermal.ui.live.LiveViewModel.TraceMode =
com.mag160c.thermal.ui.live.LiveViewModel.TraceMode.BOTH,
/** Detail enhancement level 0..32 (see AppSettings.enhanceLevel). */
val enhanceLevel: Int = 0,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state
fun publish(
rotateDeg: Int,
flipH: Boolean,
flipV: Boolean,
paletteIndex: Int = _state.value.paletteIndex,
traceMode: com.mag160c.thermal.ui.live.LiveViewModel.TraceMode = _state.value.traceMode,
enhanceLevel: Int = _state.value.enhanceLevel,
) {
_state.value = State(rotateDeg, flipH, flipV, paletteIndex, traceMode, enhanceLevel)
}
/** Read persisted values and publish them (called when settings load). */
fun publishFrom(context: Context) {
val s = AppSettings(context)
publish(
s.imageRotateDeg, s.imageFlipH, s.imageFlipV,
s.defaultPaletteIndex, s.traceMode, s.enhanceLevel,
)
}
}
/** App settings backed by SharedPreferences (app-private storage only). */
class AppSettings(context: Context) {
@@ -8,7 +57,44 @@ class AppSettings(context: Context) {
var defaultPaletteIndex: Int
get() = sp.getInt("palette", 2)
set(v) = sp.edit().putInt("palette", v).apply()
set(v) {
sp.edit().putInt("palette", v).apply()
ImageOrientationSettings.publish(
imageRotateDeg, imageFlipH, imageFlipV, paletteIndex = v,
)
}
/** Extrema shown by the trace markers (max / min / both / none). */
var traceMode: com.mag160c.thermal.ui.live.LiveViewModel.TraceMode
get() = com.mag160c.thermal.ui.live.LiveViewModel.TraceMode
.fromOrdinal(sp.getInt("traceMode", 2))
set(v) {
sp.edit().putInt("traceMode", v.ordinal).apply()
ImageOrientationSettings.publish(
imageRotateDeg, imageFlipH, imageFlipV, traceMode = v,
)
}
/**
* Local 7x7 detail enhancement level (vendor FilterDetailEnhancement_Simple).
*
* 0 = off. The pipeline strength is `level shl 3`, and the vendor's own setter
* (`MAG_SetDetailEnhancement`) accepts levels 0..32, so the value is stored as
* entered and only clamped to that range.
*
* Defaults to OFF because the port has no official reference output to verify
* against (the byte-exact baseline predates this stage) — see DetailEnhance.
* Exposed as the "图像增强" setting so the effect can be compared on a device.
*/
var enhanceLevel: Int
get() = sp.getInt("enhanceLevel", 0)
set(v) {
val n = v.coerceIn(0, 32)
sp.edit().putInt("enhanceLevel", n).apply()
ImageOrientationSettings.publish(
imageRotateDeg, imageFlipH, imageFlipV, enhanceLevel = n,
)
}
var defaultEmissivityPercent: Int
get() = sp.getInt("emissivity", 100)
@@ -21,4 +107,53 @@ class AppSettings(context: Context) {
var language: String
get() = sp.getString("locale", "auto") ?: "auto"
set(v) = sp.edit().putString("locale", v).apply()
/**
* Cloud sync opt-in. Default OFF and never flipped programmatically:
* the whole cloud module stays inert until the user turns this on.
*/
var cloudEnabled: Boolean
get() = sp.getBoolean("cloudEnabled", false)
set(v) {
sp.edit().putBoolean("cloudEnabled", v).apply()
com.mag160c.thermal.cloud.CloudClient.setEnabled(v)
}
/**
* Manual image orientation corrections, mirroring the official app's
* "旋转USB画面" / "水平翻转" / "竖直翻转" settings. The image itself stays
* LOCKED to the portrait frame (see ImageTransform).
*
* Every setter also publishes the new combination so the renderers pick it up
* immediately rather than on the next poll.
*/
var imageRotateDeg: Int
get() = sp.getInt("imageRotate", 0)
set(v) {
val n = ((v % 360) + 360) % 360
sp.edit().putInt("imageRotate", n).apply()
ImageOrientationSettings.publish(n, imageFlipH, imageFlipV)
}
var imageFlipH: Boolean
get() = sp.getBoolean("imageFlipH", false)
set(v) {
sp.edit().putBoolean("imageFlipH", v).apply()
ImageOrientationSettings.publish(imageRotateDeg, v, imageFlipV)
}
var imageFlipV: Boolean
get() = sp.getBoolean("imageFlipV", false)
set(v) {
sp.edit().putBoolean("imageFlipV", v).apply()
ImageOrientationSettings.publish(imageRotateDeg, imageFlipH, v)
}
init {
com.mag160c.thermal.cloud.CloudClient.setEnabled(cloudEnabled)
// seed the observable with the persisted settings, so a fresh process
// starts with what the user chose (not the hard-coded defaults)
ImageOrientationSettings.publish(
imageRotateDeg, imageFlipH, imageFlipV, defaultPaletteIndex, traceMode, enhanceLevel,
)
}
}
@@ -6,11 +6,15 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -21,22 +25,88 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.mag160c.thermal.core.Palettes
import com.mag160c.thermal.ui.live.LiveViewModel
@Composable
fun SettingsScreen() {
fun SettingsScreen(
onOpenRemoteClient: () -> Unit = {},
remoteHostRunning: Boolean = false,
onToggleRemoteHost: (Boolean) -> Boolean = { false },
) {
val context = androidx.compose.ui.platform.LocalContext.current
val settings = remember { AppSettings(context) }
var dialog by remember { mutableStateOf<String?>(null) }
// mirror the persisted flag in Compose state so the row label updates
var cloudEnabled by remember { mutableStateOf(settings.cloudEnabled) }
// orientation corrections: local Compose state + persistence
var rotateDeg by remember { mutableStateOf(settings.imageRotateDeg) }
var flipH by remember { mutableStateOf(settings.imageFlipH) }
var flipV by remember { mutableStateOf(settings.imageFlipV) }
// local Compose copy of the language choice, so the row label refreshes
var language by remember { mutableStateOf(settings.language) }
// trace mode (max / min / both / off)
var traceMode by remember { mutableStateOf(settings.traceMode) }
// detail enhancement level 0..4 (0 = off)
var enhanceLevel by remember { mutableStateOf(settings.enhanceLevel) }
// remote-preview server toggle (Phase F); off by default, needs live USB
var remoteOn by remember { mutableStateOf(remoteHostRunning) }
var showNeedDevice by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
// 11 settings rows overflow a phone screen; without this the lower
// ones were simply unreachable ("选项翻不动")
.verticalScroll(rememberScrollState()),
) {
SettingRow("默认调色板", Palettes.NAMES[settings.defaultPaletteIndex]) { dialog = "palette" }
SettingRow(
"默认发射率",
"%.2f".format(settings.defaultEmissivityPercent / 100f),
) { dialog = "emissivity" }
SettingRow("报警温度", "%.1f℃".format(settings.alarmTempC / 10f)) { dialog = "alarm" }
SettingRow("语言", settings.language) { dialog = "language" }
SettingRow("关于", "MAG160C 统一热像版 1.0.0") { dialog = null }
SettingRow(
"追踪",
when (traceMode) {
LiveViewModel.TraceMode.MAX -> "最高温"
LiveViewModel.TraceMode.MIN -> "最低温"
LiveViewModel.TraceMode.BOTH -> "最高+最低"
LiveViewModel.TraceMode.NONE -> "关闭"
},
) { dialog = "trace" }
SettingRow(
"语言",
if (language == "zh") "中文" else "跟随系统",
) { dialog = "language" }
// manual orientation corrections (the official app has the same three)
SettingRow("旋转USB画面", "$rotateDeg°") { dialog = "rotate" }
SettingRow("图像增强", if (enhanceLevel == 0) "关闭" else "${enhanceLevel}") {
dialog = "enhance"
}
SettingRow("水平翻转", if (flipH) "已开启" else "已关闭") {
flipH = !flipH
settings.imageFlipH = flipH
}
SettingRow("竖直翻转", if (flipV) "已开启" else "已关闭") {
flipV = !flipV
settings.imageFlipV = flipV
}
SettingRow("云同步", if (cloudEnabled) "已开启" else "已关闭") { dialog = "cloud" }
SettingRow("远程预览服务端", if (remoteOn) "已开启" else "已关闭") {
if (remoteOn) {
onToggleRemoteHost(false)
remoteOn = false
} else {
if (onToggleRemoteHost(true)) {
remoteOn = true
} else {
showNeedDevice = true
}
}
}
SettingRow("远程预览客户端", "查找主机") { onOpenRemoteClient() }
SettingRow("关于", "MAG160C 统一热像版 1.0.0") { dialog = "about" }
}
when (dialog) {
@@ -90,8 +160,206 @@ fun SettingsScreen() {
initialC = settings.alarmTempC,
onDone = { settings.alarmTempC = it; dialog = null },
)
"rotate" -> AlertDialog(
onDismissRequest = { dialog = null },
title = { Text("旋转USB画面") },
text = {
Column {
Text(
"在固定的 90° 基础上再旋转。用于传感器安装方向特殊的机器。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
listOf(0, 90, 180, 270).forEach { deg ->
Text(
"$deg°",
color = if (deg == rotateDeg) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clickable {
rotateDeg = deg
settings.imageRotateDeg = deg
dialog = null
}
.padding(14.dp),
)
}
}
},
confirmButton = {},
)
"cloud" -> AlertDialog(
onDismissRequest = { dialog = null },
title = { Text("云同步") },
text = {
Text(
"上传/任务同步需要账号,当前版本仅预留接口,不会发起任何网络请求。",
)
},
confirmButton = {
TextButton(onClick = {
cloudEnabled = !cloudEnabled
settings.cloudEnabled = cloudEnabled
dialog = null
}) {
Text(if (cloudEnabled) "保持关闭" else "开启")
}
},
)
"language" -> AlertDialog(
onDismissRequest = { dialog = null },
title = { Text("语言") },
text = {
Column {
// The UI is written in Chinese with hard-coded strings and the
// app ships no translations, so offering other languages here
// would be a lie. The choice is still recorded so the row is
// not a dead end and a future translation can honour it.
Text(
"当前版本界面仅提供中文,其它语言尚未翻译。此处选择会被记录," +
"后续版本接入翻译后生效。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
listOf("auto" to "跟随系统", "zh" to "中文").forEach { (code, label) ->
Text(
label,
color = if (language == code) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clickable {
language = code
settings.language = code
dialog = null
}
.padding(14.dp),
)
}
}
},
confirmButton = {},
)
"enhance" -> AlertDialog(
onDismissRequest = { dialog = null },
title = { Text("图像增强") },
text = {
Column {
Text(
"加强局部细节(官方同款 7×7 局部映射)。档位越高:①对比度门槛越高," +
"只有更强的细节会被增强,弱对比区域(噪声所在处)被排除;" +
"②强边缘的增强幅度更大。所以高档位是「更锐但更挑」," +
"而不是「连噪声一起放大」。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
// Vendor levels: MAG_SetDetailEnhancement accepts 0..32 and the
// pipeline uses `level << 3`. The level raises BOTH the contrast
// gate (a window qualifies only when its 7x7 range clears
// mapStrength/32) and the amplitude on strong edges (where the
// local contrast dominates the divisor). On flat, noisy areas the
// divisor floor holds the amplitude at 128/gain regardless of
// level, which is why cranking it up sharpens instead of boiling.
Column(
modifier = Modifier
.heightIn(max = 320.dp)
.verticalScroll(rememberScrollState()),
) {
listOf(
0 to "关闭",
1 to "1 级(轻微)",
2 to "2 级",
3 to "3 级",
4 to "4 级(推荐)",
6 to "6 级",
8 to "8 级(强)",
).forEach { (lvl, label) ->
Text(
label,
color = if (lvl == enhanceLevel) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clickable {
enhanceLevel = lvl
settings.enhanceLevel = lvl
dialog = null
}
.fillMaxWidth()
.padding(12.dp),
)
}
}
}
},
confirmButton = {},
)
"trace" -> AlertDialog(
onDismissRequest = { dialog = null },
title = { Text("追踪标记") },
text = {
Column {
Text(
"选择画面中追踪标记显示哪一端温度。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
listOf(
LiveViewModel.TraceMode.MAX to "最高温",
LiveViewModel.TraceMode.MIN to "最低温",
LiveViewModel.TraceMode.BOTH to "最高+最低",
LiveViewModel.TraceMode.NONE to "关闭",
).forEach { (mode, label) ->
Text(
label,
color = if (mode == traceMode) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clickable {
traceMode = mode
settings.traceMode = mode
dialog = null
}
.padding(14.dp),
)
}
}
},
confirmButton = {},
)
"about" -> AlertDialog(
onDismissRequest = { dialog = null },
title = { Text("关于") },
text = {
Column {
Text("MAG160C 统一热像版 1.0.0")
Text(
"热像仪:MAG160C160×12015fpsUSB VID 0x833C",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(onClick = { dialog = null }) { Text("") }
},
)
else -> {}
}
if (showNeedDevice) {
AlertDialog(
onDismissRequest = { showNeedDevice = false },
title = { Text("远程预览服务端") },
text = { Text("先连接热像仪") },
confirmButton = {
TextButton(onClick = { showNeedDevice = false }) { Text("") }
},
)
}
}
@Composable
@@ -74,6 +74,14 @@ class IrSession(context: Context) {
private const val MIN_CALI_LEN = 65536
private const val MAX_CALI_LEN = 104857600
private const val CALI_NO_DATA_LIMIT_MS = 5000
/**
* Consecutive failed stream reads before the session gives up. With the
* backoff schedule this is roughly a minute of a dead link — long enough
* to survive a camera reboot, short enough that a zombie session does not
* block the next connection.
*/
private const val FAILURE_LIMIT = 2000
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -82,6 +90,10 @@ class IrSession(context: Context) {
private var listener: Listener? = null
private var pipeline: RenderPipeline? = null
private val running = AtomicBoolean(false)
/** True while the stream reader thread is alive (for hand-over waits). */
private val loopRunning = AtomicBoolean(false)
private var streaming = false
private var identity = CameraIdentity(1, 0, 160, 120, 15)
@@ -93,6 +105,10 @@ class IrSession(context: Context) {
@Volatile
var recorderHook: ((IntArray) -> Unit)? = null
/** Optional raw-frame hook (LAN remote preview), runs on the reader thread. */
@Volatile
var rawHook: ((ByteArray) -> Unit)? = null
fun isStreaming(): Boolean = running.get()
/** Latest raw frame (with 0x38-byte header) for MDT capture. */
@@ -110,11 +126,37 @@ class IrSession(context: Context) {
var lastInfo1: ByteArray? = null
private set
/**
* Detail enhancement strength requested before a pipeline existed (the
* pipeline is built during connect, so a setting change made while
* disconnected would otherwise be lost).
*/
@Volatile
private var pendingEnhanceStrength: Int = 0
/** Device power-on lifetime in ms (official getDevLifeTime); -1 = unknown. */
@Volatile
var deviceLifetimeMs: Long = -1
private set
fun identitySnapshot(): CameraIdentity = identity.copy()
/** Connect + start the live stream. Must be called after USB permission. */
/**
* Connect + start the live stream. Must be called after USB permission.
*
* Re-entrancy: the guard used to be [running], but that flag is only set once
* the stream is up — so a FAILING connect could be re-entered without limit.
* On the real device that produced a reconnect storm (~10 sessions/second,
* 107% CPU): each attempt failed on a dead endpoint, the caller retried, and
* the previous reader thread was still spinning. A dedicated flag now covers
* the whole connect attempt, and a failed attempt must clear it.
*/
fun start(ddtBytes: ByteArray) {
if (running.get()) return
if (!connectInFlight.compareAndSet(false, true)) {
DebugLog.log("session", "start ignored: a connect attempt is already in flight")
return
}
scope.launch {
try {
startInternal(ddtBytes)
@@ -124,11 +166,26 @@ class IrSession(context: Context) {
transport.close()
running.set(false)
notify(State.ERROR, "exception:${e.javaClass.simpleName}")
} finally {
// cleared when the stream loop takes over (or the attempt failed),
// so the next genuine connect is not blocked
if (!running.get()) connectInFlight.set(false)
}
}
}
/** True while a connect attempt is in flight (guards against storms). */
private val connectInFlight = AtomicBoolean(false)
private fun startInternal(bundledDdt: ByteArray) {
// Never disturb a session that is already streaming. Callers can be
// trigger-happy (Android re-broadcasts ATTACHED while the camera is simply
// plugged in), and replacing a working session sends the camera through a
// reset/re-enumeration for no reason.
if (running.get()) {
DebugLog.log("session", "start ignored: already streaming")
return
}
notify(State.LINKING, null)
// one camera, one owner: a stale session holding the device would
// otherwise be robbed by our claimInterface and both would stall
@@ -136,6 +193,12 @@ class IrSession(context: Context) {
if (prev !== this) {
DebugLog.log("session", "stopping stale previous session")
prev.stop()
// WAIT for the old reader to actually exit before claiming the
// camera. stop() only flips a flag, and with the new backoff the
// old loop may still be sleeping — a second live reader on the
// same endpoints makes both fail (observed on device: one healthy
// stream plus one "no data yet" zombie reporting forever).
prev.awaitStopped(2000)
}
}
val dev = transport.findDevice()
@@ -207,6 +270,24 @@ class IrSession(context: Context) {
}
DebugLog.log("session", "cali info: size=$caliSize date=$caliDate")
// 3b) GetLifeTime (official getDevLifeTime): 675 -> 0x5BB5B561 {magic, ms}.
// Non-blocking: the official app just logs when this fails.
writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_GET_LIFETIME), "GetLifeTime")
readResp(conn, epResp, "GetLifeTime")?.let { r ->
if (r.second == MagProtocol.RSP_SEND_LIFETIME) {
// protocol doc: payload {magic, i32 ms}; official Java reads the
// bare {i32 ms} right after the response magic — accept both.
val ms = when {
r.third.size >= 8 && MagProtocol.u32(r.third, 0) == MagProtocol.RSP_SEND_LIFETIME ->
MagProtocol.u32(r.third, 4)
r.third.size >= 4 -> MagProtocol.u32(r.third, 0)
else -> null
}
if (ms != null) deviceLifetimeMs = ms.toLong() and 0xFFFFFFFFL
}
}
DebugLog.log("session", "device lifetime=${deviceLifetimeMs}ms")
// 4) calibration file: cache hit or fetch from EP 0x84
val ddt = obtainCali(conn, ep84, epOut, epResp, caliSize, caliDate, bundledDdt)
DebugLog.log("session", "ddt bytes ${ddt.size}")
@@ -220,6 +301,9 @@ class IrSession(context: Context) {
epOut, epResp, "FFC($param)",
)
},
// the user's detail-enhancement choice must survive a reconnect, so it
// is applied at construction rather than only when the setting changes
enhanceStrength = pendingEnhanceStrength,
)
if (!pipe.loadDdt(ddt)) {
DebugLog.log("session", "ddt_fail (fetched/bundled ${ddt.size} B not loadable)")
@@ -236,6 +320,8 @@ class IrSession(context: Context) {
notify(State.STREAMING, null)
notifyIdentity()
running.set(true)
// the stream loop now owns the session lifecycle; connectInFlight is
// released in start()'s finally because running is set by then
Thread.sleep(50)
if (!writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_START_TRANSFER_IMG), "StartTransferImg")) {
DebugLog.log("session", "START write failed")
@@ -294,7 +380,14 @@ class IrSession(context: Context) {
if (cache.isFile && cache.length() == size) {
DebugLog.log("session", "cali cache hit: ${cache.name}")
return try {
cache.readBytes()
val cached = cache.readBytes()
// consistency check only: the cache stays authoritative
DebugLog.log(
"session",
"cali cache vs bundled DDT: " +
if (md5(cached).contentEquals(md5(bundled))) "identical" else "differ",
)
cached
} catch (e: Exception) {
DebugLog.log("session", "cali cache read failed -> bundled: $e")
bundled
@@ -349,6 +442,10 @@ class IrSession(context: Context) {
return out
}
/** MD5 digest of a byte array (cali consistency check only). */
private fun md5(bytes: ByteArray): ByteArray =
java.security.MessageDigest.getInstance("MD5").digest(bytes)
private fun notify(state: State, message: String?) {
DebugLog.log("session", "state -> $state msg=$message")
listener?.onStateChanged(state, message)
@@ -358,13 +455,23 @@ class IrSession(context: Context) {
listener?.onIdentity(identitySnapshot())
}
/** Log endpoint status + clear a possible halt (usbfs timeout artifact). */
private fun diagnoseEndpoint(conn: UsbDeviceConnection, epAddr: Int, failureCount: Int = 0) {
/**
* Log endpoint status + clear a possible halt (usbfs timeout artifact).
* Two control transfers per call, so a caller polling a dead endpoint must
* pass [quiet] to stop the log spam (the transfers still happen — clearing a
* halt is the only way back).
*/
private fun diagnoseEndpoint(
conn: UsbDeviceConnection,
epAddr: Int,
failureCount: Int = 0,
quiet: Boolean = false,
) {
val st = ByteArray(2)
val src = conn.controlTransfer(0x80, 0, 0, epAddr, st, 2, 100)
val halted = if (src == 2) (st[0].toInt() and 0x01) else -1
val clr = conn.controlTransfer(0x02, 1, 0, epAddr, null, 0, 100)
if (failureCount <= 5 || failureCount % 100 == 0) {
if (!quiet && (failureCount <= 5 || failureCount % 100 == 0)) {
DebugLog.log(
"usb",
"ep 0x%02X fail#$failureCount get_status rc=%d halted=%d clear_halt rc=%d".format(
@@ -438,6 +545,7 @@ class IrSession(context: Context) {
epResp: UsbEndpoint,
) {
val conn: UsbDeviceConnection = transport.connection() ?: return
loopRunning.set(true)
val stream = FrameStream(38400)
val frameBuf = ByteArray(0x38 + 38400)
val out = IntArray(320 * 240)
@@ -449,6 +557,7 @@ class IrSession(context: Context) {
var frameCount = 0
var renderCount = 0
var timeouts = 0
var consecutiveFailures = 0
val t0 = android.os.SystemClock.elapsedRealtime()
var lastLog = t0
var firstReads = 0
@@ -458,24 +567,59 @@ class IrSession(context: Context) {
val n = conn.bulkTransfer(epStream, tmp, tmp.size, TIMEOUT_MS)
if (n <= 0) {
timeouts++
// usbfs marks the endpoint halted after a timed-out transfer;
// every later transfer then fails instantly until cleared.
diagnoseEndpoint(conn, epStream.address, timeouts)
consecutiveFailures++
// A halted/dead endpoint makes bulkTransfer fail INSTANTLY (the
// 800 ms timeout is not spent), so this loop would spin at ~1000
// iterations/second, each doing two control transfers inside
// diagnoseEndpoint — measured on a real device: 28000 "fail#"
// lines and 136% CPU while the camera was re-enumerating. Back
// off: try hard to recover for the first few attempts, then poll
// slowly (the reader keeps running so a replugged camera is
// picked up, but it no longer burns the battery).
val backoffMs = when {
consecutiveFailures <= 3 -> 0L // immediate: may be a stale halt
consecutiveFailures <= 100 -> 20L // fast recovery window (~2 s)
else -> 250L // dead link: idle poll
}
// Only run the (costly) endpoint diagnosis while it can help
diagnoseEndpoint(
conn, epStream.address, timeouts,
quiet = consecutiveFailures > 100,
)
if (backoffMs > 0) {
try {
Thread.sleep(backoffMs)
} catch (_: InterruptedException) {
}
}
val now = android.os.SystemClock.elapsedRealtime()
if (readCount == 0 && now - t0 > 10000 && !noDataNotified) {
noDataNotified = true
notify(State.STREAMING, "no_stream_data")
}
// Give up on a link that has been dead for a full minute: the
// camera is gone (or wedged), and a zombie session holding the
// device would block a fresh one after replug.
if (consecutiveFailures == FAILURE_LIMIT) {
DebugLog.log(
"stream",
"giving up after $consecutiveFailures consecutive failures " +
"(${now - t0} ms) -> closing session",
)
notify(State.ERROR, "stream_dead")
break
}
if (now - lastLog >= 2000) {
DebugLog.log(
"stream",
"stats: reads=$readCount frames=$frameCount rendered=$renderCount " +
"timeouts=$timeouts (no data yet)",
"timeouts=$timeouts consecutive=$consecutiveFailures (no data yet)",
)
lastLog = now
}
continue
}
consecutiveFailures = 0
readCount++
if (firstReads < 3) {
DebugLog.log(
@@ -502,6 +646,10 @@ class IrSession(context: Context) {
}
lastRawFrame = frameBuf.copyOf()
val rendered = pipe.frame(frameBuf, true, out)
// After frame(): the host-side pipeline metadata (FFC phase and
// camera temperature) refers to THIS frame, which is what a
// remote client needs to reproduce the image.
lastRawFrame?.let { raw -> rawHook?.invoke(raw) }
if (rendered) {
renderCount++
listener?.onFrameReady(out)
@@ -516,7 +664,8 @@ class IrSession(context: Context) {
"stream",
"stats: reads=$readCount frames=$frameCount rendered=$renderCount " +
"timeouts=$timeouts fps=%.1f ".format(Locale.US, fps) +
"renderState=${pipe.frameIndex()} ref=${pipe.hasReference()}",
"renderState=${pipe.frameIndex()} ref=${pipe.hasReference()}" +
" lifetime=${deviceLifetimeMs}",
)
lastLog = now
}
@@ -533,6 +682,11 @@ class IrSession(context: Context) {
if (active === this) active = null
sendCmd(MagProtocol.cmd4(MagProtocol.CMD_STOP_TRANSFER_IMG), epOut, epResp, "StopTransferImg")
transport.close()
// release the connect gate so a fresh session (e.g. after a replug or a
// recovered camera) is not blocked by this finished one
running.set(false)
connectInFlight.set(false)
loopRunning.set(false)
notify(State.IDLE, null)
}
@@ -546,9 +700,24 @@ class IrSession(context: Context) {
pipeline?.setPalette(index)
}
/** Detail enhancement strength (0 = off); applied to the live pipeline. */
fun setEnhanceStrength(strength: Int) {
pendingEnhanceStrength = strength
pipeline?.setEnhanceStrength(strength)
}
/** Slow-path probe: temperature at a sensor pixel in millidegrees C. */
fun probeTemp(x: Int, y: Int): Int? = pipeline?.probeTemp(x, y)
/** True once a frame completed the render path (temperatures are meaningful). */
fun tempsReady(): Boolean = pipeline?.tempsReady() ?: false
/** FFC phase of the frame just processed: 0 normal, 1 hidden, 2 reference. */
fun ffcPhase(): Int = pipeline?.ffcPhase() ?: 0
/** Camera temperature of the frame just processed (raw sensor units). */
fun lastShutter(): Int = pipeline?.lastShutter() ?: 0
/** Snapshot of the current NUC counts (already blind-compensated). */
fun copyNuc(out: IntArray): Boolean {
val p = pipeline ?: return false
@@ -564,6 +733,25 @@ class IrSession(context: Context) {
// (StopTransferImg -> close -> IDLE)
}
/**
* Block until the reader loop has finished (or [timeoutMs] elapses).
* Used when a new session takes over: the old reader must be gone before the
* new one claims the endpoints, otherwise both compete for the same URBs.
*/
private fun awaitStopped(timeoutMs: Long) {
val deadline = android.os.SystemClock.elapsedRealtime() + timeoutMs
while (loopRunning.get() && android.os.SystemClock.elapsedRealtime() < deadline) {
try {
Thread.sleep(20)
} catch (_: InterruptedException) {
return
}
}
if (loopRunning.get()) {
DebugLog.log("session", "previous reader did not exit within ${timeoutMs} ms")
}
}
fun destroy() {
stop()
scope.cancel()
@@ -31,6 +31,9 @@ object MagProtocol {
const val RSP_SEND_CALI_FILE = 0x5BB5B55D
const val RSP_SEND_CALI_INFO = 0x5BB5B55E
/** D2P_SendLifeTime (1538635105) — reply to GetLifeTime: {u32 magic, i32 ms}. */
const val RSP_SEND_LIFETIME = 0x5BB5B561
/**
* 4-byte LITTLE-ENDIAN command — byte-identical to the official app's
* GlobalFunc.intToByteArray (a&255 first). CRITICAL: ByteBuffer.putInt
@@ -108,16 +108,17 @@ class UsbTransport(private val context: Context) {
"usb",
"claimed interface 0: endpoints=${intf.endpointCount} configs=${dev.configurationCount}",
)
// Prefer configuration 2 when the device exposes it (vendor behavior).
if (dev.configurationCount > 1) {
val cfg = dev.getConfiguration(1)
// USB SET_CONFIGURATION request = 9
val rc = conn.controlTransfer(0x00, 0x09, cfg?.id ?: 2, 0, null, 0, 500)
com.mag160c.thermal.media.DebugLog.log(
"usb",
"setConfiguration(${cfg?.id ?: 2}) controlTransfer rc=$rc",
)
}
// DO NOT send SET_CONFIGURATION here.
//
// This used to "prefer configuration 2 (vendor behavior)" by issuing a
// USB SET_CONFIGURATION control request right after claiming the
// interface. That is a device-level reset: it invalidates the claim and
// makes the camera re-enumerate. Measured on a real phone: with the app
// stopped the camera sat at /dev/bus/usb/002/071 for 30 s; the moment the
// app started, the device number climbed 071 -> 077 while every command
// failed (write=-1). The official app never sends it either — its
// UsbCommunication only calls claimInterface (see
// analysis/sdk_re/android_app/jadx_magcx/.../UsbCommunication.java:234).
return true
}
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12,4a8,8 0 1,0 0.01,0 a8,8 0 1,0 -0.01,0z" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
<path android:pathData="M11,8 L7,12 L11,16" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
<path android:pathData="M7,12 L17,12" android:strokeColor="#FF000000" android:strokeWidth="2" />
</vector>
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12,4a8,8 0 1,0 0.01,0 a8,8 0 1,0 -0.01,0z" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
<path android:pathData="M13,13 h6 v6 h-6 z" android:fillColor="#FF000000" />
</vector>
@@ -0,0 +1,46 @@
package com.mag160c.thermal.cloud
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
/**
* Phase D contract: the cloud module is inert unless the user opts in.
* [CloudClient.api] must refuse to build a Retrofit client while disabled, so an
* accidental call is caught in tests rather than shipping data off-device.
*/
class CloudClientTest {
@Test
fun disabledByDefault() {
CloudClient.setEnabled(false)
assertFalse("cloud must start disabled", CloudClient.enabled)
}
@Test
fun apiRefusesWhileDisabled() {
CloudClient.setEnabled(false)
try {
CloudClient.api()
fail("api() must throw while the cloud is disabled")
} catch (e: IllegalStateException) {
assertTrue(e.message!!.contains("disabled"))
}
}
@Test
fun enablingIsExplicitAndReversible() {
CloudClient.setEnabled(true)
assertTrue(CloudClient.enabled)
// a Retrofit client can now be constructed (no request is made here)
CloudClient.api()
CloudClient.setEnabled(false)
assertFalse(CloudClient.enabled)
}
@Test
fun baseUrlPointsAtTheVendorCloud() {
assertTrue(CloudClient.BASE_URL.startsWith("https://"))
assertTrue(CloudClient.BASE_URL.endsWith("/"))
}
}
@@ -0,0 +1,116 @@
package com.mag160c.thermal.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Label placement is what keeps a marker readable: the box must stay inside the
* image and must not cover a label that is already there. Both failures were
* observed on a real photo — "max" printed straight through "min" — so they are
* pinned here.
*/
class AnnotSpecPlaceLabelTest {
private val scale = 1f
private fun boxW() = 60f
private fun boxH() = 14f
@Test
fun labelSitsRightOfTheMarkerWhenThereIsRoom() {
val pos = AnnotSpec.placeLabel(
cx = 100f, cy = 100f, boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale,
)
assertEquals(100f + AnnotSpec.labelOffsetX(scale), pos[0], 0.01f)
assertEquals(100f - boxH() / 2f, pos[1], 0.01f)
}
@Test
fun labelFlipsLeftInsteadOfOverflowingTheRightEdge() {
val pos = AnnotSpec.placeLabel(
cx = 310f, cy = 100f, boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale,
)
assertTrue("must stay inside the image, was ${pos[0]}", pos[0] + boxW() <= 320f)
assertTrue("must stay on the left of its marker", pos[0] < 310f)
}
@Test
fun labelStaysInsideTheImageWhenTheMarkerIsAtTheCorner() {
val pos = AnnotSpec.placeLabel(
cx = 1f, cy = 1f, boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale,
)
assertTrue("x=${pos[0]}", pos[0] >= 0f)
assertTrue("y=${pos[1]}", pos[1] >= 0f)
}
@Test
fun secondLabelIsMovedClearOfTheFirst() {
val first = AnnotSpec.placeLabel(
cx = 100f, cy = 100f, boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale,
)
val placed = listOf(floatArrayOf(first[0], first[1], boxW(), boxH()))
// a marker close enough that the default position would overlap
val second = AnnotSpec.placeLabel(
cx = 105f, cy = 104f, boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale, placed = placed,
)
val overlaps = second[0] < first[0] + boxW() && second[0] + boxW() > first[0] &&
second[1] < first[1] + boxH() && second[1] + boxH() > first[1]
assertTrue(
"second label at (${second[0]},${second[1]}) still overlaps " +
"(${first[0]},${first[1]})",
!overlaps,
)
}
@Test
fun secondLabelFitsEntirelyInsideTheImageAfterAvoiding() {
val first = AnnotSpec.placeLabel(
cx = 100f, cy = 100f, boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale,
)
val placed = listOf(floatArrayOf(first[0], first[1], boxW(), boxH()))
val second = AnnotSpec.placeLabel(
cx = 104f, cy = 102f, boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale, placed = placed,
)
assertTrue("x=${second[0]}", second[0] >= 0f)
assertTrue("x+w=${second[0] + boxW()}", second[0] + boxW() <= 320f)
assertTrue("y=${second[1]}", second[1] >= 0f)
assertTrue("y+h=${second[1] + boxH()}", second[1] + boxH() <= 240f)
}
@Test
fun aCrowdOfLabelsAllStayInsideTheImage() {
// Worst case from the device: the extremes cluster in one corner and the
// probes pile up around them.
val placed = ArrayList<FloatArray>()
for (i in 0 until 8) {
val pos = AnnotSpec.placeLabel(
cx = 10f + i * 2f, cy = 10f + i * 2f,
boxW = boxW(), boxH = boxH(),
imgW = 320f, imgH = 240f, scale = scale, placed = placed,
)
placed.add(floatArrayOf(pos[0], pos[1], boxW(), boxH()))
}
for (b in placed) {
assertTrue("x=${b[0]}", b[0] >= 0f)
assertTrue("x+w=${b[0] + b[2]}", b[0] + b[2] <= 320f + 0.01f)
assertTrue("y=${b[1]}", b[1] >= 0f)
assertTrue("y+h=${b[1] + b[3]}", b[1] + b[3] <= 240f + 0.01f)
}
}
@Test
fun scaleKeepsTheOffsetProportional() {
val small = AnnotSpec.placeLabel(100f, 100f, 60f, 14f, 320f, 240f, 1f)
val large = AnnotSpec.placeLabel(300f, 300f, 180f, 42f, 960f, 720f, 3f)
// the gap from the marker to the box grows with the scale factor
assertEquals(AnnotSpec.labelOffsetX(3f), large[0] - 300f, 0.01f)
assertEquals(AnnotSpec.labelOffsetX(1f), small[0] - 100f, 0.01f)
}
}
@@ -0,0 +1,245 @@
package com.mag160c.thermal.core
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Port of the vendor's 7x7 local detail enhancement
* (`CFunctions::FilterDetailEnhancement_Simple` + `LocalMap7x7_Simple`).
*
* What can and cannot be asserted here: there is no official output to diff
* against (the byte-exact baseline predates this stage), so these tests pin the
* CONTRACT — off is a true no-op, the filter stays inside the 8-bit range, it
* amplifies local contrast rather than shifting the overall level, and it never
* touches the border the vendor leaves alone.
*/
class DetailEnhanceTest {
private val w = 160
private val h = 120
private fun flat(value: Int): IntArray = IntArray(w * h) { value }
/** A smooth ramp: no local contrast, so nothing should be enhanced. */
private fun ramp(): IntArray = IntArray(w * h) { i -> 7000 + (i % w) }
/** A checkerboard: maximum local contrast everywhere. */
private fun checkerboard(lo: Int, hi: Int): IntArray =
IntArray(w * h) { i -> if (((i % w) / 4 + (i / w) / 4) % 2 == 0) lo else hi }
private fun grayOf(v: Int): ByteArray = ByteArray(w * h) { v.toByte() }
@Test
fun zeroStrengthIsANoOp() {
// the default must leave the verified reference path untouched
val gray = grayOf(120)
val before = gray.copyOf()
val de = DetailEnhance(w, h)
de.enhance(ramp(), gray, strength = 0, gain = 3000)
assertArrayEquals("strength 0 must not change a single pixel", before, gray)
}
@Test
fun flatFieldIsUnchanged() {
// a constant frame has zero local range: the vendor's range guard means no
// pixel may be touched (this is what keeps uniform scenes from boiling)
val gray = grayOf(100)
val before = gray.copyOf()
DetailEnhance(w, h).enhance(flat(7200), gray, strength = 8, gain = 3000)
assertArrayEquals("flat input must stay flat", before, gray)
}
@Test
fun localContrastIsAmplified() {
// a checkerboard has strong local contrast: the filter must push pixels
// away from the local mean, i.e. change something
val gray = grayOf(128)
val before = gray.copyOf()
DetailEnhance(w, h).enhance(checkerboard(6500, 8000), gray, strength = 8, gain = 3000)
var changed = 0
for (i in gray.indices) if (gray[i] != before[i]) changed++
assertTrue("expected contrast to change pixels, changed=$changed", changed > 0)
}
@Test
fun outputStaysInsideTheRangeThePaletteAccepts() {
// gray values feed a 256-entry palette; anything outside 0..255 would wrap
val gray = grayOf(250)
DetailEnhance(w, h).enhance(checkerboard(0, 65535), gray, strength = 64, gain = 0xFFFF)
for (i in gray.indices) {
val v = gray[i].toInt() and 0xFF
assertTrue("gray[$i]=$v out of range", v in 0..255)
}
}
@Test
fun bordersAreLeftAlone() {
// the vendor only processes rows/cols 3..(H/W-4); keep that margin so the
// image edge does not develop a halo
val gray = grayOf(128)
val before = gray.copyOf()
DetailEnhance(w, h).enhance(checkerboard(6000, 9000), gray, strength = 16, gain = 3000)
for (y in 0 until h) {
for (x in 0 until w) {
if (x < 3 || y < 3 || x >= w - 3 || y >= h - 3) {
assertEquals("border pixel ($x,$y) must be untouched", before[y * w + x], gray[y * w + x])
}
}
}
}
@Test
fun strongerStrengthChangesAtLeastAsMuch() {
// monotonic in the strength parameter: a plausible-strength sweep must not
// do LESS work at a higher setting
fun changedAt(s: Int): Int {
val gray = grayOf(128)
val before = gray.copyOf()
DetailEnhance(w, h).enhance(checkerboard(6500, 8000), gray, strength = s, gain = 3000)
return gray.indices.count { gray[it] != before[it] }
}
val weak = changedAt(2)
val strong = changedAt(16)
assertTrue("strong=$strong should be >= weak=$weak", strong >= weak)
}
@Test
fun gainBelowOneIsANoOp() {
// the map coefficient is (strength * gain * 2) >> 8, so a gain that rounds
// it to zero disables the stage entirely
val gray = grayOf(128)
val before = gray.copyOf()
DetailEnhance(w, h).enhance(checkerboard(6500, 8000), gray, strength = 8, gain = 0)
assertArrayEquals("gain 0 must not change a single pixel", before, gray)
}
/**
* The bug this pins: the blend coefficient is the RAW strength while only the
* map's threshold/divisor floor is gain-scaled. Multiplying the blend by the
* gain as well amplified flat-field grain by the gain factor — on the device
* that showed as a heavily speckled image at level 2, where the vendor's own
* filter is quiet on flat areas.
*/
@Test
fun gainDoesNotAmplifyNoiseOnAFlatField() {
// a "flat" field with a few counts of sensor noise
val noisy = IntArray(w * h) { 7200 + ((it * 37) % 5) - 2 }
fun spreadAt(g: Int): Int {
val gray = grayOf(128)
DetailEnhance(w, h).enhance(noisy, gray, strength = 16, gain = g)
val vals = (3 until h - 3).flatMap { y ->
(3 until w - 3).map { x -> gray[y * w + x].toInt() and 0xFF }
}
return vals.max() - vals.min()
}
val low = spreadAt(1000)
val high = spreadAt(0xFFFF)
// A 65x gain increase must not scale the output spread anywhere near 65x:
// the divisor floor holds the detail term down in low-contrast regions.
assertTrue(
"gain 65535 spread=$high must stay close to gain 1000 spread=$low",
high <= low + 8,
)
}
/**
* Golden value for the blend coefficient, derived from the decompilation.
*
* `FilterDetailEnhancement_Simple` passes `param_1 * gain * 2 >> 8` to the map
* (threshold + divisor floor) but blends with plain `param_1`. Getting that
* backwards multiplies the detail by the gain, which is how this port first
* behaved — the image looked speckled at level 2 while the vendor's own filter
* is quiet on flat areas.
*
* Derivation with the sample window below (16x16 image, window at row 0 col 0,
* centre (3,3)):
* 15 samples at 7200, one at 7240, centre 7240
* sum = 15*7200 + 7240 = 115240
* mean = 115240 >> 4 = 7202
* min/max = 7200 / 7240 -> the threshold passes: 375 <= (40)*32 = 1280
* divisor = max(7240-7202, 7202-7200, 375) = 375
* detail = (0x8000/375) * (7240-7202) = 87 * 38 = 3306
* blend = 16 * 3306 >> 15 = 1 (the buggy version: 375*3306>>15 = 37)
* so the destination pixel goes 128 -> 129.
*/
@Test
fun blendUsesTheRawStrengthNotTheGainScaledOne() {
val size = 16
val src = IntArray(size * size) { 0 }
// the 16 samples of the window (rows 0,2,4,6 x cols 0,2,4,6)
for (dy in 0 until 4) {
for (dx in 0 until 4) {
src[(dy * 2) * size + dx * 2] = 7200
}
}
src[0] = 7240
src[3 * size + 3] = 7240 // window centre
val gray = ByteArray(size * size) { 128.toByte() }
DetailEnhance(size, size).enhance(src, gray, strength = 16, gain = 3000)
assertEquals(
"centre pixel must move by exactly 1 (raw-strength blend)",
129,
gray[3 * size + 3].toInt() and 0xFF,
)
}
/**
* What the level knob really does (this is the sentence the settings dialog
* shows the user, so it is pinned here):
* - the level raises the contrast GATE: a window is enhanced only when its 7x7
* range clears `mapStrength / 32` (= level * gain * 2 / 256 / 32), so weak
* detail — where sensor noise lives — is dropped at high levels; and
* - where a window IS gated in, the divisor floor keeps its amplitude near
* `128/gain` of the deviation, so it does not grow with the level.
*
* Window used below: 15 samples at 7200 and one at 7260 (range 60), centre 7260,
* gain 3000.
* level 8 (strength 64 -> mapStrength 1500): gate 1500 <= 60*32 = 1920 passes
* level 16 (strength 128 -> mapStrength 3000): gate 3000 <= 1920 FAILS -> dropped
*/
@Test
fun theLevelRaisesTheContrastGateAndDropsWeakDetail() {
val size = 16
fun window(): IntArray {
val src = IntArray(size * size)
for (dy in 0 until 4) for (dx in 0 until 4) src[(dy * 2) * size + dx * 2] = 7200
src[0] = 7260
src[3 * size + 3] = 7260
return src
}
fun changeAt(strength: Int): Int {
val gray = ByteArray(size * size) { 128.toByte() }
DetailEnhance(size, size).enhance(window(), gray, strength = strength, gain = 3000)
return (gray[3 * size + 3].toInt() and 0xFF) - 128
}
assertTrue(
"level 8 (mapStrength 1500) must act on a range-60 window",
changeAt(64) != 0,
)
assertEquals(
"level 16 (mapStrength 3000) must drop the same window: its contrast " +
"is below the gate",
0,
changeAt(128),
)
// and the amplitude is small wherever it does apply: the divisor floor caps
// it near 128/gain, so higher levels cannot make flat areas boil
assertTrue(
"amplitude must stay small, was ${changeAt(64)}",
kotlin.math.abs(changeAt(64)) <= 4,
)
}
@Test
fun doesNotChangeTheOverallLevelOfASmoothImage() {
// on a smooth ramp the mean and the centre are equal, so no delta should be
// produced: local enhancement must not act as a brightness shift
val gray = grayOf(128)
val before = gray.copyOf()
DetailEnhance(w, h).enhance(ramp(), gray, strength = 32, gain = 3000)
var changed = 0
for (i in gray.indices) if (gray[i] != before[i]) changed++
assertEquals("a smooth ramp must be left alone", 0, changed)
}
}
@@ -0,0 +1,112 @@
package com.mag160c.thermal.core
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Phase C: the display palettes are the vendor's own tables, recovered from
* libcxsdk's runtime generator (see VendorPalettes).
*/
class PalettesTest {
private val all = Palettes.buildAll()
@Test
fun allTwelvePalettesAreFullLength() {
assertEquals("twelve palettes in UI order", 12, all.size)
all.forEachIndexed { i, pal ->
assertEquals("palette ${Palettes.NAMES[i]} length", 256, pal.size)
}
}
@Test
fun noPaletteIsASingleFlatColour() {
all.forEachIndexed { i, pal ->
assertTrue(
"palette ${Palettes.NAMES[i]} must contain more than one colour",
pal.toSet().size > 1,
)
}
}
/** The iron-bow anchor: must stay byte-identical to the verified table. */
@Test
fun ironbowMatchesOfficialTables() {
assertArrayEquals(OfficialTables.PALETTE256_ARGB, all[2])
}
/** The anchor table must also be what the port produces. */
@Test
fun ironbowMatchesPortedVendorTable() {
assertArrayEquals(OfficialTables.PALETTE256_ARGB, VendorPalettes.IRON_BOW)
}
/** Indices 0..10 came from the vendor generator; 11 is the only approximation. */
@Test
fun vendorTablesCoverIndicesZeroThroughTen() {
assertEquals(11, VendorPalettes.SOURCE_CASE.count { it >= 0 })
assertEquals(-1, VendorPalettes.SOURCE_CASE[11])
for (i in 0..10) {
assertArrayEquals(
"index $i must use the vendor table",
VendorPalettes.let {
listOf(
it.WHITE_HOT, it.BLACK_HOT, it.IRON_BOW, it.RAIN_BOW, it.GLOW_BOW,
it.AUTUMN, it.WINTER, it.HOT_METAL, it.JET, it.RED_SATURATION,
it.HIGH_CONTRAST,
)
}[i],
all[i],
)
}
}
/** Vendor tables are opaque; a transparent entry would mean a bad port. */
@Test
fun vendorPalettesAreOpaque() {
for (i in 0..10) {
all[i].forEachIndexed { k, v ->
assertEquals("palette $i entry $k alpha", 0xFF, v ushr 24)
}
}
}
/**
* Endpoint spot-checks. These are the values the ported generator actually
* produces (verified against the tables in VendorPalettes.kt); they pin the
* byte order so a swapped or truncated port cannot pass silently.
*/
@Test
fun endpointsMatchTheVerifiedVendorTables() {
// white hot: black -> white
assertEquals(0x000000, all[0][0] and 0xFFFFFF)
assertEquals(0xFFFFFF, all[0][255] and 0xFFFFFF)
// black hot: white -> black
assertEquals(0xFFFFFF, all[1][0] and 0xFFFFFF)
assertEquals(0x000000, all[1][255] and 0xFFFFFF)
// iron bow: black -> white
assertEquals(0x000000, all[2][0] and 0xFFFFFF)
assertEquals(0xFFFFFF, all[2][255] and 0xFFFFFF)
// hot metal: black -> white (through red)
assertEquals(0x000000, all[7][0] and 0xFFFFFF)
assertEquals(0xFFFFFF, all[7][255] and 0xFFFFFF)
// jet: near-black blue at the cold end, dark red at the hot end
assertEquals(0x00007F, all[8][0] and 0xFFFFFF)
assertEquals(0x830000, all[8][255] and 0xFFFFFF)
// glow bow (amber): dark orange -> yellow
assertEquals(0x320000, all[4][0] and 0xFFFFFF)
assertEquals(0xFFFF00, all[4][255] and 0xFFFFFF)
}
@Test
fun mostVendorPalettesUseTheFullDynamicRange() {
// high contrast intentionally repeats a few entries, so allow slack
for (i in 0..10) {
assertTrue(
"palette ${Palettes.NAMES[i]} should use most of its 256 slots",
all[i].toSet().size >= 250,
)
}
}
}
@@ -0,0 +1,120 @@
package com.mag160c.thermal.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Regressions for the on-device temperature defects (2026-09-11):
*
* 1. the OSD showed ~150 C for a moment during every FFC, because the FFC
* reference frames were decoded into the same `nuc` buffer the OSD samples;
* 2. before the first completed render `nuc` is all zeros and
* countsToTempMc(0) is about -161 C, which the UI displayed;
* 3. the centre readout was double-converted (countsToTempMc applied to an
* already-converted value), showing e.g. 108.7 C for a ~24 C scene.
*/
class PipelineTemperatureStateTest {
private fun res(name: String): ByteArray =
javaClass.classLoader.getResourceAsStream(name)!!.readBytes()
private fun frame(buf: ByteArray, counter: Int, filler: Int): ByteArray {
// 0x38 header + 38400 payload + 0x38 tail, as the camera sends it
val f = ByteArray(0x38 + 38400)
MagProtocolBytes.putU32(f, 0, 0x1BB1B11B)
MagProtocolBytes.putU32(f, 4, counter)
MagProtocolBytes.putU32(f, 8, 38400)
MagProtocolBytes.putU32(f, 12, 0)
for (i in 0 until 38400) f[0x1C + i] = ((filler + i) and 0xFF).toByte()
MagProtocolBytes.putU32(f, 0x1C + 38400, 0x1BB1B11C)
MagProtocolBytes.putU32(f, 0x1C + 38400 + 4, counter)
MagProtocolBytes.putU32(f, 0x1C + 38400 + 8, 7000) // fpaTemp / shutter
MagProtocolBytes.putU32(f, 0x1C + 38400 + 12, 0)
return f
}
private fun pipeline(): RenderPipeline = RenderPipeline(
w = 160, h = 120, ffcPeriod = 1800, ffcDrift = 250, warmFrames = 2,
force75 = false, onFfc = {},
).also { assertTrue("DDT must load", it.loadDdt(res("mag160c_official.ddt"))) }
@Test
fun temperaturesAreNotReadyBeforeTheFirstCompletedRender() {
val p = pipeline()
assertFalse("fresh pipeline must not report ready temps", p.tempsReady())
val out = IntArray(320 * 240)
// warm-up frames return false (nothing rendered yet)
assertFalse(p.frame(frame(ByteArray(0x38 + 38400), 0, 10), true, out))
assertFalse("still warming", p.tempsReady())
}
@Test
fun ffcReferenceWindowDoesNotLeakRawCountsIntoNuc() {
val p = pipeline()
val out = IntArray(320 * 240)
// drive the pipeline until it has rendered at least once
var counter = 0
var rendered = 0
while (rendered == 0 && counter < 200) {
if (p.frame(frame(ByteArray(0x38 + 38400), counter, counter), true, out)) rendered++
counter++
}
assertEquals("a frame must render", 1, rendered)
assertTrue("temps ready after a render", p.tempsReady())
val good = IntArray(19200)
p.copyNuc(good)
val goodMax = good.max()
// trigger a manual FFC and walk through the whole cycle, sampling `nuc`
// the way the OSD timer does
p.requestFfc()
var sawInflated = false
for (i in 0 until 40) {
// deliberately feed a very different raw frame during the window: if
// it were decoded into `nuc`, the sampled maximum would explode
p.frame(frame(ByteArray(0x38 + 38400), counter, 250), true, out)
counter++
val sample = IntArray(19200)
p.copyNuc(sample)
val mx = sample.max()
// raw counts are stored as sent (up to 250+... here) and NUC output is
// blind-compensated; anything near the full 16-bit range means raw
// data leaked into the OSD buffer
if (mx > 60000) sawInflated = true
}
assertFalse(
"raw counts must never appear in the OSD buffer during an FFC " +
"(max seen would be ~65535 -> about 150 C)",
sawInflated,
)
}
@Test
fun zeroCountsConvertToTheDocumentedFloorNotAPlausibleTemperature() {
// This is WHY the UI has to gate on tempsReady(): zero counts are a
// legal-looking number that converts to -161 C, not an error.
val t = TempMath.countsToTempMc(0)
assertEquals(-160995, t)
assertEquals(-161.0f, t / 1000f, 0.05f)
// and the double-conversion bug: converting an already-converted value
val correct = TempMath.countsToTempMc(7100)
assertTrue("a ~24 C scene", correct in 0..60_000)
val doubleConverted = TempMath.countsToTempMc(correct)
assertTrue(
"double conversion produces a wildly wrong number ($doubleConverted mC)",
doubleConverted < -50_000,
)
}
}
/** Minimal little-endian writer for the test frame header. */
internal object MagProtocolBytes {
fun putU32(dst: ByteArray, off: Int, v: Int) {
dst[off] = (v and 0xFF).toByte()
dst[off + 1] = ((v shr 8) and 0xFF).toByte()
dst[off + 2] = ((v shr 16) and 0xFF).toByte()
dst[off + 3] = ((v ushr 24) and 0xFF).toByte()
}
}
@@ -0,0 +1,61 @@
package com.mag160c.thermal.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** Phase B: MDT raw-frame -> millidegree temperature map. */
class TempMathTest {
private fun frameLe(vararg counts: Int): ByteArray {
val out = ByteArray(counts.size * 2)
counts.forEachIndexed { i, c ->
out[i * 2] = (c and 0xFF).toByte()
out[i * 2 + 1] = ((c shr 8) and 0xFF).toByte()
}
return out
}
@Test
fun mapHas19200EntriesForAFullFrame() {
val frame = ByteArray(38400) { ((it * 7 + 33) and 0xFF).toByte() }
val map = TempMath.tempMapFromPixels(frame)
assertEquals(19200, map.size)
}
@Test
fun mapIsMonotonicInCounts() {
// increasing counts must give increasing temperature (spot samples)
val counts = intArrayOf(6000, 6500, 7000, 7500, 8000)
val map = TempMath.tempMapFromPixels(frameLe(*counts), w = counts.size, h = 1)
for (i in 1 until map.size) {
assertTrue(
"monotonic: ${counts[i - 1]}->${counts[i]} gave ${map[i - 1]}->${map[i]}",
map[i] > map[i - 1],
)
}
}
@Test
fun mapMatchesScalarConversionPerPixel() {
val counts = intArrayOf(5000, 7000, 9000, 11000)
val map = TempMath.tempMapFromPixels(frameLe(*counts), w = counts.size, h = 1)
counts.forEachIndexed { i, c ->
assertEquals("pixel $i", TempMath.countsToTempMc(c), map[i])
}
}
@Test
fun ambientRegionIsPlausibleCelsius() {
// 7000 counts is the ambient-ish region used by the live pipeline test
val map = TempMath.tempMapFromPixels(frameLe(7000), w = 1, h = 1)
assertTrue("plausible mC: ${map[0]}", map[0] in 0..60_000)
}
@Test
fun shortInputIsHandledWithoutThrowing() {
// defensively stop at the available bytes instead of over-reading
val map = TempMath.tempMapFromPixels(frameLe(7000, 7001, 7002))
assertEquals(3, map.size)
assertEquals(TempMath.countsToTempMc(7000), map[0])
}
}
@@ -0,0 +1,158 @@
package com.mag160c.thermal.media
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* MDT container round trip: compose -> parse must reproduce every section,
* and a corrupted tail must be rejected (offline analysis, phase B).
*/
class MdtTest {
/** Minimal fake JPEG: SOI + payload + EOI, length deliberately not
* aligned to 4 so the padding cut-back is exercised. */
private fun fakeJpg(): ByteArray = byteArrayOf(
0xFF.toByte(), 0xD8.toByte(), 0x11, 0x22, 0x33, 0x44, 0x55,
0xFF.toByte(), 0xD9.toByte(),
)
private fun pixels(seed: Int = 1234): ByteArray {
val out = ByteArray(38400)
var s = seed
for (i in out.indices) {
s = s * 1103515245 + 12345
out[i] = ((s shr 16) and 0xFF).toByte()
}
return out
}
@Test
fun composeParseRoundTripPreservesAllSections() {
val jpg = fakeJpg()
val info0 = ByteArray(0x38) { (it * 3).toByte() }
val info1 = ByteArray(0x38) { (it * 5 + 1).toByte() }
val frame = pixels()
val note = "现场巡检 A 区 3 号柜"
val mdt = Mdt.compose(
jpg = jpg,
info0 = info0,
info1 = info1,
framePixels = frame,
text = note.toByteArray(Charsets.UTF_8),
)
val parsed = Mdt.parse(mdt)
assertNotNull("parse must succeed", parsed)
parsed!!
assertArrayEquals("jpg (padding trimmed)", jpg, parsed.jpg)
assertArrayEquals("info0", info0, parsed.info0)
assertArrayEquals("info1", info1, parsed.info1)
assertArrayEquals("framePixels", frame, parsed.framePixels)
assertEquals("text", note, parsed.text)
}
@Test
fun composeWithoutOptionalBlocksStillParses() {
val jpg = fakeJpg()
val mdt = Mdt.compose(jpg = jpg, info0 = null, info1 = null, framePixels = null)
val parsed = Mdt.parse(mdt)
assertNotNull(parsed)
parsed!!
assertArrayEquals(jpg, parsed.jpg)
assertNull(parsed.info0)
assertNull(parsed.info1)
assertNull(parsed.framePixels)
assertNull(parsed.text)
}
@Test
fun corruptedTailReturnsNull() {
val mdt = Mdt.compose(fakeJpg(), ByteArray(0x38) { 1 }, null, pixels())
// the tail carries the section magic + ddt offset; the remaining 144
// reserved bytes are opaque to us (no checksum in the format), so the
// detectable single-byte corruptions are the two meaningful fields
val tail = mdt.size - 152
for (flipAt in intArrayOf(tail, tail + 4, tail + 6)) {
val bad = mdt.copyOf()
bad[flipAt] = (bad[flipAt].toInt() xor 0x5A).toByte()
assertNull("flip at $flipAt must be rejected", Mdt.parse(bad))
}
}
@Test
fun corruptedDdtHeaderReturnsNull() {
val mdt = Mdt.compose(fakeJpg(), null, null, pixels())
val ddtOffset = Mdt.u32(mdt, mdt.size - 152 + 4)
val bad = mdt.copyOf()
bad[ddtOffset] = (bad[ddtOffset].toInt() xor 0xFF).toByte()
assertNull("bad ddt section magic must be rejected", Mdt.parse(bad))
}
@Test
fun truncatedFileReturnsNull() {
val mdt = Mdt.compose(fakeJpg(), null, null, pixels())
assertNull(Mdt.parse(mdt.copyOfRange(0, 100)))
}
@Test
fun framePixelsAre19200LittleEndianShort() {
val mdt = Mdt.compose(fakeJpg(), null, null, pixels())
val parsed = Mdt.parse(mdt)!!
val frame = parsed.framePixels!!
assertEquals("38400 bytes = 19200 u16 LE", 19200, frame.size / 2)
assertTrue("some non-zero payload", frame.any { it.toInt() != 0 })
}
// ---- probe block (2026-09-11: photos carry their measurement points) ----
@Test
fun probeRoundTrip() {
val probes = listOf(
Mdt.Probe(12, 34, "Pt1", 24_500),
Mdt.Probe(159, 119, "Pt2", -3_250),
Mdt.Probe(0, 0, "", 1_000_000),
)
val mdt = Mdt.compose(
fakeJpg(), ByteArray(0x38) { 1 }, null, pixels(),
probes = Mdt.encodeProbes(probes),
)
val parsed = Mdt.parse(mdt)!!
assertEquals("all probes survive the round trip", probes, parsed.probes)
}
@Test
fun photoWithoutProbesParsesAsEmptyList() {
// older photos (and plain captures with no probes) must not break
val mdt = Mdt.compose(fakeJpg(), null, null, pixels(), text = "note".toByteArray())
val parsed = Mdt.parse(mdt)!!
assertTrue("no probe block -> empty list", parsed.probes.isEmpty())
assertEquals("note", parsed.text)
}
@Test
fun malformedProbeLinesAreSkippedNotFatal() {
val bad = "12,34,Pt1,24500\nbroken line\n5,6\n,,\n7,8,Pt2,1000"
.toByteArray(Charsets.UTF_8)
val probes = Mdt.parseProbes(bad)
assertEquals("only the two valid lines survive", 2, probes.size)
assertEquals(Mdt.Probe(12, 34, "Pt1", 24_500), probes[0])
assertEquals(Mdt.Probe(7, 8, "Pt2", 1_000), probes[1])
}
@Test
fun probesAndNoteCoexist() {
val probes = listOf(Mdt.Probe(80, 60, "中心", 30_000))
val mdt = Mdt.compose(
fakeJpg(), null, null, pixels(),
text = "现场 A 区".toByteArray(Charsets.UTF_8),
probes = Mdt.encodeProbes(probes),
)
val parsed = Mdt.parse(mdt)!!
assertEquals("现场 A 区", parsed.text)
assertEquals(probes, parsed.probes)
assertNotNull(parsed.framePixels)
}
}
@@ -0,0 +1,326 @@
package com.mag160c.thermal.media
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Photo geometry and the NUC block (sensor-space design, 2026-09-12).
*
* Policy being verified:
* - the saved photo keeps the SENSOR's orientation (4:3 landscape), so photo
* direction and burned-in text direction both match the sensor;
* - the user's manual mirrors are applied (sensor-mount corrections);
* - the NUC block is stored on the 160x120 sensor grid, because the photo has no
* rotation relative to the sensor — the offline lookup is a uniform scale.
*/
class PhotoNucMappingTest {
private fun rampCounts(): IntArray = IntArray(160 * 120) { it }
private val noMirror = PhotoSaver.Mirror(false, false)
@Test
fun renderScaleIsAppliedToTheSavedPhotoSize() {
// keeps the sharpness fix pinned: text drawn at 1x was visibly soft
assertTrue("photo is rendered larger than the sensor frame", PhotoSaver.RENDER_SCALE >= 2)
assertEquals(320, PhotoSaver.SENSOR_W)
assertEquals(240, PhotoSaver.SENSOR_H)
// the saved photo therefore stays 4:3 landscape — the sensor's own
// orientation, with no display rotation baked in
assertTrue(
"sensor frame is landscape (4:3)",
PhotoSaver.SENSOR_W > PhotoSaver.SENSOR_H,
)
}
@Test
fun sensorToPhotoIsAPlainScaleWithoutMirror() {
val pos = PhotoSaver.sensorToPhoto(0, 0, noMirror, 0, 960, 720)
assertEquals(3f, pos[0], 0.01f) // sensor pixel 0 centre -> 1.5/160 of width
assertEquals(3f, pos[1], 0.01f)
val mid = PhotoSaver.sensorToPhoto(79, 59, noMirror, 0, 960, 720)
assertEquals(960f / 2f, mid[0], 4f)
assertEquals(720f / 2f, mid[1], 4f)
}
@Test
fun mirrorFlipsTheMappingConsistently() {
val h = PhotoSaver.Mirror(true, false)
val left = PhotoSaver.sensorToPhoto(0, 0, noMirror, 0, 960, 720)
val flipped = PhotoSaver.sensorToPhoto(0, 0, h, 0, 960, 720)
assertEquals("flipH moves x to the far side", 960f, flipped[0], 4f)
assertEquals("y is untouched by flipH", left[1], flipped[1], 0.01f)
val v = PhotoSaver.Mirror(false, true)
val flippedV = PhotoSaver.sensorToPhoto(0, 0, v, 0, 960, 720)
assertEquals("flipV moves y to the bottom", 720f, flippedV[1], 4f)
}
/** Photo pixel and sensor pixel must round-trip for every mirror/rotation. */
@Test
fun photoToSensorInvertsSensorToPhoto() {
for (flipH in booleanArrayOf(false, true)) {
for (flipV in booleanArrayOf(false, true)) {
val m = PhotoSaver.Mirror(flipH, flipV)
for (rot in intArrayOf(0, 90, 180, 270)) {
val w = if (rot == 90 || rot == 270) 720 else 960
val h = if (rot == 90 || rot == 270) 960 else 720
for (sx in intArrayOf(0, 40, 79, 120, 159)) {
for (sy in intArrayOf(0, 30, 59, 90, 119)) {
val p = PhotoSaver.sensorToPhoto(sx, sy, m, rot, w, h)
val back = PhotoSaver.photoToSensor(
p[0].toInt(), p[1].toInt(), m, rot, w, h,
)
assertTrue(
"flipH=$flipH flipV=$flipV rot=$rot sensor($sx,$sy) " +
"-> photo(${p[0]},${p[1]}) -> ${back}",
kotlin.math.abs(back.first - sx) <= 1 &&
kotlin.math.abs(back.second - sy) <= 1,
)
}
}
}
}
}
}
/**
* The rotation turns the sensor's landscape frame into the upright one the user
* was looking at: rotating 90 clockwise sends the sensor's left edge to the top,
* so sensor (0,0) lands at the photo's TOP-RIGHT and the photo is taller than it
* is wide. This is the fix for "竖屏拍照的时候,出来的照片不是竖屏的啊".
*/
@Test
fun rotatingNinetyMakesThePhotoPortraitAndMapsCornersCorrectly() {
val noMirror = PhotoSaver.Mirror(false, false)
val tl = PhotoSaver.sensorToPhoto(0, 0, noMirror, 90, 720, 960)
// sensor top-left -> photo top-right corner
assertEquals("x", 720f, tl[0], 8f)
assertEquals("y", 0f, tl[1], 8f)
val br = PhotoSaver.sensorToPhoto(159, 119, noMirror, 90, 720, 960)
assertEquals("x", 0f, br[0], 8f)
assertEquals("y", 960f, br[1], 8f)
// the sensor's x axis becomes the photo's VERTICAL axis: stepping sx moves
// down the photo, while the photo's x stays put
val xTop = PhotoSaver.sensorToPhoto(0, 60, noMirror, 90, 720, 960)
val xBottom = PhotoSaver.sensorToPhoto(159, 60, noMirror, 90, 720, 960)
assertTrue(
"sensor x runs down the photo (y: ${xTop[1]} -> ${xBottom[1]})",
xBottom[1] - xTop[1] > 900f,
)
assertEquals("photo x is set by sensor y, so it is unchanged", xTop[0], xBottom[0], 0.01f)
// and the sensor's y axis becomes the photo's horizontal axis, mirrored
val yLeft = PhotoSaver.sensorToPhoto(80, 0, noMirror, 90, 720, 960)
val yRight = PhotoSaver.sensorToPhoto(80, 119, noMirror, 90, 720, 960)
assertEquals("photo y is set by sensor x, so it is unchanged", yLeft[1], yRight[1], 0.01f)
assertTrue("stepping sensor y moves left across the photo", yLeft[0] > yRight[0] + 600f)
}
/** 180 flips both axes and keeps the landscape aspect. */
@Test
fun rotating180IsAPointReflection() {
val m = PhotoSaver.Mirror(false, false)
val a = PhotoSaver.sensorToPhoto(0, 0, m, 180, 960, 720)
val b = PhotoSaver.sensorToPhoto(159, 119, m, 180, 960, 720)
assertEquals(960f, a[0], 8f)
assertEquals(720f, a[1], 8f)
assertEquals(0f, b[0], 8f)
assertEquals(0f, b[1], 8f)
}
/** Rotation normalizes any angle to 0/90/180/270. */
@Test
fun rotationNormalizesToQuarterTurns() {
assertEquals(0, PhotoSaver.normalizeDeg(0))
assertEquals(90, PhotoSaver.normalizeDeg(90))
assertEquals(180, PhotoSaver.normalizeDeg(180))
assertEquals(270, PhotoSaver.normalizeDeg(270))
assertEquals(0, PhotoSaver.normalizeDeg(360))
assertEquals(90, PhotoSaver.normalizeDeg(-270))
assertEquals(270, PhotoSaver.normalizeDeg(-90))
}
/**
* A saved file's rotation = the live view's rotation + the physical grip, so the
* scene comes out the right way up whatever way the phone is held.
*
* This is the rule that was missing: the display rotation alone gave a portrait
* file with a sideways scene whenever the user turned the phone ("切到横屏拍照片
* 和视频方向又不对了"), measured on device as exactly 90 deg out against a photo
* of the same room taken upright.
*/
@Test
fun savedFileRotationFollowsTheGrip() {
val display = 90 // LOCKED_ROT_DEG with no manual correction
assertEquals("upright: portrait file, as before", 90, PhotoSaver.captureRotation(display, 0))
assertEquals("turned clockwise", 180, PhotoSaver.captureRotation(display, 90))
assertEquals("upside down", 270, PhotoSaver.captureRotation(display, 180))
assertEquals("turned counter-clockwise", 0, PhotoSaver.captureRotation(display, 270))
// the manual "rotate USB image" correction adds on top of the grip
assertEquals(270, PhotoSaver.captureRotation(180, 90))
assertEquals(90, PhotoSaver.captureRotation(0, 90))
}
/**
* Only the two upright-ish grips may give a portrait file; both landscape grips
* must give a landscape file, and they differ by 180 deg from each other (the
* scene is upside down between them).
*/
@Test
fun landscapeGripsProduceLandscapeFiles() {
val display = 90
for (grip in intArrayOf(0, 90, 180, 270)) {
val rot = PhotoSaver.captureRotation(display, grip)
val portrait = rot == 90 || rot == 270
val expectedPortrait = grip == 0 || grip == 180
assertEquals(
"grip=$grip rot=$rot must be ${if (expectedPortrait) "portrait" else "landscape"}",
expectedPortrait,
portrait,
)
}
val cw = PhotoSaver.captureRotation(display, 90)
val ccw = PhotoSaver.captureRotation(display, 270)
assertEquals(
"the two landscape grips differ by 180 deg",
180,
PhotoSaver.normalizeDeg(cw - ccw),
)
}
/**
* A version-1 render block (written before the rotation was baked in) reports no
* rotation, so old photos keep being interpreted as sensor-oriented instead of
* being silently turned.
*/
@Test
fun renderParamsUpgradeFromVersionOne() {
val v1 = ByteArray(8)
Mdt.put32(v1, 0, 1)
Mdt.put32(v1, 4, Mdt.RENDER_FLAG_FLIP_V)
val parsed = Mdt.parseRenderParams(v1)
assertTrue("flipV survives", parsed.flipV)
assertEquals("no rotation in a v1 block", 0, parsed.rotDeg)
val v2 = Mdt.encodeRenderParams(Mdt.RenderParams(true, true, 90))
val back = Mdt.parseRenderParams(v2)
assertTrue(back.flipH)
assertTrue(back.flipV)
assertEquals(90, back.rotDeg)
}
@Test
fun nucBlockIsTheSensorGrid() {
val counts = rampCounts()
val bytes = PhotoSaver.packNucForPhoto(counts)
// 160x120 u16 = 38400 bytes: the photo and the sensor share an orientation,
// so no re-ordering is needed (the old per-photo-pixel packing was 4x
// bigger AND silently dropped when its size check did not match)
assertEquals(38400, bytes.size)
val back = PhotoSaver.unpackNuc(bytes)
assertEquals(19200, back.size)
for (i in counts.indices) assertEquals("sample $i", counts[i], back[i])
}
@Test
fun nucRoundTripSurvivesAnMdtContainer() {
val counts = rampCounts()
val mdt = Mdt.compose(
jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 1, 2, 0xFF.toByte(), 0xD9.toByte()),
info0 = null, info1 = null, framePixels = ByteArray(38400),
nucPixels = PhotoSaver.packNucForPhoto(counts),
)
val parsed = Mdt.parse(mdt)!!
assertTrue("photo must be measurable", parsed.hasTemperatureData)
val back = PhotoSaver.unpackNuc(parsed.nucPixels!!)
for (i in counts.indices) assertEquals("sample $i", counts[i], back[i])
}
@Test
fun photosWithoutNucAreReportedAsUnmeasurable() {
// older files must NOT be silently measurable: the UI has to say "no
// temperature data" rather than print a wrong number
val mdt = Mdt.compose(
jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xD9.toByte()),
info0 = null, info1 = null, framePixels = ByteArray(38400),
)
assertTrue("no NUC block -> no temperature data", !Mdt.parse(mdt)!!.hasTemperatureData)
}
/**
* The extremes are stored in SENSOR coordinates next to probes that use the
* same space, so the photo builder must map them exactly like a probe. On a
* real photo they were added raw, which put both "max" and "min" a few dozen
* pixels from the origin — in the top-left corner — instead of over the hot
* and cold spots they name.
*/
@Test
fun extremesUseTheSameSensorMappingAsProbes() {
val mirror = PhotoSaver.Mirror(false, true) // the user's mount correction
val probe = PhotoSaver.sensorToPhoto(120, 90, mirror, 0, 960, 720)
val extreme = PhotoSaver.sensorToPhoto(120, 90, mirror, 0, 960, 720)
assertEquals("an extreme at a probe's pixel lands on the same photo pixel",
probe[0], extreme[0], 0.01f)
assertEquals(probe[1], extreme[1], 0.01f)
// and it is nowhere near the origin: sensor (120,90) is right of centre,
// flipped vertically it is above centre
assertTrue("x=${extreme[0]} must be well right of the origin", extreme[0] > 480f)
assertTrue("y=${extreme[1]} must sit in the upper half after flipV", extreme[1] < 360f)
}
/** A probe and an extreme 3 sensor pixels apart must stay 3 pixels apart. */
@Test
fun extremesKeepTheirDistanceFromProbesUnderEveryMirror() {
for (flipH in booleanArrayOf(false, true)) {
for (flipV in booleanArrayOf(false, true)) {
val m = PhotoSaver.Mirror(flipH, flipV)
val a = PhotoSaver.sensorToPhoto(100, 60, m, 0, 960, 720)
val b = PhotoSaver.sensorToPhoto(103, 60, m, 0, 960, 720)
assertEquals(
"flipH=$flipV: 3 sensor pixels stay 3 photo pixels",
18f, kotlin.math.abs(a[0] - b[0]), 0.01f,
)
}
}
}
/**
* The capture's own extremes travel in the container. The analysis screen used
* to re-derive them from the NUC block, which cannot reproduce the live answer
* exactly (the sensor drifts), so a photo showed two "min" markers a few pixels
* apart — the burned-in one and the freshly computed one.
*/
@Test
fun recordedExtremesSurviveTheContainer() {
val e = Mdt.Extremes(minPos = 60 * 160 + 12, maxPos = 22 * 160 + 130, minMc = 21_987, maxMc = 32_615)
val mdt = Mdt.compose(
jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xD9.toByte()),
info0 = null, info1 = null, framePixels = ByteArray(38400),
nucPixels = PhotoSaver.packNucForPhoto(IntArray(160 * 120)),
extremes = Mdt.encodeExtremes(e),
)
val back = Mdt.parse(mdt)!!.extremes
assertEquals(e.minPos, back.minPos)
assertEquals(e.maxPos, back.maxPos)
// millidegrees survive exactly: rounding them to whole degrees would make
// the analysis readout differ from the marker burned into the photo
assertEquals(e.minMc, back.minMc)
assertEquals(e.maxMc, back.maxMc)
}
@Test
fun aPhotoWithoutExtremesReportsNone() {
val mdt = Mdt.compose(
jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xD9.toByte()),
info0 = null, info1 = null, framePixels = ByteArray(38400),
)
val back = Mdt.parse(mdt)!!.extremes
assertEquals(-1, back.minPos)
assertEquals(-1, back.maxPos)
assertTrue("nothing traced -> nothing to prefer", !Mdt.Extremes.hasAny(back))
// and the parse of a malformed block must not throw or invent data
assertTrue(!Mdt.Extremes.hasAny(Mdt.parseExtremes("garbage".toByteArray())))
assertTrue(!Mdt.Extremes.hasAny(Mdt.parseExtremes(null)))
}
}
@@ -0,0 +1,200 @@
package com.mag160c.thermal.net
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Random
/**
* Phase F wire contract: encode/feed round trip plus the cases a raw socket read
* always produces — truncated records and several records glued together — and
* the frame metadata the client needs to reproduce the host's image.
*/
class RemoteContractTest {
private fun payload(seed: Int): ByteArray {
val out = ByteArray(RemoteContract.FRAME_PIXELS)
Random(seed.toLong()).nextBytes(out)
return out
}
@Test
fun encodeFramePacketLayoutMatchesTheContract() {
val raw = payload(1)
val pkt = RemoteContract.encodeFramePacket(
raw, counter = 7, flags = RemoteContract.FLAG_RENDERS,
ffcPhase = RemoteContract.PHASE_REFERENCE, shutter = 1234,
)
assertEquals(38424, pkt.size)
assertEquals(RemoteContract.FRAME_MAGIC, RemoteContract.u32(pkt, 0))
assertEquals(7, RemoteContract.u32(pkt, 4))
assertEquals(RemoteContract.FRAME_PIXELS, RemoteContract.u32(pkt, 8))
assertEquals(RemoteContract.FLAG_RENDERS, RemoteContract.u32(pkt, 12))
assertEquals(RemoteContract.PHASE_REFERENCE, RemoteContract.u32(pkt, 16))
assertEquals(1234, RemoteContract.u32(pkt, 20))
assertArrayEquals(raw, pkt.copyOfRange(RemoteContract.FRAME_HEADER, pkt.size))
}
@Test
fun singleFrameRoundTripPreservesMetadata() {
val raw = payload(2)
val pkt = RemoteContract.encodeFramePacket(raw, 1, RemoteContract.FLAG_RENDERS, 2, 4242)
val frames = RemoteContract.FramePacketReader().feed(pkt)
assertEquals(1, frames.size)
val f = frames[0]
assertEquals(1, f.counter)
assertEquals(2, f.ffcPhase)
assertEquals(4242, f.shutter)
assertTrue(f.renders)
assertArrayEquals(raw, f.pixels)
}
@Test
fun nonRenderingFrameIsFlagged() {
// the host marks frames it could not render (FFC/hidden) so the client
// does not mistake them for image data
val pkt = RemoteContract.encodeFramePacket(
payload(21), 5, flags = 0, ffcPhase = RemoteContract.PHASE_HIDDEN, shutter = 90,
)
val f = RemoteContract.FramePacketReader().feed(pkt)[0]
assertFalse("flags=0 means not rendered", f.renders)
assertEquals(RemoteContract.PHASE_HIDDEN, f.ffcPhase)
}
@Test
fun truncatedInputIsBufferedUntilComplete() {
val raw = payload(3)
val pkt = RemoteContract.encodeFramePacket(raw, 2)
val reader = RemoteContract.FramePacketReader()
// split at an awkward offset: mid-header, then mid-payload
assertTrue("nothing complete yet", reader.feed(pkt.copyOfRange(0, 7)).isEmpty())
assertTrue("still incomplete", reader.feed(pkt.copyOfRange(7, 1000)).isEmpty())
val frames = reader.feed(pkt.copyOfRange(1000, pkt.size))
assertEquals(1, frames.size)
assertArrayEquals(raw, frames[0].pixels)
assertEquals(0, reader.pending())
}
@Test
fun threeFramesGluedInOneRead() {
val raws = listOf(payload(4), payload(5), payload(6))
val glued = java.io.ByteArrayOutputStream()
raws.forEachIndexed { i, r -> glued.write(RemoteContract.encodeFramePacket(r, i)) }
val frames = RemoteContract.FramePacketReader().feed(glued.toByteArray())
assertEquals(3, frames.size)
frames.forEachIndexed { i, f ->
assertEquals(i, f.counter)
assertArrayEquals(raws[i], f.pixels)
}
}
@Test
fun gluedWithRaggedBoundaries() {
val raws = List(3) { payload(10 + it) }
val glued = java.io.ByteArrayOutputStream()
raws.forEachIndexed { i, r -> glued.write(RemoteContract.encodeFramePacket(r, i)) }
val bytes = glued.toByteArray()
val reader = RemoteContract.FramePacketReader()
val got = ArrayList<RemoteContract.FramePacket>()
var pos = 0
val chunkPattern = intArrayOf(5, 38423, 1, 20000, 100, 40000)
var ci = 0
while (pos < bytes.size) {
val n = minOf(chunkPattern[ci % chunkPattern.size], bytes.size - pos)
got += reader.feed(bytes.copyOfRange(pos, pos + n))
pos += n
ci++
}
assertEquals(3, got.size)
got.forEachIndexed { i, f -> assertArrayEquals(raws[i], f.pixels) }
}
@Test
fun garbageBeforeMagicResynchronises() {
val raw = payload(20)
val pkt = RemoteContract.encodeFramePacket(raw, 3)
val frames = RemoteContract.FramePacketReader().feed(byteArrayOf(1, 2, 3, 4, 5) + pkt)
assertEquals(1, frames.size)
assertArrayEquals(raw, frames[0].pixels)
}
@Test
fun bogusLengthHeaderIsSkipped() {
val raw = payload(21)
val pkt = RemoteContract.encodeFramePacket(raw, 4)
val bad = RemoteContract.encodeFramePacket(payload(22), 5).copyOf()
RemoteContract.putU32(bad, 8, 12345) // wrong payload length
val frames = RemoteContract.FramePacketReader().feed(bad + pkt)
assertEquals("only the valid frame is delivered", 1, frames.size)
assertArrayEquals(raw, frames[0].pixels)
}
@Test
fun beaconRoundTrip() {
val line = RemoteContract.hostBeacon("Pixel 8 \"test\"", 160043865L)
val info = RemoteContract.parseBeacon(line, "192.168.1.23")
assertNotNull(info)
info!!
assertEquals("Pixel 8 \"test\"", info.name)
assertEquals("192.168.1.23", info.address)
assertEquals(47511, info.tcpPort)
assertEquals(160043865L, info.serial)
}
@Test
fun foreignOrBrokenBeaconsAreIgnored() {
assertNull(RemoteContract.parseBeacon("{\"app\":\"other\"}", "10.0.0.1"))
assertNull(RemoteContract.parseBeacon("not json at all", "10.0.0.1"))
assertNull(RemoteContract.parseBeacon("{}", "10.0.0.1"))
}
@Test
fun controlLineParsing() {
assertEquals("hello", RemoteContract.commandOf(RemoteContract.hello("Client")))
assertEquals("Client", RemoteContract.nameOf(RemoteContract.hello("Client")))
assertEquals("start", RemoteContract.commandOf(RemoteContract.cmd("start")))
assertEquals("ffc", RemoteContract.commandOf(RemoteContract.cmd("ffc")))
assertEquals("welcome", RemoteContract.typeOf(RemoteContract.welcomeLine(160, 120, 15, 1)))
assertTrue(RemoteContract.isKeepalive(RemoteContract.pingLine()))
assertFalse(RemoteContract.isKeepalive(RemoteContract.okLine()))
assertEquals("stream-start", RemoteContract.typeOf(RemoteContract.streamStartLine()))
assertEquals("stream-stop", RemoteContract.typeOf(RemoteContract.streamStopLine()))
}
@Test
fun busyLineIsRecognised() {
// the host rejects a second client with this line; the client must
// surface it instead of silently waiting forever
assertTrue(RemoteContract.isBusyLine(RemoteContract.busyLine()))
assertFalse(RemoteContract.isBusyLine(RemoteContract.okLine()))
assertFalse(RemoteContract.isBusyLine(RemoteContract.pingLine()))
}
@Test
fun welcomeParsing() {
val w = RemoteContract.parseWelcome(RemoteContract.welcomeLine(160, 120, 15, 160043865L))
assertNotNull(w)
w!!
assertEquals(160, w.w)
assertEquals(120, w.h)
assertEquals(15, w.fps)
assertEquals(160043865L, w.serial)
assertNull(RemoteContract.parseWelcome(RemoteContract.okLine()))
}
@Test
fun readerRecoversAfterReset() {
val reader = RemoteContract.FramePacketReader()
reader.feed(RemoteContract.encodeFramePacket(payload(30), 1).copyOfRange(0, 100))
assertTrue(reader.pending() > 0)
reader.reset()
assertEquals(0, reader.pending())
val raw = payload(31)
val frames = reader.feed(RemoteContract.encodeFramePacket(raw, 2))
assertEquals(1, frames.size)
assertArrayEquals(raw, frames[0].pixels)
}
}
@@ -0,0 +1,218 @@
package com.mag160c.thermal.net
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Random
/**
* End-to-end loopback check of the Phase F host/client pair: a real TCP socket
* on localhost, real framing, real control handshake. Run on the JVM because
* RemoteHost/RemoteClient use only java.net types.
*/
class RemoteLoopbackTest {
private fun payload(seed: Int): ByteArray {
val out = ByteArray(RemoteContract.FRAME_PIXELS)
Random(seed.toLong()).nextBytes(out)
return out
}
@Test
fun hostStreamsFramesToClientOverLoopback() = runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val host = RemoteHost("jvm-test", 160043865L)
try {
host.start()
// give the server socket a moment to bind
delay(300)
val session = RemoteClient.connect("127.0.0.1", RemoteContract.CONTROL_PORT, scope)
assertNotNull("client must connect to the loopback host", session)
session!!
// hello -> welcome
session.hello("jvm-client")
val welcome = withTimeout(5000) {
session.lines.first { RemoteContract.typeOf(it) == "welcome" }
}
val parsed = RemoteContract.parseWelcome(welcome)
assertNotNull("welcome must parse", parsed)
assertEquals(160, parsed!!.w)
// start -> stream-start, then frames
val received = ArrayList<RemoteContract.FramePacket>()
val collector = scope.launch {
session.frames.collect { received.add(it) }
}
session.startStream()
val startLine = withTimeout(5000) {
session.lines.first { RemoteContract.typeOf(it) == "stream-start" }
}
assertEquals("stream-start", RemoteContract.typeOf(startLine))
// feed frames like the live session would
val sent = List(5) { payload(100 + it) }
for ((i, f) in sent.withIndex()) {
host.offerFrame(f)
delay(60)
if (host.state == RemoteHost.State.STREAMING && i == 0) {
// ensure the queue drains between frames
}
}
// wait for all frames to arrive
val ok = withTimeout(8000) {
while (received.size < sent.size) delay(50)
true
}
assertTrue(ok)
collector.cancel()
assertTrue("at least ${sent.size} frames received, got ${received.size}", received.size >= sent.size)
sent.forEachIndexed { i, expect ->
assertTrue(
"frame $i must round-trip byte-exactly",
expect.contentEquals(received[i].pixels),
)
}
// ffc request reaches the host callback
var ffcSeen = false
host.onFfcRequest = { ffcSeen = true }
session.requestFfc()
withTimeout(5000) {
while (!ffcSeen) delay(50)
}
assertTrue("host must receive the ffc request", ffcSeen)
// stop -> stream-stop
session.stopStream()
val stopLine = withTimeout(5000) {
session.lines.first { RemoteContract.typeOf(it) == "stream-stop" }
}
assertEquals("stream-stop", RemoteContract.typeOf(stopLine))
session.close()
} finally {
host.stop()
scope.cancel()
}
}
@Test
fun hostRejectsNothingWhenNoClientIsConnected() = runBlocking {
val host = RemoteHost("jvm-test", 1L)
try {
host.start()
delay(200)
// offering frames with no client must not throw or block
repeat(20) { host.offerFrame(payload(it)) }
assertEquals(RemoteHost.State.WAITING, host.state)
} finally {
host.stop()
}
}
@Test
fun beaconFormatIsWhatTheClientParses() {
val line = RemoteContract.hostBeacon("Redmi K70", 160043865L)
val info = RemoteContract.parseBeacon(line, "192.168.31.7")
assertNotNull(info)
assertEquals("Redmi K70", info!!.name)
assertEquals("192.168.31.7", info.address)
assertEquals(RemoteContract.CONTROL_PORT, info.tcpPort)
assertEquals(160043865L, info.serial)
}
/**
* Regression for the command-ordering defect: commands must reach the host in
* call order. The first implementation launched one writer coroutine per
* command, so `hello` immediately followed by `start` could arrive swapped and
* the client lost the `welcome` line (~6.7% of runs).
*/
@Test
fun commandOrderIsPreservedUnderRapidSends() = runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val host = RemoteHost("jvm-order", 7L)
try {
host.start()
delay(300)
val session = RemoteClient.connect("127.0.0.1", RemoteContract.CONTROL_PORT, scope)
assertNotNull(session)
session!!
val seen = java.util.Collections.synchronizedList(ArrayList<String>())
val collector = scope.launch { session.lines.collect { seen.add(RemoteContract.typeOf(it)) } }
delay(100)
// a) hello immediately followed by start must yield welcome first
session.hello("order-test")
session.startStream()
withTimeout(5000) {
while (!seen.contains("welcome") || !seen.contains("stream-start")) delay(20)
}
val iWelcome = seen.indexOf("welcome")
val iStart = seen.indexOf("stream-start")
assertTrue("welcome ($iWelcome) must precede stream-start ($iStart)", iWelcome < iStart)
// b) many rapid commands must all reach the host, in order, and none
// may garble the frame stream. Replies are intentionally not sent
// while streaming (a JSON line between frame records would be read
// as stray bytes), so the host callback is the observable.
val ffcSeen = java.util.concurrent.atomic.AtomicInteger(0)
host.onFfcRequest = { ffcSeen.incrementAndGet() }
repeat(200) { session.requestFfc() }
withTimeout(8000) {
while (ffcSeen.get() < 200) delay(20)
}
assertEquals("all 200 ffc requests reached the host", 200, ffcSeen.get())
collector.cancel()
session.close()
} finally {
host.stop()
scope.cancel()
}
}
/**
* A second client must be told the host is busy instead of being left to
* hang in the kernel backlog (plan F2).
*/
@Test
fun secondClientIsRejectedWithBusy(): Unit = runBlocking {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val host = RemoteHost("jvm-busy", 8L)
try {
host.start()
delay(300)
val first = RemoteClient.connect("127.0.0.1", RemoteContract.CONTROL_PORT, scope)
assertNotNull("first client connects", first)
// start streaming so the host is definitely occupied
first!!.startStream()
withTimeout(5000) { first.lines.first { RemoteContract.typeOf(it) == "stream-start" } }
val second = RemoteClient.connect("127.0.0.1", RemoteContract.CONTROL_PORT, scope)
assertNotNull("second client still connects at TCP level", second)
val busy = withTimeout(5000) {
second!!.lines.first { RemoteContract.isBusyLine(it) }
}
assertTrue(RemoteContract.isBusyLine(busy))
// the first client must be unaffected
assertEquals(RemoteHost.State.STREAMING, host.state)
second?.close()
first?.close()
} finally {
host.stop()
scope.cancel()
}
}
}
@@ -0,0 +1,92 @@
package com.mag160c.thermal.ui.live
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Orientation of the live image.
*
* USER DECISION (2026-09-11): the image is LOCKED to the phone's portrait frame,
* like the composition. It does NOT rotate with the grip:
*
* - the thermal sensor is physically attached to the phone, so it turns with
* it; a locked image therefore keeps the scene aligned with the world without
* any accelerometer input (and that is also what the official app shows on
* screen, since its window auto-rotates);
* - an earlier revision counter-rotated the image by the grip angle
* (`rot = 90 - grip`); that double-compensated and produced the reported
* "turn right, picture goes the other way" defect. It was reverted.
*
* Only the three official-style manual corrections can change the rotation:
* 旋转USB画面 (0/90/180/270), 水平翻转, 竖直翻转.
*/
class ImageTransformOrientationTest {
@Test
fun imageRotationIsLockedAndGripIndependent() {
// whatever the phone does, the image keeps the fixed 90 deg rotation
assertEquals(90, ImageTransform.LOCKED_ROT_DEG)
// params() takes no grip argument at all — this is the compile-time
// guarantee that the image cannot follow the phone
assertEquals(90, ImageTransform.params().rotDeg)
assertEquals(90, ImageTransform.params(userRotateDeg = 0).rotDeg)
assertEquals(90, ImageTransform.params(flipH = true, flipV = true).rotDeg)
}
@Test
fun manualRotationAddsOnTopOfTheLockedBase() {
assertEquals(90, ImageTransform.params(0).rotDeg)
assertEquals(180, ImageTransform.params(90).rotDeg)
assertEquals(270, ImageTransform.params(180).rotDeg)
assertEquals(0, ImageTransform.params(270).rotDeg)
// full turns are no-ops and are normalised away
assertEquals(90, ImageTransform.params(360).rotDeg)
assertEquals(90, ImageTransform.params(-360).rotDeg)
assertEquals(180, ImageTransform.params(450).rotDeg)
}
@Test
fun flipsAreCarriedThroughToTheGeometry() {
val p = ImageTransform.params(userRotateDeg = 180, flipH = true, flipV = false)
assertEquals(270, p.rotDeg)
assertTrue(p.flipH)
assertTrue(!p.flipV)
}
/**
* The equivalence the user discovered on the device: a vertical flip together
* with a 90 deg rotation gives the SAME image as a horizontal flip with a
* 270 deg rotation. Verified here by transforming every pixel corner, so the
* settings screen documentation can state it as a fact.
*/
@Test
fun flipVWith90EqualsFlipHWith270() {
val a = ImageTransform.params(userRotateDeg = 0, flipH = false, flipV = true) // 90 + flipV
val b = ImageTransform.params(userRotateDeg = 180, flipH = true, flipV = false) // 270 + flipH
assertEquals(90, a.rotDeg)
assertEquals(270, b.rotDeg)
val fit = ImageTransform.fit(0f, 0f, 1080f, 1900f, 90)
val crop = ImageTransform.cropForZoom(1)
// compare where every sensor pixel lands; a and b must agree
for (sx in 0 until ImageTransform.SENSOR_W step 7) {
for (sy in 0 until ImageTransform.SENSOR_H step 7) {
val pa = ImageTransform.sensorToScreen(sx.toFloat(), sy.toFloat(), a, fit, crop)
val pb = ImageTransform.sensorToScreen(sx.toFloat(), sy.toFloat(), b, fit, crop)
assertEquals("x at ($sx,$sy)", pa[0], pb[0], 0.01f)
assertEquals("y at ($sx,$sy)", pa[1], pb[1], 0.01f)
}
}
}
@Test
fun lockedRotationKeepsThePortraitFootprint() {
// the locked 90 deg rotation draws the 4:3 sensor as a 3:4 image
assertEquals(3f / 4f, ImageTransform.screenAspect(90), 1e-4f)
// the manual 90/270 corrections still swap it, for special mounts
assertEquals(4f / 3f, ImageTransform.screenAspect(180), 1e-4f)
assertEquals(4f / 3f, ImageTransform.screenAspect(0), 1e-4f)
assertEquals(3f / 4f, ImageTransform.screenAspect(270), 1e-4f)
}
}
@@ -0,0 +1,135 @@
package com.mag160c.thermal.ui.live
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Geometry shared by the renderer and the tap handler. These tests exist because
* a mismatch between "what is drawn" and "where a tap maps" is invisible in unit
* tests but immediately wrong on a device — the sensor<->screen pair must be
* exact inverses for every grip angle and flip combination.
*/
class ImageTransformTest {
private val fit = ImageTransform.fit(
availLeft = 0f, availTop = 100f, availW = 1080f, availH = 1900f, rotDeg = 90,
)
@Test
fun rotationIsLockedPlusManualCorrection() {
// the image does NOT follow the phone: only the base 90 plus the user's
// manual correction (see ImageTransformOrientationTest for the rationale)
assertEquals(90, ImageTransform.params().rotDeg)
assertEquals(180, ImageTransform.params(userRotateDeg = 90).rotDeg)
assertEquals(0, ImageTransform.params(userRotateDeg = 270).rotDeg)
}
@Test
fun sensorToScreenAndBackAreInversesForEveryOrientation() {
val crop = ImageTransform.cropForZoom(1)
for (rot in intArrayOf(0, 90, 180, 270)) {
for (flipH in booleanArrayOf(false, true)) {
for (flipV in booleanArrayOf(false, true)) {
val p = ImageTransform.Params(rot, flipH, flipV)
val f = ImageTransform.fit(0f, 100f, 1080f, 1900f, p.rotDeg)
for (sx in intArrayOf(0, 37, 80, 159)) {
for (sy in intArrayOf(0, 22, 60, 119)) {
val scr = ImageTransform.sensorToScreen(sx.toFloat(), sy.toFloat(), p, f, crop)
val back = ImageTransform.screenToSensor(scr[0], scr[1], p, f, crop)
assertNotNull(
"rot=$rot flipH=$flipH flipV=$flipV pixel=($sx,$sy)",
back,
)
assertEquals(
"rot=$rot flipH=$flipH flipV=$flipV sx",
sx, back!!.first,
)
assertEquals(
"rot=$rot flipH=$flipH flipV=$flipV sy",
sy, back.second,
)
}
}
}
}
}
}
@Test
fun fitUsesTheCorrectAspectForEachRotation() {
// 90/270 -> 3:4 portrait footprint; 0/180 -> 4:3 landscape footprint
assertTrue(ImageTransform.swapped(90))
assertTrue(ImageTransform.swapped(270))
assertFalse(ImageTransform.swapped(0))
assertFalse(ImageTransform.swapped(180))
// 1080x1900 area: the 3:4 footprint is 1080x1440, width-limited
val portrait = ImageTransform.fit(0f, 0f, 1080f, 1900f, 90)
assertEquals(1080f, portrait.width, 1f)
assertEquals(1440f, portrait.height, 1f)
// a short wide area makes the same rotation height-limited
val shortArea = ImageTransform.fit(0f, 0f, 1080f, 800f, 90)
assertEquals(800f, shortArea.height, 1f)
assertEquals(600f, shortArea.width, 1f)
// unrotated the footprint is 4:3 landscape
val wide = ImageTransform.fit(0f, 0f, 1080f, 400f, 0)
assertEquals(400f * 4f / 3f, wide.width, 1f) // height-limited
assertEquals(400f, wide.height, 1f)
}
@Test
fun screenPointsOutsideTheImageMapToNull() {
val p = ImageTransform.params(0)
val f = ImageTransform.fit(0f, 100f, 1080f, 1900f, p.rotDeg)
val crop = ImageTransform.cropForZoom(1)
// above the image and below it
assertNull(ImageTransform.screenToSensor(f.cx, 10f, p, f, crop))
assertNull(ImageTransform.screenToSensor(f.cx, 2000f, p, f, crop))
// far left of the image
assertNull(ImageTransform.screenToSensor(-500f, f.cy, p, f, crop))
}
@Test
fun zoomConfinesTheMappingToTheCroppedArea() {
val p = ImageTransform.params(0)
val f = ImageTransform.fit(0f, 100f, 1080f, 1900f, p.rotDeg)
val crop = ImageTransform.cropForZoom(2)
// the crop insets by 1/4 on each side for 2x zoom
assertEquals(0.25f, crop[0], 1e-4f)
assertEquals(0.75f, crop[2], 1e-4f)
// the centre still maps to the centre
val centre = ImageTransform.screenToSensor(f.cx, f.cy, p, f, crop)
assertNotNull(centre)
// the corner of the drawn rect maps to the CROP edge — that is what
// "zoomed in" means. Which sensor pixel depends on the rotation, so just
// require it to be inside the cropped quarter rather than at the border.
val corner = ImageTransform.screenToSensor(f.left + 1f, f.top + 1f, p, f, crop)
assertNotNull(corner)
val c = corner!!
assertTrue("corner lands inside the crop: ($c)", c.first in 40..119 || c.second in 30..89)
// and a point outside the drawn rect is rejected
assertNull(ImageTransform.screenToSensor(f.left - 50f, f.cy, p, f, crop))
}
@Test
fun rotatedTextBoxExtentsFollowTheRotation() {
// unrotated: half extents are half the box
val flat = ImageTransform.rotatedBoxHalfExtents(100f, 20f, 0f)
assertEquals(50f, flat[0], 1e-3f)
assertEquals(10f, flat[1], 1e-3f)
// rotated 90 deg: the extents swap
val turned = ImageTransform.rotatedBoxHalfExtents(100f, 20f, 90f)
assertEquals(10f, turned[0], 1e-3f)
assertEquals(50f, turned[1], 1e-3f)
// an unrotated and a 180-rotated box occupy the same footprint
val flip = ImageTransform.rotatedBoxHalfExtents(100f, 20f, 180f)
assertEquals(50f, flip[0], 1e-3f)
assertEquals(10f, flip[1], 1e-3f)
}
}
@@ -0,0 +1,73 @@
package com.mag160c.thermal.ui.settings
import com.mag160c.thermal.ui.live.ImageTransform
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The orientation settings must reach the renderer even when they are changed on
* the settings tab (the live screen is not composed then). AppSettings publishes
* every change to [ImageOrientationSettings]; these tests pin that contract.
*/
class ImageOrientationSettingsTest {
private fun reset() {
ImageOrientationSettings.publish(0, false, false)
}
@Test
fun publishedValuesAreReadableImmediately() {
reset()
ImageOrientationSettings.publish(90, flipH = true, flipV = false)
val s = ImageOrientationSettings.state.value
assertEquals(90, s.rotateDeg)
assertTrue(s.flipH)
assertFalse(s.flipV)
}
@Test
fun publishedValuesFeedTheImageTransform() {
reset()
ImageOrientationSettings.publish(180, flipH = false, flipV = true)
val s = ImageOrientationSettings.state.value
val p = ImageTransform.params(s.rotateDeg, s.flipH, s.flipV)
// locked base 90 + 180 = 270, with only the vertical flip set
assertEquals(270, p.rotDeg)
assertFalse(p.flipH)
assertTrue(p.flipV)
}
@Test
fun defaultStateLeavesTheLockedOrientationAlone() {
reset()
val s = ImageOrientationSettings.state.value
assertEquals(0, s.rotateDeg)
assertFalse(s.flipH)
assertFalse(s.flipV)
assertEquals(ImageTransform.LOCKED_ROT_DEG, ImageTransform.params(s.rotateDeg, s.flipH, s.flipV).rotDeg)
}
/**
* The equivalent pair the user found on the device: (flipV, +90) and
* (flipH, +270) produce the same on-screen mapping. Both must be reachable
* through the published settings.
*/
@Test
fun theTwoEquivalentConfigurationsAreBothRepresentable() {
ImageOrientationSettings.publish(0, flipH = false, flipV = true)
val a = ImageOrientationSettings.state.value
val pa = ImageTransform.params(a.rotateDeg, a.flipH, a.flipV)
ImageOrientationSettings.publish(180, flipH = true, flipV = false)
val b = ImageOrientationSettings.state.value
val pb = ImageTransform.params(b.rotateDeg, b.flipH, b.flipV)
assertEquals(90, pa.rotDeg)
assertEquals(270, pb.rotDeg)
// see ImageTransformOrientationTest for the pixel-level equivalence proof
assertTrue(pa.flipV && !pa.flipH)
assertTrue(pb.flipH && !pb.flipV)
}
}
+3
View File
@@ -6,6 +6,7 @@ activityCompose = "1.10.1"
coreKtx = "1.16.0"
lifecycle = "2.9.1"
junit = "4.13.2"
retrofit = "2.11.0"
[libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
@@ -20,6 +21,8 @@ core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx"
lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
Binary file not shown.
+102 -33
View File
@@ -61,27 +61,45 @@ android/app/src/main/kotlin/com/mag160c/thermal/
├─ ui/UiInsets.kt 单例:底部导航高度 px(渲染器用它留白)
├─ ui/theme/Theme.kt M3 动态取色(Android12+
├─ ui/live/ ★实时画面(最核心)
│ ├─ LiveViewModel.kt 状态机/演示模式/多点测温/追踪开关/拍照/录像/inset上报
├─ LiveScreen.kt 稳定单分支相机布局:顶栏4控制项+底部快门区(相册/拍照/录像)+SurfaceView
─ LiveRenderer.kt SurfaceView 软件渲染:图像恒90°旋转(3:4竖)钉在竖屏框架固定区域,
文字恒屏幕水平;色标条/圆圈标记/中心温OSD
│ ├─ LiveViewModel.kt 状态机/多点测温/追踪开关/拍照/录像/inset上报/PIP状态/
│ 远程服务端开关+rawHook→RemoteHost
─ LiveScreen.kt 稳定单分支相机布局:顶栏5控制项(FFC/变倍/追踪/调色板/画中画)
+底部快门区(相册/拍照/录像)+SurfaceView+PIP浮层
│ ├─ PipCameraView.kt 可见光PIP相机引擎(Camera2最小实现,异常只记日志)
│ ├─ ImageTransform.kt 方向/翻转/letterbox/缩放几何 + 传感器↔屏幕互逆映射(纯 Kotlin,单测覆盖)
│ └─ LiveRenderer.kt SurfaceView 软件渲染:图像锁定竖屏框架(90°,可按设置手工旋转/翻转)、
│ letterbox+变倍、色标条/圆环标记/中心温OSD(标签按旋转包围盒定位)
├─ ui/gallery/ 相册页(MediaStore DCIM/MAG160C 扫描 + 内嵌JPEG缩略图 + 运行时媒体权限)
├─ ui/analyze/ MDT 离线分析(缩放/调色板重渲染/备注回写/PDF报告)
├─ ui/settings/ 设置页(默认调色板/发射率/报警温度/语言/关于,SharedPreferences私有
├─ ui/analyze/ MDT 离线分析(缩放/调色板重渲染/温度条+点击测温/备注回写/PDF报告)
├─ ui/settings/ 设置页(默认调色板/发射率/报警温度/语言/云同步/远程预览两行/关于
├─ ui/remote/ 远程预览客户端(Phase F)
│ ├─ RemoteClientListScreen.kt 主机列表(UDP发现/手动IP/重新扫描)
│ ├─ RemoteViewerViewModel.kt 本地管线渲染远端原始帧(调色板/变倍本地化)
│ ├─ RemoteViewerScreen.kt 查看页(与实时页同构图,快门区换成红色"断开")
│ └─ RemoteRendererHost.kt 与 LiveRenderer 同构图的渲染器(LiveRenderer 已冻结)
├─ net/ 网络互连(Phase F,纯 java.net,可 JVM 单测)
│ ├─ RemoteContract.kt 端口/魔数/JSON data/帧封包与流式重组 FramePacketReader
│ ├─ RemoteHost.kt 服务端:UDP 47510 广播 + TCP 47511 单客户端流
│ └─ RemoteClient.kt 客户端:发现(去重3s) + 连接/行模式→帧模式/10s判死
├─ cloud/CloudApi.kt 云接口占位(Retrofitopt-in 默认关闭,api() 未开启即抛异常)
├─ media/
│ ├─ Mdt.kt MDT 容器格式(JPG + DDT段[info块+原始帧] + 152B Tail
│ ├─ Mdt.kt MDT 容器格式(JPG + DDT段[info块+原始帧] + 152B Tail+ parse
│ ├─ PhotoSaver.kt MediaStore 保存(DCIM/MAG160C,无权限也可写自有文件)
│ ├─ Mp4Recorder.kt MP4录像(Surface 输入 H.264MediaCodec+MediaMuxer
│ ├─ DebugLog.kt 现场调试日志(落盘 DCIM→Download→私有目录 + logcat 镜像)
│ └─ PdfReport.kt PDF 巡检报告(PdfDocument
├─ usb/
│ ├─ UsbTransport.kt UsbManager 枚举(VID 0x833C)/权限/claim/EP
│ ├─ MagProtocol.kt 命令码 0x6BB6B6xx响应 0x5BB5B5xx
│ └─ IrSession.kt 连接/启动流/读线程→FrameStream→RenderPipeline→回调;FFC回调
│ ├─ MagProtocol.kt 命令码 0x6BB6B6xx(小端)/响应 0x5BB5B5xx
│ └─ IrSession.kt 连接/启动流/读线程→FrameStream→RenderPipeline→回调;
│ FFC回调;rawHook(远程预览)/recorderHook(录像);
│ deviceLifetimeMs675);cali缓存MD5对照
├─ core/
│ ├─ OfficialTables.kt 自动生成:PALETTE256_ARGB(官方铁虹)/T2E(646)/T2E274/E2T
│ ├─ Palettes.kt 12调色板(铁虹=官方,其余标准曲线近似
│ ├─ VendorPalettes.kt 官方运行时时序生成器的移植结果(11/12 精确表,生成物
│ ├─ Palettes.kt 12调色板(0..10=官方精确表,11 红热=近似)
│ ├─ RenderPipeline.kt ★官方渲染管线 Kotlin 移植(与C参考逐字节一致)
│ ├─ TempMath.kt 温度换算(counts→毫度,T2E)
│ ├─ TempMath.kt 温度换算(counts→毫度,T2E)+ tempMapFromPixelsMDT温度图)
│ └─ FrameStream.kt 流帧重组(0x1BB1B11B 搜索)
└─ task/TaskParser.kt 任务巡检 sqlite/xml 解析
assets/mag160c.ddt 官方 DDT 标定文件(1.8MB,渲染必需)
@@ -112,25 +130,41 @@ res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形
- 旧 analysis/protocol_spec.md 的 66f/670 描述("prepare/version query")不准,
以 magcx_official_flow.md 为准。
### 4.3 实时画面渲染(最终约定:构图钉死竖屏框架
### 4.3 实时画面渲染(2026-09-11 定稿:图像锁定 + 官方同款手工修正
- **Activity 锁定竖屏**manifest `screenOrientation="portrait"`):屏幕相对手机框架
永不旋转。顶栏、热像区域、底栏的**绝对位置**永远贴着手机竖屏的物理顶边(挖孔侧)、
中间、物理底边;手机怎么物理旋转,构图都不动(用户明确要求:屏显方向必须始终
与热像镜头实际方向对应,横屏后显示区域跟着屏幕转是错的)。**不要改回 fullSensor。**
- **图标/文字按物理持机朝向补偿旋转**(第六轮定稿):`ui/DeviceOrientation.kt` 用
加速度计得出手机相对竖屏的顺时针物理转角 φ(0/90/180/270,带滞回;竖屏锁定下
Display.rotation 恒 0 不可用)。顶栏/底部导航条目 `graphicsLayer rotationZ=-φ`
原位预旋转;渲染器 OSD 文字 `canvas.rotate(-φ)` 绕锚点旋转(标记圆点、色标条
几何仍钉死在图像上)。对话框与其他页签暂不补偿
中间、物理底边;手机怎么物理旋转,构图都不动。**不要改回 fullSensor。**
- **图像内容同样锁定**(用户 2026-09-11 明确)**,不随握持角旋转**:热像传感器
物理装在手机上、跟着手机一起转,所以锁定图像时场景与世界的相对方向自动保持
正确,**不需要**加速度计参与。这也正是官方 App 的屏上效果(官方窗口随传感器
转,其面板级图像旋转因此是恒定的 90°)。
- ⚠️ **曾经改错又改回**:2026-09-11 中途有一版把图像按握持角反向补偿
`rot = 90 - φ`),结果**双重补偿**,正是用户报的"右转画面往反方向转"
**不要再引入握持角参与图像旋转。**
- `ImageTransform.params()` **不接受握持角参数**——这是编译期保证。
- **手工修正项**(官方"旋转USB画面/水平翻转/竖直翻转"同款,设置页):
`imageRotateDeg`(0/90/180/270) 叠加在锁定基准 90° 之上;两个翻转作用在
**传感器帧**上(与官方一致:翻转交给 native,旋转在显示矩阵上)。
持久化于 `AppSettings`。
- **数学等价关系(单测锁定)**:**"竖直翻转 + 旋转 90°" ≡ "水平翻转 + 旋转 270°"**
(逐像素比对验证)。用户实测"竖直翻转 + 旋转 90°"可把画面转到另一方向,
正是这条恒等式;两者只差一次水平镜像。
- **图标/文字按握持角补偿**`graphicsLayer rotationZ=-φ`Compose/
`canvas.rotate(-φ)`(Canvas),保证任何握持姿态下可读。**标签一律按旋转后
包围盒定位**`ImageTransform.rotatedBoxHalfExtents` + `drawGripText`),
不要再拿基线锚点摆位——色标条两端数字曾因此与色条错位。
- **几何单一来源**`ui/live/ImageTransform.kt`(纯 Kotlin,可单测)同时供
渲染器与点击/探针映射使用(`sensorToScreen` / `screenToSensor` 互为逆映射,
已对 0/90/180/270 × 翻转组合做往返测试)。渲染与取温映射**必须**用同一套参数,
否则标记会落到错误像素上。
- **加速度计符号约定(第七轮教训,勿改回)**:真机 TYPE_ACCELEROMETER 静止读数
指向世界上方(竖屏正持 y=+9.81);模拟器虚拟传感器是反的重力约定(y=-9.81)。
DeviceOrientation 映射按**真机约定**写,模拟器测试须用反号值驱动
(φ=0→`adb emu sensor set acceleration 0:9.81:0`)。
- **图像恒 90°CW 绘制为 3:4 竖向**,填满可用区域(顶栏下~底导航上)。
- **文字/图标恒屏幕水平**(可读);标记文字位置自动跟随(probeToScreen 固定 90° 映射:
`fx=1-sy/120, fy=sx/160`
- 顶栏=4 个相机控制项(FFC / 变倍×N / 追踪·开 / 调色板+名称),恒在顶部并带小字
标签,`safeDrawing` 顶部inset 适配挖孔屏。
- **追踪开关控制最高+最低两个标记**(此前只关最高,最低永远画着)。
- 顶栏=5 个控制项(FFC / 变倍×N / 追踪·开 / 调色板+名称 / 画中画),恒在顶部并带
小字标签,`safeDrawing` 顶部 inset 适配挖孔屏
- 底部(仅实时页)导航栏上方为**相机快门区**:相册快捷入口 / 大快门拍照 /
录像-停止;快门区高度并入 `vm.uiBottomPx`= 导航高+快门区高)。
- 顶栏高度经 `onSizeChanged`→`vm.uiTopPx`**必须挂在 safeDrawing inset 之前**
@@ -141,6 +175,18 @@ res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形
`connect()` 置 `status="no_device"`LiveScreen 显示占位文案"未检测到热像仪,
请插入MAG160C",渲染器无帧黑底;startDemo/合成帧代码已删除。
### 4.3.1 温度显示(2026-09-11 修复三个真机缺陷)
- **中心温度**`probeTemp()` 返回的**已经是毫度**,界面只许除以 1000 一次。
旧代码又调了一次 `countsToTempMc()`,真机显示 108.7℃(实际约 24℃)。
- **FFC 期间的温度跳变**:FFC 参考帧过去被解码进 `nuc`OSD 采样的同一缓冲区),
未补偿的原始 counts 直接参与显示 → 最高/最低短暂跳到 ~150℃。
现在参考帧走独立 `refScratch` 缓冲。
- **上电/未就绪**:首帧渲染前 `nuc` 全 0,而 `countsToTempMc(0) = -161.0℃`
(看着像合法读数)。`RenderPipeline.tempsReady()` / `IrSession.tempsReady()`
门控 UI 取温;远程页同样门控。
- 回归测试:`core/PipelineTemperatureStateTest.kt`。
### 4.4 温度
- 温度 = 毫度 int(÷1000 = ℃)。`counts_to_temp_mc`(NUC域→T2E逆映射)用于探针/OSD,
在真机上需按 DDT 标定核对绝对值(待办)。
@@ -180,12 +226,31 @@ git -C C:\Project\MAG160C commit -m "android: ..."
## 7. 当前状态与待办
**已完成**:核心管线移植(字节级验证)、USB 层、实时画面(相机式布局:
顶栏4控制项 FFC/变倍/追踪/调色板 + 底部快门区 相册/拍照/录像 + 底导航)、
顶栏5控制项 FFC/变倍/追踪/调色板/画中画 + 底部快门区 相册/拍照/录像 + 底导航)、
图标文字按物理持机朝向补偿旋转(加速度计)、拍照 MDT 容器→MediaStore、
MP4 录像、媒体库、离线分析查看器、PDF 报告、任务解析、设置页、Android16 沉浸
(状态栏隐藏+竖屏锁定+挖孔安全区)、乱码多次修复。
MP4 录像、媒体库、离线分析(含温度条+点击测温)、PDF 报告、任务解析、设置页、
Android16 沉浸(状态栏隐藏+竖屏锁定+挖孔安全区)、乱码多次修复。
(演示模式 2026-09-09 按用户要求撤下,见 §4.3。)
**执行计划 2026-09-10 七阶段全部落地**docs/android_app/execution_plan.md):
- Phase AGetLifeTime(675) 查询→`deviceLifetimeMs` + 心跳 stats 带 lifetime
cali 缓存与内置 DDT 的 MD5 一致性日志;真机自检清单
`docs/android_app/real_device_checklist.md`24 步,含预期日志行)。
- Phase B`Mdt.parse` 返回 `MdtFile`jpg 按 FFD9 裁尾、text 去 NUL 填充、
framePixels 齐全);`TempMath.tempMapFromPixels` 毫度图;分析页温度条
(中心/最低/最高)+ 点击测温探针。**注意:分析页图像按原生横向绘制,
探针映射是直接映射,不要照抄实时页的 90° 逆映射。**
- Phase C:厂商调色板——结论是 **libcxsdk.so 无静态表**,全部由
`CFunctions::SetColorPalette` 运行时算术生成;移植该函数后 11/12 个表
已达官方精确(0..10,铁虹与官方表 256/256 一致),索引 11 红热保留近似。
详见 `analysis/sdk_re/android_app/palette_extraction_findings.md`。
- Phase D:云模块占位(Retrofit 2.11.0`AppSettings.cloudEnabled` 默认 false
`CloudClient.api()` 未开启即 check 抛异常——**不允许静默联网**)。
- Phase E:可见光 PIPCamera2 最小实现,三档尺寸、可拖动、双击关闭;
相机任何异常只记 `[pip]` 日志并收窗,绝不影响热像主画面)。
- Phase F:局域网远程预览(UDP 47510 发现 + TCP 47511 控制与原始帧流,
**客户端本地管线渲染**,调色板/变倍不过网;单测含真实 TCP loopback 端到端)。
**布局约定(最终,勿回退)**
- Activity **锁定竖屏**`portrait`),构图钉死手机竖屏框架:顶栏/热像区域/快门区/底导航
绝对位置永不随手机物理旋转变化(屏显方向永远对应镜头方向)。
@@ -194,12 +259,16 @@ MP4 录像、媒体库、离线分析查看器、PDF 报告、任务解析、设
- 顶栏 `uiTopPx` 必须挂在 `safeDrawing` inset **之前**(包含挖孔安全区,否则真机热像
顶端被顶栏盖住);`uiBottomPx` = 底导航高 + 快门区高。
**待办**(记录在 session_state.md):
1. 真机 USB 实测:温度绝对值标定、FFC/录像/MDT 保存端到端。
2. 网络互连远程预览(用户需求:UDP 自动发现 + 手动内网 IP 连另一台插热像仪的手机)
3. 离线 MDT 温度解码(ConvertResponse2Temperature + 标定参数)。
4. 厂商 12 调色板精确提取(运行时抓取或逆向)。
5. 可见光 PIP 融合、云模块(预留结构)。
**禁改清单**(详见 execution_plan.md §0,改前先读):Manifest 的
`screenOrientation="portrait"`、`MagProtocol` 命令字节序(MagProtocolTest 锁定)、
`IrSession` 握手序列、`LiveRenderer` 构图逻辑、`analysis/` 目录
**待办**
1. 真机 USB 实测:温度绝对值标定、FFC/录像/MDT 保存端到端——照
`docs/android_app/real_device_checklist.md` 24 步走。
2. 红热调色板(索引 11)精确表:官方 APK 的预览图是占位副本,需真机抓帧反推。
3. 双机远程预览实测(清单第 16-24 步)。
4. 云服务真实接口对接(当前仅占位,需账号与接口文档)。
## 8. 文件路径速查
+212
View File
@@ -0,0 +1,212 @@
# MAG160C 安卓 App 操作手册
适用版本:MAG160C 统一热像版 1.0.0`com.mag160c.thermal`
硬件:MAG160C 热像仪机芯(160×120 @15fps),USB VID `0x833C` PID `0x0001`
> 本文中的插图:**截图**取自真机(小米 22041211AC / Android 12);**示意图**用于说明
> 布局与标记结构。图片源文件在 `manual_images/`,可用同目录的 `MakeFigures.java`
> 重新生成(界面改动后请重新出图,避免手册与实物不一致)。
---
## 1. 安装与首次连接
1. 安装 APK`build-artifacts/mag160c-app-debug.apk`):
```
adb install -r -d mag160c-app-debug.apk
```
也可以直接把 APK 拷进手机点击安装(需允许"安装未知应用")。
2. 把热像仪插到手机的 USB 口。系统会弹出 **"允许 App 访问该 USB 设备"** →
勾选"始终允许"并确定。**这个授权在手机重启后会失效,需要重新确认一次**。
3. App 会自动连接并出图,通常 1~2 秒(内部流程:查设备 → 请求权限 → 握手 →
读标定文件 → 开始出流)。
> 顶栏下方显示"未检测到热像仪"时,见第 11 节。
---
## 2. 界面总览
![实时界面示意图](manual_images/01_live_schematic.png)
| 编号 | 位置 | 作用 |
|---|---|---|
| ① | 顶栏 · FFC | 手动做一次快门校正(画面短暂冻结属正常) |
| ② | 顶栏 · 1× | 数码变倍,点一次在 1× → 2× → 3× → 4× 循环 |
| ③ | 顶栏 · 追踪 | 全画面最高/最低温标记(最高、最低、两者、关闭) |
| ④ | 顶栏 · 铁虹 | 切换调色板(12 种) |
| — | 顶栏 · 画中画 | 可见光画面叠加,三档尺寸,可拖动 |
| ⑤ | 拍摄栏 | 相册 / **拍照** / 录像 |
| ⑥ | 底部导航 | 实时 / 相册 / 分析 / 设置 |
画面里还有两个读数:左上角 **"中心 xx.x℃"** 是画面中心点温度;右侧竖条是
**温度色标**,上下端标注当前画面的最高温与最低温。
App 固定竖屏显示。转动手机时**界面和画面构图都不跟着转**(见第 8 节)。
---
## 3. 实时界面操作
- **测温点**:直接在画面上**点一下**就加一个测点;**点已有的测点**则删除它。
测点会显示序号 + 温度,并随照片一起保存。
- **拍照**:立刻把当前画面存成照片(含温度数据、测点、最高/最低温标记)。
- **录像**:点一次开始,再点一次停止并保存;录制中按钮显示"停止"。
---
## 4. 画面上的标记怎么读
![标记结构](manual_images/02_markers.png)
| 编号 | 部位 | 说明 |
|---|---|---|
| ① | 方形准星 + 四根短臂 | 指住被测量的**那一个像素**;中心镂空,不遮挡被测区域 |
| ② | 读数 | `Pt1`/`Pt2`… 是手动测点;`MAX`/`MIN` 是全画面最高/最低温 |
| ③ | 引线 | 把读数和它自己的准星连起来,测点多时不会看错 |
| ④ | 文字样式 | 白字 + 黑描边,**没有白色底板**:冷端(黑)和热端(白)上都读得清,且不挡画面 |
温度单位统一 ℃,一位小数。
---
## 5. 相册
![相册](manual_images/07_gallery.png)
- 列出 `DCIM/MAG160C` 里的照片,按时间倒序(最新的在最前)
- **点开**:全屏查看;**双指缩放**、**拖动平移**、**双击复位**
- **删除**:右上角"删除" → 确认后从手机中移除(不可恢复)
- 返回键回到列表,不会退出 App
---
## 6. 分析(离线测温)
![分析界面](manual_images/06_analysis.png)
进入"分析"页会列出**带温度数据的照片**(早期没有温度数据的旧照片会被过滤)。
| 操作 | 说明 |
|---|---|
| **点击画面** | 在空白处加一个测点;点在已有测点上则删除该测点 |
| **底部面板** | 显示 最高 / 最低 / 中心 温度,以及测点列表 |
| **顶部 · 调色板名** | 切换显示用的调色板 |
| **顶部 · 备注** | 给这张照片写一段文字备注(写入文件本身) |
| **顶部 · 保存** | 把当前测点烧录进图像,**另存为一张新照片**(温度数据一并保留) |
分析页显示的标记与照片里已烧录的标记位置完全一致,不会出现"两个测点"。
**注意**:分析读的是照片里存的原始温度数据,所以同一张照片任何时候打开,
读数都一样(不会因为环境变化而漂移)。
---
## 7. 设置
![设置](manual_images/05_settings.png)
| 设置项 | 说明 | 是否影响画面 |
|---|---|---|
| **默认调色板** | 启动时使用的调色板 | ✔ |
| **默认发射率** | 目标发射率(0.01~1.00) | ✖ 目前仅保存,尚未参与温度修正 |
| **报警温度** | 报警阈值 | ✖ 目前仅保存,尚未接入报警 |
| **追踪** | 最高温 / 最低温 / 最高+最低 / 关闭 | ✔ |
| **语言** | 中文 / 跟随系统 | ✖ 目前仅保存(界面为中文) |
| **旋转USB画面** | 0° / 90° / 180° / 270°,画面方向整体旋转 | ✔ |
| **图像增强** | 关闭 / 1~8 级,官方同款 7×7 局部细节增强 | ✔ |
| **水平翻转** | 画面左右镜像 | ✔ |
| **竖直翻转** | 画面上下镜像 | ✔ |
| **云同步** | 占位项,需账号与接口才能对接 | ✖ |
| **远程预览服务端** | 让本机作为主机,向局域网提供画面 | ✔ |
| **远程预览客户端** | 搜索局域网内的主机并连接观看 | ✔ |
| **关于** | 版本信息 | — |
**画面方向不对怎么调**:热像仪是插在手机上的,安装姿态决定了画面朝向。
若画面上下颠倒,用"旋转USB画面"(每次 90°);若左右镜像反了,用"水平翻转"或
"竖直翻转"。这三项是**永久生效的设置**,实时、拍照、录像都会跟着变。
**图像增强怎么选**:档位越高,纳入增强的对比度门槛越高、强边缘的增强幅度越大,
即"更锐但更挑"——弱对比区域(噪声所在处)会被排除,所以调高**不会**把噪声放大。
日常建议 2~4 级;需要看清弱纹理时再上 6~8 级。默认关闭。
---
## 8. 拍照 / 录像的方向(重要)
![竖屏握持拍出的照片](manual_images/04_photo_portrait.png)
*竖着拿手机拍 → 720×960 竖幅照片,场景是正的*
![横屏握持拍出的照片](manual_images/03_photo_landscape.png)
*横着拿手机拍 → 960×720 横幅照片,场景同样是正的*
- **文件方向跟随你拿手机的方向**(和普通相机一样),所以**不需要事后旋转**。
- **预览画面本身是锁定的**:横着拿手机时,屏幕里的画面看起来是侧的
(因为屏幕构图固定在竖屏框架上),但保存下来的文件是正的。
这是刻意设计:热像仪固定在手机上,锁定预览才能保证转动手机时不晃方向。
- **录像方向在按下"录像"的瞬间确定**。录制过程中转动手机不会改变文件方向
(编码尺寸固定,所有相机都是这个行为)。
---
## 9. 文件与格式
| 类型 | 位置与命名 |
|---|---|
| 照片 | `DCIM/MAG160C/MAG160C_yyyyMMdd_HHmmss.jpg` |
| 分析另存 | `DCIM/MAG160C/MAG160C_yyyyMMdd_HHmmss_edit.jpg` |
| 录像 | `DCIM/MAG160C/MAG160C_V_yyyyMMdd_HHmmss.mp4` |
照片文件是 **MDT 容器**:文件开头就是标准 JPEG(任何看图软件都能打开),
后面追加了温度数据、测点坐标、备注、渲染参数等信息。
所以照片既能当普通图看,也能回到 App 里继续测温。
---
## 10. 远程预览(两台设备)
把手机画面投到另一台设备上看(同一局域网):
1. **主机**(插着热像仪的那台):设置 → **远程预览服务端** → 打开
2. **客户端**:设置 → **远程预览客户端** → 点"重新扫描"自动发现主机 →
点主机连接;也可以手动输入 `IP:端口`
3. 客户端是**本地渲染**:主机把原始温度数据发过去,客户端自己画图,
所以能正确显示主机端的追踪标记和 FFC 状态
退出远程查看用返回键,会回到设置页。
---
## 11. 常见问题
**显示"未检测到热像仪"**
- 确认热像仪插紧;换一个 USB 口试试
- 手机重启后 USB 授权会失效 → 重新插拔一次,在弹窗里点"允许"
- 仍不行:退出 App 重进(App 会自动重试,不用反复插拔)
**画面卡住或黑屏**
- FFC 校正时画面会短暂冻结,属正常
- 持续黑屏:退出 App 重进;确认没有别的 App 占用了热像仪
**温度看起来不准**
- 先做一次 FFC(环境温度变化后尤其需要)
- 分析页的读数来自照片里存的原始数据,不会随时间变化;实时页读数会随环境漂移
**手机息屏后回来**
- 息屏时渲染停止(省电)、USB 读取继续,已保存的文件不受影响
**想拿文件到电脑上**
```
adb pull /sdcard/DCIM/MAG160C/
```
---
## 12. 已知限制
- **云同步**为占位项,需要账号与接口文档才能对接
- **发射率 / 报警温度 / 语言**三项目前只做保存,尚未接入实际功能
- **红热调色板**是近似实现(官方该调色板的原始数据未取到)
- **相册不列录像**:录像请用系统相册/文件管理器查看
- **预览锁定**是设计如此,不是缺陷(见第 8 节)
- PDF 报告与任务巡检的代码已实现,但**尚未接入界面**,当前版本无法从 App 中使用
+21 -3
View File
@@ -1,5 +1,12 @@
# MAG160C 执行计划 —— session_state 剩余待办(为无视觉执行模型编写)
> **执行状态(2026-09-10**Phase A / B / C / D / E / F / Z **全部完成**
> 每阶段一个 commitA=06c1f30 B=656d419 C=b7a928e D=c34940e E=f8b3200
> F=512508e),44 个单测全绿,debug+release 双构建通过,APK 已更新。
> 唯一未完成项为**真机实测**(需用户配合,清单见
> `docs/android_app/real_device_checklist.md`)与红热调色板精确表(材料不足)。
> 详细记录与诚实记录的限制见 `docs/android_app/session_state.md`。
> 本文件写给**没有视觉能力的执行模型**。所有 UI 位置已由主模型用精确 dp/颜色/
> 对齐方式定义,照抄即可,不要自行发明布局。所有协议字节已由官方反编译源码
> 确认(见 `analysis/magcx_official_flow.md`),不要修改协议常量。
@@ -317,12 +324,23 @@ class PipCameraEngine(val context, val textureView) :
`{"type":"welcome","w":160,"h":120,"fps":15,"serial":...}`
- `{"cmd":"start"}` → `{"type":"stream-start"}` 后开始二进制帧
- `{"cmd":"stop"}` → `{"type":"stream-stop"}`
- `{"cmd":"ffc"}` → 主机触发 FFC → `{"type":"ok"}`
- `{"cmd":"ffc"}` → 主机触发 FFC → `{"type":"ok"}`**仅未出流时回复**;出流后
客户端处于帧模式,主机只执行不回复,见下)
- **图像帧**stream-start 之后,TCP 二进制流):
`[u32 LE 0x1BB1B11B][u32 LE frameCounter][u32 LE 38400][38400B 原始 u16LE 像素]`
共 38412B/帧。**客户端用本地 RenderPipeline(160,120)+内置 DDT 自行渲染**
`[u32 LE 0x1BB1B11B][u32 LE frameCounter][u32 LE 38400]`
`[u32 LE flags][u32 LE ffcPhase][i32 LE shutter][38400B 原始 u16LE 像素]`
头部 24B,共 **38424B/帧**。**客户端用本地 RenderPipeline(160,120)+内置 DDT 自行渲染**
(调色板/变倍全在客户端本地,无需回传)。
- `flags` bit0 = 主机本帧是否渲染成功;`ffcPhase` = 主机管线 FFC 阶段
(0 正常 / 1 快门关闭 / 2 参考帧采集);`shutter` = 本帧相机温度(原始单位)。
- **为什么必须带这几项**2026-09-11 修订):NUC 表按快门温度插值,缺了它
counts 会饱和(真机表现为满屏噪声 + 读数 -161℃);参考帧还必须在客户端
按同一规则平均。早期版本只传像素、客户端独立跑 FFC 状态机,画面不正确。
- 客户端相应入口:`RenderPipeline.frameRemote(frame, phase, shutter, out)`。
- **保活**:主机 3s 无帧发 `{"type":"ping"}`;客户端 10s 无任何数据判死重连。
- **单客户端**:已有客户端时,第二连接立即收到 `{"type":"busy"}` 并被关闭。
- **流中不写控制回复**stream-start 之后客户端处于帧模式,此时主机不得再写
JSON 行(会被当作帧内杂散字节);`ffc` 在流中执行但不回复。
### F2. 文件与职责
1. `net/RemoteContract.kt`:常量(端口/魔数)、JSON data class、
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,265 @@
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import javax.imageio.ImageIO;
/**
* Derive the manual's figures: scale/crop real screenshots, and build an annotated
* schematic of the live screen. Kept as a tool so the figures can be regenerated
* after a UI change instead of being stale hand-edited PNGs.
*
* Usage: MakeFigures <outDir> <imageDir>
*/
public class MakeFigures {
static File outDir;
public static void main(String[] args) throws Exception {
outDir = new File(args[0]);
File imgs = new File(args[1]);
outDir.mkdirs();
// --- real screenshots: scale to a readable width and crop the chrome-free part
// cropped to the rows: the full-screen shots are mostly empty on a 9:20 display
scale(new File(imgs, "01_settings_raw.jpg"), "05_settings.png", 780, 0, 60, 1440, 1720);
scale(new File(imgs, "05_analysis_raw.jpg"), "06_analysis.png", 700, 0, 60, 1440, 3040);
scale(new File(imgs, "06_gallery_raw.jpg"), "07_gallery.png", 780, 0, 60, 1440, 1260);
// --- reference photos (already the real saved files)
scale(new File(imgs, "MAG160C_20260912_145010.jpg"), "03_photo_landscape.png", 900, 0, 0, 0, 0);
scale(new File(imgs, "MAG160C_20260912_141530.jpg"), "04_photo_portrait.png", 560, 0, 0, 0, 0);
// --- marker anatomy: zoom on the MAX reticle of the landscape photo
BufferedImage lp = ImageIO.read(new File(imgs, "MAG160C_20260912_141530.jpg"));
markerAnatomy(lp);
// --- live screen schematic (the real screen cannot be screenshotted for the
// manual: its OSD text is pre-rotated to the current grip, so a landscape-held
// phone yields sideways labels in the picture)
liveSchematic();
}
// ---------------------------------------------------------------- helpers
static BufferedImage scaled(BufferedImage s, int w) {
int h = Math.max(1, (int) Math.round(s.getHeight() * (w / (double) s.getWidth())));
BufferedImage o = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Graphics2D g = o.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(s, 0, 0, w, h, null);
g.dispose();
return o;
}
static void scale(File src, String out, int w, int x, int y, int cw, int ch) throws Exception {
BufferedImage s = ImageIO.read(src);
if (cw > 0) s = s.getSubimage(x, y, cw, ch);
BufferedImage o = scaled(s, w);
ImageIO.write(o, "png", new File(outDir, out));
System.out.println("wrote " + out + " " + o.getWidth() + "x" + o.getHeight());
}
static Font font(int size, int style) {
// a CJK-capable family; falls back to the platform default if absent
return new Font("Microsoft YaHei", style, size);
}
/** Zoomed crop around the MAX reticle, with labelled callouts. */
static void markerAnatomy(BufferedImage photo) {
// Pt1 on the portrait reference photo (720x960): the reticle sits at
// (249,147) and the label to its right, so this window holds the whole glyph
// plus its readout.
int cx = 300, cy = 150;
int cw = 420, ch = 260;
int x = Math.max(0, cx - cw / 2), y = Math.max(0, cy - ch / 2);
BufferedImage crop = photo.getSubimage(x, Math.min(y, photo.getHeight() - ch), cw, ch);
int zoom = 3;
BufferedImage big = new BufferedImage(cw * zoom, ch * zoom, BufferedImage.TYPE_INT_RGB);
Graphics2D g = big.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
g.drawImage(crop, 0, 0, cw * zoom, ch * zoom, null);
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g.setFont(font(26, Font.BOLD));
// callouts: the reticle, the label, the leader
int rx = (cx - x) * zoom, ry = (cy - y) * zoom;
callout(g, "① 方形准星", rx - 150, ry - 120, rx, ry, new Color(0x4FC3F7));
callout(g, "② MAX/MIN 读数", rx + 150, ry + 120, rx + 210, ry, new Color(0x81C784));
g.setColor(new Color(0xFFD54F));
g.setFont(font(24, Font.BOLD));
g.drawString("③ 引线(读数连到自己的准星)", 24, ch * zoom - 60);
g.drawString("④ 白字 + 黑描边(无底板)", 24, ch * zoom - 24);
g.dispose();
try {
ImageIO.write(big, "png", new File(outDir, "02_markers.png"));
System.out.println("wrote 02_markers.png " + big.getWidth() + "x" + big.getHeight());
} catch (Exception e) {
e.printStackTrace();
}
}
static void callout(Graphics2D g, String text, int lx, int ly, int tx, int ty, Color c) {
g.setColor(c);
g.setStroke(new BasicStroke(3f));
g.drawLine(lx, ly, tx, ty);
g.fillOval(tx - 7, ty - 7, 14, 14);
FontMetrics fm = g.getFontMetrics();
int w = fm.stringWidth(text);
int bx = lx < tx ? lx - w - 12 : lx + 12;
g.setColor(new Color(0, 0, 0, 190));
g.fillRoundRect(bx - 6, ly - fm.getAscent() - 4, w + 12, fm.getHeight(), 8, 8);
g.setColor(c);
g.drawString(text, bx, ly);
}
/**
* The live screen as a labelled diagram. Positions mirror the real layout
* (top control bar glued to the portrait top edge, image below it, capture row,
* bottom navigation).
*/
static void liveSchematic() throws Exception {
int W = 720, H = 1280;
BufferedImage im = new BufferedImage(W, H, BufferedImage.TYPE_INT_RGB);
Graphics2D g = im.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g.setColor(new Color(0x101014));
g.fillRect(0, 0, W, H);
// top bar
int barH = 96;
g.setColor(new Color(0x5A5A50));
g.fillRect(0, 24, W, barH);
String[] top = {"FFC", "1×", "追踪", "铁虹", "画中画"};
for (int i = 0; i < top.length; i++) {
int cx = 78 + i * 142;
g.setColor(new Color(0xD8D8C8));
g.setStroke(new BasicStroke(4f));
g.drawOval(cx - 16, 24 + barH / 2 - 26, 32, 32);
g.setFont(font(22, Font.PLAIN));
FontMetrics fm = g.getFontMetrics();
g.drawString(top[i], cx - fm.stringWidth(top[i]) / 2, 24 + barH - 8);
}
// image area with a marker, mimicking the real look
int imgX = 8, imgY = 24 + barH + 16, imgW = W - 16, imgH = 830;
GradientPaint gp = new GradientPaint(
imgX, imgY, new Color(0x1A0733),
imgX + imgW, imgY + imgH, new Color(0xB25A00));
g.setPaint(gp);
g.fillRect(imgX, imgY, imgW, imgH);
g.setColor(new Color(0x5E1580));
g.fillOval(imgX + 40, imgY + 250, 380, 320);
g.setColor(new Color(0xFFE066));
g.fillOval(imgX + 380, imgY + 520, 260, 240);
// a probe marker drawn the way the app draws it
int mx = imgX + 420, my = imgY + 400;
g.setColor(Color.WHITE);
g.setStroke(new BasicStroke(3f));
g.drawRect(mx - 12, my - 12, 24, 24);
g.drawLine(mx - 30, my, mx - 12, my);
g.drawLine(mx + 12, my, mx + 30, my);
g.drawLine(mx, my - 30, mx, my - 12);
g.drawLine(mx, my + 12, mx, my + 30);
g.drawLine(mx + 30, my, mx + 62, my);
g.setFont(font(30, Font.BOLD));
drawOutlined(g, "Pt1 25.0℃", mx + 62, my + 10);
// a max marker
int ax = imgX + 90, ay = imgY + 170;
g.setStroke(new BasicStroke(3f));
g.setColor(Color.WHITE);
g.drawRect(ax - 12, ay - 12, 24, 24);
g.drawLine(ax + 12, ay, ax + 30, ay);
g.drawLine(ax, ay - 30, ax, ay - 12);
g.drawLine(ax, ay + 12, ax, ay + 30);
g.drawLine(ax - 30, ay, ax - 12, ay);
g.drawLine(ax + 30, ay, ax + 62, ay);
g.setFont(font(30, Font.BOLD));
drawOutlined(g, "MAX 41.5℃", ax + 62, ay + 10);
// centre readout, upper-left of the image
g.setFont(font(30, Font.BOLD));
drawOutlined(g, "中心 26.4℃", imgX + 20, imgY + 40);
// colour bar along the right edge
int barX = imgX + imgW - 34, barW = 22;
for (int i = 0; i < 120; i++) {
float t = i / 119f;
g.setColor(new Color(
(int) (255 * Math.min(1, t * 1.6)),
(int) (255 * Math.max(0, t * 1.6 - 0.6)),
(int) (255 * Math.max(0, 1 - t * 2.2))));
g.fillRect(barX, imgY + 40 + i * 6, barW, 7);
}
// capture row
int rowY = imgY + imgH + 30;
g.setColor(new Color(0x1B1B20));
g.fillRect(0, rowY - 10, W, 210);
circle(g, 200, rowY + 60, 44, new Color(0x555555), "相册");
circle(g, 360, rowY + 60, 62, Color.WHITE, "拍照");
circle(g, 520, rowY + 60, 44, new Color(0xE53935), "录像");
// bottom navigation
int navY = H - 118;
g.setColor(new Color(0x2A2A22));
g.fillRect(0, navY, W, 118);
String[] nav = {"实时", "相册", "分析", "设置"};
for (int i = 0; i < nav.length; i++) {
int cx = 90 + i * 180;
g.setColor(i == 0 ? new Color(0xD8C88A) : new Color(0x9A9A9A));
g.setStroke(new BasicStroke(4f));
g.drawOval(cx - 16, navY + 22, 32, 32);
g.setFont(font(24, Font.PLAIN));
FontMetrics fm = g.getFontMetrics();
g.drawString(nav[i], cx - fm.stringWidth(nav[i]) / 2, navY + 92);
}
// numbered callouts
g.setFont(font(26, Font.BOLD));
badge(g, 1, 100, 40);
badge(g, 2, 242, 40);
badge(g, 3, 384, 40);
badge(g, 4, 526, 40);
badge(g, 5, 420, rowY + 20);
badge(g, 6, 130, navY + 24);
g.dispose();
ImageIO.write(im, "png", new File(outDir, "01_live_schematic.png"));
System.out.println("wrote 01_live_schematic.png " + W + "x" + H);
}
static final String[] CIRCLED = {"", "", "", "", "", "", "", "", "", ""};
static void badge(Graphics2D g, int n, int x, int y) {
String s = CIRCLED[n - 1];
g.setColor(new Color(0xE53935));
g.fillOval(x - 20, y - 20, 40, 40);
g.setColor(Color.WHITE);
FontMetrics fm = g.getFontMetrics();
g.drawString(s, x - fm.stringWidth(s) / 2, y + 10);
}
static void circle(Graphics2D g, int cx, int cy, int r, Color c, String label) {
g.setColor(c);
g.fillOval(cx - r, cy - r, r * 2, r * 2);
if (c == Color.WHITE) {
g.setColor(new Color(0x222222));
g.setStroke(new BasicStroke(3f));
g.drawOval(cx - r, cy - r, r * 2, r * 2);
}
g.setColor(Color.WHITE);
g.setFont(font(24, Font.PLAIN));
FontMetrics fm = g.getFontMetrics();
g.drawString(label, cx - fm.stringWidth(label) / 2, cy + r + 34);
}
/** White text with a dark outline — the same trick the app's markers use. */
static void drawOutlined(Graphics2D g, String s, int x, int y) {
Shape shape = g.getFont().createGlyphVector(g.getFontRenderContext(), s).getOutline(x, y);
g.setColor(new Color(0, 0, 0, 220));
g.setStroke(new BasicStroke(5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
g.draw(shape);
g.setColor(Color.WHITE);
g.fill(shape);
}
}
@@ -0,0 +1,27 @@
import java.awt.image.BufferedImage;
import java.io.File;
import javax.imageio.*;
import javax.imageio.stream.FileImageOutputStream;
/** Convert capture PNGs to JPEG (the figures' inputs do not need to be lossless). */
public class RawJpeg {
public static void main(String[] a) throws Exception {
for (String n : a) {
BufferedImage src = ImageIO.read(new File(n));
// screencap PNGs carry an alpha channel; JPEG cannot take that colorspace
BufferedImage im = new BufferedImage(
src.getWidth(), src.getHeight(), BufferedImage.TYPE_INT_RGB);
im.createGraphics().drawImage(src, 0, 0, null);
String out = n.replace(".png", ".jpg");
ImageWriter w = ImageIO.getImageWritersByFormatName("jpeg").next();
ImageWriteParam p = w.getDefaultWriteParam();
p.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
p.setCompressionQuality(0.88f);
try (FileImageOutputStream os = new FileImageOutputStream(new File(out))) {
w.setOutput(os);
w.write(null, new IIOImage(im, null, null), p);
}
w.dispose();
System.out.println(out + " " + new File(out).length() / 1024 + " KB");
}
}
}
+117
View File
@@ -0,0 +1,117 @@
# MAG160C 真机端到端自检清单(Phase A3)
> 用法:把最新 `build-artifacts/mag160c-app-debug.apk` 装到手机,插上 MAG160C
> 热像仪,按顺序做一遍。每步的"预期 DebugLog 行"可在应用内 debug 日志文件中
> 对照(DCIM/MAG160C/debug_*.log;若 DCIM 拒收则 Download/MAG160C;文件头
> `sink=` 注明实际落点),也可用 `adb logcat -s MAG160C/*` 实时看。
>
> 日志行格式:`HH:mm:ss.SSS [tag] message`。下表消息列为**现有代码中的原文**
> (变量处用 <...> 占位)。若某项对不上,把整个日志文件发回分析。
## 步骤表
| # | 操作 | 预期 DebugLog 行(原文) | 通过标准 |
|---|------|--------------------------|----------|
| 1 | 插入热像仪,系统弹出 USB 权限对话框,点"允许" | `[usb] requestPermission: no device`(无设备时)/ `[usb] requesting permission (dialog)``[usb] permission result: true` | 授予权限后自动开始连接 |
| 2 | 等待连接(首次约 1~3 s,首次装机需下载标定文件时更久) | `[vm] connect()``[vm] permission ok, ddt <N> bytes, starting session``[session] state -> LINKING msg=null``[cmd] GetParameter1 write=4/4``[cmd] GetParameter1 resp=0x5BB5B55B len=60 head=...``[cmd] BasePara1: serial=160043865 devType=3 160x120 @15fps` | 五条全出现;serial/devType 与实际相机一致(devType=3 即 core160 |
| 3 | 同上(参数/标定握手) | `[cmd] GetParameter2 resp=0x5BB5B55C len=...``[cmd] GetCaliInfo resp=0x5BB5B55E len=...``[session] cali info: size=<size> date=<date>``[session] device lifetime=<ms>ms` | GetParameter2/GetCaliInfo 有响应;lifetime 非 -1(0 或正数均可,首次上电后很小正常) |
| 4 | 首次安装运行(无 cali 缓存) | `[session] first run on this host: downloading cali file <size> B (EP 0x84)``[session] cali download <p>%`(若干行)→ `[session] cali downloaded+cached: <product>.<serial>.<date> (<size> B)`;第二次运行改为 `[session] cali cache hit: <name>` + `[session] cali cache vs bundled DDT: identical`(或 `differ`,两者都不算失败) | 缓存命中或下载成功二者之一 |
| 5 | 等待 DDT 加载 + 出流 | `[session] ddt bytes <N>``[session] state -> STREAMING msg=null``[cmd] StartTransferImg write=4/4``[cmd] StartTransferImg ack=0x...``[stream] reader loop start: sync bulkTransfer (800 ms) + halt recovery``[stream] first reads [0] n=<n> head=...``[vm] first rendered frame -> UI` | 出现 `first rendered frame -> UI` 即画面已出(实时页显示热像,不再黑屏) |
| 6 | 观察实时页 5 s 以上 | 每 2 s:`[stream] stats: reads=<n> frames=<n> rendered=<n> timeouts=<n> fps=15.0 renderState=<n> ref=true lifetime=<ms>`;每 ~5 s`[vm] hb: state=streaming streaming=true uiFrames=<n> latest=true` | `frames`/`rendered` 持续递增,fps≈15ref=truetimeouts 偶发可接受,不应持续增长而 frames 不涨 |
| 7 | 顶栏点 FFC 两次(每次间隔 ~1 s) | 每次:`[cmd] FFC(0) write=8/8``[cmd] FFC(0) ack=0x... len=...`ack 读不到时日志为 `[cmd] FFC(0) ack=<n> bytes`,相机仍会执行校正,不算失败) | 画面出现一次快门校正(短暂黑/停顿后恢复) |
| 8 | 点底部大圆快门拍照 | `[vm] hb: state=saved ...`(下一次 5 s 心跳即可看到 status=saved);UI 上状态文案短暂显示 | 相册/文件管理器 DCIM/MAG160C 出现 `MAG160C_yyyyMMdd_HHmmss.jpg`(实际为 MDT 容器,扩展名 .jpg) |
| 9 | 点录像,录 10 s,再点停止 | `[vm] hb: state=recording ...` → 停止后 `[vm] hb: state=rec_done ...` | 状态回到 rec_done,无崩溃 |
| 10 | 切到"相册"页 | (无 DebugLog;纯 UI) | 列表出现步骤 8 的照片缩略图(MDT 尾部校验通过才显示) |
| 11 | 点该照片进入分析页 | (无 DebugLog;纯 UI) | 图片可缩放/平移;调色板重渲染可用;**图像顶部出现温度条**(`中心 x.x℃ 最低 x.x℃ 最高 x.x℃`);点图任意位置出现白色圆点+温度标签,再点同一点可清除;备注可编辑保存 |
| 12 | 分析页生成 PDF 报告 | (无 DebugLog;纯 UI | 报告文件在 DCIM/MAG160C 或 Download/MAG160C 生成,可打开 |
| 13 | 实时页顶栏第 5 项点"画中画"(首次会弹相机权限,点允许) | `[pip] camera opened id=<id>``[pip] repeating preview started` | 右上角(色标条左侧)出现 128×96dp 白框小窗,显示可见光画面;顶栏该项高亮为"画中画·开" |
| 14 | 拖动 PIP 小窗到其它角落;单击小窗(循环 96/128/160dp);双击小窗关闭 | `[pip] released`(双击关闭时) | 小窗跟手移动且不出界;单击在三档宽度间循环;双击后小窗消失,顶栏恢复"画中画"未开启态;日志有 `released` |
| 15 | PIP 开启时按 Home 回到桌面再返回 | `[pip] released`(进入后台时) | 返回后若 PIP 仍为开启态,画面重新出现(重新 open);无崩溃 |
## 远程预览(Phase F,需要两台设备)
两台安卓设备(或一台手机 + 一个模拟器)连**同一个 Wi-Fi**。以下称 A=插热像仪的
主机手机,B=远程查看的手机。
| # | 操作 | 预期 DebugLog 行(原文) | 通过标准 |
|---|------|--------------------------|----------|
| 16 | A:插热像仪,确认实时页已出图(清单前 6 步) | `[session] state -> STREAMING` | A 本地画面正常 |
| 17 | A:设置页 → "远程预览服务端"(点一下) | `[remote] host started (name=<机型> serial=0)` | 该行文案变为"已开启" |
| 18 | A:若第 17 步弹"先连接热像仪" | (无日志) | 说明 USB 会话不活跃:先回到实时页确认出流 |
| 19 | B:设置页 → "远程预览客户端" → "查找主机" | (B 端)列表出现 A 的主机名 | 10 秒内列出 A(卡片显示 `<主机名>``<IP>:47511`);未列出可用"手动添加"填 A 的 IP |
| 20 | B:点该卡片(或手动 IP 后点"连接" | A 端:`[remote] client connected from <B的IP>`B 端:`[remote] client connected to <A的IP>:47511``[remote] line: {"type":"welcome",...}``[remote] line: {"type":"stream-start"}``[remote] first remote frame` | B 显示 A 的热像实时画面,**温度读数与 A 端一致**(同一场景下中心温/最高最低相差应在 1℃ 内);画面朝向与 A 端相同(两机握持姿态不同时,各自按自己的姿态补偿) |
| 20b | B 端确认温度不再是 -161℃、画面不是满屏噪声 | B 端 `[remote] first remote frame` 之后读数正常 | 这是 2026-09-11 修复项:协议改为随帧传 FFC 阶段+相机温度(`[flags][ffcPhase][shutter]`,帧记录 38424B)。若仍异常,检查两端 APK 是否同版本(新旧协议不兼容) |
| 20c | 第三台设备(或 A 本机再连一次)尝试连接同一主机 | A 端:`[remote] rejecting <IP>: already serving a client` | 后到者立即收到 `{"type":"busy"}`,B 端弹"主机正忙(已有客户端连接)"并返回列表;**已在流的那台不受影响** |
| 21 | B:顶栏点变倍/追踪/调色板 | (无日志,全部本地) | 立即生效、无卡顿(调色板/变倍不下发到 A) |
| 22 | B:顶栏点 FFC | A 端出现一次快门校正;B 画面随之更新 | FFC 经 A 的热像仪执行 |
| 23 | B:点底部红色"断开"圆钮 | A 端:`[remote] client <IP> disconnected (frames=<N>)` | B 返回主机列表并弹出"已断开"提示;A 服务端保持"已开启"待重连 |
| 24 | A:设置页再点"远程预览服务端"关闭 | `[remote] host stopped` | 文案变回"已关闭";A 本地热像画面不受影响 |
远程预览失败时的表现(设计如此):连不上主机时 B 显示"无法连接主机"并返回列表;
中途断网/主机退出时 B 显示"连接已断开";A 端相机与本地画面**始终不受网络影响**;
日志中 `frames=…` 每 300 帧记一次。
## 方向与温度(2026-09-11 修复项,重点验证)
| # | 操作 | 预期 | 通过标准 |
|---|------|------|----------|
| 25 | 竖屏正持,观察画面 | (无日志) | 画面正常;中心温读数与手摸/环境常识一致(**不是 -161℃、不是 108℃ 这类离谱值**) |
| 26 | 手持手机转四个方向(0/90/180/270),观察画面内容 | (无日志) | **画面内容始终不动**(图像锁定在竖屏框架,与第四/五轮约定一致);顶栏/底栏文字仍随持机朝向保持可读 |
| 27 | 点"追踪"关闭 | (无日志) | **最高温和最低温两个标记同时消失**;再点开→两个都出现(此前最低温标记关不掉) |
| 28 | 点 FFC,紧盯最高/最低温读数 | `[cmd] FFC(0) write=8/8` | 读数**不出现 ~150℃ 的瞬时跳变**(可短暂保持不变,但不得跳到离谱值) |
| 29 | 设置页"旋转USB画面"依次选 0/90/180/270° | (无日志) | 画面按所选角度整体旋转(在锁定基准 90° 之上叠加),用于修正传感器安装方向 |
| 29b | 设置页开"竖直翻转",同时"旋转USB画面"设为 90° | (无日志) | 这是用户实测能把画面转到另一方向的组合;**它等价于"水平翻转 + 旋转270°"**(只差一次水平镜像)——若 90°+竖直翻转 看着左右是反的,改用 270°+水平翻转 |
| 30 | 设置页"水平翻转"/"竖直翻转"开关 | (无日志) | 画面镜像;与官方 app 的同名设置表现一致 |
| 31 | 横屏持机时观察色标条 | (无日志) | 色标条**两端**的最高/最低温数字紧贴色条两端,**不与色条重叠**、不偏移 |
| 32 | 分析页打开一张 MDT,观察顶部温度条 | (无日志) | 温度条显示"中心/最低/最高",数值与拍照时实时页读数接近 |
| 33 | 设置页**从上往下滑动**,看能否滚到最底部("关于") | (无日志) | 页面可以滚动,**11 行设置项全部可达**(此前无滚动,下方几行点不到) |
| 34 | 设置页依次点每一行,确认都有反应 | 调色板/发射率/报警温度/语言/旋转USB画面/云同步/关于 → 弹对话框;水平翻转/竖直翻转 → 文案在"已开启/已关闭"间切换;远程预览服务端 → 切换或弹"先连接热像仪";远程预览客户端 → 进入主机列表 | **没有点了没反应的行**(此前"语言"和"关于"是死行) |
| 35 | 在**设置页**改"竖直翻转"或"旋转USB画面",然后切回实时页 | (无日志) | 画面**立即**按新设置显示(设置变更即时下发;此前靠实时页 400ms 轮询,仅在实时页处于打开状态时才生效) |
## 第二轮真机修复项(2026-09-11,重点验证)
| # | 操作 | 预期 | 通过标准 |
|---|------|------|----------|
| 36 | 实时页点"录像",等 10 秒,再点"停止" | `[rec] stopped: frames=<N> dropped=<N> file=<字节>`,随后实时页出现"录像已保存到相册"提示 | **不闪退**(此前停止即崩溃);相册/文件管理器 DCIM/MAG160C 出现 `MAG160C_V_*.mp4` 且**能播放**;日志 `frames` 应为几百(15fps×10s≈150 |
| 36b | 若录像仍失败 | `[rec] start failed: …``[rec] frame dropped (…) dropped=<N>` / `[rec] video publish failed: …` | 日志会指出是编码启动、丢帧还是入库失败;**不再有未捕获异常** |
| 37 | 相册页点任意一张照片 | (无日志) | **立即进入查看页**(此前点了没反应);按返回键**回到相册列表**,不是退出软件 |
| 38 | 分析页点图像任意位置 | (无日志) | 该处出现白点+圆环+`Pt* 温度`标签;侧边栏"测温点"列表同步新增一行;**再点同一点可删除** |
| 39 | 分析页点"保存" | 提示"已保存为新照片" | 相册出现一张 `MAG160C_*_edit.jpg`(**原照片仍在**);打开新照片能看到烧录的测温点标记 |
| 40 | 关掉 APP 完全重开,进设置页 | (无日志) | 之前改过的**旋转USB画面/水平翻转/竖直翻转/追踪/默认调色板**都还在(此前会变回默认) |
| 41 | 设置页改"追踪"为"最低温",回实时页 | (无日志) | 顶栏显示"追低",画面上**只标最低温**;改成"最高+最低"→ 两个都标;"关闭"→ 都不标 |
| 42 | 设置"竖直翻转"开启后拍照 | (无日志) | **照片方向与屏幕一致**(此前保存的是传感器原始朝向,与屏幕不符) |
| 43 | 拍照前先在实时页点几个测温点,再拍照 | (无日志) | 照片上带这些测温点与温度;进分析页打开该照片,**测温点仍在**且可继续编辑 |
## 第三轮真机修复项(2026-09-11,重点验证)
| # | 操作 | 预期 | 通过标准 |
|---|------|------|----------|
| 44 | 拍照后在分析页打开该照片 | 日志 `[vm] capture: nuc=yes probes=<n> rot=90 saved=true` | 顶部显示的照片与实时页看到的一致(方向、标注) |
| 45 | 看分析页底部数据面板 | (无日志) | 最高/最低/中心温度**与拍照瞬间实时页读数一致**(同一数据源);**不再是 145℃ / -161℃ / 76℃ 这类错误值**;数据在屏幕**底部**(不是右侧竖栏) |
| 46 | 分析页对照实时页的测温点位置 | (无日志) | 照片上测温点的**位置与拍照时屏幕上的一致**(此前会偏移/错位) |
| 47 | 观察分析页与照片上的测温点标记大小 | (无日志) | 圆点/圆环**小而不遮挡画面**(此前环直径约占图宽 1/9,盖住内容);与实时页观感接近 |
| 48 | 分析页点图像新增一个测温点,再点"保存" | 提示"已保存为新照片" | 新照片的测温点位置正确、标记大小正常、温度合理;**原照片仍在** |
| 49 | 相册 tab 点一张照片 | (无日志) | 进入**全屏查看**:双指可缩放(1–8×)、拖动可平移、双击复位;**不是**直接进测温分析页 |
| 50 | 相册查看页点"删除" | (无日志) | 弹确认对话框;确认后照片消失、返回网格 |
| 51 | 相册 tab 与 分析 tab 对比 | (无日志) | **两个界面明显不同**:相册是浏览/缩放/删除;分析只列出可测温的照片并进入测温页(此前两个 tab 渲染同一个网格) |
| 52 | 分析页打开一张**本轮之前拍的**旧照片 | (无日志) | 显示"无温度数据",**不显示编造的温度**(旧照片没有 NUC 数据块,这是预期行为) |
## 相机(PIP)失败时的表现(设计如此,不算 bug)
PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**:
- 无相机硬件 / 权限被拒 → `[pip] no camera available` / `[pip] camera permission not granted`
小窗空白或未出现;
- 相机被其它应用占用 → `[pip] camera error=<N>` + `[pip] released`
- 上述情况下主画面、拍照、录像、分析全部照常工作。
## 失败时的快速定位(沿用第 11 轮起的诊断路径)
- `reads=0``timeouts` 持续上涨 → 相机没出流:查第 5 步 START 前后的 `[cmd]`
行与 `[usb] ep 0x81 fail#... halted=...`halt 会自动清除并重试)。
- `frames>0 rendered=0` → 管线在暖机/FFC/参考窗口:看 `ref=false` 是否长期持续
(持续 false → BasePara2/66c 响应异常)。
- `GetParameter1 resp=` 超时 → `[session] parameter1 handshake failed -> abort`
屏显"相机无应答,请拔插热像仪重试"。
- 相机中途重新枚举(`[vm] usb detached vid=0x833C` + 再次弹权限)→ 多为
OTG 供电/线材问题,换短线或带供电 OTG 再试(第 13 轮结论)。
- 任何闪退:日志文件开头 `[crash]` 行有完整堆栈(DebugLog 崩溃钩子)。
+636 -7
View File
@@ -376,14 +376,643 @@
`Controller::PushFrame` 输入=整帧缓冲(28B头+像素+28B尾),与本管线一致。
- 预期:66b/66c/66f 全部有响应 → cali 下载(首次)→ 流出帧。
## 待办
## 待办2026-09-10 更新:执行计划 A→F 已全部完成)
- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)——进行中:
第13轮加固 APK 待用户复测;若仍无流,按上方供电排查三步走
- 网络互连远程预览(用户需求:UDP自动发现+手动IP)
- 离线MDT温度解码(ConvertResponse2Temperature+标定参数,待真机文件对照)
- 厂商12调色板精确提取(需运行时抓取)
- 可见光PIP融合、云模块(预留结构)
- **真机实测**(唯一未完成的主线):照 `docs/android_app/real_device_checklist.md`
24 步走一遍——1-12 单机(USB 出流/FFC/拍照/录像/媒体库/分析页温度条/PDF)、
13-15 可见光 PIP、16-24 双机远程预览。每步都有预期 DebugLog 原文可对照。
- 红热调色板(索引 11)精确表:官方 APK 预览图为占位副本,需真机抓帧反推。
- 云端点对接:当前为占位接口,需账号与接口文档。
- 任务巡检 UI(TaskParser 已就绪,尚无界面)——沿自早期计划,未列入本轮范围。
- 旧的"网络互连/离线MDT解码/调色板提取/PIP/云模块"五项**已在本轮全部实现**
(见下方阶段表),此处不再重复。
## 执行计划进度(2026-09-10docs/android_app/execution_plan.md
- [x] Phase A2026-09-10):GetLifeTime(675→0x5BB5B561) 查询 + deviceLifetimeMs
cali 缓存与内置 DDT MD5 一致性日志;新增 real_device_checklist.md
gradlew test 全绿,APK 已更新。commit: "android: lifetime query + cali consistency check + real-device checklist"
- [x] Phase B2026-09-10):Mdt.parse 返回 MdtFilejpg 按 FFD9 裁尾、text 去 NUL
填充、framePixels/info0/info1 齐全);TempMath.tempMapFromPixels 毫度图;
分析页温度条(中心/最低/最高)+ 点击测温探针(白点+环+温度标签,再点清除);
新增 MdtTest/TempMathTest17 个单测全绿)。注:分析页图像为原生横向显示,
故探针映射用直接映射(不是实时页的 90° 逆映射)。APK 已更新。
- [x] Phase C2026-09-10):**结论:libcxsdk.so 无静态调色板表**12 调色板由
CFunctions::SetColorPalette 运行时算术生成(全文件扫描 alpha=0xFF 的
256-run 命中 0)。改走"移植生成函数"路线:case 2 与 OfficialTables
PALETTE256_ARGB **256/256 逐字节一致**(锚点锁死算术+字节序),
每 case 恰好写满 256 槽;官方 APK 预览图覆盖率自身恒为最高。
**UI 索引 0..10 共 11 个调色板已换成官方精确表**VendorPalettes.kt
Palettes.kt 接线);索引 11 红热未解决(官方预览图是白热的占位副本、
case12/13 对任何预览覆盖率≤6%)→ 保留近似并注明。
新增 PalettesTest8 项);产物见 palette_extraction_findings.md。
APK 已更新。
- [x] Phase D2026-09-10):云模块脚手架(Retrofit 2.11.0 opt-in,默认关闭):
cloud/CloudApi.kt(接口+DATA classCloudClient.api() 在未开启时直接
check() 抛异常——绝不静默联网);AppSettings.cloudEnabled(默认 false
构造时同步 CloudClient);设置页"语言"与"关于"之间新增"云同步"行 +
说明对话框("当前版本仅预留接口,不会发起任何网络请求");
proguard-rules.pro 新建(-keep cloud.**;原文件缺失但被 build.gradle
引用)。新增 CloudClientTest4 项,锁"默认关闭/api() 拒绝"契约)。
debug + release(R8) 双构建通过,29 单测全绿。APK 已更新。
- [x] Phase E2026-09-10):可见光 PIP 融合:Manifest 加 CAMERA 权限 +
camera.any uses-featureorientation 未动);LiveState 加
pipOn/pipSizeIndex/pipXf/pipYf + togglePip/cyclePipSize/setPipPos
新增 ui/live/PipCameraView.ktCamera2 最小实现,TextureView 预览,
全部异常只记 DebugLog("pip",…) 并 release,绝不崩溃);顶栏第 5 项
"画中画"+ ic_pip.xml(双弧圆+右下实心矩形);浮层三档 96/128/160dp
(高=宽×3/4),位置相对热像视口、右边缘额外留 32dp 色标条位,
拖动/单击换档/双击关闭;PIP 关闭、离页、ON_STOP 三处释放相机;
运行时权限用 rememberLauncherForActivityResult。真机项已写入
real_device_checklist.md(第 13-15 步 + 失败表现)。APK 已更新。
- [x] Phase F2026-09-10):网络互连远程预览。net/RemoteContract.kt(协议常量+
手写扁平 JSON 助手,**无 Android 依赖**便于 JVM 单测 + FramePacketReader
流式重组);net/RemoteHost.ktUDP 47510 每秒广播 + TCP 47511 单客户端;
**单一协程拥有 socket**,控制回复与帧记录不会交错;帧队列 DROP_OLDEST 不拖慢
相机);net/RemoteClient.ktdiscover 去重 3sconnect 行模式→收到
stream-start 后切帧模式,同一 chunk 内混合也能正确切分;10s 无数据判死);
IrSession 加 rawHook(与 recorderHook 并列);LiveViewModel 接 rawHook →
remoteHost.offerFrame + setRemoteHostEnabled(需活跃 USB 会话);
设置页新增"远程预览服务端"/"远程预览客户端"两行(前置校验弹"先连接热像仪");
ui/remote/ 三文件:RemoteClientListScreen56dp 标题栏/64dp 卡片/重新扫描/
手动添加/空态)、RemoteViewerViewModel(本地 RenderPipeline + 内置 DDT
渲染)、RemoteViewerScreen + RemoteRendererHost(与 LiveRenderer 同构图,
未改冻结文件)、AppRoot 加两个全屏目的地。Manifest 加 INTERNET。
单测 44 个全绿,含 **loopback 端到端测试**(真实 TCPhello→welcome→
start→stream-start→5 帧逐字节往返→ffc→stop→stream-stop)与粘包/截断/
坏长度/重同步用例。debug+release 双构建通过。真机双机步骤写入清单第 16-24 步。
- [x] Phase Z2026-09-10):收尾完成。docs 更新(本文件 + HANDOFF_DEVELOPMENT
§3 结构表加入 net/ cloud/ ui/remote/ 与 VendorPalettes/PipCameraView
§7 状态与待办重写 + 禁改清单);全量 `assembleDebug test`44 单测全绿)
+ `assembleRelease`R8)通过;APK 已覆盖提交
build-artifacts/mag160c-app-debug.apk12.66MBmd5 76ff4e12…);
dex 抽查 10 个新增中文串 UTF-8 正确、无 GBK 乱码;APK 校验:
screenOrientation=portrait 保持、camera/camera.any 均为 required=false
(CAMERA 权限原本会让相机变必需,已显式声明为可选)。
**未 push**(按用户指令)。
## 用户反馈修复 第十九轮(2026-09-11,第二轮真机:相册/录像/分析/设置持久化)
用户第二轮实机测试报出以下问题,本轮全部处理:
- [x] **录像停止即闪退**(严重)。两处根因:
`Mp4Recorder.offerFrame` 先取 `inputSurface` 再判断,而 `stop()` 会 release
它——采集线程随后 `lockCanvas` 在已释放 Surface 上抛异常,**该异常发生在
USB 读线程且无人捕获 → 进程崩溃**。现改为所有 surface/encoder 访问同锁,
`offerFrame` 整体 try/catch 吞掉编码层异常(丢帧优于崩溃)。
**录完的文件从未保存**(只改了状态文案,相册里什么都没有)。现新增
`MediaStore.Video` 保存(`PhotoSaver.saveVideo`+ 临时文件清理,
状态区分 `rec_done`/`rec_save_fail`/`rec_fail`
- [x] **相册点照片打不开**`AnalyzeViewer` 被放在 `fillMaxSize()` 的 Column
**之后**,布局到屏幕外,所以点击像"没反应"。改为 `Box` 内**覆盖层**
并加 `BackHandler` 让返回键关闭查看器。
- [x] **远程预览列表按返回键直接退出软件**:缺 `BackHandler`。列表页与查看页
均补上(列表→返回设置;查看页→断开并回列表)。
- [x] **设置不生效/不持久**(用户:"每次重开设置就变回默认"):根因是
**默认调色板/追踪模式从未被实时页读取**(只有方向设置接了)。
`AppSettings` 全部相关 setter 现在都 publish 到 `ImageOrientationSettings`
`init` 用持久化值播种;实时页 collect 后即时应用调色板+追踪模式。
`defaultEmissivityPercent`/`alarmTempC` 仍未被管线使用——见"诚实记录"。)
- [x] **照片要按设置竖直翻转/旋转后再保存**:新增
`PhotoSaver.encodeRendered(frame, orientation, probes)`,拍照时按**与屏幕
一致**的方向(翻转→旋转)生成 JPEG,不再保存传感器原始朝向。
- [x] **照片上烧录测温点+温度**:拍照时把探针(含 `Pt* 温度` 标签)绘到 JPEG 上。
- [x] **MDT 新增探针数据块**`0x5BB5B55F`UTF-8 文本行 `x,y,label,tempMc`),
分析页可**重新载入**原有测温点并可编辑。
- [x] **分析页改造**(按用户要求):只显示保存的原始渲染图(不再按调色板重渲染,
避免"改调色板看起来照片被改了");点击图像添加测温点、再点删除;
"保存"生成**新照片**(标注烧录、探针入库),原文件不动;
最高/最低/中心温度与测温点列表显示在**侧边栏**。
- [x] **追踪模式设置项**:设置页新增"追踪"(最高温/最低温/最高+最低/关闭),
顶栏按钮显示当前模式(追高/追低/追高·低/追踪),录入 `TraceMode`
- [x] **操作反馈**:拍照/录像完成后在实时页显示 2.5 秒提示(此前这些状态只在
"未连接"分支显示,正常出图时用户看不到任何反馈)。
- [x] 单测 66 → **76 项全绿**debug + release(R8) 双构建通过;APK 已更新。
## 用户反馈修复 第二十五轮(2026-09-12,关闭 debug 日志 + 带图操作手册)
用户三点要求:把 App 的 debug 日志关掉;做一个带图的手册;push 上去。
### 1) debug 日志默认关闭
`DebugLog` 增加总开关 `enabled`**默认 false**`log()` 直接返回(不写 logcat 也不写文件),
`init()` 不安装崩溃钩子,`startFile()` 不建文件。要在设备上排查时把该常量改成 true 重新
构建即可,其它代码一行不用动。
- 关掉后崩溃仍按安卓常规方式可见(logcat `AndroidRuntime` + 系统"应用停止运行"弹窗),
只是不再有那份落在相册目录旁的 `debug_*.log`
- 真机烟雾测试(小米 22041211AC):出流正常、拍照成功(相册 19 → 20 张,
新增 `MAG160C_20260912_153522.jpg`)、崩溃数 0、**App 自身日志行数 0**、
`Download/MAG160C` 无新日志文件
- 注意:`adb logcat | grep MAG160C` 会匹配到系统 MediaProvider 打印的**文件路径**
不是本 App 的日志。判断 App 是否还在打日志要按 tag 过滤:
`grep -E "MAG160C/(vm|usb|session|stream|render|rec|analyze)"`
### 2) 带图操作手册
`docs/android_app/app_manual.md` 升级为插图版,图在 `manual_images/`
| 图 | 内容 | 来源 |
|---|---|---|
| `01_live_schematic.png` | 实时界面布局与 ①~⑥ 控件编号 | **绘制**(见下) |
| `02_markers.png` | 标记结构解剖:准星/读数/引线/描边字 | 真机照片放大 3 倍 |
| `03/04_photo_*.png` | 横屏 960×720 与竖屏 720×960 的真实照片 | 真机拍摄 |
| `05_settings.png` | 设置页 | 真机截图 |
| `06_analysis.png` | 分析页(含底部测量面板) | 真机截图 |
| `07_gallery.png` | 相册 | 真机截图 |
**为什么实时界面用绘制图而不是截图**:实时界面的 OSD 文字会按握持角度预旋转
(这是用户要的可读性设计),手册拍摄时手机是横放的,截图里全部文字都是侧躺的、
顶部/底部栏也一样,放进手册无法阅读。因此实时页改为自绘示意图(控件位置与真实
布局一致),其余界面文字本来就是水平的,直接用真机截图。
图由 `manual_images/MakeFigures.java` 生成(截图裁剪缩放、标记解剖放大与标注、
示意图绘制),界面改动后重跑即可,避免手册里的图与实物不一致。原始截图存为
JPEG`*_raw.jpg`PNG 转 JPEG 省下约 8MB)作为可再生的输入。
### 3) README 同步
阅读顺序加入操作手册,仓库结构表更新,里程碑补记 2026-09-11~12 的三个根因级修复
launchMode / SET_CONFIGURATION / 软件画布卡顿 6.5→15.1fps)与同期完成项。
105 项测试全绿;本轮提交后 push 到 origin(用户明确要求)。
## 用户反馈修复 第二十四轮(2026-09-12,横屏拍摄方向:文件跟随握持角度)
用户反馈:"切到横屏拍照片和视频方向又不对了"。
**根因**:上一轮把照片旋转定为**显示旋转**(锁定 90° + 手动旋转),但这个值与手机的
物理姿态无关。相机模组是**固定在手机上的**,手机转 90°,场景就在传感器画面里转 90°,
所以文件必须跟着转——否则横屏拍出来就是"竖幅文件里装着横躺的场景"。
**修法**(普通相机的做法):
```
文件旋转 = 显示旋转 + 握持角度 (PhotoSaver.captureRotation)
```
握持角度取自 `DeviceOrientation.deg`(0 竖直/90 顺时针/180 倒置/270 逆时针)。
推论:竖直与倒置 → 竖幅文件(720×960);两个横屏姿态 → 横幅文件(960×720),
且两者相差 180°(场景在两种横屏姿态下本来就上下颠倒)。录像在**开始录制时**采样一次
(编码尺寸固定,中途换手不旋转——所有相机都如此)。
注意这**不影响预览**:预览仍是锁定的(用户此前明确要求"热成像画面还是要锁定")。
所以横屏下拍出来的文件不是预览的截图,而是预览**再转一个握持角**——正是这一步让
场景在文件里是正的。
**真机验证(关键证据)**:测试时设备恰好停在 270° 横屏姿态(`dumpsys sensorservice`
读数 gx=9.88, gy=-1.02 → deg=270),于是直接复现了用户的问题:
- 修复前:720×960 **竖幅**,内容相对竖直姿态拍的照片转了 90°
- 修复后:960×720 **横幅**;把修复前的文件转 **-90°** 与新文件逐像素比对,
平均亮度差 **5.06**,而转 +90° 的差是 **40.94** → 方向正确、只差这一转
- 录像:`tkhd 960 x 720`、avc1、97 帧 0 丢弃
- 分析页读该横幅照片(rot=0):MIN/MAX 各一个、与烧录标记重合,
面板 41.4 / 21.7 / 27.0 与照片一致
新增测试 `savedFileRotationFollowsTheGrip`(四种握持角度 + 手动旋转叠加)与
`landscapeGripsProduceLandscapeFiles`(形状规则 + 两个横屏姿态相差 180°)。
105 项测试全绿。
**教训**:文件方向是"世界坐标"问题,预览方向是"屏幕坐标"问题——两者可以不同,
混为一谈就会在某个握持姿态下出错。固件级/相机类功能的"方向正确"必须用
**同一场景在不同姿态下的可复现拍摄**来验证,不能只靠推导。
**本轮新增文档**`docs/android_app/app_manual.md`App 操作手册:界面、标注含义、
设置逐项说明与"是否真正生效"、方向规则、文件位置与格式、远程预览、常见问题、
已知限制),`README.md` 里程碑与阅读顺序已同步更新。
## 用户反馈修复 第二十三轮(2026-09-12,照片竖屏 / 标注风格重做 / 录像清晰度)
用户第五轮反馈(三点):竖屏拍照出来的照片不是竖屏;录像里的温度标点糊;
温度标点不要白底,参考大牌热成像重做一套、要有工业感。
### 1) 照片改为按"用户看到的方向"保存(推翻上一轮的决定)
上一轮把照片定为**传感器朝向**(4:3 横),用户明确否掉了。现在把**显示旋转**
(锁定 90° + 设置里的手动旋转)连同手动翻转一起烘进 JPEG:竖屏持机拍出来就是
竖屏(720×960)。顺序与实时渲染器完全一致——**先镜像、再旋转**。
- 温度数据**不跟着转**probes / NUC / extremes 仍留在原始传感器空间,旋转只存在于
呈现层;`BLOCK_RENDER` 升级到 **version 2**,多存一个 `rotDeg`。旧文件(8 字节
v1 块)解析为 rot=0,正好符合它们确实是传感器朝向的事实——不会被静默转错。
- `sensorToPhoto` / `photoToSensor` 增加旋转参数(正向 (u,v)→(1-v,u),逆向对应),
并有 16 组 mirror×rotation 的往返单测。
- 分析页的映射改为**直接复用** `PhotoSaver.sensorToPhoto(..., 1, 1)`,让"分析显示的
位置"和"烧进照片的位置"不可能各算一套。
### 2) 标注风格重做(去白底,工业感)
按 FLIR/Testo 那类仪表的做法重做(`AnnotSpec` + `MarkerPainter`):
- **图标**:细线方形准星 + 四根短臂(原来是不带臂的圆环+圆点)。方框限定测量区域、
四臂指明确切像素、中间镂空不遮挡被测点。
- **文字**:白字 + 深色描边(先描边后填充),**不再有白色底板**。描边是为了在黑冷的
和白色的两端都读得清——白底会挡住被测画面,而且看着像消费级 App。
- 极值标签改用 **MAX / MIN** 大写,并加一条**引线**把读数与自己的准星连起来。
- 极值颜色与测点一样是白色:这是参考仪表的做法,彩色标点在铁虹的橙黄区会糊掉,
区分靠 MAX/MIN 文字。
### 3) 标注尺寸统一(顺带修掉的不一致)
AnnotSpec 的 320 单位是**图像长边**。实时界面原来用 `viewport.width()`(竖屏时是短边)
去除 320,导致实时标点只有照片标点的 3/4 大——正是用户早先"照片标点太大"的由来。
现在实时/照片/录像/分析**统一用 `max(w,h)/320`**,同一画面里标点相对图像的尺寸完全一致。
### 4) 录像清晰度
原来按原生 320×240 编码,文字只有 9.5px、放大后必然糊。现在:
- 编码尺寸 = 传感器 × **3**(竖屏 720×960,横屏 960×720),码率随像素数自动放大
`w*h*10`,下限 2Mbps)。
- 帧的翻转+旋转用**一个 Matrix** 在 reader 线程上一次 drawBitmap 完成;
标点经 `sensorToPhoto` 落在同一变换下。矩阵与映射的一致性我按坐标推导核对过:
矩阵复合结果 = `sensorToPhoto`rot90 时 u'=1-v、v'=u),两者不会各转各的。
- 真机实测:`tkhd 720 x 960`、avc1、96 帧 / 0 丢弃 / 7.3MB。
### 5) 分析页"每个标记出现两次"
照片里已经烧录了标记,分析页再叠一层,两层**标签位置不同**(准星位置是对的)。
根因不是坐标错误,而是**标签避让与顺序有关**:拍摄时按 probes→MAX→MIN 的顺序绘制,
分析页按 MAX→MIN→probes,碰撞避让把标签推到不同位置。已改为与分析页一致的顺序,
叠加后完全重合(真机裁剪对比确认)。
**排查方法记录**:判断"文件里的标记位置对不对"不要靠肉眼看截图叠加,直接**解析 MDT**
`analysis` 里已有的块格式)算出每个标记的期望像素,再统计该处的中性白色像素数。
本轮据此一次性证伪了"旋转没生效"的猜测:rot=90 处 markerPixels=204/219/296/719
rot=0 处全为 0。
**本轮真机验证(小米 22041211AC / Android 12 / MIUI**
- [x] 照片为 **720×960 竖屏**,方向与竖屏实时画面一致
- [x] MDT 内 `flags=2 flipV=true rot=90`,四个标记在旋转后的期望位置均有标记像素
- [x] 照片标注为新风格(准星+描边字、无白底),MAX/MIN 大写带引线,标签互不重叠
- [x] 分析页叠加后与烧录标记**完全重合**(每个标记只有一套)
- [x] 面板数值与照片一致(40.3 / 21.3 / 24.9Pt1 25.0 / Pt2 23.2
- [x] 录像 720×960 竖屏、96 帧 0 丢弃、首帧日志确认极值标记烧入
- [x] 103 项测试全绿
## 用户反馈修复 第二十二轮(2026-09-12,7×7 细节增强 + 标注统一 + 卡顿根因)
用户第四轮实机反馈(四点):移植 7×7 局部细节增强;分析界面标点没对齐;
实时界面与照片的标注风格必须**完全一致**("就像直接从实时界面截图"),
最高最低用 `min`/`max` 标注;实时画面还是卡。
### 1) 卡顿根因(本轮最有价值的发现,已量化)
先前只靠 `dumpsys gfxinfo`(显示 7.26% janky)猜原因,但**它根本看不到热像画面**:
热像走 `SurfaceView.lockCanvas()` 软件画布,gfxinfo 只统计 Compose 层
(实测 5 秒内只记到 28 帧,而流本身在 15fps)。于是给渲染线程加了自测量日志
`MAG160C/render: paints=N avg=... worst=...`),一测即见真相:
| 阶段 | 绘制帧率 | 单帧绘制耗时 |
|------|---------|------------|
| 修复前 | **6.5 帧/秒** | **134 ms** |
| 加硬件画布后 | **15.1 帧/秒** | **6.4 ms** |
根因:`SurfaceHolder.lockCanvas()` 返回**软件**画布,于是每帧都在 CPU 上做
320×240 → 约 810×1080 的双线性放大 + 手工清整屏(约 10MB)。
**改用 `lockHardwareCanvas()`(API 29+,旧系统自动回退软件路径)**,放大与清屏
交给 GPU,CPU 只剩 76800 像素的拷贝。相机 15fps 因此第一次能完整到达屏幕。
配套修掉的每帧垃圾(卡顿的次要来源,也是 GC 停顿的来源):
- `setFramePixels` 每帧 `IntArray(320*240)`4.6MB/s 垃圾)→ 复用 `flipped`
- 色条每帧 new 96 个 `Paint` + 96 次 `drawRect` → 预渲染 1×96 位图,仅换色板时重建
- 标记列表每帧 new ArrayList → 复用
- 温度点名 `refreshTemps()` 原本在**主线程**LaunchedEffect 驱动):
400ms 一次分配 19200 整型数组 + 全屏扫描 + `copyNuc`(持渲染线程的锁)
→ 移到后台 `Dispatchers.Default` 循环,结果经 `Dispatchers.Main` 发布
### 2) 标注统一(照片 = 实时截图)
- 新增 `core/AnnotSpec.kt`(唯一几何定义)+ `media/MarkerPainter.kt`(唯一绘制例程),
实时/分析/照片/视频四处共用。此前各画各的,所以"看起来不一样"。
- 极端值标签由 高/低 改为 **`max` / `min`**(用户指定)。
- 照片以**传感器朝向**保存(4:3 横),文字在该帧内水平,3 倍分辨率(文字清晰)。
### 3) 分析界面标点对齐(两个真实 bug)
1. **极端值没有走坐标映射**probes 经 `sensorToPhoto` 转换,extremes 却被
**原样加入**`marks.addAll(extremes)`)。传感器坐标是 0..159/0..119,在
960×720 的照片上就落在**左上角几十像素内**——真机照片实测两个 min/max
全挤在左上角。已改为同一映射。
2. **`annotateJpeg` 忽略照片镜像**:从分析界面另存时,标记按未镜像坐标烧录,
于是**镜像到另一侧**。已加 `mirror` 参数并走 `sensorToPhoto`
### 4) 标签互相压字(真机照片可见)
`min 21.9℃``max 32.6℃` 印在一起成一团。`placeLabel` 只判边界不判重叠,
现已支持"已放置盒"列表:默认右置 → 被占则翻到左侧 → 仍冲突则下移让位,
并保证始终在画面内。`MarkerPainter` 一次调用内跟踪,分析界面改为把 probes 与
extremes **合并成一次 draw**(原来分两次调用,彼此看不见)。
### 5) 重复的 min 标记(分析页 vs 照片)
分析页重新扫描 NUC 推导极值,与拍摄时 live 扫描的结果**不可能逐位一致**
(传感器在拍摄与重载之间会漂移,平坦区 argmin 极易移位)——真机出现
两个相距几像素、22.0/22.1℃ 的 min。已在容器新增
`BLOCK_EXTREMES (0x5BB5B562)`:拍摄时把 minPos/maxPos/minMc/maxMc 一并存盘,
分析页优先采用,从而与照片上烧录的标记**完全相同**。另存新照片也继续携带。
### 6) 分析页测量面板被导航栏遮住
`UiInsets.navPx` 是普通 `var` 且初值 0,全屏覆盖层(分析查看器)读它时
拿到的是首帧值 0,于是底部面板落在导航栏之下(截图可见数值不可见)。
已改为 `mutableStateOf` 并让分析查看器 `padding(bottom = navPx)`
### 7) 7×7 局部细节增强(官方同款)
按反编译的 `CFunctions::FilterDetailEnhancement_Simple` + `LocalMap7x7_Simple`
移植(`core/DetailEnhance.kt`):7×7 窗口按 4×4 抽样(x/y 步长 2),
`mean = sum >> 4``if (strength <= (max-min)*32)`
`detail = (0x8000/divisor)*(center-mean)`,最后 `gray += (strength*detail) >> 15`
管线位置与官方一致:`grayMap → 细节增强 → upscale2x → 调色板`
**两个 strength 不可互换(本轮抓到的实现 bug)**:官方把**增益缩放后**的
`k = strength * gain * 2 >> 8` 传给 map(决定阈值与 divisor 下限),
但**混叠系数用的是原始 strength**。本移植最初两处都用 `k`,等于把细节
再乘一遍增益——真机 level 2 画面满屏噪点。修正后 HF 能量与关闭档几乎相同
0.47~0.51 对 0.46~0.47),边缘仍被增强。
数值由 `blendUsesTheRawStrengthNotTheGainScaledOne` 黄金值测试锁定
(推导写在测试注释里;把 bug 改回去该测试立刻失败,已验证)。
**档位换算经官方 SDK 核对**`MAG_SetDetailEnhancement` 把档位钳到 **0..32**
`SetEX` 与之无关(那是数码变焦 ROI)。**官方 App 本身不调用
SetDetailEnhancement**jadx 全量搜索无此符号),所以官方实时画面用的是
SDK 内部默认档——无法据此对齐具体档位,故本应用把它做成用户可见设置
0/1/2/3/4/6/8 档),默认**关闭**(关闭时 `RenderPipelineTest` 逐位基线不受影响)。
**档位到底控制什么(推导 + 真机 HF 实测,曾一度写反)**:
`amplitude = strength*(center-mean)/divisor``divisor = max(max-mean, mean-min, mapStrength)`
`mapStrength = strength*gain*2>>8`gain=1000 → 每档 62.5)。由此:
- 弱对比区域:divisor 被 mapStrength 兜住 → 幅度化简为 `128/gain`**与档位无关**
- 强边缘:divisor 由局部对比度主导 → 幅度**随档位线性增长**;
- 门槛 `mapStrength <= range*32` → 档位越高,**弱对比区域被整个排除**(噪声所在处)。
所以高档位是"更锐但更挑",不是"连噪声一起放大"(设置页文案曾写反,已改)。
真机 HF 能量实测:关 0.365、6 档 0.438、8 档 0.4336/8 相近,符合"8 档门槛更高、
只增强更强边缘")。该行为由 `theLevelRaisesTheContrastGateAndDropsWeakDetail` 锁定
window range=608 档 mapStrength 1500 通过、16 档 3000 被拒)。
**另一个一致性修复**:分析页原先把测温点拿去**重新测量** NUC,导致照片上烧录的
`Pt2 26.1℃` 与面板显示的 25.9℃ 不一致(同极值那类漂移)。现在优先采用文件里
记录的 `tempMc`(照片上印的就是它),仅在缺失时才测量。
实机实测:级别 2 下仍 15.1fps,单帧绘制 11.3ms(滤波器约 +5ms,帧预算 66ms 内);
6/8 档 9.9~10.3ms,同样不掉帧。
### 8) 其他顺带修复(本轮真机复现)
- **`no_device` 死锁**:首次 connect 若赶上相机正在重枚举(`findDevice()` 返回
null)就停在"无相机",而 200ms 后到达的 ATTACHED 广播又落在 800ms 防抖窗口里
被丢弃 → 永久无画面,必须手动重启 App。现在防抖与退避都改为**延后重试而非丢弃**,
`no_device` 本身也会安排一次 1.5s 重试(`scheduleRetry` 单槽位、最新请求优先)。
- **平放时 OSD 文字整体转 90°**:手机平放在桌面时加速度计 x/y 都接近 0,
迟滞逻辑保留了上一次的姿态角。现在 tilt < 4 m/s²(约 24°)判定为"平放"
姿态角归零。
- **视频录制缺少 max/min 标记**:实时画面显示的两个极值没有烧进录像帧,
现在 `probesAsMarks()` 一并返回(录像因此与实时画面一致)。
- **屏幕熄灭时的行为(实测)**:渲染线程随 SurfaceView 停止绘制(无空转),
USB 读取继续,整机 CPU 占用 3.7%——后台不烧电、不崩溃。
**本轮真机验证(小米 22041211AC / Android 12 / MIUI**
- [x] 渲染帧率 6.5 → **15.1 帧/秒**,单帧 134ms → **6.4ms**(开增强 9~11ms
- [x] 拍照:`capture: nuc=yes probes=2 mirror(h=false,v=true) extremes=2 saved=true`
- [x] 照片上标记风格与实时界面一致(同一 MarkerPainter),`max` 在热区、
`min` 在冷区(贴近右边缘时标签自动翻到左侧)、Pt1/Pt2 带白底标签,互不压字
- [x] 分析界面:标点与照片烧录位置对齐,**只有一个 min、一个 max**
- [x] 照片 / 分析面板 / 底部面板三方数值完全一致
(真机同帧:照片 max 36.0 min 20.4 Pt1 21.7 Pt2 26.1;面板 36.0/20.4/24.5 + 21.7/26.1
- [x] 图像增强:6 档与 8 档生效、修正后不再放大噪声、15.1fps 不掉帧
- [x] 设置持久化跨冷启动(`mag160c_settings.xml`enhanceLevel / imageFlipV
- [x] 录像:99 帧 / 0 丢弃 / 1.6MB,首帧日志证明极值标记已烧入
`annotation: 2 markers ... trace=BOTH minPos=159 maxPos=16269`
- [x] **重启后冷启动**(USB 权限被系统清空后重新授权):一次成功、15.1fps、
无重枚举、无重连风暴
- [x] 屏幕熄灭时无空转(CPU 3.7%)、USB 读取继续、不崩溃
- [x] 长时间运行 60s+:15.1fps 稳定、无丢帧、无崩溃
**排查方法记录(本轮新增)**
- 判断"自绘画面"的帧率/耗时**不能用 `dumpsys gfxinfo`**(它看不到 SurfaceView 的
lockCanvas 绘制,实测只记到 28 帧而流在 15fps)。本应用已内置 `MAG160C/render`
日志:每 5 秒打印 `paints=N avg=x ms worst=y ms`,这是唯一能看到真实绘制性能的途径。
- 录像标注是否齐全可从 `MAG160C/rec` 首帧日志直接读出(含 trace 模式与极值输入)。
**教训记录**
- `dumpsys gfxinfo` 不统计 SurfaceView 的 lockCanvas 绘制。判断自绘画面性能
必须自己打点(本轮加的 `MAG160C/render` 日志就是为此,已保留)。
- 断言"某处卡"之前先量出**每帧耗时**和**实际帧率**:本轮原以为是温度扫描
(主线程 400ms 扫描)导致,量化后才发现是软件画布放大,量级差 20 倍。
## 用户反馈修复 第二十一轮(2026-09-12,无线 adb 真机调试:根因是 launchMode
**本轮最大的发现**:前几轮反复出现的"连接风暴/重连循环/相机每 2 秒重枚举"
(设备号 060→093 一路涨、CPU 136%、状态在 streaming/no_device 间抖动)
**根因是 `MainActivity` 少了 `android:launchMode="singleTask"`**。
- Manifest 给 MainActivity 声明了 `USB_DEVICE_ATTACHED` intent-filter,而
**MIUI 在相机插着时会持续重复广播该事件**。默认 `standard` 启动模式下,
每次广播都创建一个**新的 MainActivity 实例**——真机 `dumpsys` 实测同时存在
**2 个实例**,各自持有自己的 ViewModel、IrSession、广播接收器,全部争抢同一台
相机;谁被挤掉谁就重新握手,于是相机被反复复位→重枚举。
- 官方 App 的 manifest 正是 `launchMode=2 (singleTask)`aapt 实测)。
- 加上 `singleTask` 后实测:Activity 实例 1 个、**设备号 65 恒定 40 秒不变**、
状态稳定 streaming、fps 15.1、CPU 归零。
- 排查过程中曾按症状加过多层防御(connect 互斥锁、代际守卫、attach 去重、
失败退避+自动重试、stream 循环退避)。这些**已一并保留**:它们各自修掉了
真实存在的小问题(见下),但都不是那个根因;根因只有 launchMode 一处。
**本轮真机(小米 22041211AC / Android 12 / MIUI)实测通过的功能**:
- [x] 出流:冷启动一次成功,15fps、ref=true、timeouts=0
- [x] **拍照**`capture: nuc=yes rot=90 saved=true`;文件入 `DCIM/MAG160C/`
- [x] **照片含正确温度数据**NUC 块 153600B / 76800 样本(照片分辨率 1:1)、
零空洞、温度 22.6~32.9℃(均值 25.9℃)——不再有 -161℃/145℃
- [x] **录像**:开始→停止**不闪退**MP4 2.2MB / 134 帧,`moov` box 完整可播
- [x] **分析页温度正确**`min=22.606 max=32.877 center=24.683`
与文件真值**逐位吻合**
- [x] **分析页加测温点 + 另存新照片**:生成 `_edit.jpg`,含 PROBES 块
`116,102,Pt1,24674` / `150,169,Pt2,24838`,数值合理)+ NUC 块保留
- [x] **相册**:列出 4 张 → 点开全屏查看器(返回/删除)→ 返回键回列表 →
删除确认对话框 → 删除后文件 4→3、列表同步
- [x] **分析 tab 只列可测温照片**(旧的无 NUC 照片被过滤,符合设计)
**本轮顺带修掉的真实缺陷**(都在真机上复现过):
1. `UsbTransport.open()``claimInterface` 之后发 **SET_CONFIGURATION**——
这是设备级复位,会让相机立刻重新枚举。官方代码从不发(仅 claimInterface)。
**已删除**。(实测:app 停止时相机 30 秒稳定不动,启动后设备号立刻开始爬升。)
2. 分析页 `_minTempC.value = mn / 1000f`——`mn` 是 NUC **counts** 却被当温度,
显示 9.2/10.4℃(centre 走了正确路径所以是对的)。已改为过温度曲线。
3. `Mdt.compose` 只接受 `nucPixels.size == 38400`,而真实块是照片分辨率
153600)→ **静默丢弃**,而 capture 日志仍打 `nuc=yes` 掩盖了它。
已改为按尺寸下限校验。
4. 三个全屏查看器(相册查看器、分析查看器)**都没有 BackHandler**——
返回键直接退出 app。已补。
5. USB attach 广播未校验 VID,任何 USB 事件都会触发 `connect()`MIUI 重复
广播时把健康会话的状态覆盖成 `no_device`(画面在跑却显示"未检测到热像仪")。
已改为只认 0x833C 且已有会话时忽略。
6. `streamLoop` 在端点 halt 后 `bulkTransfer` **立即返回失败**(不等超时),
循环以约 1000 次/秒空转、每次两次控制传输——实测 28000 条失败日志、
**CPU 136%**。已加三级退避(0/20/250ms)+ 日志静默 + 死链约 1 分钟后收尾。
7. `connect()` 失败后无人重试(backoff 只拦截、不重发),UI 会永远停在
`no_handshake`。已加单实例自动重试。
8. `Mp4Recorder.stop()` 的收尾 drain 异常现在被记录说明(`file kept`),
不再让人误以为文件没生成;实测该异常下 MP4 仍完整可播。
**调试方法记录**(供后续排查):
- 设备端日志:`/sdcard/Download/MAG160C/debug_*.log.txt`DCIM 被 MIUI 拒收
text/plain,自动回退到 Download),可 `adb pull` 取回
- MIUI 禁止 adb 注入输入(`INJECT_EVENTS`),但设备有 **Magisk root**
`adb shell su -c 'input tap X Y'` 可以,`uiautomator dump` 配合读控件坐标
- 相机是否在重枚举:`dumpsys usb | grep -oE 'bus/usb/002/[0-9]+'` 连续采样看
设备号是否变化——这是判断"是硬件问题还是我们代码问题"的最快手段
(app 停止时稳定、启动后爬升 = 我们的代码在复位设备)
- Activity 实例数:`dumpsys activity activities | grep 'Activities=\['`
出现两个 MainActivity = launchMode 问题
## 用户反馈修复 第二十轮(2026-09-11,第三轮真机:分析测温/标注/相册/界面)
用户第三轮实机测试(含截图)报出以下问题,本轮全部处理:
- [x] **分析温度全错**(截图显示最高 145.1℃ / 最低 -161.0℃ / 中心 76.5℃)。
双重根因:
**数据源错了**:实时读数用的是 `copyNuc()` 的**NUC 补偿后 counts**
而照片里存的 `BLOCK_FRAME` 是**传感器原始响应**,标定表对它无效 →
直接换算就是垃圾值。新增 **`BLOCK_NUC` (0x5BB5B560)**:存拍照当刻的
**NUC counts**,分析端用它测量(与实时屏幕同一数据源)。
**我自己引入的索引 bug**:第一版把 160×120 的样本"散布"进 320×240 的
照片空间,76800 个槽位只填了 19200,其余全 0 → `countsToTempMc(0)`
= **-161.0℃**(正是截图里的最低温)。改为
`buildPhotoOrderedCounts()`:**每个照片像素一个样本**,查表就是
`counts[iy*photoW+ix]`,不做任何旋转/翻转/缩放换算。
- [x] **测温点位置标错**:探针原先按**传感器坐标**存储,显示时却当**照片像素**用。
现在拍照时即转换到照片像素(`sensorToImage`),并且新增
`photoToSensor()` 反变换用于测温;两者互逆性有单测覆盖(4 旋转 × 2 翻转)。
- [x] **测温点太大**:标注尺寸原先用**屏幕密度**(4.5×density 的点、9×density 的环)
画进 320×240 的位图 → 环直径约 36px / 320px 图宽,视觉上盖住画面。
新增 `MarkStyle`:尺寸按**图像宽度**比例(点 2.6、环 5.5、字 9 @320px),
与实时屏幕观感一致。实时页/分析页/保存照片三处统一。
- [x] **分析页布局**:数据面板从**右侧竖栏**改为**底部横条**(用户要求),
三个数值(最高/最低/中心)等分排布 + 测温点横向滚动列表,标题栏收窄。
- [x] **分析页不显示极值标记**:现在在图上用小环标出最高/最低位置(带"高/低"字样)。
- [x] **相册与分析界面一样**:分析 tab 原先**直接渲染相册的网格**(同一个
`GalleryScreen`)。拆分为:
- `GalleryScreen`(相册 tab):像正常相册一样,点开进入**全屏查看器**
(双指缩放 1–8×、拖动平移、双击复位、**删除**含确认对话框);
- `AnalyzeScreen`(分析 tab):只列出**带温度数据**的照片
`MdtProbe.isMeasurable`),点开进入测温分析页。
- [x] **旧照片优雅降级**:没有 `BLOCK_NUC` 的照片(本轮之前拍的)在分析页显示
"无温度数据",**不再编造温度**;新拍照片都带该数据块。
- [x] 单测 76 → **83 项全绿**(新增 `PhotoNucMappingTest`:密集性/中心对齐/
探针往返/字节往返/无 NUC 判定);debug + release(R8) 双构建通过;APK 已更新。
**已知取舍(诚实记录)**
- `BLOCK_NUC` 是**照片分辨率**76800 样本 = 153KB,未压缩),照片文件因此增大
约 150KB。选择它的理由:分析端查表无需任何坐标换算,从根上消除"索引对不上"
这一类缺陷(本轮的两个温度 bug 都源于此)。若日后要压缩,需保证查找端使用
同一套映射函数。
- 默认发射率/报警温度仍未接入管线(同第十八轮记录)。
**诚实记录(第十八轮未做)**
- `defaultEmissivityPercent`(默认发射率)与 `alarmTempC`(报警温度)**仍未被
测温管线使用**:发射率需要官方 `CorrectTemperature` 的完整浮点公式(已从
libcxsdk 伪代码定位到 `@000298f0`,但牵涉 T2E/环境温度/`Energe2Temp` 多处
状态,属独立议题),报警温度需要超温提示 UI。当前这两项**只保存与显示**,
不声称已生效。
## 用户反馈修复 第十八轮(2026-09-11,真机实测:温度/朝向/远程三类缺陷)
用户实机安装测试(含两台手机远程预览)报出以下问题,本轮全部处理:
- [x] **中心温度测量错误**`probeTemp()` 返回的**已是毫度**`updateTemps` 又调了
一次 `countsToTempMc()` → 真机显示 108.7℃(实际约 24℃)。
远程页同一 bug(显示 -161℃)。两处都改为只除 1000 一次,并在参数注释里
写明单位契约。
- [x] **FFC 时最高/最低温跳到 ~150℃**FFC 参考帧被解码进 `nuc`OSD 采样的同一
缓冲区),未补偿的原始 counts 被当成温度。改为独立 `refScratch` 缓冲;
另加 `tempsReady()` 门控(首帧渲染前 `nuc` 全 0,而 `countsToTempMc(0)`
= **-161.0℃**,正是远程截图显示的读数)。
新增回归测试 `PipelineTemperatureStateTest`3 项)。
- [x] **追踪开关只关最高温**:最低温标记未受开关控制(`LiveRenderer.drawOsd`)。
现在开=最高+最低都画("高"/"低"),关=都不画。
- [x] **画面转向反了 / 横屏应翻 180°**:先按"图像内容随握持角补偿(`rot = 90 - φ`"
改了一版,**用户实测后指正:图像应保持锁定**——传感器装在手机上跟着一起转,
锁定图像时场景相对世界方向自动正确,加速度计参与反而双重补偿(这正是
"右转画面往反方向转"的原因)。**已改回锁定**:`ImageTransform.params()`
不再接受握持角参数(编译期保证),只保留官方同款手工修正。
用户还实测发现"**竖直翻转 + 旋转 90°**"能把画面转到另一方向——已用单测
证明它等价于"水平翻转 + 旋转 270°"(逐像素比对),两者只差一次水平镜像。
- [x] **新增官方同款方向设置**:设置页"旋转USB画面"0/90/180/270,叠加在锁定
基准 90° 上)、"水平翻转"、"竖直翻转"(作用在传感器帧上,与官方一致),
持久化于 `AppSettings`
- [x] **色标条两端最高/最低温与色条错位**:标签原先按未旋转的基线锚点定位。
改为**按旋转后包围盒**定位(`ImageTransform.rotatedBoxHalfExtents` +
`drawGripText`),任意握持角都贴着色条两端。
- [x] **几何单一来源**:新增 `ui/live/ImageTransform.kt`(纯 Kotlin),渲染器与
点击/探针映射共用同一套参数与互逆映射;新增 `ImageTransformTest`6 项,
覆盖 0/90/180/270 × 两种翻转的往返一致性)+ `ImageTransformOrientationTest`
(4 项,锁官方映射与"转身时图像反向")。
- [x] **远程预览画面不正确**(用户截图:-161℃、满屏噪声):根因是协议只传像素,
客户端独立跑 FFC 状态机且**拿不到帧的相机温度**,而 NUC 表要按快门温度插值
→ counts 饱和、参考帧缺失。协议改为传元数据:
`[magic][counter][38400][flags][ffcPhase][shutter][像素]`(头 24B,记录 38424B),
客户端用 `RenderPipeline.frameRemote(frame, phase, shutter, out)` 复刻主机状态。
(用户建议"传原始数据本地渲染"正是此方向;此前的错误在于只传了数据的一半。)
- [x] **顺带修核查报告的两项证伪**`RemoteSession.send()` 改为**单写协程 + 队列**
(原先每条命令各起协程写同一 socket,`hello`/`start` 可能乱序,~6.7% 丢
`welcome`;新增 `commandOrderIsPreservedUnderRapidSends` 回归);
主机实现"后来者写 busy"accept 循环不再阻塞在 serve 上,第二客户端立即
收到 `{"type":"busy"}`;新增 `secondClientIsRejectedWithBusy`)。
另删除未使用的 `ACCESS_NETWORK_STATE` 权限。
- [x] **设置页翻不动 + 死行**(用户报"设置页选项翻不动"):
① 设置页 `Column` **缺 `verticalScroll`**11 行内容一屏放不下,下方几行
完全够不到 → 已加滚动;
② "语言"行点了没反应:`when(dialog)` 里根本没有 `"language"` 分支,
点击后落进 `else -> {}`;且该项**没有任何代码读取、也没有 i18n 资源**
(全部界面为中文字面量、无 strings.xml)→ 补上对话框并**如实说明"当前仅中文,
选择会被记录待后续翻译"**,不再假装能切语言;
③ "关于"行同样设 `dialog = null` 属死行 → 补上说明对话框;
**设置变更即时生效**:原先实时/远程页每 400ms 轮询 `AppSettings`,而该轮询
只在实时页处于组合状态时运行(在设置页改动后要切回实时页才生效)→ 新增
`ImageOrientationSettings`(进程级 StateFlow),`AppSettings` 三个方向
setter 写入后立即 publish,实时/远程页 collect 后即时应用。
新增 `ImageOrientationSettingsTest`4 项);单测 62 → **66 项全绿**
- [x] 单测 62 → **66 项全绿**debug + release(R8) 双构建通过;APK 已更新
12.66MB)。`screenOrientation=portrait` 复核保持、camera 系列仍
not-required、多余权限已消失。
⚠️ **规格说明(用户裁定)**:图像**锁定**在竖屏框架(第四/五轮约定维持不变);
新增的仅是官方同款三项手工方向修正。中途曾按握持角补偿图像,用户实测指正后
已改回,`ImageTransform.params()` 不再接受握持角参数以防回退。
## 本轮(2026-09-10 执行计划 A→F)总结
七个阶段全部落地,每阶段一次 commit:
| 阶段 | commit | 内容 |
|---|---|---|
| A | 06c1f30 | lifetime 查询 + cali MD5 对照 + 真机自检清单 |
| B | 656d419 | MDT 温度解码 + 分析页温度条/测温探针 |
| C | b7a928e | 厂商调色板提取(11/12 精确表) |
| D | c34940e | 云模块脚手架(opt-in 默认关) |
| E | f8b3200 | 可见光 PIP 叠加(Camera2,三档可拖) |
| F | 512508e | 局域网远程预览(UDP 发现 + 原始帧 TCP,客户端渲染) |
测试:29(D 结束)→ 44(F 结束),含真实 TCP loopback 端到端用例。
**留给用户的实测**`docs/android_app/real_device_checklist.md` 共 24 步
1-12 单机 USB/拍照/录像/分析;13-15 PIP16-24 双机远程预览),
每步标注预期 DebugLog 原文。
**已知未完成项(诚实记录)**
1. 红热调色板(索引 11)仍是近似——官方 APK 预览图是白热的占位副本,
生成的 case12/13 对任何预览覆盖率 ≤6%,不猜测;需真机抓帧反推。
2. 全部真机验证(温度绝对值、PIP 相机、双机远程、MDT 温度条)需用户配合;
本机无模拟器(SDK 无 emulator 组件)、无连接设备,只能保证编译与单测。
3. 云端点为占位,需账号与接口文档才能对接。
## 里程碑日志
+423
View File
@@ -0,0 +1,423 @@
# 核查指南 —— Phase A→F 执行轮(交给核查 AI 的入口)
> **核查对象**commit `06c1f30..8e1312a`7 个提交,基线 `087e15c`),
> 位于 `C:\Project\MAG160C`,源码在 `android\`Gradle,包 `com.mag160c.thermal`)。
>
> **第一原则:不要相信执行模型的总结与文档叙述。** 所有结论必须来自
> ① git 提交内容 ② 仓库内的官方逆向产物(可独立复核)③ 亲自重跑的脚本/构建
> ④ 硬件实测。执行模型(下称"执行者")的文档只当"待验证的声明"处理。
>
> 本文件给出一条可直接粘贴的起始指令、阅读顺序、三道必过关口、
> 逐阶段核查方法、**执行者主动交底的高风险项**,以及本机无法验证的边界。
---
## 0. 可直接粘贴给核查 AI 的起始指令
```
你是本项目的独立核查者。项目根 C:\Project\MAG160Cgit 仓库),
核查范围是提交 06c1f30..8e1312a7 个提交,基线 087e15c),
任务是把 docs/android_app/execution_plan.md 中 Phase A→F 的每项声明
独立复核一遍并对每条给出 证实/证伪/无法判定 的结论。
铁律:
1. 不要采信执行者写的总结、session_state、checklist 的任何叙述,
它们只是待验证声明;结论必须来自代码、官方逆向产物、可复现命令。
2. 先读 docs/android_app/verification_guide.md(核查指南),按它的
三道关口顺序执行;第一道不通过就停止并报告。
3. 不要修改任何源码或提交;你是只读核查者。发现问题只报告,不修复。
4. 无法在本机验证的(需真机/双设备)必须显式标注"无法判定",不要默认通过。
5. 执行者的构建命令(Windows Git Bash):
cd /c/Project/MAG160C/android && export JAVA_HOME="C:\\Tools\\jdk-21" && \
cmd //c "C:\Project\MAG160C\android\gradlew.bat :app:assembleDebug test --no-daemon"
(本机无 python3、无模拟器、无连接设备;JDK/SDK 见 android/local.properties
最后按"阶段 → 声明 → 方法 → 结论 → 反例(若证伪)"输出报告。
```
---
## 1. 阅读顺序(先建立判据,再看实现)
1. `docs/android_app/execution_plan.md` —— **验收判据来源**。特别是 §0 全局约束
(禁改清单、构建命令、提交格式)与各阶段的"精确规格"。核查就是"拿计划对实现"。
2. `docs/android_app/verification_guide.md`(本文件)—— 核查路线。
3. `git log --oneline 087e15c..HEAD` 与每个提交的 `git show --stat` —— 改动边界。
4. `docs/android_app/session_state.md` 的"执行计划进度""本轮总结""待办"——
执行者的自述(**待验证**)。
5. `docs/android_app/real_device_checklist.md` —— 24 步真机清单(**待验证**,
见 §4.9:其中第 15 步的预期与实现可能不符)。
6. 官方逆向产物(判据,非执行者产出):
- `analysis/magcx_official_flow.md`(协议权威)
- `analysis/sdk_re/android_app/jadx_magcx/`(官方普通版 Java 源码)
- `analysis/sdk_re/android_app/libcxsdk_decomp.txt`Ghidra 伪代码)
- `analysis/sdk_re/android_app/palette_extraction_findings.md`Phase C 结论)
判据优先级:**官方产物 > 计划文档 > 执行者文档**。三者冲突时以官方产物为准,
并指出冲突本身。
---
## 2. 第一关:硬门槛(不通过则停止核查)
### 2.1 git 完整性
```bash
cd /c/Project/MAG160C
git status --short # 期望:无输出(工作树干净)
git log --oneline 087e15c..HEAD # 期望:恰好 7 条,见下表
git log --oneline -1 origin/main # 期望:087e15c**未 push**,符合用户指令)
```
期望的提交与消息(消息须与执行计划 §各阶段"提交信息"**逐字一致**):
| 序 | hash | 消息(计划要求) | 阶段 |
|---|---|---|---|
| 1 | 06c1f30 | `android: lifetime query + cali consistency check + real-device checklist` | A |
| 2 | 656d419 | `android: MDT temperature decode + analyzer probe UI` | B |
| 3 | b7a928e | `android: vendor palette extraction tooling (+exact tables if found)` | C |
| 4 | c34940e | `android: cloud module scaffold (retrofit, opt-in, disabled by default)` | D |
| 5 | f8b3200 | `android: visible-light PIP overlay (camera2, draggable, 3 sizes)` | E |
| 6 | 512508e | `android: LAN remote preview (UDP discovery + raw-frame TCP stream, client-side rendering)` | F |
| 7 | 8e1312a | `android: phase Z wrap-up (...)`(Z 无强制消息,内容须仅为文档/清单/APK) | Z |
**反例**:条数≠7;消息被改写;Z 里混入功能代码改动。
### 2.2 禁改清单(执行计划 §0 第 1 条)
逐条独立验证,不要相信"执行者说没改":
```bash
# ① 竖屏锁定:源码 + 已构建 APK 双重确认
git diff 087e15c..HEAD -- android/app/src/main/AndroidManifest.xml | grep -i orientation
# 期望:无 '-' 开头的行(只有新增注释/权限行)
"C:\Tools\android-sdk\build-tools\36.0.0\aapt2.exe" dump xmltree \
build-artifacts/mag160c-app-debug.apk --file AndroidManifest.xml | grep -i screenOrientation
# 期望:screenOrientation(0x0101001e)=1 1 = portrait
# ② 命令字节序锁:MagProtocolTest 与 cmd4/cmd8 实现都不得变
git diff 087e15c..HEAD --stat -- android/app/src/test/kotlin/com/mag160c/thermal/usb/MagProtocolTest.kt
git diff 087e15c..HEAD -- android/app/src/main/kotlin/com/mag160c/thermal/usb/MagProtocol.kt
# 期望:测试文件无 diffMagProtocol 仅新增 RSP_SEND_LIFETIME 常量
# ③ LiveRenderer 构图逻辑
git diff 087e15c..HEAD -- android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt
# 期望:空
# ④ analysis/ 只读参考:已存在文件不得被改(新增文件允许)
git diff 087e15c..HEAD --stat --diff-filter=M -- analysis/
# 期望:空
# ⑤ IrSession 握手序列(A 阶段按要求加了代码,需人工确认序列未被改)
git diff 087e15c..HEAD -- android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt
# 核查点:66b(GetParameter1)→66c→66f→[670]→673 顺序、800ms 超时、
# 前半段失败即 abort/回退 的行为均未被改动;删除行应仅有两处
# cache.readBytes() 改为先比 MD5stats 行末尾追加 lifetime=
```
**反例**APK 里 screenOrientation≠1MagProtocolTest 有改动;
LiveRenderer 有 diff`--diff-filter=M` 的 analysis/ 输出非空。
### 2.3 构建与测试(全绿是计划的硬性验收)
```bash
cd /c/Project/MAG160C/android && export JAVA_HOME="C:\\Tools\\jdk-21"
cmd //c "C:\Project\MAG160C\android\gradlew.bat :app:assembleDebug :app:assembleRelease test --no-daemon"
# 期望:BUILD SUCCESSFULdebug + release(R8) + 单测)
```
单测计数须可复现(执行者报告为 44/0/0):
```bash
cd /c/Project/MAG160C
grep -h -o 'tests="[0-9]*" skipped="[0-9]*" failures="[0-9]*" errors="[0-9]*"' \
android/app/build/test-results/testDebugUnitTest/*.xml \
| awk -F'"' '{t+=$2;f+=$6;e+=$8} END {print "tests="t" failures="f" errors="e}'
```
期望的逐类分布(缺一类即说明有用例被静默跳过):
| 测试类 | 用例数 | 覆盖 |
|---|---|---|
| usb.MagProtocolTest | 3 | 命令字节序(既有,未改) |
| core.RenderPipelineTest | 3 | 与 C 参考逐字节一致(既有) |
| core.TempMathTest | 5 | Phase B 温度图 |
| core.PalettesTest | 8 | Phase C 调色板 |
| media.MdtTest | 6 | Phase B MDT 容器 |
| cloud.CloudClientTest | 4 | Phase D 默认关闭契约 |
| net.RemoteContractTest | 12 | Phase F 协议/粘包/截断 |
| net.RemoteLoopbackTest | 3 | Phase F **真实 TCP 回环端到端** |
**重要**`RenderPipelineTest.matchesCReferencePixelExact` 必须仍然通过——
它是整个渲染管线正确性的地基;若它挂了,后面所有阶段都无意义。
---
## 3. 第二关:逐阶段声明 → 核查方法
### Phase A06c1f30
| 声明 | 核查方法 | 通过判据 / 反例 |
|---|---|---|
| `RSP_SEND_LIFETIME = 0x5BB5B561` 等于官方 `D2P_SendLifeTime` | 读 `jadx_magcx/\cn\com\magnity\magnitycx\sdk\D2PCmd.java` 的十进制值,手算十六进制 | =1538635105;反例:数值不符 |
| `CMD_GET_LIFETIME` 未被改动 | 同上(P2DCmd.java)对照 | =1807136373=0x6BB6B675 |
| lifetime 失败不阻断连接 | 读 `IrSession.startInternal` 第 3b 步 | 失败仅 `DebugLog.log`,无 return/abort |
| cali 缓存 MD5 对照**不改变**返回值 | 读 `obtainCali` cache-hit 分支 | 仍 return cached;日志文案 `cali cache vs bundled DDT: identical/differ` |
| 心跳 stats 追加 lifetime | 读 `streamLoop` | 行尾 ` lifetime=${deviceLifetimeMs}` |
| checklist 的日志串与代码一致 | 跑 `analysis/tools/CheckStrings.java`(见下),再人工追溯它标出的 7 条 | 无凭空编造的日志行 |
**checklist 日志串机械核对**(执行者已自测,核查者须独立重跑):
```bash
cd /c/Project/MAG160C && \
"C:\Tools\jdk-21\bin\java" analysis/tools/CheckStrings.java \
docs/android_app/real_device_checklist.md android/app/src/main
# 执行者 2026-09-11 实测:exact=16, skeleton=30, needs-review=7
```
那 7 条**不是缺陷**,而是"整行都是实例数据"的情形(模板在一个字符串字面量里,
命令名由调用点传入),工具无法自动匹配,必须人工追溯。执行者已逐条追溯完毕:
| 标出的行 | 源码出处(已核对) |
|---|---|
| `[cmd] GetParameter1 write=4/4` | `"$name write=$n/${packet.size}"` (IrSession.kt:418) + 调用点 `"GetParameter1"` (:185) |
| `[cmd] GetParameter1 resp=0x5BB5B55B len=60 head=…` | `"$name resp=0x%08X len=%d head=%s"` (:433) + 同调用点 |
| `[cmd] GetParameter2 resp=0x5BB5B55C len=…` | 同上模板 + 66c 调用点 (:192) |
| `[cmd] BasePara1: serial=… devType=3 160x120 @15fps` | `"BasePara1: serial=$serial devType=$devType ${width}x$height @${fps}fps"` (:292) |
| `[usb] ep 0x81 fail#… halted=…` | `"ep 0x%02X fail#$failureCount get_status rc=%d halted=%d clear_halt rc=%d"` (:408) |
| `[cmd]`(空)与 `[crash]` | 正则产物,非清单条目 |
**核查者要做的是独立确认这张对应表**(尤其模板与调用点是否真能拼出清单示例),
而不是重跑得到同样的 7 条后就放行。
> A 的 `obtainCali` 无单测(需 `UsbDeviceConnection`)。核查者应显式标注
> "仅静态审查,无运行时证据"。
### Phase B656d419
| 声明 | 核查方法 | 通过判据 / 反例 |
|---|---|---|
| `Mdt.parse``compose` 互逆 | 读代码 + 跑 `MdtTest`;**另写独立用例**:构造含 EXIF 缩略图的 JPEG(内部含 `FF D9`)再 compose/parse | jpg 字节往返一致(`extractJpg` 反向搜索最后一个 FFD9,应取主图 EOI,不被缩略图干扰) |
| text 去 NUL 填充 | 读 parse | `trimEnd('\u0000')` |
| 温度图 = u16LE → `countsToTempMc` | 读 `TempMath.tempMapFromPixels`**独立复算**:用 `csdk/src/mag160c_temp.c` 或已提交的 C 参考对同一 counts 算温度 | 与 `TempMathTest.mapMatchesScalarConversionPerPixel` 同值 |
| 分析页探针映射为"直接映射"而非 90° 逆映射 | 读 `AnalyzeViewer`:画布 `aspectRatio(4/3)``drawImage` **无 canvas.rotate** | 判断成立则直接映射正确;反例:若实际绘制有旋转,则探针温度取错像素 |
| 温度条固定在未缩放 fitRect 顶部 | 读 `drawTemperatureOsd`(传 `imageRect(size,1f,Offset.Zero)`) | 属**规格歧义**(计划原文"随 fitRect 走,不随手势矩阵"),核查者需判定取舍是否可接受 |
### Phase Cb7a928e)——本轮最需要深度复核的一相
| 声明 | 核查方法 | 通过判据 / 反例 |
|---|---|---|
| `libcxsdk.so` 无静态调色板表 | 跑 `analysis/tools/PalScan.java`(**决定性**:正对照);python 版仅作弱旁证 | 三种编码(`B,G,R,0` / `B,G,R,0xFF` / 忽略第 4 字节的 `B,G,R`)全部 not found |
| 索引↔case 映射(0..10 | ①读 `libcxsdk_decomp.txt``CFunctions::SetColorPalette`,确认是 `switch(param_2)` 且 case 标签即调色板序号;②锚点:case 2 → `OfficialTables.PALETTE256_ARGB` 256/256;③预览图交叉验证 | ①②为结构性证据;③仅作旁证 |
| 索引 11 未解决 | 检查 `VendorPalettes.SOURCE_CASE[11] == -1``Palettes.buildAll()[11]` 为近似曲线 | 与 findings 文档一致 |
| 铁虹仍是官方表 | 读 `Palettes.officialIronbow()``PalettesTest.ironbowMatchesOfficialTables` | 返回 `OfficialTables.PALETTE256_ARGB` 本身 |
**否证复核 + 生成物可复现**(执行者已自测,核查者须独立重跑;完整命令见
`analysis/sdk_re/android_app/palette_extraction_findings.md` 的"可复现产物"节):
```bash
cd /c/Project/MAG160C
# 决定性:官方铁虹表是否真的不在库里(3 种编码都要 not found)
"C:\Tools\jdk-21\bin\java" analysis/tools/PalScan.java \
analysis/sdk_re/android_app/bin/libcxsdk.so \
csdk/src/mag160c_official_palette256.h
# 生成物可复现(两文件一起编译;重新生成后 diff 应为空)
# 实测:VendorPalettes.kt 与 palette_candidates.json diff 均为空,锚点 256/256
```
> ⚠️ **核查者必须避开的坑**:铁虹表在内存里的布局是 `(B, G, R, 0)`——**第 4 字节
> 是 0**,而 `OfficialTables.kt` 的 ARGB int 被生成脚本强制 `alpha=0xFF`
> 所以"直接把 Kotlin 表当针去二进制里搜"**必然搜不到**,会得到假的"未找到"。
> 针必须从 `csdk/src/mag160c_official_palette256.h`(真实内存布局)构造,
> 或用已修正的 `PalScan.java`。执行者在第一版工具里就犯过这个错,已修正。
>
> 另:通用"256 项渐变"启发式扫描在本库上误报约 11% 的位置(全是指令/整数
> 数组),`PalScan.java` 的 [C] 段已明确标注**不可作为证据**,不要引用该计数。
**重新生成并比对**(Phase C 最强核查手段;执行者已自测通过,**核查者须独立重跑**):
> 注意:`PalExport2.java` **不是**单文件可直跑——它依赖同一目录
> `PalIdentify.java` 中的 `PalBody`(13 个生成体)。必须两文件一起编译。
> 另外 Windows 版 java 不认 Git Bash 的 `/tmp` 路径,必须用 `C:\...` 形式。
```bash
# ① 解包官方普通版的调色板预览图(交叉验证用)
mkdir -p /c/Users/zxc/AppData/Local/Temp/cxres && \
cd /c/Users/zxc/AppData/Local/Temp/cxres && \
"C:\Tools\jdk-21\bin\jar" xf "C:\Project\MAG160C\app\【普通版】MAG-Cx.apk" res/mipmap-hdpi-v4/
# ② 两文件编译
mkdir -p /c/Users/zxc/AppData/Local/Temp/regen_build && \
cd /c/Users/zxc/AppData/Local/Temp/regen_build && \
cp "C:\Project\MAG160C\analysis\tools\PalIdentify.java" . && \
cp "C:\Project\MAG160C\analysis\tools\PalExport2.java" . && \
"C:\Tools\jdk-21\bin\javac" -d out PalIdentify.java PalExport2.java
# ③ 重新生成(输出到临时目录,勿覆盖仓库)
"C:\Tools\jdk-21\bin\java" -cp out PalExport2 \
"C:\Project\MAG160C\android\app\src\main\kotlin\com\mag160c\thermal\core\OfficialTables.kt" \
"C:\Users\zxc\AppData\Local\Temp\cxres\res\mipmap-hdpi-v4" \
"C:\Users\zxc\AppData\Local\Temp\regen_core" \
"C:\Users\zxc\AppData\Local\Temp\regen_out"
# 期望最后一行:iron_bow vs OfficialTables anchor: 256/256
# ④ 与仓库文件逐字节比对(两条都必须为空)
diff "C:\Users\zxc\AppData\Local\Temp\regen_core\VendorPalettes.kt" \
"C:\Project\MAG160C\android\app\src\main\kotlin\com\mag160c\thermal\core\VendorPalettes.kt"
diff "C:\Users\zxc\AppData\Local\Temp\regen_out\palette_candidates.json" \
"C:\Project\MAG160C\analysis\sdk_re\android_app\palette_candidates.json"
```
**执行者自测结果(2026-09-11,供核查者对照)**:上述四条命令全部成功,
铁虹锚点 256/256,两份生成物 diff 均为空,即**从 .so 伪代码 → 生成体移植 →
Kotlin 表**这条链完全可复现。这证明的是"移植实现与提交物一致",**不**证明
"移植的语义与厂商二进制一致"(后者只有 case 2 锚点能证明)。
**执行者主动交底(务必复核)**:index 4/8/9(琥珀/喷射/红饱和)的映射在
**两种独立度量下结论不一致**——像素加权的最近色距离倾向把"红饱和"的预览图
判给灰阶表(case 0),而颜色集合覆盖率倾向 case 9。原因是该预览图近乎无彩
(执行者测得 meanChroma≈9.7)。执行者最终采用了"结构性证据(`switch(param_2)`
的 case 标签即调色板序号)+ case 2 锚点"的论证,预览图仅作旁证。**核查者应
独立判定索引 9(以及 4、8)的映射是否成立**;更强证据需反解 ARM32 跳表
(文件偏移 0x26cd2 附近,执行者尝试未成功)或真机抓帧比对。
可复现的"红热预览图是占位副本"检查(支撑索引 11 未解决的结论):
```bash
# palette_red_hot.png 与 palette_white_hot.png11914 个不透明像素中 11777 个完全相同
# 可用任意图像工具逐像素比对(执行者用自写 Java 工具测得 meanAbsDiff=0.5/255
```
### Phase Dc34940e
| 声明 | 核查方法 | 通过判据 / 反例 |
|---|---|---|
| Retrofit 2.11.0 已接入 | 读 `gradle/libs.versions.toml``app/build.gradle.kts` | 两处均在 |
| 默认关闭 | 读 `AppSettings.cloudEnabled`(默认 false);跑 `CloudClientTest` | 4 项全绿 |
| **任何生产代码路径都不会静默联网** | `grep -rn "CloudClient" android/app/src/main` | 只应有 AppSettings 的 setEnabled 与声明;**反例**:任何 `api()` 调用点 |
| release 混淆不破 | 跑 `assembleRelease` | BUILD SUCCESSFUL |
| PROGUARD 规则存在 | 读 `app/proguard-rules.pro` | 含 `-keep class com.mag160c.thermal.cloud.**` |
### Phase Ef8b3200
| 声明 | 核查方法 | 通过判据 / 反例 |
|---|---|---|
| CAMERA 权限已加且**相机可选** | `aapt2 dump badging` 看 uses-feature | `camera`/`camera.any`/`autofocus` 均为 not-requiredusb.host 仍为 required |
| 顶栏第 5 项 + 图标 | 读 `LiveScreen``res/drawable/ic_pip.xml` | 双弧圆 + 右下实心矩形(符合计划图标约定) |
| 三档尺寸 96/128/160dp、高=宽×3/4 | 读 `LiveViewModel.PIP_WIDTHS_DP``PipOverlay` | 一致 |
| 右侧留色标条位 | `PIP_RIGHT_MARGIN_DP = 32` | 一致 |
| 异常不崩溃 | 读 `PipCameraView`:所有回调 try/catch + release | 无未捕获路径 |
| 相机释放(关闭/离页/ON_STOP) | 读 `PipOverlay.DisposableEffect``LiveScreen` 生命周期观察者 | 三处均有 release |
**执行者主动交底(务必真机验证)**:`ON_STOP` 释放相机后,**是否有代码自动重新
打开未经证实**。实现中 `PipCameraEngine.released` 标志被置位后不再复位,重新
打开依赖 TextureView surface 重建路径(`onSurfaceTextureAvailable`)。
`real_device_checklist.md` 第 15 步写了"返回后若 PIP 仍为开启态,画面重新出现",
这是**推断而非实测**;若真机不符,应判定为该步预期有误(清单过强),
而非功能必须修复——请按实测修正文档结论。
**另一处待验证**:PIP 浮层用了两个并列 `pointerInput`(一个拖拽、一个点按/双击)。
Compose 中多 detector 并存的行为需真机确认(单击换档、双击关闭、拖拽是否互不干扰)。
### Phase F512508e
| 声明 | 核查方法 | 通过判据 / 反例 |
|---|---|---|
| 端口/魔数/帧长与计划一致 | 读 `RemoteContract`47510/47511/0x1BB1B11B/38400/38412/3s/10s | 逐个对表 |
| 封包格式 | 读 `encodeFramePacket` + 跑 `RemoteContractTest` | `[magic][counter][len][38400B]` |
| 粘包/截断/坏长度/重同步 | 跑 `net.RemoteContractTest`12 项) | 全绿 |
| 端到端可用 | 跑 `net.RemoteLoopbackTest`(真实 TCP) | 全绿;核查者亦可**自写独立回环测试**(契约见 `RemoteContract` 注释) |
| 渲染在客户端本地 | 读 `RemoteViewerViewModel`:本地 `RenderPipeline`+内置 DDT`setPalette/setZoom` 只动本地 | 调色板/变倍无网络调用 |
| 主机侧不拖慢相机 | 读 `RemoteHost`:帧队列 DROP_OLDEST、单协程写 socket | 一致 |
| 默认不启动 | 读设置页与 `setRemoteHostEnabled` | 默认关闭;开启需活跃 USB 会话,否则返回 false 弹"先连接热像仪" |
| INTERNET 权限 | `aapt2 dump badging` | 已声明 |
**执行者主动交底(潜在缺陷,建议构造用例)**:
`RemoteSession.send()` 每条命令 `launch` 一个新协程写同一 socket,**两条相邻命令
(如 `hello` 后紧跟 `startStream`)理论上可能交错写入**(小包在 TCP 上通常原子,
但无保证)。建议核查者写一个高频命令用例(如连续多次 `requestFfc()`)观察主机侧
是否出现解析不了的半行 JSON。若复现,属真实缺陷(应串行化写协程)。
**未验证边界**UDP `255.255.255.255` 广播在真机/真实 Wi-Fi 上是否可达
(部分网络/设备会过滤广播,且息屏策略可能影响收包)——仅回环测试通过。
---
## 4. 第三关:执行者主动交底的可疑点汇总(按风险排序)
| # | 事项 | 风险 | 建议动作 |
|---|---|---|---|
| 1 | Phase C 索引 4/8/9 的映射在两种度量下不一致 | 高(可能装错表) | 独立复验;必要时反解跳表/真机比对 |
| 2 | Phase C 静态扫描判据仅覆盖 alpha=0xFF | 中 | 补扫 alpha=0x00 / RGB888 / BGR 等编码 |
| 3 | PIP 从后台返回能否自动恢复相机 | 中 | 真机验证;不符则修正清单预期 |
| 4 | PIP 双 `pointerInput` 手势并存 | 中 | 真机验证三种手势 |
| 5 | Phase F 命令写入未串行化 | 中 | 高频命令用例压测 |
| 6 | Phase B 探针映射方向(直接 vs 旋转) | 中 | 真机点已知位置比对温度 |
| 7 | Phase B 温度条"随 fitRect"的规格歧义 | 低 | 判定取舍 |
| 8 | Phase A lifetime 负载接受两种布局的"对冲" | 低 | 判定是否应只留官方一种(官方 Java 为 `[magic][ms]`,共 8B |
| 9 | checklist 第 15 步预期与实现可能不符 | 低 | 见 #3 |
| 10 | `build-artifacts` 内 APK 非可复现字节(时间戳) | 低 | 以"重建并验证字符串/安装行为"替代字节比对 |
| 11 | 超出计划的一处主动改动:camera feature 显式设为 required=false | 低 | 判定是否可接受(不影响 USB 主机功能) |
---
## 5. 核查边界:本机无法验证的事项(须标注"无法判定")
本机(Windows`C:\Tools\android-sdk`)**没有模拟器组件、没有连接的真机**,
`adb devices` 为空。因此以下只能在用户真机上判定:
1. USB 出流与温度绝对值标定(`real_device_checklist.md` 第 1-12 步)。
2. 可见光 PIP 的全部运行时行为(第 13-15 步)。
3. 局域网远程预览双机行为(第 16-24 步)。
4. 分析页温度条/探针的真机显示与取温正确性(第 11 步)。
5. 任何"画面/像素"层面的确认(执行者无视觉能力,只能保证编译与单测)。
核查者不应把"未实测"一律记为失败;应记为**无法判定**,并保留执行者的
真机清单作为待用户执行的验证脚本(但清单本身的日志串与预期须先按 §3A/§4
机械核对一遍)。
---
## 6. 复现命令速查
```bash
# 构建 + 全量单测(Windows Git Bash
cd /c/Project/MAG160C/android && export JAVA_HOME="C:\\Tools\\jdk-21" && \
cmd //c "C:\Project\MAG160C\android\gradlew.bat :app:assembleDebug :app:assembleRelease test --no-daemon"
# 单类测试
cmd //c "C:\Project\MAG160C\android\gradlew.bat :app:testDebugUnitTest --tests 'com.mag160c.thermal.net.*' --no-daemon"
# 单测计数
grep -h -o 'tests="[0-9]*"' /c/Project/MAG160C/android/app/build/test-results/testDebugUnitTest/*.xml | ...
# APK 清单核验(竖屏 + 权限 + feature
"C:\Tools\android-sdk\build-tools\36.0.0\aapt2.exe" dump badging build-artifacts/mag160c-app-debug.apk
"C:\Tools\android-sdk\build-tools\36.0.0\aapt2.exe" dump xmltree build-artifacts/mag160c-app-debug.apk --file AndroidManifest.xml
# dex 中文串(防乱码回归;无需脚本)
unzip -p build-artifacts/mag160c-app-debug.apk 'classes*.dex' | grep -ac '画中画'
unzip -p build-artifacts/mag160c-app-debug.apk 'classes*.dex' | grep -ac '正在扫描局域网主机'
# checklist 日志串核对(执行者已跑通:exact=16 skeleton=30 review=7
"C:\Tools\jdk-21\bin\java" analysis/tools/CheckStrings.java \
docs/android_app/real_device_checklist.md android/app/src/main
# Phase C 重新生成并比对(见 §3 Phase C 完整命令)
```
**可用工具**`C:\Tools\jdk-21`java/javac/jar)、
`C:\Tools\android-sdk\build-tools\36.0.0`aapt2)、`C:\Tools\jadx-1.5.1`
**不可用 / 易踩的坑**(执行者已实测,核查者省得重踩):
| 事项 | 实际 |
|---|---|
| python3 | **不可用**Microsoft Store 桩,退出码 49)。`extract_palettes.py` 需改用 JDK 复写或安装 python |
| node | 未安装 |
| 模拟器 / 连接设备 | 无(`adb devices` 为空,SDK 无 emulator 组件) |
| `analysis/tools/PalExport2.java` | **不能单文件直跑**:依赖 `PalIdentify.java``PalBody`,须两文件一起 `javac -d out` |
| Git Bash 的 `/tmp` 路径 | Windows 版 java **不认**,必须写 `C:\Users\zxc\AppData\Local\Temp\...` |
| `analysis/tools/CheckStrings.java` | 可单文件直跑(无外部依赖) |
+454
View File
@@ -0,0 +1,454 @@
# Phase A→F 独立核查报告(可交给修复模型)
> **核查者立场**:本报告由独立核查 AI 撰写,不采信执行者(下称"执行者")的
> 总结/session_state/checklist 叙述,全部结论来自 ① git 提交内容
> ② 仓库内官方逆向产物 ③ 核查者亲自重跑的命令与亲自编写的独立工具
> ④ 明确标注"无法判定"的真机项。
>
> **核查范围**`06c1f30..8e1312a`7 个提交),基线 `087e15c`
> 核查时 HEAD = `0919f5e`(含核查指南提交)。
> **本轮核查未修改任何源码、未提交、未 push**;本报告文件是唯一新增物
> (未提交,`git status` 会显示为 untracked)。
>
> **总体结论**:第一关硬门槛(git 完整性 / 禁改清单 / debug+release 构建 /
> 44 项单测)**全部通过**。逐阶段核查发现 **2 项证伪**Phase F 命令写入
> 未串行化、Phase F busy 未实现)、**1 项 Phase Z 越界**Manifest 改动)、
> **2 项低风险偏差**,其余声明证实;需真机/双设备的项一律"无法判定"。
> Phase C 最高风险项(索引 4/8/9 映射)经独立复核**证实**,且本次找到了比
> 执行者更强的决定性证据。
---
## 0. 修复任务清单(给修复模型)
按优先级排列。每条含:位置、现象、根因、建议改法、改完怎么验、不修的风险。
**修复时仍须遵守 `execution_plan.md` §0 的禁改清单**(竖屏锁定、命令字节序、
握手序列、LiveRenderer 构图、analysis/ 只读)。
### FIX-1(高)`RemoteSession.send()` 并发写同一 socket 导致命令乱序
- **位置**`android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt:256-269`
- **现象(已复现)**`hello()` 紧接 `startStream()` 时,主机可能先收到 `start`
主机随即回 `stream-start`,客户端 reader 切到帧模式
`RemoteClient.kt:194`),后到的 `welcome` 文本被 `FramePacketReader` 当垃圾丢弃。
- **实测数据**:裸 socket 抓字节,5 次运行中 1 次观测到 `hello`/`start` 顺序颠倒;
面向真实 `RemoteHost` 的生产序列压测 **120 次中 8 次丢失 `welcome` 行(≈6.7%**
帧本身始终正常(30/30 各轮均收到)。
- **根因**`send()` 每条命令 `scope.launch(Dispatchers.IO)` 新协程写同一个
`socket.getOutputStream()`。多协程竞争同一流,**顺序无法保证**。
- **建议改法****不要用 Mutex**Mutex 只保证互斥,不保证"launch 顺序=写入顺序"
本缺陷是乱序而非字节交错,加锁修不掉)。改为单写协程 + 队列:
```kotlin
// RemoteSession 内新增
private val outQueue = kotlinx.coroutines.channels.Channel<String>(
kotlinx.coroutines.channels.Channel.UNLIMITED,
)
private val writerStarted = java.util.concurrent.atomic.AtomicBoolean(false)
private fun ensureWriter() {
if (!writerStarted.compareAndSet(false, true)) return
scope.launch(Dispatchers.IO) {
val out = socket.getOutputStream()
try {
for (line in outQueue) {
out.write((line + "\n").toByteArray(Charsets.UTF_8))
out.flush()
}
} catch (e: Exception) {
if (!closed.get()) DebugLog.log("remote", "writer ended: ${e.javaClass.simpleName}")
}
}
}
private fun send(cmd: String) {
if (closed.get()) return
ensureWriter()
outQueue.trySend(cmd) // 调用方(UI 协程)顺序入队 → 顺序落盘
}
fun close() {
if (!closed.getAndSet(true)) {
runCatching { outQueue.close() }
runCatching { socket.close() }
}
}
```
要点:`trySend` 在**调用方线程**顺序入队,写 socket 只发生在唯一协程里。
- **可选补充(不替代上面的修复)**:客户端在帧模式下遇到非帧数据时,
可先尝试按 JSON 行解析再丢弃,作为对旧主机的兼容容错。属于加固,不解决根因。
- **验证方法**:复跑"高频命令"用例——`hello()`+`startStream()` 连续 3 对,
再接 400 次 `requestFfc()`,断言收到的行数、内容与**顺序**完全一致、
无嵌套花括号行。(核查者的独立 harness 已实现该用例,见 §6。)
- **不修的风险**:目前 `welcome` 只被打印(`RemoteViewerViewModel.kt:103`),
用户可见影响小;但 `hello→welcome` 契约已被破坏。同类乱序也可能发生在
快速 stop→start、连续 FFC 等序列上,属真实缺陷。**建议修**。
### FIX-2(中)"后来者拒绝写 busy" 未实现
- **位置**`android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt:130-151`acceptLoop/serve
- **现象**`serve(socket)` 在 accept 循环内**同步**执行,第二个客户端只会在内核
backlog 里排队等待第一个断开;主机**从不写 `busy`**。
`RemoteContract.isBusyLine()``RemoteContract.kt:147`)全仓库**无调用点**,是死代码。
- **与计划冲突**`execution_plan.md` F2 明确要求"单客户端,后来者拒绝写 busy"。
- **建议改法(二选一)**
- **(a) 实现**accept 循环不阻塞在 serve 上,用标志位拒绝后来者:
```kotlin
private val serving = java.util.concurrent.atomic.AtomicBoolean(false)
// acceptLoop 的 while(running) 内:
val socket = server.accept()
if (!serving.compareAndSet(false, true)) {
runCatching {
val o = socket.getOutputStream()
o.write((RemoteContract.busyLine() + "\n").toByteArray(Charsets.UTF_8))
o.flush()
}
runCatching { socket.close() }
continue
}
scope?.launch { try { serve(socket) } finally { serving.set(false) } }
```
需在 `RemoteContract``fun busyLine(): String = "{\"type\":\"busy\"}"`
客户端侧建议在 `lines` 收集处对 `busy` 记一条日志(否则用户只看到连不上)。
- **(b) 如不实现**:同步修改 `execution_plan.md` F2 与相关文档,如实写明
"第二客户端排队等待,不写 busy",并把 `isBusyLine()` 删除或标注保留用途。
- **验证方法**:单测/回环:client1 连上后 client2 连接,断言 client2 在 1s 内
收到 `{"type":"busy"}` 或被立即关闭,且 client1 的帧流不受影响。
- **不修的风险**:单客户端约束事实成立,风险低;但"计划声明了、代码没有、
解析器留了死代码"三者不一致,会误导后续维护者。
### FIX-3(低)lifetime 负载接受两种布局(超出官方规格的对冲)
- **位置**`android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt:226-231`
- **现象**:实现同时接受 ①`[magic][magic][ms]`12B,计划代码片段的字面写法,
与计划自己的"共 8 字节"描述矛盾)②`[magic][ms]`8B,官方 Java 唯一实现)。
- **官方权威**`jadx_magcx/.../UsbCommunication.java:190-205`
`bb.getInt()==1538635105` 后再 `getInt()`,即 **8 字节 `[magic][ms]`**
另注意 `IrSession.readResp` 已剥掉前 4 字节 magic`r.third` 是**去掉** magic 的负载),
故官方布局下 `r.first==8``r.third.size==4`、ms = `u32(r.third,0)`
- **建议改法**(收紧为官方唯一布局):
```kotlin
readResp(conn, epResp, "GetLifeTime")?.let { r ->
// readResp 已剥离 4B magic;官方帧共 8B → 余 4B 即 i32 ms
if (r.second == MagProtocol.RSP_SEND_LIFETIME && r.third.size >= 4) {
deviceLifetimeMs = MagProtocol.u32(r.third, 0).toLong() and 0xFFFFFFFFL
}
}
```
- **验证方法**:真机清单第 3 步出现 `[session] device lifetime=<ms>ms` 且非 -1。
- **不修的风险**:不会误判为连接失败,仅接受一个官方不发送的布局;低。
### FIX-4(低)多余权限 `ACCESS_NETWORK_STATE`
- **位置**`android/app/src/main/AndroidManifest.xml:15`
- **现象**:全仓库 grep 无 `ConnectivityManager`/`NetworkCapabilities`/权限检查,
该权限从未被使用(计划 F9 只要求 INTERNET)。
- **建议改法**:删除该行;若保留,需在提交信息/文档中说明用途。
- **验证方法**`aapt2 dump badging build-artifacts/mag160c-app-debug.apk` 不再列出。
### FIX-5(低)Phase Z 越界:`8e1312a` 含 Manifest 功能性改动
- **位置**`8e1312a` 修改了 `android/app/src/main/AndroidManifest.xml`+3 条
`uses-feature``camera``camera.autofocus` 显式 `required=false`,并改写注释)。
- **性质**:改动本身**合理**(防止 CAMERA 权限隐式要求相机硬件,保证无相机
设备仍可运行热像主功能),执行者已主动交底;但违反"Z 内容须仅为文档/清单/APK"。
- **建议**:不需要回滚功能,**只需在 `session_state.md` / `execution_plan.md`
如实记录该改动发生在 Z 提交**(承认越界),避免后续核查再次对不上账。
### FIX-6(低)文档口径修正
1. `docs/android_app/real_device_checklist.md:29`(第 15 步):预期"返回后 PIP 画面
重新出现"是**推断而非实测**。代码里 `PipCameraEngine.released` 置位后不复位,
只能依赖 `onSurfaceTextureAvailable` 再次触发。**先按真机实测结果决定改文档
还是改代码**(指南 §3E 明确:若真机不符,应判定为清单预期有误)。
2. `analysis/sdk_re/android_app/palette_extraction_findings.md:71`:红热占位图的
分母表述混用了两类像素。实测(ImageIO 逐像素):
**全部像素 11777/11914 相同****不透明像素 8536/8673 相同**meanAbsDiff 2.18/765)。
原文"11914 个**不透明**像素中 11777 个相同"应改为上述两种口径之一。
3. `session_state.md` / `execution_plan.md`:补记本报告 §3-F 的两项证伪
FIX-1 乱序、FIX-2 busy 未实现),使文档不再高于实现。
### FIX-7(可选,测试加固)
1. `MdtTest`:补一条**含 EXIF 缩略图(内部含 `FF D9`)的 JPEG** 往返用例。
核查者已独立验证当前实现能正确取主图 EOI(不会被缩略图干扰),
但仓库测试缺失该场景,属回归风险。
2. `VendorPalettes``PalettesTest.vendorTablesCoverIndicesZeroThroughTen`
`PalettesTest.kt:47-63`)只证明 `Palettes.buildAll()` 用了 `VendorPalettes`
的对应槽位,**不能**证明 `VendorPalettes` 里 case N 的内容确实来自 case N
(全库仅 case 2 有官方锚点)。建议为 11 张表各加一条"金标"断言
(如 256 项 CRC32/采样点),把本次已外部验证过的映射冻结住。
3. `RemoteLoopbackTest`:在 FIX-1 完成后,补"高频命令顺序"用例(见 FIX-1 验证方法)。
4. 清理死代码:`Palettes.kt:77/89/98/110``rainbow()`/`highContrast()`/
`hotMetal()`/`jet()` 已无调用点(Phase C 接入精确表后遗留)。
### 判定为"可接受、无需修改"的项
- **Phase B 温度条固定未缩放 fitRect**:与计划字面一致(`imageRect(size,1f,Offset.Zero)`),
取舍合理。
- **Phase B 探针标签随缩放/平移**`AnalyzeViewer.kt:266``imageRect(size,zoom,pan)`):
与计划"标签随 fitRect 走"字面不符,但**实现明显更正确**(标签须跟随像素),
判定为实现优于字面规格,建议保留(可在文档注明)。
- **Phase B 分析页直接映射(无 90° 旋转)**`AnalyzeViewer` 画布 `aspectRatio(4f/3f)`
`drawImage``canvas.rotate`,位图 `Bitmap.createBitmap(160,120)` 为原始朝向,
直接映射正确。
---
## 1. 第一关:硬门槛(全部通过,故继续逐阶段核查)
| 声明 | 核查方法 | 结论 |
|---|---|---|
| 工作树干净 | `git status --short` 无输出 | 证实 |
| 范围内恰 7 个提交、消息与计划逐字一致 | `git log --oneline 087e15c..HEAD`,逐条比对计划"提交信息" | 证实 |
| 未 push | `git log --oneline -1 origin/main` = `087e15c` | 证实 |
| 竖屏锁定未改 | 源码 diff 无 `orientation` 删改;`aapt2 dump xmltree``screenOrientation(0x0101001e)=1` | 证实 |
| `MagProtocolTest` 未变 | `git diff --stat` 为空 | 证实 |
| `MagProtocol.kt` 仅新增常量 | diff 仅 `RSP_SEND_LIFETIME` 三行 | 证实 |
| `LiveRenderer.kt` 未变 | `git diff` 为空 | 证实 |
| `analysis/` 既有文件未被改 | `--diff-filter=M` 为空;仅 8 个新增文件 | 证实 |
| `IrSession` 握手序列未改 | 人工读 diff66b→66c→66f→[670]→673、800ms 超时、前半段失败即 abort 全部保留;删除行仅两处(cache 读后加 MD5、stats 行追加 lifetime | 证实 |
| `assembleDebug + assembleRelease + test` 全绿 | 亲自重跑:`BUILD SUCCESSFUL`release 走完 R8 | 证实 |
| 44 个单测、逐类分布 3/3/5/8/6/4/12/3 | `--rerun-tasks` 强制重跑,逐 XML 汇总 = 44/0/0;每类用例名与指南表格一一对应 | 证实 |
| `RenderPipelineTest.matchesCReferencePixelExact` 仍通过 | 重跑后该用例在列且失败数为 0 | 证实 |
---
## 2. 逐阶段核查明细
### Phase A`06c1f30`
| 声明 | 方法 | 结论 |
|---|---|---|
| `RSP_SEND_LIFETIME = 0x5BB5B561` = 官方 `D2P_SendLifeTime` | `jadx_magcx/.../D2PCmd.java:10` = 1538635105;手算 `0x5BB5B561` = 1538635105 | 证实 |
| `CMD_GET_LIFETIME` 未改 = 官方 `P2D_GetLifeTime` | `P2DCmd.java:8` = 1807136373 = `0x6BB6B675` | 证实 |
| lifetime 失败不阻断连接 | `IrSession.kt:219-235` 失败仅 log,无 return/abort;官方 `getDevLifeTime` 亦仅 `Logging.error` | 证实(仅静态审查,无运行时证据) |
| cali 缓存 MD5 对照不改变返回值 | cache-hit 分支先算 `cached`log `identical/differ` 后仍 `return cached` | 证实 |
| 心跳 stats 追加 lifetime | `IrSession.kt:559` `" lifetime=${deviceLifetimeMs}"` | 证实 |
| checklist 日志串与代码一致 | 独立重跑 `CheckStrings.java`exact=16/skeleton=30/review=7;并逐条人工追溯 7 条到模板与调用点(`"$name write=$n/${packet.size}"` 418 行、`"$name resp=0x%08X len=%d head=%s"` 433 行、`"BasePara1: serial=... @${fps}fps"` 292 行、`"ep 0x%02X fail#..."` 408 行;调用点 `"GetParameter1"` 185、`"GetParameter2"` 201)。另抽查 `hb: state=``first reads``first rendered frame``first run on this host``requesting permission``permission result``[crash]`(DebugLog 崩溃钩子)均存在 | 证实 |
| 唯一偏差 | lifetime 负载接受两种布局(见 FIX-3) | 偏差(低) |
### Phase B`656d419`
核查者用 Gradle 缓存内的 Kotlin 编译器(`kotlin-compiler-embeddable-2.2.0.jar`
在仓库外搭建独立 harness,直接编译并驱动**仓库真实源码**
`Mdt.kt`/`TempMath.kt`/`OfficialTables.kt`),不依赖执行者的任何测试代码。
| 声明 | 方法 | 结论 |
|---|---|---|
| `Mdt.parse``compose` 互逆 | 独立构造**含 EXIF 缩略图(内部含 `FF D9`)**、长度 1081(非 4 对齐)的 JPEG,往返比对各段 | 证实:jpg/info0/info1/frame/text 全部字节一致,裁剪点为主图 EOI |
| 坏尾/截断返回 null | 翻转尾部 1 字节、截断至 100B | 证实 |
| text 去 NUL 填充 | `Mdt.kt:123` `trimEnd('\u0000')`;独立用例短文本往返 | 证实 |
| 温度图 = u16LE → `countsToTempMc` | **独立复算**:从 `csdk/src/mag160c_official_t2e.h` 解析 646 项真表(非 Kotlin 表),照 `mag160c_render.c:64` 重写 C 版算法,对 19200 像素全量比对 | 证实:19200/19200 完全一致 |
| 探针为"直接映射" | `AnalyzeViewer.kt``aspectRatio(4f/3f)``drawImage``canvas.rotate``Bitmap.createBitmap(160,120)` | 证实(静态) |
| 温度条固定未缩放 fitRect | `drawTemperatureOsd``imageRect(size,1f,Offset.Zero)` | 证实 |
| 探针标签 | 用 `imageRect(size,zoom,pan)`,与计划字面"随 fitRect"不同 | 偏差(实现更优,建议保留,见 §0) |
### Phase C`b7a928e`)—— 本轮最需深挖的一相
| 声明 | 方法 | 结论 |
|---|---|---|
| `libcxsdk.so` 无静态调色板表 | 独立重跑 `PalScan.java`:三种编码(`B,G,R,0`/`B,G,R,0xFF`/忽略第 4 字节)**全部 not found**;[A] 段自校准证明扫描非盲 | 证实 |
| 索引↔case 映射 0..10(case 标签即调色板序号) | ① `libcxsdk_decomp.txt:456-1180``switch(param_2)`,case 0..10 各一份生成体(另有 `case 0xc`/`case 0xd`);② **核查者新找到的决定性证据**`jadx_magcx/.../DialogFragmentPalette.java``mapIndex2Id_` 把 UI 索引 0..11 依次对应白热…红热,点击时 `DeviceController.setColorPalette(index)`**UI 索引原样传给 native** → case 标签 = UI 索引;③ case 2 → `OfficialTables.PALETTE256_ARGB` 256/256(重跑生成链复现) | 证实 |
| 索引 4/8/9 映射成立(执行者交底的不一致项) | **自写独立工具**JDK `ImageIO` 解码官方预览图,不用执行者的手写 PNG 解码器),从 `PalBody` 重算全部 case 颜色集合,对 12 张预览图做完整覆盖矩阵 | 证实:case 0..10 **各自都是自身预览图的最佳解释者**;红饱和 case9 26.7% vs case0 22.1%,其 meanChroma=9.7 解释了"像素加权最近色距离"为何误判 |
| 索引 11 未解决、`SOURCE_CASE[11]==-1`、保留近似曲线 | `VendorPalettes.kt:24``Palettes.kt:47` | 证实 |
| 铁虹仍是官方表 | `officialIronbow()` 直接返回 `OfficialTables.PALETTE256_ARGB``PalettesTest.ironbowMatchesOfficialTables` 通过 | 证实 |
| 生成物可复现 | 两文件一起编译 → 重新生成:锚点 `iron_bow vs OfficialTables anchor: 256/256``VendorPalettes.kt``palette_candidates.json``palette_match_report.txt` **diff 全空** | 证实 |
| 红热预览图为占位副本 | ImageIO 逐像素:全像素 11777/11914 相同;不透明像素 8536/8673 | 证实(文档分母表述需修正,见 FIX-6) |
| `extract_palettes.py` | 本机无 python3,无法运行 | 无法判定(弱旁证) |
### Phase D`c34940e`
| 声明 | 方法 | 结论 |
|---|---|---|
| Retrofit 2.11.0 已接入 | `libs.versions.toml:9,24,25``app/build.gradle.kts:52,53` | 证实 |
| 默认关闭 | `AppSettings.kt:29-38``CloudClientTest` 4 项全绿 | 证实 |
| 无生产路径静默联网 | `grep -rn CloudClient android/app/src/main` 仅命中声明与 `AppSettings.setEnabled`main 无 `api()` 调用点;`api()` 未开启即 `check()` 抛异常 | 证实 |
| release 混淆不破 | 亲自跑 `assembleRelease`R8)成功 | 证实 |
| PROGUARD 规则 | `proguard-rules.pro:5` `-keep class com.mag160c.thermal.cloud.** { *; }` | 证实 |
### Phase E`f8b3200`
| 声明 | 方法 | 结论 |
|---|---|---|
| CAMERA 权限 + 相机可选;usb.host 仍 required | `aapt2 dump badging`camera/camera.any/autofocus 均 not-required`usb.host`/`screen.portrait` required | 证实 |
| 顶栏第 5 项 + 图标 | `LiveScreen.kt` 5 个 `weight(1f)` 项,第 5 项 `ic_pip`+`画中画``ic_pip.xml` 双弧圆描边 + 右下实心矩形 | 证实 |
| 三档 96/128/160dp、高=宽×3/4 | `PIP_WIDTHS_DP``hDp = wDp * 3 / 4`72/96/120,整除无误差) | 证实 |
| 右侧留色标条位 | `PIP_RIGHT_MARGIN_DP = 32`,初始 `pipXf=1f,pipYf=0f` | 证实 |
| 异常不崩溃 | `PipCameraView.kt` 全部回调 try/catch,失败路径收敛到 `release()` | 证实(静态) |
| 相机释放三处 | `PipOverlay``DisposableEffect onDispose`、离页同路径、`LiveScreen``LifecycleEventObserver(ON_STOP)` | 证实(静态) |
| ON_STOP 后能否自动重开 | 代码侧 `released` 置位后不复位,重开依赖 `onSurfaceTextureAvailable`;本机无设备 | **无法判定** |
| 双 `pointerInput` 手势并存 | Compose 运行时行为无法在本机验证 | 无法判定 |
### Phase F`512508e`)—— 2 项证伪
核查者用独立 harness 在 JVM 上驱动**真实的** `RemoteHost`/`RemoteSession`
(仅对 `DebugLog` 做最小桩),不打桩网络层。
| 声明 | 方法 | 结论 |
|---|---|---|
| 端口/魔数/帧长/超时与计划一致 | `RemoteContract`47510/47511/`0x1BB1B11B`/38400/38412/3000ms/10000ms 逐项比对 | 证实 |
| 封包格式小端 | 独立断言字段偏移、字节序(`pkt[0..3]={1B,B1,B1,1B}`)、payload 位于 12 | 证实 |
| 粘包/截断/坏长度/重同步 | 3 帧一次读入→3 payload;按 1000B 任意切分→恰好重组 1 次;截断 20000B→0 输出且 `pending()==20000`;坏 length→下一 magic 恢复 | 证实 |
| 端到端可用 | 真实回环:connect→welcome→stream-start→5 帧(payload 逐字节)→ffc 到达主机回调→stream-stop`RemoteLoopbackTest` 3 项亦全绿 | 证实 |
| 渲染在客户端本地 | `RemoteViewerViewModel`:本地 `RenderPipeline(160,120)`+assets `mag160c.ddt``setPalette` 只调本地管线;`setZoom` 只改状态 | 证实 |
| 主机侧不拖慢相机 | `Channel(capacity=8, DROP_OLDEST)`+`trySend`USB 读线程非阻塞)+单一 `serve()` 协程写 socket | 证实 |
| 默认不启动;开启需活跃 USB 会话 | `setRemoteHostEnabled``if (!session.isStreaming()) return false`UI 弹"先连接热像仪" | 证实 |
| INTERNET 权限 | badging 已声明 | 证实 |
| 单客户端,后来者拒绝写 busy | `acceptLoop` 内同步 `serve()`,第二连接排队等待,从不写 busy`isBusyLine` 死代码 | **证伪**(见 FIX-2 |
| 命令写入不交错 | 见 FIX-1:120 次生产序列中 8 次丢 `welcome`;裸 socket 抓到 `hello`/`start` 颠倒 | **证伪**(见 FIX-1 |
| UDP 广播真机可达性 | 仅回环证据 | **无法判定** |
| `ACCESS_NETWORK_STATE` | 全代码无使用点 | 偏差(见 FIX-4) |
### Phase Z`8e1312a`
| 声明 | 方法 | 结论 |
|---|---|---|
| 消息无强制格式 | `git log -1 --format=%B` | 证实 |
| 内容仅为文档/清单/APK | `git show --stat`3 份文档 + APK**外加 Manifest+3 条 uses-feature** | **证伪**(见 FIX-5 |
| 文档更新到位 | HANDOFF §3 含 `ui/remote/``net/``cloud/CloudApi.kt`session_state 勾选 AZ 并追加总结;execution_plan 顶部加执行状态 | 证实 |
| 全量构建 + 提交 APK,且不 push | 重跑 debug+release+test 成功;`origin/main``087e15c` | 证实 |
| 提交的 APK 与 HEAD 一致 | 对已提交 APK 核验:`screenOrientation=1`、camera 系列 not-required、INTERNET/CAMERA 已声明、dex 内含 `画中画`/`正在扫描局域网主机`/`云同步`(E/D/F 代码确在包内)。APK 字节不可复现(时间戳),故以内容核验替代字节比对 | 证实(以内容为准) |
---
## 3. 核查边界:本机无法判定(须真机裁决,未默认通过)
本机(Windows)无模拟器、无连接设备、`adb devices` 为空。以下必须真机判定:
1. USB 出流与温度绝对值标定(清单 1–12 步)。
2. 可见光 PIP 全部运行时行为(13–15 步):**含"返回后画面是否重新出现"**、
单击换档/双击关闭/拖拽三种手势是否互不干扰。
3. 局域网远程预览双机行为(16–24 步):**含 `255.255.255.255` 广播在真实
Wi-Fi 上是否可达**(部分路由/AP 会过滤广播;息屏策略也可能影响收包)。
4. 分析页温度条/探针的实际显示与取温正确性(11 步)。
5. 任何"画面/像素"层面的确认。
清单本身已按指南机械核对:16 条字面命中、30 条骨架命中、7 条人工追溯全部
落实到真实模板与调用点,未发现凭空编造。清单可继续作为真机验证脚本。
---
## 4. 真机测试时请重点确认(与修复决策挂钩)
| 要确认的事 | 怎么看 | 影响哪个修复 |
|---|---|---|
| 远程预览:连上后是否每次都看到"welcome"日志 | B 端日志 `[remote] line: {"type":"welcome"...}` 是否出现;连续重连 10 次统计 | FIX-1(当前约 6.7% 丢失) |
| 快速 stop→start、连点 FFC 是否偶发失灵 | 反复操作,看 B 端画面是否与按钮状态不一致 | FIX-1 |
| PIP:Home 返回后小窗是否重新出现 | 清单 15 步 | FIX-6.1(决定改文档还是改代码) |
| PIP:单击换档 / 双击关闭 / 拖拽是否互不干扰 | 清单 14 步,各做 5 次 | 若互相干扰则需改手势实现(本机无法判定) |
| 分析页:点已知温度位置,读数是否合理 | 清单 11 步 | 探针直接映射的运行时确认 |
| 远程预览:局域网是否能在 10s 内发现主机 | 清单 19 步 | UDP 广播可达性 |
| lifetime 是否非 -1 | 清单第 3 步 `[session] device lifetime=<ms>ms` | FIX-3 |
---
## 5. 核查者使用的判据来源(可独立复核)
| 判据 | 路径 |
|---|---|
| 协议权威(官方 Java | `analysis/sdk_re/android_app/jadx_magcx/cn/com/magnity/magnitycx/sdk/{D2PCmd,P2DCmd,UsbCommunication}.java` |
| 官方调色板 UI 顺序与 native 调用点(**本次新用** | `analysis/sdk_re/android_app/jadx_magcx/cn/com/magnity/magnitycx/DialogFragmentPalette.java` |
| native 伪代码(12+2 个生成体) | `analysis/sdk_re/android_app/libcxsdk_decomp.txt``SetColorPalette @00026c70`case 0..10、0xc、0xd |
| 铁虹真表(真实内存布局 `B,G,R,0` | `csdk/src/mag160c_official_palette256.h` |
| 温度 C 参考实现 + T2E 真表 | `csdk/src/mag160c_render.c:64``csdk/src/mag160c_official_t2e.h` |
| 帧布局权威 | `csdk/src/mag160c_frame.c:11-13`(像素自 `+0x1c`,总长 `0x38+len` |
| 官方预览图 | `app/【普通版】MAG-Cx.apk``res/mipmap-hdpi-v4/palette_*.png`12 张) |
---
## 6. 复现命令(修复模型可直接照跑)
### 6.1 构建 + 全量单测(Windows Git Bash
```bash
cd /c/Project/MAG160C/android && export JAVA_HOME="C:\\Tools\\jdk-21" && \
cmd //c "C:\Project\MAG160C\android\gradlew.bat :app:assembleDebug :app:assembleRelease test --no-daemon"
# 强制重跑(否则 UP-TO-DATE 不产生新结果)
cmd //c "C:\Project\MAG160C\android\gradlew.bat :app:testDebugUnitTest --rerun-tasks --no-daemon"
# 单测计数
grep -h -o 'tests="[0-9]*" skipped="[0-9]*" failures="[0-9]*" errors="[0-9]*"' \
/c/Project/MAG160C/android/app/build/test-results/testDebugUnitTest/*.xml
```
### 6.2 Phase C 否证复核 + 生成物可复现
```bash
cd /c/Project/MAG160C
"C:\Tools\jdk-21\bin\java" analysis/tools/PalScan.java \
analysis/sdk_re/android_app/bin/libcxsdk.so csdk/src/mag160c_official_palette256.h
# 期望:三种编码全部 not found[A] 段自校准 detected 1 candidate
mkdir -p /c/Users/zxc/AppData/Local/Temp/regen_build && \
cd /c/Users/zxc/AppData/Local/Temp/regen_build && \
cp "C:\Project\MAG160C\analysis\tools\PalIdentify.java" . && \
cp "C:\Project\MAG160C\analysis\tools\PalExport2.java" . && \
"C:\Tools\jdk-21\bin\javac" -d out PalIdentify.java PalExport2.java && \
"C:\Tools\jdk-21\bin\java" -cp out PalExport2 \
"C:\Project\MAG160C\android\app\src\main\kotlin\com\mag160c\thermal\core\OfficialTables.kt" \
"C:\Users\zxc\AppData\Local\Temp\cxres\res\mipmap-hdpi-v4" \
"C:\Users\zxc\AppData\Local\Temp\regen_core" \
"C:\Users\zxc\AppData\Local\Temp\regen_out"
# 期望末行:iron_bow vs OfficialTables anchor: 256/256
diff "C:\Users\zxc\AppData\Local\Temp\regen_core\VendorPalettes.kt" \
"C:\Project\MAG160C\android\app\src\main\kotlin\com\mag160c\thermal\core\VendorPalettes.kt"
diff "C:\Users\zxc\AppData\Local\Temp\regen_out\palette_candidates.json" \
"C:\Project\MAG160C\analysis\sdk_re\android_app\palette_candidates.json"
# 期望:两条 diff 均为空
```
### 6.3 独立 Kotlin harness(仓库外编译仓库真实源码)
无 gcc / 无 kotlinc,用 Gradle 缓存内的编译器:
```bash
KC=/c/Users/zxc/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin
KX=/c/Users/zxc/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx
STDLIB=$KC/kotlin-stdlib/2.2.0/fdfc65fbc42fda253a26f61dac3c0aca335fae96/kotlin-stdlib-2.2.0.jar
COMPILER=$KC/kotlin-compiler-embeddable/2.2.0/8cfa2b049a4006d94474296df4abd9b50f288821/kotlin-compiler-embeddable-2.2.0.jar
SCRIPT=$KC/kotlin-script-runtime/2.2.0/87c92e866fcd68680966a3005a2992e1ab8ec6ad/kotlin-script-runtime-2.2.0.jar
CORO=$KX/kotlinx-coroutines-core-jvm/1.8.0/ac1dc37a30a93150b704022f8d895ee1bd3a36b3/kotlinx-coroutines-core-jvm-1.8.0.jar
ANNOT=/c/Users/zxc/.gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/23.0.0/8cc20c07506ec18e0834947b84a864bfc094484e/annotations-23.0.0.jar
# 编译(示例:Phase FPhase B 同理,只换成 core/media 源码 + 自己的断言)
java -cp "$COMPILER;$STDLIB;$SCRIPT;$CORO;$ANNOT" org.jetbrains.kotlin.cli.jvm.K2JVMCompiler \
-no-stdlib -cp "$STDLIB;$CORO" -d out \
<DebugLog 桩>.kt \
android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteContract.kt \
android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt \
android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt \
<自己的断言>.kt
java -cp "out;$STDLIB;$CORO" <主类>Kt
```
要点:
- `DebugLog` 依赖 Android 类型,需自写同包同名的 JVM 桩
`package com.mag160c.thermal.media; object DebugLog { fun log(t: String, m: String) {} }`),
网络类即可在纯 JVM 上运行。
- Windows 版 java **不认** Git Bash 的 `/tmp`,路径必须写 `C:\...`
### 6.4 其它
```bash
# 清单日志串核对(期望 exact=16 skeleton=30 review=7
"C:\Tools\jdk-21\bin\java" analysis/tools/CheckStrings.java \
docs/android_app/real_device_checklist.md android/app/src/main
# APK 清单核验
"C:\Tools\android-sdk\build-tools\36.0.0\aapt2.exe" dump badging build-artifacts/mag160c-app-debug.apk
"C:\Tools\android-sdk\build-tools\36.0.0\aapt2.exe" dump xmltree build-artifacts/mag160c-app-debug.apk --file AndroidManifest.xml
```
---
## 7. 结论一句话
**Phase A/B/C/D/E 的声明全部证实(C 的索引 4/8/9 映射经独立复核成立;
B 的温度换算经独立 C 移植全量比对一致);Phase F 有两处真实缺陷
(命令乱序导致约 6.7% 丢 welcome、busy 未实现),Phase Z 有一处越界
(Manifest 改动);其余为低风险偏差。真机相关项无法判定,等实测。**