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 项测试全绿。
This commit is contained in:
ZXCLI
2026-09-12 15:36:34 +08:00
parent 6ba90f2bb6
commit ee6859877a
20 changed files with 459 additions and 70 deletions
+3
View File
@@ -36,3 +36,6 @@ __pycache__/
# ZCode 会话目录 # ZCode 会话目录
.zcode/ .zcode/
# Java build output for the manual figure generator
docs/android_app/manual_images/*.class
@@ -12,8 +12,10 @@ import com.mag160c.thermal.ui.theme.Mag160cTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// field-debug crash hook + log file (opened at startup so even // Field-debug logging is OFF by default (see DebugLog.enabled): a shipping
// early crashes are captured; logging never throws) // 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.init(applicationContext)
com.mag160c.thermal.media.DebugLog.startFile(applicationContext) com.mag160c.thermal.media.DebugLog.startFile(applicationContext)
// enableEdgeToEdge exists since API 21 but reaches for the modern inset // enableEdgeToEdge exists since API 21 but reaches for the modern inset
@@ -16,8 +16,20 @@ import java.util.Locale
/** /**
* Field-debug logger: mirrors every line to logcat AND appends it to a * 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 * timestamped text file next to the photos (DCIM/MAG160C), so a problem on the
* hand over the file without adb (real-device bring-up, round 11). * 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 * Logging must NEVER crash the app: every sink operation is guarded, and
* writes are capped. Sink fallback chain (scoped storage only allows media * 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. * The header line records where the file actually ended up.
*/ */
object DebugLog { 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 fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.ENGLISH)
private val fileFmt = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH) private val fileFmt = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH)
private val lock = Any() private val lock = Any()
@@ -38,6 +57,7 @@ object DebugLog {
/** Install once from MainActivity.onCreate: crash hook + app context. */ /** Install once from MainActivity.onCreate: crash hook + app context. */
fun init(context: Context) { fun init(context: Context) {
if (!enabled) return
synchronized(lock) { synchronized(lock) {
if (previousHandler != null) return if (previousHandler != null) return
previousHandler = Thread.getDefaultUncaughtExceptionHandler() previousHandler = Thread.getDefaultUncaughtExceptionHandler()
@@ -51,6 +71,7 @@ object DebugLog {
/** Log one line (also to logcat as MAG160C/<tag>). Never throws. */ /** Log one line (also to logcat as MAG160C/<tag>). Never throws. */
fun log(tag: String, msg: String) { fun log(tag: String, msg: String) {
if (!enabled) return
try { try {
Log.d("MAG160C/$tag", msg) Log.d("MAG160C/$tag", msg)
} catch (_: Exception) { } catch (_: Exception) {
@@ -80,9 +101,10 @@ object DebugLog {
/** /**
* Open a fresh debug_*.log. Called once at app start (so even startup * Open a fresh debug_*.log. Called once at app start (so even startup
* crashes are captured); never throws. Returns the display path for * 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 { fun startFile(context: Context): String {
if (!enabled) return "(debug log disabled)"
val name = "debug_${fileFmt.format(Date())}.log" val name = "debug_${fileFmt.format(Date())}.log"
closeFile() closeFile()
synchronized(lock) { synchronized(lock) {
Binary file not shown.
+50 -64
View File
@@ -3,6 +3,10 @@
适用版本:MAG160C 统一热像版 1.0.0`com.mag160c.thermal` 适用版本:MAG160C 统一热像版 1.0.0`com.mag160c.thermal`
硬件:MAG160C 热像仪机芯(160×120 @15fps),USB VID `0x833C` PID `0x0001` 硬件:MAG160C 热像仪机芯(160×120 @15fps),USB VID `0x833C` PID `0x0001`
> 本文中的插图:**截图**取自真机(小米 22041211AC / Android 12);**示意图**用于说明
> 布局与标记结构。图片源文件在 `manual_images/`,可用同目录的 `MakeFigures.java`
> 重新生成(界面改动后请重新出图,避免手册与实物不一致)。
--- ---
## 1. 安装与首次连接 ## 1. 安装与首次连接
@@ -14,79 +18,62 @@
也可以直接把 APK 拷进手机点击安装(需允许"安装未知应用")。 也可以直接把 APK 拷进手机点击安装(需允许"安装未知应用")。
2. 把热像仪插到手机的 USB 口。系统会弹出 **"允许 App 访问该 USB 设备"** → 2. 把热像仪插到手机的 USB 口。系统会弹出 **"允许 App 访问该 USB 设备"** →
勾选"始终允许"并确定。**这个授权在手机重启后会失效,需要重新确认一次**。 勾选"始终允许"并确定。**这个授权在手机重启后会失效,需要重新确认一次**。
3. App 会自动连接并出图,通常 1~2 秒。画面出现即表示握手完成(内部流程: 3. App 会自动连接并出图,通常 1~2 秒(内部流程:查设备 → 请求权限 → 握手 →
查设备 → 请求权限 → 握手 → 读标定文件 → 开始出流)。 读标定文件 → 开始出流)。
> 如果顶栏下显示"未检测到热像仪",见第 11 节。 > 顶栏下显示"未检测到热像仪",见第 11 节。
--- ---
## 2. 界面总览 ## 2. 界面总览
``` ![实时界面示意图](manual_images/01_live_schematic.png)
┌─────────────────────────────────────────┐
│ FFC 1× 追高·低 铁虹 画中画 │ ← 控制顶栏 | 编号 | 位置 | 作用 |
├─────────────────────────────────────────┤ |---|---|---|
│ │ | ① | 顶栏 · FFC | 手动做一次快门校正(画面短暂冻结属正常) |
│ 热成像画面(锁屏构图) │ | ② | 顶栏 · 1× | 数码变倍,点一次在 1× → 2× → 3× → 4× 循环 |
│ │ | ③ | 顶栏 · 追踪 | 全画面最高/最低温标记(最高、最低、两者、关闭) |
├─────────────────────────────────────────┤ | ④ | 顶栏 · 铁虹 | 切换调色板(12 种) |
│ 相册 拍照(快门) 录像 │ ← 拍摄栏 | — | 顶栏 · 画中画 | 可见光画面叠加,三档尺寸,可拖动 |
├─────────────────────────────────────────┤ | ⑤ | 拍摄栏 | 相册 / **拍照** / 录像 |
│ 实时 相册 分析 设置 │ ← 底部导航 | ⑥ | 底部导航 | 实时 / 相册 / 分析 / 设置 |
└─────────────────────────────────────────┘
``` 画面里还有两个读数:左上角 **"中心 xx.x℃"** 是画面中心点温度;右侧竖条是
**温度色标**,上下端标注当前画面的最高温与最低温。
App 固定竖屏显示。转动手机时**界面和画面构图都不跟着转**(见第 8 节)。 App 固定竖屏显示。转动手机时**界面和画面构图都不跟着转**(见第 8 节)。
--- ---
## 3. 实时界面 ## 3. 实时界面操作
| 控件 | 作用 | - **测温点**:直接在画面上**点一下**就加一个测点;**点已有的测点**则删除它。
|---|---| 测点会显示序号 + 温度,并随照片一起保存。
| **FFC** | 手动做一次快门校正。画面会短暂冻结一下,属正常;校正后温度读数会刷新 | - **拍照**:立刻把当前画面存成照片(含温度数据、测点、最高/最低温标记)。
| **1×** | 数码变倍,点一次在 1× → 2× → 3× → 4× 之间循环(从画面中心裁剪放大) | - **录像**:点一次开始,再点一次停止并保存;录制中按钮显示"停止"。
| **追高·低** | 全画面最高温/最低温标记的开关与目标(最高、最低、两者、关闭) |
| **铁虹** | 切换调色板(12 种,点开选择) |
| **画中画** | 可见光画面叠加(首次使用需授予摄像头权限),三档尺寸,可拖动摆放 |
| **相册 / 拍照 / 录像** | 见下 |
**测温点**:直接在画面上**点一下**就加一个测点;**点已有的测点**则删除它。
测点会显示序号 + 温度,并随画面一起保存进照片。
**拍照**:立刻把当前画面存成照片(含温度数据、测点、最高/最低温标记)。
**录像**:点一次开始,再点一次停止并保存。录制中按钮显示"停止"。
--- ---
## 4. 画面上的标记怎么读 ## 4. 画面上的标记怎么读
从本轮起标记改成仪表风格,含义如下: ![标记结构](manual_images/02_markers.png)
``` | 编号 | 部位 | 说明 |
|---|---|---|
┌─┴─┐ | ① | 方形准星 + 四根短臂 | 指住被测量的**那一个像素**;中心镂空,不遮挡被测区域 |
───┤ ├─── Pt1 25.0℃ | ② | 读数 | `Pt1`/`Pt2`… 是手动测点;`MAX`/`MIN` 是全画面最高/最低温 |
└─┬─┘ | ③ | 引线 | 把读数和它自己的准星连起来,测点多时不会看错 |
| ④ | 文字样式 | 白字 + 黑描边,**没有白色底板**:冷端(黑)和热端(白)上都读得清,且不挡画面 |
方形准星 + 四根短臂:指住被测量的那一个像素,中心镂空不遮挡被测区域 温度单位统一 ℃,一位小数。
右侧描边白字:温度读数;引线把读数和自己的准星连起来,测点多时不会看错
没有白色底板 —— 冷端(黑)和热端(白)上都靠描边保证可读,且不遮挡画面
```
- **`MAX` / `MIN`**:全画面的最高温与最低温,显示哪一个由"追踪"设置决定
- **`Pt1` `Pt2` …**:你手动点的测点
- **引线**:从读数连到自己那个准星,测点多时不会看错
温度单位统一为 ℃,显示一位小数。
--- ---
## 5. 相册 ## 5. 相册
![相册](manual_images/07_gallery.png)
- 列出 `DCIM/MAG160C` 里的照片,按时间倒序(最新的在最前) - 列出 `DCIM/MAG160C` 里的照片,按时间倒序(最新的在最前)
- **点开**:全屏查看;**双指缩放**、**拖动平移**、**双击复位** - **点开**:全屏查看;**双指缩放**、**拖动平移**、**双击复位**
- **删除**:右上角"删除" → 确认后从手机中移除(不可恢复) - **删除**:右上角"删除" → 确认后从手机中移除(不可恢复)
@@ -96,9 +83,9 @@ App 固定竖屏显示。转动手机时**界面和画面构图都不跟着转**
## 6. 分析(离线测温) ## 6. 分析(离线测温)
进入"分析"页会列出**带温度数据的照片**(早期没有温度数据的旧照片会被过滤掉)。 ![分析界面](manual_images/06_analysis.png)
打开一张照片后: 进入"分析"页会列出**带温度数据的照片**(早期没有温度数据的旧照片会被过滤)。
| 操作 | 说明 | | 操作 | 说明 |
|---|---| |---|---|
@@ -108,7 +95,7 @@ App 固定竖屏显示。转动手机时**界面和画面构图都不跟着转**
| **顶部 · 备注** | 给这张照片写一段文字备注(写入文件本身) | | **顶部 · 备注** | 给这张照片写一段文字备注(写入文件本身) |
| **顶部 · 保存** | 把当前测点烧录进图像,**另存为一张新照片**(温度数据一并保留) | | **顶部 · 保存** | 把当前测点烧录进图像,**另存为一张新照片**(温度数据一并保留) |
分析页显示的标记与照片里已烧录的标记位置完全一致,不会出现"两个测点"。 分析页显示的标记与照片里已烧录的标记位置完全一致,不会出现"两个测点"。
**注意**:分析读的是照片里存的原始温度数据,所以同一张照片任何时候打开, **注意**:分析读的是照片里存的原始温度数据,所以同一张照片任何时候打开,
读数都一样(不会因为环境变化而漂移)。 读数都一样(不会因为环境变化而漂移)。
@@ -117,6 +104,8 @@ App 固定竖屏显示。转动手机时**界面和画面构图都不跟着转**
## 7. 设置 ## 7. 设置
![设置](manual_images/05_settings.png)
| 设置项 | 说明 | 是否影响画面 | | 设置项 | 说明 | 是否影响画面 |
|---|---|---| |---|---|---|
| **默认调色板** | 启动时使用的调色板 | ✔ | | **默认调色板** | 启动时使用的调色板 | ✔ |
@@ -137,7 +126,7 @@ App 固定竖屏显示。转动手机时**界面和画面构图都不跟着转**
若画面上下颠倒,用"旋转USB画面"(每次 90°);若左右镜像反了,用"水平翻转"或 若画面上下颠倒,用"旋转USB画面"(每次 90°);若左右镜像反了,用"水平翻转"或
"竖直翻转"。这三项是**永久生效的设置**,实时、拍照、录像都会跟着变。 "竖直翻转"。这三项是**永久生效的设置**,实时、拍照、录像都会跟着变。
**图像增强怎么选**:档位越高,纳入增强的对比度门槛越高、强边缘增强幅度越大, **图像增强怎么选**:档位越高,纳入增强的对比度门槛越高、强边缘增强幅度越大,
即"更锐但更挑"——弱对比区域(噪声所在处)会被排除,所以调高**不会**把噪声放大。 即"更锐但更挑"——弱对比区域(噪声所在处)会被排除,所以调高**不会**把噪声放大。
日常建议 2~4 级;需要看清弱纹理时再上 6~8 级。默认关闭。 日常建议 2~4 级;需要看清弱纹理时再上 6~8 级。默认关闭。
@@ -145,10 +134,13 @@ App 固定竖屏显示。转动手机时**界面和画面构图都不跟着转**
## 8. 拍照 / 录像的方向(重要) ## 8. 拍照 / 录像的方向(重要)
- **文件方向跟随你拿手机的方向**(和普通相机一样): ![竖屏握持拍出的照片](manual_images/04_photo_portrait.png)
- 竖着拿 → 照片 720×960竖幅),录像也是竖幅 *竖着拿手机拍 → 720×960 竖幅照片,场景是正的*
- 横着拿 → 照片 960×720(横幅),录像也是横幅
- 无论怎么拿,**场景都是正的**,不需要事后旋转 ![横屏握持拍出的照片](manual_images/03_photo_landscape.png)
*横着拿手机拍 → 960×720 横幅照片,场景同样是正的*
- **文件方向跟随你拿手机的方向**(和普通相机一样),所以**不需要事后旋转**。
- **预览画面本身是锁定的**:横着拿手机时,屏幕里的画面看起来是侧的 - **预览画面本身是锁定的**:横着拿手机时,屏幕里的画面看起来是侧的
(因为屏幕构图固定在竖屏框架上),但保存下来的文件是正的。 (因为屏幕构图固定在竖屏框架上),但保存下来的文件是正的。
这是刻意设计:热像仪固定在手机上,锁定预览才能保证转动手机时不晃方向。 这是刻意设计:热像仪固定在手机上,锁定预览才能保证转动手机时不晃方向。
@@ -164,7 +156,6 @@ App 固定竖屏显示。转动手机时**界面和画面构图都不跟着转**
| 照片 | `DCIM/MAG160C/MAG160C_yyyyMMdd_HHmmss.jpg` | | 照片 | `DCIM/MAG160C/MAG160C_yyyyMMdd_HHmmss.jpg` |
| 分析另存 | `DCIM/MAG160C/MAG160C_yyyyMMdd_HHmmss_edit.jpg` | | 分析另存 | `DCIM/MAG160C/MAG160C_yyyyMMdd_HHmmss_edit.jpg` |
| 录像 | `DCIM/MAG160C/MAG160C_V_yyyyMMdd_HHmmss.mp4` | | 录像 | `DCIM/MAG160C/MAG160C_V_yyyyMMdd_HHmmss.mp4` |
| 调试日志 | `Download/MAG160C/debug_*.log`(部分机型写进 `DCIM/MAG160C/`,排查问题时取这个文件) |
照片文件是 **MDT 容器**:文件开头就是标准 JPEG(任何看图软件都能打开), 照片文件是 **MDT 容器**:文件开头就是标准 JPEG(任何看图软件都能打开),
后面追加了温度数据、测点坐标、备注、渲染参数等信息。 后面追加了温度数据、测点坐标、备注、渲染参数等信息。
@@ -201,17 +192,12 @@ App 固定竖屏显示。转动手机时**界面和画面构图都不跟着转**
- 先做一次 FFC(环境温度变化后尤其需要) - 先做一次 FFC(环境温度变化后尤其需要)
- 分析页的读数来自照片里存的原始数据,不会随时间变化;实时页读数会随环境漂移 - 分析页的读数来自照片里存的原始数据,不会随时间变化;实时页读数会随环境漂移
**录像停止时日志里有 "drain error"**
- 文件仍然完整可播放(尾部可能略短)。这是编码器在停止瞬间偶发的状态问题,
已做保护不会闪退,文件也已保存
**手机息屏后回来** **手机息屏后回来**
- 息屏时渲染停止(省电)、USB 读取继续,已保存的文件不受影响 - 息屏时渲染停止(省电)、USB 读取继续,已保存的文件不受影响
**电脑上想取文件** **想拿文件到电脑上**
``` ```
adb pull /sdcard/DCIM/MAG160C/ adb pull /sdcard/DCIM/MAG160C/
adb pull /sdcard/Download/MAG160C/ # 调试日志(部分机型在 DCIM/MAG160C
``` ```
--- ---
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");
}
}
}
+48
View File
@@ -489,6 +489,54 @@
"未连接"分支显示,正常出图时用户看不到任何反馈)。 "未连接"分支显示,正常出图时用户看不到任何反馈)。
- [x] 单测 66 → **76 项全绿**debug + release(R8) 双构建通过;APK 已更新。 - [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,横屏拍摄方向:文件跟随握持角度) ## 用户反馈修复 第二十四轮(2026-09-12,横屏拍摄方向:文件跟随握持角度)
用户反馈:"切到横屏拍照片和视频方向又不对了"。 用户反馈:"切到横屏拍照片和视频方向又不对了"。