diff --git a/.gitignore b/.gitignore index 73f36f5bc5b78..6ce4619da8748 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,22 @@ sphinx_*/ *.dtb* *.scr extboot* + +# DLCVCAM local build / test artifacts (not sources) +/bad_packages/ +*.img.sha256 +*.img.dlcvcam.json # legacy; no longer generated +/boot-rk3576-*.img + +# local worktree junk +kernel_backup/ +scripts/__pycache__/ +trigger +*.bak.* + +# local IMX296 verification dumps / build logs (not sources) +/IMX296_verify_*/ +/IMX296_2h_*/ +/IMX296_user_*/ +/IMX296_build_*.log +/dist/ diff --git a/DLCVCAM_BUILD_VERSION b/DLCVCAM_BUILD_VERSION new file mode 100644 index 0000000000000..c487fed3a6e1c --- /dev/null +++ b/DLCVCAM_BUILD_VERSION @@ -0,0 +1 @@ +2026090301 diff --git "a/DLCVCAM\345\206\205\346\240\270\347\274\226\350\257\221\344\270\216\346\233\264\346\226\260\346\223\215\344\275\234\346\211\213\345\206\214.md" "b/DLCVCAM\345\206\205\346\240\270\347\274\226\350\257\221\344\270\216\346\233\264\346\226\260\346\223\215\344\275\234\346\211\213\345\206\214.md" index bafffd7c9b044..dc9626c9be964 100644 --- "a/DLCVCAM\345\206\205\346\240\270\347\274\226\350\257\221\344\270\216\346\233\264\346\226\260\346\223\215\344\275\234\346\211\213\345\206\214.md" +++ "b/DLCVCAM\345\206\205\346\240\270\347\274\226\350\257\221\344\270\216\346\233\264\346\226\260\346\223\215\344\275\234\346\211\213\345\206\214.md" @@ -20,7 +20,54 @@ which aarch64-linux-gnu-gcc ## 2. 编译内核 -### 2.1 初次配置(仅需执行一次) +### 2.1 日期构建编号规则 + +DLCVCAM 发布内核使用仓库根目录的 `DLCVCAM_BUILD_VERSION` 作为构建编号, +格式固定为: + +```text +YYYYMMDDNN +``` + +其中 `YYYYMMDD` 是发布日期,`NN` 是当天两位序号,从 `01` 开始。例如: + +```text +2026072201 +``` + +合入 `master` 前必须把该文件更新为实际合入日期;同一天发布多个内核时依次使用 +`01`、`02`、`03`。不要通过修改 `VERSION/PATCHLEVEL/SUBLEVEL` 记录发布日期, +这样可以保持 `uname -r` 和 `/lib/modules/6.1.99-rk3576` 路径稳定。 + +开发机查看仓库默认构建编号: + +```bash +cat DLCVCAM_BUILD_VERSION +make -s dlcvcam-build-version +``` + +板卡刷入对应内核后查看: + +```bash +uname -v +cat /proc/version +``` + +输出示例: + +```text +#2026072201 SMP Wed Jul 22 10:46:10 CST 2026 +``` + +正常发布构建不需要再手工传入 `KBUILD_BUILD_VERSION`。CI 或临时构建仍可显式传入 +该变量覆盖仓库值,但交付镜像必须使用 `DLCVCAM_BUILD_VERSION` 中已提交的编号。 +建议镜像文件名同时记录构建编号和 Git 短提交,例如: + +```text +boot-rk3576-6.1.99-2026072201-e8c10e0f.img +``` + +### 2.2 初次配置(仅需执行一次) ```bash cd /home/ypw/kernel @@ -36,7 +83,7 @@ make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- lubancat_linux_rk3576_defconfig make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- olddefconfig ``` -### 2.2 增量编译(日常修改后) +### 2.3 增量编译(日常修改后) ```bash cd /home/ypw/kernel @@ -67,6 +114,13 @@ BOOT_ITS=boot.its ./scripts/mkimg --dtb dlcvcam-rk3576.dtb mkimage -l boot.img ``` +同时确认本次编译使用的日期构建编号: + +```bash +make -s ARCH=arm64 O=/path/to/kernel-out dlcvcam-build-version +# 2026072201 +``` + --- ## 3. 烧录到板子 @@ -113,36 +167,118 @@ sudo rkdeveloptool rd # 重启 --- -## 4. 完整编译更新脚本 +## 4. 完整编译更新脚本(推荐) -在编译主机上创建 `build_bootimg.sh`: +> **推荐日常使用仓库根目录的 `build_bootimg.sh`**(见下)。 +> 防砖(U-Boot FIT fallback / boot-try、recovery 金镜像、confirm 服务)**不在本仓库实现**: +> - U-Boot:https://github.com/dl-cv/u-boot/pull/1 (`tools/dlcvcam/` 含 confirm 脚本协议副本) +> - CamOS Admin:seed recovery、安装 boot-try / WDT、维护脚本 `dlcvcam_sync_boot_to_recovery.sh` + +### 4.1 一键编译 ```bash -#!/bin/bash -set -e +cd /path/to/kernel # 本仓库 -cd /home/ypw/kernel +./build_bootimg.sh -echo "[1/4] 增量编译内核..." -make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- Image dtbs modules -j8 +# 内存较小时降低并行度 +./build_bootimg.sh -j4 -echo "[2/4] 生成 Image.lz4..." -lz4 -f arch/arm64/boot/Image arch/arm64/boot/Image.lz4 +# 强制重新 defconfig + 合并裁剪配置 +./build_bootimg.sh --force-config -echo "[3/4] 打包 boot.img..." -BOOT_ITS=boot.its ./scripts/mkimg --dtb dlcvcam-rk3576.dtb +# 删除 .config 后走完整初次配置 +./build_bootimg.sh --clean-config + +# 不自动改写 DLCVCAM_BUILD_VERSION(沿用文件当前值) +./build_bootimg.sh --no-bump +``` -echo "[4/4] 提示:如需更新内核模块(如 WiFi/BT 驱动等),请执行:" -echo " sudo make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- modules_install INSTALL_MOD_PATH=/path/to/rootfs" +### 4.2 脚本会做什么 -echo "" -echo "完成!" -ls -lh boot.img +1. **构建编号**:按 `DLCVCAM_BUILD_VERSION`(`YYYYMMDDNN`)规则自动 bump(见 §2.1)。 +2. **配置**:无合适 `.config` 时 `lubancat_linux_rk3576_defconfig` + `dlcvcam_rk3576_kernel_cut.config`。 +3. **编译打包**:`Image` / `dtbs` / `modules` → `Image.lz4` → `boot.img`(FIT)。 +4. **交付文件名**(同时保留根目录 `boot.img`): -echo "" -echo "下一步:通过 RKDevTool 或 rkdeveloptool 烧录 boot.img" +```text +boot-rk3576-6.1.99--.img ``` +5. **完整性 sidecar(无签名,仅整包 hash)**:为 `boot.img` 与交付镜像各生成: + +```text +<镜像>.sha256 # 整包 SHA-256(sha256sum 格式) +``` + +交付物就是 **`.img` + `.sha256`**,不再生成 `.dlcvcam.json`。 + +FIT 镜像本身在打包时已写入各 image 的内嵌 `sha256`(见 `boot.its`)。校验时两层分工: + +| 检查 | 依据 | 作用 | +|------|------|------| +| 整包 SHA-256 | `.sha256` | 发现下载截断、拷贝损坏、**传错成另一份合法旧包** | +| FIT 内嵌 hash | boot.img 内 fdt/kernel/resource 的 sha256 节点 | 发现 payload 被改;**不依赖 sidecar** 也能查内容损坏 | + +`.sha256` **不能替代签名**,只作运维防呆。 + +### 4.3 编译完成后 + +```bash +ls -lh boot.img boot-rk3576-*.img boot-rk3576-*.img.sha256 +mkimage -l boot.img + +# 本地再验一次(打包脚本结束时已自检) +python3 scripts/dlcvcam_verify_bootimg.py verify boot-rk3576-*.img --require-sidecar +``` + +### 4.4 板卡上传后、烧录前校验(推荐) + +把**镜像 + `.sha256`(建议连同校验脚本)**一起拷到板子,例如 `/tmp`: + +```bash +# 在板卡上(需 python3) +python3 scripts/dlcvcam_verify_bootimg.py verify /tmp/boot-rk3576-....img \ + --require-sidecar +# 退出码 0 才允许烧录;非 0 则重新传输,不要 wl/dd + +# 无 python 时也可用: +# sha256sum -c /tmp/boot-rk3576-....img.sha256 +``` + +只带了镜像、没有 `.sha256` 时,仍可只验 FIT 内嵌 hash: + +```bash +python3 scripts/dlcvcam_verify_bootimg.py verify /tmp/boot-rk3576-....img +``` + +对已有镜像补生成 `.sha256`(开发机): + +```bash +python3 scripts/dlcvcam_verify_bootimg.py gen-sidecar boot-rk3576-....img +``` + +下一步:校验通过后,用第 3 节的 RKDevTool 或 `rkdeveloptool` 烧录(或经 **CamOS Admin** 内核升级面板)。 + +### 4.5 防砖与 recovery(外链,本仓不提供脚本) + +| 能力 | 仓库 / 位置 | +|------|-------------| +| boot FIT 坏 → 读 recovery | [u-boot#1](https://github.com/dl-cv/u-boot/pull/1) `fit.c` | +| 合法 FIT 起不来 → boot-try 计数 | 同上;misc@48KiB `DCBT` | +| multi-user 后清计数 | u-boot `tools/dlcvcam/` + **Admin** 安装 confirm unit | +| recovery 金镜像 status/sync/restore | **CamOS Admin** `dlcvcam_sync_boot_to_recovery.sh` | +| 硬挂复位 | Admin `10-dlcvcam-watchdog.conf`(`RuntimeWatchdogSec`) | +| 升级前 seed recovery / 只写 boot | Admin 内核升级逻辑 | + +板端完整说明(sync / boot-try / confirm / 看门狗 / 检查表):CamOS `docs/admin/DLCVCAM内核防砖与金镜像维护.md`。 + +**原则(产品路径):** + +- 日常升级**只写 boot**;recovery 为金镜像,稳定后才手工提升。 +- 不使用完整 userspace bootguard 状态机。 +- 板端安装与操作以 Admin 上述文档及 u-boot `tools/dlcvcam/README.md` 为准。 + --- ## 附录:常用排查命令 @@ -151,6 +287,9 @@ echo "下一步:通过 RKDevTool 或 rkdeveloptool 烧录 boot.img" # 查看 FIT 镜像内容 mkimage -l boot.img +# 完整性校验(整包 + FIT 内嵌 hash,无签名) +python3 scripts/dlcvcam_verify_bootimg.py verify boot.img --require-sidecar + # 查看 eMMC 分区表 fdisk -l /dev/mmcblk0 diff --git "a/IMX296_N1\347\233\270\344\275\215\346\273\236\345\220\216_\350\257\212\346\226\255\344\270\216\344\277\256\345\244\215\350\256\260\345\275\225.md" "b/IMX296_N1\347\233\270\344\275\215\346\273\236\345\220\216_\350\257\212\346\226\255\344\270\216\344\277\256\345\244\215\350\256\260\345\275\225.md" new file mode 100644 index 0000000000000..4d2d8b1108376 --- /dev/null +++ "b/IMX296_N1\347\233\270\344\275\215\346\273\236\345\220\216_\350\257\212\346\226\255\344\270\216\344\277\256\345\244\215\350\256\260\345\275\225.md" @@ -0,0 +1,299 @@ +# IMX296 N-1 相位滞后 — 诊断与修复记录 + +> 独立于绿线文档。本文件跟踪 **触发第 N 次却拿到第 N−1 次曝光内容** 的排查与修复。 +> 创建:2026-08-06 +> 板端:root@192.168.1.180,CamOS :8000,sensor `4-001a`,capture `/dev/video11` +> 验证内核:`#2026080601 SMP Thu Aug 6 17:59:30 CST 2026`,boot sha256 `70490539408638b86673fcde3785296eede15b59d8387ae576cb31bbe1d57301` + +--- + +## 0. 问题描述(用户确认) + +- **现象**:进入 trigger 模式后,第 N 次触发返回的是第 N−1 次触发对应的帧内容(相位晚一帧),不是简单的「少一帧」。 +- **诱发条件**:监控模式(free_run)下,或 **监控→触发切换过程中**,存在外部触发(可用 `echo 1 > /sys/bus/i2c/devices/4-001a/trigger` 模拟);问题在进入触发模式后 **稳定保持**。 +- **判定法**:gain=0,曝光在 min≈21 与 max≈33289 间交替,每次改曝光后触发一次;用画面中心 ROI 均值区分 LO/HI,看是否与 **当前** 曝光一致。 + +--- + +## 1. 根因(日志确认) + +### 1.1 主因:V39 early-done 永久 ceiling-miss → 只能靠「下一拍 XTRIG」的 MI FE 关帧 + +IMX296 master_fast_trigger(FT)语义:一拍 XTRIG **打开** MIPI 帧,**下一拍** 才关帧。 +ISP V39 的 early-done 本意是在 OUT_FRM_HALF + residual ceiling 后,用 MI 进度验证写完再 `vb2_done`,使 **当前触发** 对应的曝光内容在本拍内发布。 + +实测(n1trace): + +``` +early ceiling-force ... y=0/1584128 base=0 saw=1 cb=0/792064 + y_shd=0 y_st=0 cb_shd=0 cb_st=0 +``` + +- `CIF_MI_MP_Y/CB_OFFS_CNT_SHD` 与 `*_START` 在整段 FT 开帧期间 **始终为 0**。 +- live-ramp 条件永远无法把 offs 推到 `need` → 旧逻辑 **abandon to MI FE**。 +- MI FE 只在 **下一用户 XTRIG 关帧** 时到来 → 用户拿到的永远是上一拍曝光 → **稳定 N-1**。 + +这与 mon+ext / cold 无关时是 early-done 在本硅上的结构性失效;mon+ext 只是把入口相位搅得更脏、更容易锁死。 + +### 1.2 次因 A:CamOS STREAMOFF→STREAMON 路径跳过 enter-FT dummy + +`imx296_mode_switch()` 在 `!streaming` 时 early-return,不发 enter dummy。 +CamOS run_mode 边界是 PAUSED→READY→apply→PLAYING(STREAMOFF/ON),因此 dummy 从未跑到 → 首拍用户 XTRIG 只关半开帧,相位锁 N-1。 + +**修复**:`imx296_s_stream(1)` 在 FT 上电后固定 dummy×3(`enter-fast-trigger` / `-2` / `-3`,短脉宽 800µs)。 + +### 1.3 次因 B:free_run 期间 XTRIG 脏写 PWM + +mon 期外部触发曾真实打出 PWM(`trigger pulse was emitted while active mode is free_run`),污染 FT 入口。 + +**修复**:`imx296_trigger_once_locked` 在 `streaming && active != XTRIG_ONE_SHOT` 时 **直接忽略**(`return 0` + ratelimited warn)。 + +--- + +## 2. 修复内容(内核) + +| 文件 | 改动 | +|---|---| +| `drivers/media/platform/rockchip/isp/capture.c` | early-done residual ceiling:**不再 abandon**;记 `ceiling-force`,置 `mi_ready`,走 post-MI hold,再 early complete | +| 同上 | `rkisp_early_done_read_y/cb_offs`:`max(SHD, START)`(双视图采样,仍被本硅全 0 击穿,故需 ceiling-force) | +| `drivers/media/i2c/imx296.c` | free_run 忽略 XTRIG | +| 同上 | `s_stream` + `mode_switch` 进入 FT 时 dummy×3,`IMX296_DUMMY_PULSE_US=800` | +| `drivers/media/platform/rockchip/isp/rkisp.c` | FT arm:`SKIP_EARLY=4` / `SKIP_BUF=4` / `POST_MI=8000` / `WARMUP_POST_MI=18000` / `WARMUP_FRAMES=4` + n1trace | + +### 2.1 ceiling-force 关键路径(capture.c) + +``` +if (!mi_ready) { + if (mi_finished) → post-MI hold → complete + else if (now < deadline) → poll + else { + /* V39 OFFS 全 0:强制 mi_ready + post-MI hold → complete */ + diag_ceiling++; log ceiling-force; + mi_ready = 1; + if (post_us) restart timer; + /* else fall through complete */ + } +} +``` + +相对「在 wait_line 立刻 force」:仍吃满 residual ceiling + post_mi hold,降低绿带风险。 +相对「abandon to MI FE」:本拍即可 `mi_pub state=WORK early=1`,相位对齐当前曝光。 + +--- + +## 3. 验证结果(ceiling-force 内核) + +脚本:`/tmp/n1_confirm_v3.py`(低对比阈值:span≈3.3 时 thr≈max(0.35·span, 0.4))。 + +现场 gain=0 时 LO mean≈21.67,HI mean≈24.9–25.1(span≈3.3);bot48 在 HI 约 27–28(非完整绿带形态)。旧脚本 `span<8 → NO CONTRAST` 会误杀,不可用。 + +| 用例 | mon 外触发 | 结果(T2–T10) | +|---|---|---| +| L0_MONEXT_0 | 8 | **CUR 9/9,N1=0** | +| L0_MONEXT_1 | 8 | **CUR 9/9,N1=0** | +| L0_MONEXT_2 | 8 | **CUR 9/9,N1=0** | +| L0_COLD | 0 | **CUR 9/9,N1=0** | +| SW_WS_MONEXT | 8 | **CUR 9/9,N1=0** | +| SW_WS_COLD | 0 | **CUR 9/9,N1=0** | + +**SUMMARY:`['CUR','CUR','CUR','CUR','CUR','CUR']`** + +### 3.1 dmesg 旁证(同一次矩阵) + +| 计数 | 含义 | +|---|---| +| COMPLETE=89 | early complete 成功 | +| ceiling-force=89 | 本硅 OFFS 全 0,全部走 force 路径 | +| mi_pub WORK early=1 ≥43 | 用户可见帧走 early 发布 | +| mi_pub IRQ =22 | 部分 drop/warmup 仍走 IRQ | +| enter-fast-trigger-3 =12 | 每次进 FT 的第三 dummy 到位 | +| pulse ignored =32 | mon 期 8×4 场景 XTRIG 被吞 | + +时间线样例: + +``` +ceiling-force sof=S ... y_shd=0 y_st=0 +early complete sof=S ... +mi_pub state=WORK vb_seq=N early=1 ... sof=S +``` + +同 sof 上 force→complete→WORK,与用户触发同拍。 + +--- + +## 4. 历史复现矩阵(修复前,供对照) + +脚本 `/tmp/n1_confirm.py`(高对比阈值): + +| 用例 | 模式 | 触发 | mon 外触发 | 结果 | +|---|---|---|---|---| +| L0_SYS_NODISC | line0 | sysfs | 0 | CUR | +| L0_SYS_MONEXT | line0 | sysfs | 8 | **N-1(9/9)** | +| SW_WS_NODISC | software | WS | 0 | CUR | +| SW_WS_DISC2 | software | WS | 0 | N-1(人为打乱 pending) | +| SW_BOTH_NODISC | software | WS+sysfs | 0 | N-1(双路径 pending) | +| SW_WS_MONEXT | software | WS | 8 | CUR(当时 early-done 偶发仍工作 + soft pending) | + +MONEXT 首帧特征(修前 L0): + +``` +T1 LO tag=hold mean=HI dt=4s → 切换前 hold-last +T2 HI tag=1 mean=LO → 实为 T1 的 LO +… 全程 N-1 +``` + +--- + +## 5. 相关代码锚点 + +### 5.1 内核 + +- `drivers/media/i2c/imx296.c` + - `imx296_trigger_once_locked`:free_run ignore + - `imx296_dummy_trigger_locked` + `IMX296_DUMMY_PULSE_US` + - `imx296_mode_switch` / `imx296_s_stream`:enter-FT dummy×3 +- `drivers/media/platform/rockchip/isp/capture.c` + - `rkisp_early_done_timer`:**ceiling-force** + - `rkisp_early_done_mi_finished` / `read_*_offs` +- `drivers/media/platform/rockchip/isp/rkisp.c`:FT skip/drop/warmup 常数 +- `capture_v39.c`:`mi_frame_end` drop / FRAME_WORK vs IRQ + +### 5.2 CamOS(板端,非本树) + +- `gst_v4l2_grabber.py`:run_mode boundary、pending_soft、line0 latest-only、warmup 6×150ms + +--- + +## 6. 风险与后续 + +1. **ceiling-force 与绿线**:强制 complete 依赖 residual ceiling + `POST_MI=8ms`(warmup 18ms)。本轮 HI bot48≈27–28 vs center≈25,未见典型底 8 行纯绿带;建议 endurance 再抽检 `gre=0`。 +2. **OFFS 永久 0**:ceiling-force 是本硅上的务实解;若后续固件/寄存器视图恢复 live ramp,force 路径会自然减少(mi_finished 先成功)。 +3. **n1trace 日志量**:已默认关闭(`imx296.n1trace=0` / `video_rkisp.n1trace=0`)。诊断时: + ```bash + echo 1 > /sys/module/imx296/parameters/n1trace + echo 1 > /sys/module/video_rkisp/parameters/n1trace + ``` + 产线保持 0,避免 rsyslog 双写打满 rootfs。 +4. **测试脚本**:产线/回归请用低对比阈值版本(span 可能仅 3–4);或提高 gain/补光再测。 +5. **版本号**:当前板端仍标 `#2026080601`(内容已含本修复);正式合入前建议 bump DLCVCAM 构建号并清理 n1trace。 + +--- + +## 7. 工作日志 + +### 2026-08-06 复现 + +- 脚本:`/tmp/n1_confirm.py`,L0_MONEXT 稳定 N-1。 +- 基线内核:绿线 early-done + procfs 门闩。 + +### 2026-08-06 诊断 + +- 加 n1trace:pulse / dummy / early arm·complete·ceiling / mi_pub。 +- 确认 mon 期 free_run 曾 emit PWM;STREAMOFF 路径无 enter dummy。 +- 确认 early-done:`y_shd=y_st=0` 永久 ceiling-miss,mi_pub 仅在下一 XTRIG 的 IRQ。 + +### 2026-08-06 修复 + +1. free_run ignore XTRIG +2. s_stream enter dummy×3(补 mode_switch 在 !streaming 的空洞) +3. early-done:**ceiling-force**(取代 abandon-to-MI-FE) +4. 刷机 `17:59:30` / sha `70490539…` + +### 2026-08-06 验证(本轮收口) + +- 全矩阵 6/6 **CUR**,N1=0。 +- COMPLETE≈ceiling-force,WORK early=1 与用户触发同 sof。 +- free_run ignore + enter-fast-trigger-3 计数符合预期。 + +**结论:N-1 相位滞后根因已定位,内核修复已在板端矩阵验证通过。** + +--- + +## 8. 2026-08-07 ROI 绿线回归与共存修复 + +**现象**:N-1 ceiling-force 合入后,触发模式 ROI 后前几帧再出现绿线/串帧风险。 + +**根因并存**: +- N-1 需要 ceiling-force(本硅 MI OFFS 全 0,abandon→下一拍 MI FE = N-1) +- 绿线需要足够 residual + post-MI;旧 force 仅 8ms post + drop/skip=4,ROI 短 height 时不够 + +### 8.1 第一轮共存(14:12 刷板,仍绿) + +| 参数 | 旧 (N-1) | 第一轮 | +|---|---|---| +| residual default/max | 15/40 ms | 20/60 ms | +| POST_MI | 8 ms | 12 ms | +| FORCE_POST_MI | (=POST) | 16 ms | +| WARMUP_POST / frames | 18 ms / 4 | 28 ms / 8 | +| SKIP_EARLY / DROP | 4 / 4 | 6 / 6 | + +**用户样帧回归(14:32:55)**: +- `images/2026-08-07/NG/20260807_143255_431_012242.jpg`,1456×592 +- bot8 ≈ `[57.7, 73.0, 36.6]`,末 ~8–9 行经典绿带 +- dmesg:`isp_ft_arm height=592 ... ceiling_us=22930 force_post=16000 skip=6 drop=6 warmup=8` +- 全程 `ceiling-force y_shd=y_st=0`;drop/skip 耗尽后首个 `mi_pub WORK early=1` 即对上 NG 时间戳 +- **结论**:16ms force_post + ~23ms residual 对 592 高 ROI 仍不够 UV 收尾 + +### 8.2 第二轮加强(本轮) + +| 参数 | 第一轮 | 第二轮 | +|---|---|---| +| residual default/max | 20/60 ms | **35/100 ms**,min floor 30ms;公式 `(remain+TAIL*6)*line + (h/2)*(line/2)+5ms` | +| POST_MI | 12 ms | **20 ms** | +| FORCE_POST_MI | 16 ms | **45 ms** | +| WARMUP_POST / frames | 28 ms / 8 | **55 ms / 12** | +| SKIP_EARLY / DROP | 6 / 6 | **8 / 10** | + +实现增量: +- `capture.c`:`rkisp_early_done_post_hold_us(..., force_path)` + - force 路径:`hold = max(hold, force_post) + residual/2` + - 验证路径仍用 steady/warmup,不加 residual 半额 +- `rkisp.c`:上表 + residual 公式 +- sensor 侧 mon 禁 XTRIG、enter dummy×3 **保留** + +预期 592 高:residual≥30ms,force hold warmup≈55+15=70ms(steady≈45+15=60ms), +从 wait_line 到 complete 合计 ~90–100ms,覆盖末行 UV;ROI stream-on 多丢 10 帧半开缓冲。 + +验证清单: +1. dmesg `isp_ft_arm ... force_post=45000 skip=8 drop=10 warmup=12` +2. FT+ROI 1456×592 前 20 用户可见帧 bot8 无 G 主导绿带 +3. N-1 矩阵仍全 CUR(force 路径仍在,仅 hold 变长) + +### 8.3 第二轮板端验证(2026-08-07 14:38 内核) + +- 内核:`#2026080601 SMP Fri Aug 7 14:38:29 CST 2026` +- boot sha256:`7f9d1424612aab11983e2070cf93e057bf981aaa0d71975b7d202d9cb2fd164b` +- dmesg arm 样例:`isp_ft_arm height=592 ... skip=8 drop=10 warmup=12 ceiling_us=42310 force_post=45000` + +**绿线(用户复现几何 1456×592)** +- `/tmp/roi592_patient.py`:6 次 ROI(含 592×3、400×1),**96/96 帧 ok,green=0,miss=0** +- 592 bot8 典型 ≈ `[31, 26, 37]`(G 不主导,对比用户 NG bot8 `[57.7, 73.0, 36.6]`) +- 仍全程 `ceiling-force`(OFFS 全 0),force→complete 持有加长后无底绿带 + +**N-1 矩阵**(`/tmp/n1_confirm_v4.py`,进 FT 后 prime 14 次触发烧 drop) +| 用例 | 结果 | +|---|---| +| SW_WS_COLD | **CUR** 9/9 | +| SW_WS_MONEXT | **CUR** 9/9 | +| L0_MONEXT_0 | **CUR** 9/9 | +| L0_MONEXT_1 | **CUR** 9/9 | +| L0_COLD | **CUR** 9/9 | + +**SUMMARY:`['CUR','CUR','CUR','CUR','CUR']`,N1=0** + +**结论**:第二轮共存参数在 ROI 绿线与 N-1 上同时成立;请 UI 再抠 1456×592 做人工 spot-check。 + +### 8.4 drop 收敛(产品:触发必出图,3–5 fps) + +**现象**:切 trigger 后头 10–12 次用户触发无图。dmesg 显示 `pulse emitted` 成功,但 `mi_skip_frame skip=10…1`,第 11 次才 `mi_pub`。 + +**原因**:第二轮为压绿把 `SKIP_BUF=10`/`SKIP_EARLY=8` 叠在 CamOS warmup×6 之上;绿线主防护已是 residual+force_post,大 drop 只伤害产品指标。 + +**调整**: +| 参数 | 8.2 | 8.4 | +|---|---|---| +| SKIP_BUF / SKIP_EARLY | 10 / 8 | **2 / 2** | +| WARMUP_FRAMES | 12 | **6**(只加长 hold,不丢用户帧) | +| FORCE_POST / residual | 45ms / 35–100ms | **保持** | + +预期:mode-switch 后 CamOS warmup 结束开 gate,内核最多再丢 2 帧半开缓冲;用户触发应几乎张张 `mi_pub`。 diff --git "a/IMX296_\347\273\277\347\272\277_20260812_gate_IRQ\344\277\256\345\244\215\344\270\216\351\252\214\350\257\201.md" "b/IMX296_\347\273\277\347\272\277_20260812_gate_IRQ\344\277\256\345\244\215\344\270\216\351\252\214\350\257\201.md" new file mode 100644 index 0000000000000..581fb5f6f4206 --- /dev/null +++ "b/IMX296_\347\273\277\347\272\277_20260812_gate_IRQ\344\277\256\345\244\215\344\270\216\351\252\214\350\257\201.md" @@ -0,0 +1,477 @@ +# IMX296 底绿 gate/IRQ 修复与验证(2026-08-12) + +> 状态:**#1016 `#2026081301`** — 条件 UV 修补 + **多信号底带检测**;切换耗时纳入验收。 +> 板:`root@192.168.1.180` `#2026081301`;工作区 `fix_imx296_n1_pwm`。 +> **禁止**无条件 0x80 / 加长 warmup·gate;目标 mon→trig apply **2–3s**(硬上限 5s)。 + +--- + +## 1. 目标(产品) + +| 项 | 要求 | +|----|------| +| 画质 | mon↔trigger / ROI set/reset 后无底绿、底串帧、底绿线 | +| 触发 | 进触发后用户第 1 枪有图(不吞触发) | +| 时延 | **硬上限约 5s**;目标 **2–3s**(稳定且无绿/不吞枪) | +| 手段 | **内核完成路径正确性**;禁止再靠加 CamOS post residual / 加长 gate 消绿 | + +--- + +## 2. 根因链(已坐实) + +1. V39+IMX296 FT 上 **MI OFFS 长期为 0** → 几乎 100% **ceiling-force** 完成(N-1 修复必需)。 +2. force_post hold 跑满后,**底 ~8 行 UV 仍可未写完** → classic bot8 绿带。 +3. #1008 IRQ 路径曾把 **pub_gate 在 warm 期烧光**(§11)→ #1009 修相位后仍绿。 +4. #1010/#1011/#1012 加长 gate(6→8→12→13)只能**推迟**脏 pub,且顶破 CamOS 脉冲预算 → apply 变慢/挂死。 + **结论:quarantine 不是根治。** + +--- + +## 3. #1013 根治(`#2026081205`) + +### 3.1 内核 + +| 文件 | 改动 | +|------|------| +| `capture_v39.c` | FT early-done 下 MP **pub 前** `rkisp_ft_sanitize_nv12_bot()`:底 8 行 UV 写中性 chroma `0x80` + DMA cache sync | +| `rkisp.c` | quarantine 收缩:`WARMUP=2`、`PUB_GATE=2`(skip/drop 仍 2)— 仅挡半开入口帧 | +| `DLCVCAM_BUILD_VERSION` | `2026081205` | + +原理:绿带来自 **未完成 UV**,不是整帧无效。Y 已由 MI 写好;补齐/中和尾 UV 即可首枪干净,无需 20+ 次 complete 隔离。 + +### 3.2 CamOS + +- **已回撤**板端 `runtime_settings_dlcvcam.json` 里人为加的 + `SMARTCAM_TRIGGER_WARMUP_TOTAL_PULSE_MAX=32` / `WARMUP_FRAMES=12` 等覆盖。 +- 使用代码默认脉冲预算;**不再**为消绿加 post/min_appsink。 + +### 3.3 板验 mon→software(CamOS 默认,localhost 隔离 UI) + +**矩阵 A** `/userdata/imx296_warmup_ab/verify_k1205_sw/` ×8 head12: + +| 指标 | 结果 | +|------|------| +| green_total | **0**(96 帧,green/ 空) | +| first_hit | 全 **1** | +| bot8 抽检 | 中性灰 `(≈97,97,97)`,无 `(75,102,49)` classic 绿带 | +| dmesg arm | `warmup 2 skip 2 drop 2 pub_gate 2` | +| warmup 日志 | prime≈4–5,pulses≈10,warmup dt≈**2.2s** | +| apply | 有效轮 ~2.8–4.2s;R3 曾 CamOS ReadTimeout 90s(非绿) | + +**矩阵 B 时延复测** `/userdata/imx296_warmup_ab/verify_k1205_sw_lat/` ×8: + +| 指标 | 结果 | +|------|------| +| green_total | **0** | +| first_hit | 全 **1** | +| quality_all_pass / strict_all_pass | **true** | +| apply(有效 mon→trig) | **2.79 / 2.83 / 2.88 / 2.84 / 2.86 / 2.88 / 2.80 s** | +| apply_max(有效) | **2.879s**(≤3s 目标内;≤5s 硬上限) | +| 空切 | R2=0.013s(已在 trigger,不计时延) | + +### 3.4 成功标准对照(**已被 §8 #1015 取代**;#1013 产品接缝 FAIL) + +| 项 | 状态 | +|----|------| +| 无底绿 | **PASS** | +| first_hit=1 | **PASS** | +| apply ≤5s | **PASS**(复测有效轮) | +| apply 2–3s | **PASS**(复测有效轮全在 2.8–2.9s) | +| 不加长 warmup / 已回撤 CamOS 覆盖 | **PASS** | + +--- + +## 4. 历史迭代(勿回退) + +| 构建 | 内容 | 结论 | +|------|------|------| +| #1008 | gate 仅 drop+warm 后 | mon→sw 曾 0 绿;line0/IRQ 洞 | +| #1009 `#1201` | warm/gate IRQ 相位 | 相位 OK;head 仍绿×5 | +| #1010 `#1202` | gate=12 | 仍绿 + 脉冲预算爆 | +| #1011 `#1203` | gate=8 | 仍绿;CamOS success 早于脏窗结束 | +| #1012 `#1204` | gate=13 | 方向错误(更长隔离) | +| **#1013 `#1205`** | **UV sanitize + warm/gate=2** | **绿清零 + 切换回到 ~3s** | + +--- + +## 5. 决策树(后续) + +1. **ROI set/reset** 矩阵(同 sanitize 路径,应同样无绿)。 +2. line0:需外部边沿;无源不硬测 soft。 +3. CamOS 偶发 apply 90s:查 `run-mode-switch` 锁 / 连续 PATCH,与绿线无关。 +4. 若要稳定卡在 2–3s:在 **green=0 已成立** 前提下,可评估降低 CamOS `min_total`/prime(默认仍 ~10 脉冲),**不要**再加内核 gate。 +5. sanitize 是尾 UV 中性化:工业场景底 8 行 chroma 会略去饱和;若不可接受,再改为「仅当检测 bot UV 异常才写」或等 MI FE 后再 pub(慎防 N-1)。 + +--- + +## 6. 文件清单 + +| 路径 | 说明 | +|------|------| +| `drivers/.../isp/capture_v39.c` | `rkisp_ft_sanitize_nv12_bot` + pub 前调用 | +| `drivers/.../isp/rkisp.c` | warm=2 gate=2 注释 #1013 | +| `DLCVCAM_BUILD_VERSION` | 2026081205 | +| 板 `/userdata/imx296_warmup_ab/verify_k1205_sw/` | 矩阵结果 | +| 板 `/userdata/imx296_warmup_ab/scripts/verify_k1205_sw.py` | 可复现脚本(勿放 /tmp) | +| 镜像 | `boot-rk3576-6.1.99-2026081205-01726d6f-dirty.img` | + +--- + +*记录:2026-08-12;板 180;#1013 验证 green=0。* + +--- + +## 7. #1205 真实 CamOS 2h endurance(含 line0)— **已中止 FAIL** + +| 项 | 值 | +|----|----| +| 开始 | 2026-08-12 19:56:10 CST | +| 预计结束 | ~21:56 CST(`--hours 2`) | +| 时长 | **2h** | +| 内核 | `#2026081205` warm=2 gate=2 + UV sanitize | +| 脚本 | `/userdata/imx296_warmup_ab/scripts/imx296_day_endurance_1205.py` | +| 输出 | `/userdata/imx296_warmup_ab/endurance_2h_1205/`(**非 /tmp**) | +| 路径 | mon → software(+ROI) → **line0(+ROI)** → mon;line0 用 sysfs XTRIG | +| CamOS | 默认 runtime_settings(无 pulse_max/warmup 覆盖);n1trace=N | +| 主机监控 | `scripts/imx296_2h_watchdog_1205.py` → `IMX296_2h_line0_watch_1205/` | + +### 7.1 成功标准(用户当次) + +- 切换模式(**尤其进 trigger**)与 **ROI set/reset**:无 **N-1** / **绿帧** / **底部串帧** / **底部绿线** +- line0 真路径(sysfs XTRIG)同标准 +- bugs / green/ 目录无绿线类产物 + +### 7.2 中途快照(~20:00,uptime≈4–5min) + +| 指标 | 值 | +|------|-----| +| cycles | 3 | +| cases | ~20 | +| frames/valid | 140/140 | +| **green** | **0**(green/ 空) | +| **n1 / mix** | **0 / 0** | +| bugs / switch_fail / roi_fail | **0 / 0 / 0** | +| recoveries / crashes | 0 / 0 | +| phase | CUR + INCONCLUSIVE(无 N1/MIX) | +| 已覆盖 | MON、SW_BASE、SW_ROI_*、SW_ROI_RESET、LINE0_BASE、LINE0_ROI_*、LINE0_ROI_RESET | + +完跑后以 `summary.json` + `events.jsonl` + `bugs.jsonl` + `green/` 终态回填本节。 + +### 7.3 用户驳回(2026-08-12 ~20:06)— 检测漏报 + sanitize 接缝 + +**用户样例(产品存图 OK)**: +- `images/2026-08-12/OK/20260812_200339_019_111723.jpg` +- `images/2026-08-12/OK/20260812_200536_442_585339.jpg` +- `images/2026-08-12/OK/20260812_200455_830_639063.jpg` + +**像素事实(非 classic G 偏绿)**: +- bot8 RGB ≈ **(18,18,18)** 平坦中性灰;`g_delta=0`,`pure_g=0` +- 紧邻上行 above ≈ **(20,14,28)**(场景偏蓝紫) +- 人眼看到的「底绿线/底带」= **#1013 UV sanitize 中性化造成的色度接缝**(Y 仍在、UV 被写 0x80),不是旧式 (70,88,40) classic 绿带 +- 19:50–20:10 产品 OK **643/643** 张均 `flat_gray/seam`;**classic_or_pure=0** +- endurance `analyze_jpeg` 只判 G>R/B → **系统性漏报**,故 summary green=0 不可信 + +**处置**: +- 已停 2h endurance(PID 76739) +- **#1013 不可当交付**:消 classic 绿的同时引入**每帧可见底灰带/接缝** +- 下一根治方向:禁止无条件整幅尾 UV 刷 0x80;改为「仅异常 UV 才修」或等 MI FE/完整 chroma 再 pub(慎 N-1),并修正检测:flat seam + classic 双判 + +产物:板 `/userdata/imx296_warmup_ab/user_green_recheck_1205/`;主机 `IMX296_user_green_recheck_1205/` + + +### 7.4 中止终态(2026-08-12 20:07,未满 2h) + +| 项 | 值 | +|----|----| +| 进程 | **已停**(用户报产品底绿后 kill;~uptime 11min) | +| cycles/cases/frames | 6 / 45 / 308 valid | +| 脚本 green/n1/mix | 0/0/0(**不可信**,见 §7.3 漏检) | +| green/ 目录 | 0 | +| bugs | **3**(非绿):`SW_ROI2_640x480` TOO_FEW;`switch_fail:LINE0` stopped;`switch_fail:MON_END` stopped | +| switch_fail / roi_fail | 2 / 0 | +| 内核 | `#2026081205` | + +**判定:2h 未完成;#1205 交付 FAIL**(产品底 sanitize 接缝 + 检测假阴性)。勿再跑同一套 green 判据 endurance 当验收。 + +### 7.5 cron/会话复核(2026-08-12 晚,板已升 `#2026081207`) + +| 项 | 值 | +|----|-----| +| 进程 | **无** endurance 进程(已结束) | +| 产物路径 | `/userdata/imx296_warmup_ab/endurance_2h_1205/` 仍在 | +| summary | cycles=**6** cases=45 frames/valid=**308/308** green_frames=**0** n1=**0** mix=0 | +| switch_fail / roi_fail / bugs | **2** / **0** / **3** | +| green/ 目录 | **0** 文件 | +| bugs.jsonl | `SW_ROI2_640x480` TOO_FEW;`switch_fail:LINE0` stopped;`switch_fail:MON_END` stopped | +| 脚本 green=0 | **不可信**(§7.3 旧 G-only 判据;产品样例为 flat seam) | +| mon→trigger / ROI(本 run 内) | SW_BASE/SW_ROI_*/SW_ROI_RESET/LINE0_* 事件均有;**未发现 classic 绿记数**,但 **seam 未检** | +| 后续可信验收 | 见 **§8 #1015 `#2026081207`**:双判 mon→sw + WS ROI **classic=0 seam=0** | + +**终判不变:#1205 2h endurance = 中止 FAIL,不作交付依据。** + + + +--- + + +### 7.6 最终分析(会话收口,数据冻结 20:07) + +**1) 进程** +- PID 文件 `76739`;当前 **无 endurance 进程**(已退出,非跑满 2h)。 +- 运行内核当时:`#2026081205`;复核时板已升 `#2026081207`。 + +**2) 产物(已 scp → 主机 `IMX296_2h_line0_watch_1205/final/`)** +- `summary.json` / `summary.txt` / `events.jsonl` / `bugs.jsonl` / `nohup_outer.log` +- `green/`:**0** 文件 + +**3) 汇总表** + +| 指标 | 值 | 可信度 | +|------|-----|--------| +| 时长 | ~11 min(19:56:10–20:07:02),**未满 2h** | 硬事实 | +| cycles / cases | 6 / 45 | 硬事实 | +| frames / valid | 308 / 308 | 硬事实 | +| green_frames / green/ | **0 / 空** | **脚本 G-only 假阴性**;产品样例为 flat seam | +| n1_cases / mix_cases | **0 / 0** | 事件相位无 hard N1/MIX | +| switch_fail / roi_fail | **2 / 0** | kill 后 stopped | +| bugs | **3** | 见下 | +| recoveries / crashes | 0 / 0 | OK | + +**4) phase_dist(events case,n=45)** + +| phase | count | +|-------|------:| +| CUR | 27 | +| INCONCLUSIVE | 17 | +| TOO_FEW | 1 | +| **N1 / MIX** | **0 / 0** | + +无 hard N-1 / MIX。INCONCLUSIVE 多为 low contrast(MON_FREERUN、部分 ROI reset),**不是** N1。 + +**5) by_case 关键路径** + +| case | n | green | n1 | mix | cur | incon | 备注 | +|------|--:|------:|---:|----:|----:|------:|------| +| SW_BASE | 6 | 0 | 0 | 0 | **6** | 0 | mon→software 全 CUR | +| LINE0_BASE | 5 | 0 | 0 | 0 | **5** | 0 | line0 基线全 CUR(第 6 轮未到) | +| SW_ROI_RESET | 6 | 0 | 0 | 0 | 5 | 1 | ROI 重置无绿记数 | +| LINE0_ROI_RESET | 5 | 0 | 0 | 0 | 1 | 4 | 多 incon(低对比) | +| 各 SW_ROI_* / LINE0_ROI_* | 多次 | 0 | 0 | 0 | 多为 CUR | 部分 incon | 尺寸切换有覆盖 | +| SW_ROI2_640x480 | 1 | 0 | 0 | 0 | 0 | 0 | **TOO_FEW valid=0** → bug | + +**6) bugs 明细** + +1. `SW_ROI2_640x480` phase=TOO_FEW valid=0/6(取流失败,非绿) +2. `switch_fail:LINE0` info=**stopped**(用户/会话中止) +3. `switch_fail:MON_END` info=**stopped** + +**7) 对照用户标准(模式切换尤其进 trigger + ROI set/reset:无 N-1 / 绿帧 / 底串 / 底绿线)** + +| 标准 | 本 2h 脚本结论 | 真实结论 | +|------|----------------|----------| +| 无 hard N-1 | 事件 n1=0、phase 无 N1 | **成立**(短窗内) | +| 无 classic 绿 | green=0 | 脚本未报 classic | +| 无底绿线/接缝 | green=0 | **不成立**:#1013 无条件 UV 0x80 → 产品 bot 平坦灰接缝;旧检测漏报 | +| 跑满 2h 稳定 | — | **不成立**:~11 min 中止 | + +**终判:FAIL(中止 + 检测不可信 + 产品接缝)** +勿将本 run 的 green=0 当作交付。可信短验见 **§8 `#2026081207` 双判** mon→sw / WS ROI。 + + +## 8. #1014/#1015 条件 UV 修补(`#2026081206` → `#2026081207`) + +### 8.1 问题(#1205 交付 FAIL) + +| 项 | 事实 | +|----|------| +| #1013 | FT pub 前 **无条件** `memset` 底 8 行 UV=`0x80` | +| 产品 | bot8 RGB≈(18,18,18) 平坦灰;above 场景色 → **每帧色度接缝** | +| 检测 | endurance 只判 G>R/B → **漏报**;643/643 产品 OK 有接缝 | +| 2h | 中止;脚本 green=0 **不可信** | + +### 8.2 内核 + +| 构建 | 改动 | 板验 | +|------|------|------| +| #1014 `#1206` | 仅当 UV 行相对上行 mean 差 >24 / 近 0/FF 才 `memcpy` 扩展上行 | mon→sw:**seam 仍 9**(暗场 incomplete UV≈0x80,thr 过高) | +| **#1015 `#1207`** | **中性 chroma 占比 >50%** 判坏;mean thr=**6**;坏行扩展 last-good;好行推进 ref | mon→sw + ROI **green=0/seam=0** | + +文件: + +- `capture_v39.c`:`rkisp_ft_uv_line_bad` / `rkisp_ft_sanitize_nv12_bot`(条件修补,禁止无条件 0x80) +- `rkisp.c`:warm=2 gate=2 注释 #1015 +- `DLCVCAM_BUILD_VERSION`:`2026081207` +- 镜像:`boot-rk3576-6.1.99-2026081207-01726d6f-dirty.img` + +### 8.3 检测脚本(同步) + +`scripts/imx296_day_endurance.py` `analyze_jpeg`: + +- **classic**:G 主导 / pure 绿底(原逻辑) +- **seam**:bot8 平坦近灰 + 跳过 bot 上 8 行后的参考带有色 + mean_l1;或 mid 有色 + mid_l1 +- `green = classic OR seam`;明细含 `classic_green` / `seam` + +板端:`/userdata/imx296_warmup_ab/scripts/imx296_day_endurance_1207.py` +矩阵:`verify_k1207_sw.py`、`verify_k1207_roi_ws.py` + +### 8.4 板验 `#2026081207`(双判) + +**mon→software** `/userdata/imx296_warmup_ab/verify_k1207_sw/` ×6 head12: + +| 指标 | 结果 | +|------|------| +| green / classic / seam | **0 / 0 / 0**(72 帧) | +| first_hit | 全 **1** | +| head bot | 场景色 e.g. `(21.9,15.8,30.0)`,**非** (18,18,18) | +| apply 有效 | 2.78–4.31s(R2 CamOS 90s ReadTimeout 与绿无关;空切 0.01s) | +| dmesg arm | warmup 2 skip 2 drop 2 pub_gate 2 | + +**ROI set/reset(WS `rkcam_set_roi`)** `/userdata/imx296_warmup_ab/verify_k1207_roi_ws/`: + +| 步 | dim | green | +|----|-----|-------| +| base full | 1456×1088 | 0 | +| roi 640×480 | 640×480 | 0 | +| roi 800×600 | 800×600 | 0 | +| reset | 1456×1088 | 0 | +| roi 512×384 | 512×384 | 0 | +| reset2 | 1456×1088 | 0 | + +first_hit 全 1;`quality_pass=true`;`dim_changed=true`(ROI 真生效)。 + +### 8.5 对照 + +| 构建 | classic | 接缝 | 检测可信 | 结论 | +|------|---------|------|----------|------| +| #1205 #1013 | 0 | **每帧** | 否 | FAIL | +| #1206 #1014 | 0 | 头枪仍有 | 是 | 条件 thr 不够 | +| **#1207 #1015** | **0** | **0** | **是** | 短矩阵 PASS | + +### 8.6 未做 / 下一步 + +- 长时 endurance(2h line0)尚未用新检测重跑 +- line0 外部边沿短矩阵可选 +- CamOS 偶发 apply 90s 仍与绿线无关 + +--- + +*记录:2026-08-12 晚;板 180;#1015 `#2026081207` mon→sw + ROI 双判 green=0。* + + +--- + +## 9. #1016 + 新检测 `#2026081301`(2026-08-13) + +### 9.1 内核 + +| 项 | 内容 | +|----|------| +| 构建 | `#2026081301` / `boot-rk3576-6.1.99-2026081301-01726d6f-dirty.img` | +| 策略 | 条件 UV 尾修补:mean thr=6;**neu 占比仅当 ref 有 chroma**(`REF_CHROMA_MIN=10`);坏行 `memcpy` last-good;禁止整尾 0x80 | +| quarantine | warm=2 gate=2 skip=2 drop=2(**未加长**) | + +### 9.2 新检查方法(替换旧 G-only) + +`scripts/imx296_bottom_quality.py`(Pillow,板端可用): + +| 信号 | 含义 | +|------|------| +| **classic** | 底带比 above/mid **更** G 主导(避免全局绿场景误报) | +| **seam** | bot 平坦近灰 + 场景有色 + L1(抓 #1013 接缝) | +| **tear** | 行跳变 / 强 L1 不连续 | +| `green/bad` | classic ∨ seam ∨ tear | + +接入:`imx296_day_endurance.py`、`verify_k1301_lat.py`。 +已知 #1013 产品样例:seam=True(回归检测有效)。 + +### 9.3 板验(含切换耗时)`/userdata/imx296_warmup_ab/verify_k1301_lat/` + +**mon→software ×8**(仅统计 real_switch apply≥0.5s): + +| 指标 | 结果 | +|------|------| +| bad / classic / seam / tear | **0 / 0 / 0 / 0** | +| first_hit | 全 **1** | +| apply min/avg/max | **2.832 / 2.869 / 2.902 s** | +| ≤3s / ≤5s | **PASS / PASS** | +| 全部 real apply | `[2.902, 2.880, 2.879, 2.876, 2.845, 2.832, 2.836, 2.900]` | + +**ROI WS set/reset**:base→640→800→reset→512→reset2;尺寸真变;bad=0;first=1。 + +| 总判 | quality_pass | latency_pass_goal(≤3s) | latency_pass_hard(≤5s) | +|------|--------------|------------------------|-------------------------| +| | **true** | **true** | **true** | + +### 9.4 后续缩短耗时(方向,未做) + +当前 ~2.87s 已在 2–3s 目标内。若要再压: + +1. CamOS 侧:warmup 脉冲/prime/cooldown(**先质量再减**) +2. 内核:在 seam/classic=0 前提下评估 warm/gate 1 或略减 force_post(需双判+时延矩阵) +3. 勿再靠加长 quarantine + +板 `/userdata` 仅 1G:旧 verify 帧已清理;产物勿堆满。 + + +--- + +## 10. 真实验收(smartcam_bs 用户场景,`#2026081301`) + +### 10.1 环境 + +| 项 | 内容 | +|----|------| +| 板 | 180 `root@192.168.1.180` | +| 内核 | `#2026081301` / #1016 条件 UV 尾修补 | +| CamOS 启动 | **`/root/smartcam_bs/0_测试运行_camos.sh`**(官方测试脚本,含 frontend build + `python server.py`) | +| runtime | 默认;**无**额外 warmup/gate 覆盖 | +| n1trace | off | +| 检测 | `imx296_bottom_quality`(classic / seam / tear) | +| 脚本 | `scripts/verify_accept_user.py` | +| 产物 | 板 `/userdata/imx296_warmup_ab/verify_accept_user/`;主机 `IMX296_verify_accept_user_1301/` | + +### 10.2 场景矩阵 + +| 场景 | 次数 | 说明 | +|------|------|------| +| mon→software | ×6,每轮 head=12 | PATCH flow_config;测 apply 墙钟 + 底带 + first_hit + tag 唯一 | +| N-1 software | 连续 16 次 soft trigger | 每枪新 `x-frame-tag`;first=1;非冻结 mean | +| mon→line0 | ×4,head=12 | `trigger_mode=line0` + sysfs `4-001a/trigger` | +| N-1 line0 | 连续 16 次 XTRIG | 同上 | +| ROI WS | base→640→800→reset→512→reset2 | `rkcam_set_roi` / `rkcam_reset_roi` | + +### 10.3 结果(ACCEPT **PASS**) + +| 项 | 结果 | +|----|------| +| bad_total / classic / seam / tear | **0 / 0 / 0 / 0** | +| first_hit 全 1 | **true** | +| tag 唯一(无重复发布帧) | **true** | +| n1_pass software + line0 | **true / true**(16/16,miss=0,frozen=false) | +| ROI 尺寸真变 | 1456→640→800→1456→512→1456 | +| apply real n=10 | min **2.791** / avg **2.835** / max **2.866** s | +| ≤3s / ≤5s | **PASS / PASS** | +| `accept_pass` | **true** | + +说明:进 trigger 后 tag 常从 1 重计(prev 监控 tag→1 的 delta 为负属复位,**不**判 N-1);序列内 tag 严格 1,2,3… 递增且每枪新帧。 + +### 10.4 关注点核对 + +| 用户关注 | 结论 | +|----------|------| +| 切到触发后绿帧 | 未检出 | +| 底部绿线(classic) | 0 | +| 底部接缝(#1013 灰缝) | 0 | +| 底部串帧/tear | 0 | +| 第 n 次触发输出第 n-1 帧 | 未出现:first_hit=1、tag 唯一递增、无 miss | +| 模式切换耗时 | ~2.83s,在 2–3s 目标内、硬上限 5s 内 | + +### 10.5 备注 + +- CamOS monitor 侧 `trigger_mode` 规范值为 **`close`**(`none` 会映射为 close)。 +- 验收帧已从板端清理,仅留 `summary.json` + n1 rows,避免 `/userdata` 爆满。 +- 长时 endurance 仍可用新检测另开;本轮为用户场景短矩阵真实验收。 + +--- + +*记录:2026-08-13;板 180;`#2026081301` + smartcam_bs 默认 CamOS;ACCEPT PASS。* diff --git "a/IMX296_\351\227\256\351\242\230\345\205\250\346\231\257_\347\273\231\345\220\216\347\273\255Agent_20260820.md" "b/IMX296_\351\227\256\351\242\230\345\205\250\346\231\257_\347\273\231\345\220\216\347\273\255Agent_20260820.md" new file mode 100644 index 0000000000000..473ea5a35af4a --- /dev/null +++ "b/IMX296_\351\227\256\351\242\230\345\205\250\346\231\257_\347\273\231\345\220\216\347\273\255Agent_20260820.md" @@ -0,0 +1,125 @@ +# IMX296 / V39 FT 问题全景(给后续 Agent) + +> 修订:2026-08-24(补 148 / 测法陷阱;勿当 08-20 冻结稿) +> 内核:`fix/imx296-fast-trigger-n1-pwm-period` @ `12abd272` / `#2026082001` +> 交付:`boot-rk3576-6.1.99-2026082001-12abd272.img` +> sha256:`46bc9873845bc4f5caf539cc745b6d7b4294fdf0c02440c4bc8ca71fee372b85` +> 板:84=`192.168.1.84` DLCVCAM-3585;180=`192.168.1.180` DLCVCAM-49e7;**148=`192.168.1.148` DLCVCAM-5f95** +> 硬件:sensor `4-001a`,capture `/dev/video11`,曝光/增益常 `/dev/v4l-subdev3` +> CamOS:**不在本仓**,三板不是同一份 grabber。84/180 常见 `/root/smartcam_bs` + `/root/CamOS`;**148 cwd `/data/dlcv/CamOS`**(与 `/root/dlcv/CamOS` 可同 inode)。HTTP `:8000`。 +> 机理附录:`IMX296_N1相位滞后_诊断与修复记录.md`、`IMX296_绿线_20260812_gate_IRQ修复与验证.md`。刷机见 `DLCVCAM内核编译与更新操作手册.md`。早期 24h/4h 与 0810 ceiling-force 过程稿已从树中移除,口径以本文为准。 + +--- + +## 0. 硅上事实(所有底带问题的同一根) + +FT:一拍 XTRIG **开** MIPI 帧,**下一拍**才关。V39 上 `MI Y/CB_OFFS_CNT_SHD` 开帧期间常整帧为 0。abandon 等 MI FE → **稳定 N-1**。因此每帧 **ceiling-force**:`wait_line ≈ height−8`,hold 后再 `vb2_done`。底 **~8 行**经常没写完 → UV 绿、灰缝、Y 亮度串帧,都在这条缝上。 + +内核 quarantine(`rkisp.c`,**禁止加长**):skip/drop/warm/gate **全 2**(约 8 complete;CamOS dummy×3 先烧掉一部分)。`TAIL_WAIT=45000`,`POST_MI=25000`,`FORCE_POST=70000`,`WARMUP_POST=85000`。 + +FT 下 `V4L2_CID_EXPOSURE` **no-op**;曝光 = PWM `trigger_pulse_us`(CamOS:`pulse ≈ exposure_us − 14`)。`GAINDLY=0x08` 当次生效,无 REGHOLD 切枪。 + +**禁止(已用失败实验打过):** 加长 warm/gate/CamOS post residual 消绿;无条件 UV `0x80`;无条件刷底 8 行 Y;伪造 `appsink_seen`;把 ROI warmup `min` 打回 1;为消绿再减 dummy×3;commit 带 `Co-Authored-By: Claude`。 + +画质:`scripts/imx296_bottom_quality.py`(classic / seam / tear)。**不要只看 green=0。** +n1trace 默认关;诊断:`echo Y > /sys/module/video_rkisp/parameters/n1trace`。 + +刷机:`python3 scripts/dlcvcam_verify_bootimg.py verify --require-sidecar` → +`dd if= of=/dev/disk/by-partlabel/boot bs=4M conv=fsync` → reboot → `uname -v`。 +84 重启后 **systemd `camos` 是 disabled**,需手动:`cd /root/smartcam_bs && python server.py --host 0.0.0.0 --port 8000`。 +148 **不要**跑 `0_测试运行_camos.sh`(会停服务、重建前端);用已有 `python server.py --host 0.0.0.0`。控制走 **`/ws/cmd`**(cookie `access_token`),`/ws` 只读,没有 REST `set_exposure`。 + +--- + +## 1. 看到 NG 先分类 + +| 样子 | 家族 | 层 | 不该当 | +|------|------|----|--------| +| 底 8 行 G 明显高于 R/B,主体正常 | classic UV 绿带 | 内核 sanitize UV | 整帧脏、WB | +| 底 8 行平灰、主体有色 | #1013 无条件 0x80 | 勿把该逻辑加回 | classic 绿 | +| 底 8 行亮度差一截,缝在 `h-8` | Y 串帧 / 曝光跳 | 内核 sanitize Y | UV 绿 | +| **整幅**橄榄,常 `(~73,98,45)` | 脏缓冲或 WB 未写 ISP | **CamOS** | 底缝 sanitize | +| 第 N 枪是第 N−1 枪曝光 | N-1 相位 | 内核 ceiling-force | 底带 | +| 无触发帧数 +1(常整幅绿) | line0 late complete | CamOS irq 闸 | 底 UV | +| 真触发被吞 `drop consumed` | post-arm drop | CamOS | 加长 gate | +| preview 503 / `streaming=0` | ROI media-ctl / pool | CamOS | 内核绿线 | +| `/preview/frame` 的 `X-Frame-Tag` 在 apply 期间 +N | **hold-last 预览 tag ≠ 入模 seq** | CamOS 编码层 | 当 first_hit / 无触发 +1 | + +点名(84,修前):`/root/CamOS/images/2026-08-20/NG/20260820_112555_813_632100.jpg` +body~46,bot8~120,dY≈+74,跳在 `h-8` → **Y 串帧**。 + +--- + +## 2. 缺陷(按还要不要动手) + +### 内核已修,勿回退 + +**N-1** — 复现:gain=0,曝光 min↔max 交替,每改一枪看中心 luma 是否跟**当前** pulse。修:`capture.c` ceiling-force;`imx296.c` 进 FT dummy×3(800µs);free_run 忽略 XTRIG。`#2026080601` 起。附录 N-1 文。 + +**入模脏帧 / IRQ 烧 gate** — #1008 曾在 warm 期把 `pub_gate` 烧光。#1009:drop→warm→gate 在 WORK **和** IRQ 都遵守;warm>0 时 gate 不减。**不能**靠把 gate 加到 8/12/13 消绿。附录 0812 文。 + +**底 UV 绿带 #1016** — ceiling-force 后 chroma 尾未写完。`rkisp_ft_sanitize_nv12_bot` **条件**修 UV(stuck、ref 有 chroma 时的中性占比、mean 跳>6、classic G),last-good memcpy。`821edce1` / `#2026081301`。180 ACCEPT bad=0,apply avg 2.835s。无条件 0x80(#1013)已用 endurance #1205 FAIL 否决,不要加回。 + +**底 Y 串帧 #1017(当前内核工作)** — 84、gain=240、1500↔15000 头几帧底亮/暗一截。180 同内核可没有。PWM 大跳后 Y 尾仍是上一档或未写完;#1016 只修 UV。同一 sanitize:Y 底 8 行相对 pre-tail mean 跳 **>12** 且坏行 **≥4** → 整带 memcpy 上一行。`12abd272` / `#2026082001`。84 一轮:1500→15000 max \|dY\|=2.23(修前≈74);反向 0.67;tear=0。 +副作用:坏带被上一行纹理盖住,不是真读出。未做:soak、真 GPIO line0、ROI 跳、180 回归。 +不要和更早的「短曝后 PWM period 未恢复」(`4d2a190`)搞混——那是 period,不是 Y 尾。 + +**其它已合入、本地图不展开:** ROI 后交付当前 FT 帧(`3eef7dac`);procfs MMIO 门闩防 teardown SError(`1a89f1c`);n1trace 默认关(`01726d6`)。 + +### CamOS(本仓改不到;84≠180) + +整幅绿 = 脏缓冲或 AWB 默认,sanitize 只动底 8 行。改 grabber 必须重启 `server.py`。下列长期在 **180 工作区**,memory 写未 commit/未 push。 + +| 问题 | 复现 | 现行修 | 坑 | +|------|------|--------|----| +| 冷启动 line0 首枪 +2/绿 | 持久化 line0 重启 CamOS | close→FT `hard_reset` | 曾 `drop=1`,会吞真枪 | +| 无触发仍 +1 整幅绿 | 稳态 mon→line0 不打 GPIO | 开闸后要 `trigger-dev irq` 相对基线增量 | `echo 1 > 4-001a/trigger` **不增 irq** | +| 真 GPIO 首枪被吞 | `drop consumed=1` | **`POST_ARM_USER_DROP` 默认 0** | 贴边残帧只丢不扣额度 | +| 仅冷启动首枪整幅橄榄、第二枪正常 | `20260814_141955` (72.5,98,45) | WB **pre-warmup + pre-gate** 各写一次 | 禁止再用 drop 吞首枪 | +| ROI preview 503 | HW ROI sensor `set_crop` | 全幅/ROI 优先 **media-ctl ISP pad crop** | 板 commit 曾 `9b39efd`,是否 push 未知 | +| mon→trig 墙钟 | `set_trigger_mode` 到 apply done | 180 fast warmup avg **2.26s**;默认 CamOS ~2.8s | ≤2.0s 是新产品要求;**不要减内核 skip/drop/warm/gate** | +| 触发态 ROI 墙钟 | 拆 pipeline | R8 avg 2.51s,6/6≤3s | 1s 物理不够;2s 中位可冲但计划未实施。量 `roi restart done total=`,不要 `roi_cmd_s`。勿 `min=1`/伪造 appsink | +| **148 line0 入模无 GPIO 仍 +1/+2** | 产品线是 `echo 1 > 4-001a/trigger`(**不增** trigger-dev irq) | `_line0_arming` 盖 warmup→snapshot;`REQUIRE_IRQ=1` 时 `irq_ok is not True` 且无 i2c mtime 则丢(堵住 None fail-open);appsink 信号 hold 到二次 irq snapshot(`hold_signals=1` → `resumed reason=line0-armed`) | 未加长 gate、未默认 `POST_ARM_USER_DROP=1`。预览 tag 仍可 +N。CamOS `fix/imx296-switch-latency-clean` 曾 ahead 未 push。备份 `gst_v4l2_grabber.py.bak.20260821_095918` | + +**测法(曾把「0 绿 / 0 N-1」测成假 PASS):** + +- 用 **CamOS** 路径,不要 GstV4L2Grabber 直连 `/dev/video11`(会和 CamOS 抢设备)。 +- 等 **入模/存图 seq** 或明确的新帧,不要把 `/preview/frame` hold-last `X-Frame-Tag` 当用户枪。 +- 判 N-1 先把增益打到 **0**,否则暗场 LO/HI 被压扁变假 MIX。 +- 板无硬件触发时:software 用 `/debug/trigger_once`;**产品 line0 用 i2c echo**;验收 first_hit 不要只用 GPIO irq(148 产品路径 echo 不增 irq)。 +- `rkisp_start` 会清 `stream->skip_frame`,丢头帧不能只靠它(用 `early_done_drop_left` 等跨 start 计数)。 +- 早期文档写「residual 超时绝不 force complete」——那是 **#1008 前** 策略,已被 ceiling-force 取代;以 N-1 附录 + 现行 `rkisp.c` 为准。 + +--- + +## 3. 代码入口 + +| 路径 | 作用 | +|------|------| +| `drivers/media/platform/rockchip/isp/capture.c` | ceiling-force / post hold | +| `drivers/media/platform/rockchip/isp/rkisp.c` | skip/drop/warm/gate、hold us、n1trace | +| `drivers/media/platform/rockchip/isp/capture_v39.c` | `rkisp_ft_sanitize_nv12_bot`(#1016 UV + #1017 Y),`mi_frame_end` pub 前 | +| `drivers/media/i2c/imx296.c` | FT / PWM / dummy×3 / free_run 忽略 XTRIG | +| `scripts/imx296_bottom_quality.py` | classic / seam / tear | +| `scripts/verify_accept_user.py` | mon→sw、line0、N-1 tag、ROI;默认 `/ws/cmd` | +| `scripts/verify_k1301_lat.py` | apply 墙钟;默认 `/ws/cmd` | +| `scripts/imx296_day_endurance.py` | 长稳;默认 `/ws/cmd` | +| `scripts/imx296_148_extra.py` | §4 E/F/I/J 补验(Y 跳、idle line0、pulse、n1trace) | +| `build_bootimg.sh` | 命名 `boot-rk3576-6.1.99--.img`;未跟踪 md **不算** dirty | + +板:`smartcam_backend/camera/rkcam/_runtime/gst_v4l2_grabber.py`。 + +--- + +## 4. 验收(不加长 gate) + +1. `uname -v` = `#2026082001`。 +2. **Y**:84,FT,gain=240,1500↔15000,头 8 帧 \|dY\|≪10、`h-8` 无跳、tear=0。 +3. **UV**:mon→software ×6 head12,classic=0、seam=0。 +4. **N-1**:16 枪 unique tag;可选 LO/HI 中心 luma 跟当前 pulse。 +5. **first_hit**:mon→sw=1。line0:产品路径用 **i2c echo**(148);有真实 GPIO 的板另验 irq 增量。不要把预览 tag 当入模。 +6. **冷启动 / 入模 line0**:无触发时用户 seq 不涨;真首枪 +1 非整幅绿;允许 `drop skipped (irq-quota)`,禁止无故 `drop consumed` 真枪。 +7. **T_apply**:稳态 2–3s,硬上限 5s。 +8. **ROI**:尺寸真变、preview 非 503、first_hit=1。 + +84 上 #1017 只跑过 **software + 一来一回曝光跳**。CamOS 冷启动/irq/WB/ROI:**180 与 148 代码不同步**,不要假设 84 已有任一侧的 grabber。 diff --git a/Makefile b/Makefile index 84f782580f962..2eb37484ffc7b 100644 --- a/Makefile +++ b/Makefile @@ -203,6 +203,21 @@ endif this-makefile := $(lastword $(MAKEFILE_LIST)) abs_srctree := $(realpath $(dir $(this-makefile))) +# DLCVCAM release build identifier. Keep the Linux release and module path +# stable (for example 6.1.99-rk3576), while exposing a date-based identifier +# through UTS_VERSION (`uname -v`) on the target board. A command-line +# KBUILD_BUILD_VERSION still takes precedence for temporary/CI builds. +DLCVCAM_BUILD_VERSION_FILE := $(abs_srctree)/DLCVCAM_BUILD_VERSION +DLCVCAM_BUILD_VERSION := $(strip $(shell cat $(DLCVCAM_BUILD_VERSION_FILE) 2>/dev/null)) +DLCVCAM_BUILD_VERSION_VALID := $(shell printf '%s\n' '$(DLCVCAM_BUILD_VERSION)' | \ + grep -Eq '^[0-9]{8}(0[1-9]|[1-9][0-9])$$' && echo y) +ifneq ($(DLCVCAM_BUILD_VERSION_VALID),y) +$(error DLCVCAM_BUILD_VERSION must use YYYYMMDDNN with NN in 01..99) +endif +KBUILD_BUILD_VERSION ?= $(DLCVCAM_BUILD_VERSION) + +export DLCVCAM_BUILD_VERSION KBUILD_BUILD_VERSION + ifneq ($(words $(subst :, ,$(abs_srctree))), 1) $(error source directory cannot contain spaces or colons) endif @@ -286,7 +301,8 @@ no-dot-config-targets := $(clean-targets) \ cscope gtags TAGS tags help% %docs check% coccicheck \ $(version_h) headers headers_% archheaders archscripts \ %asm-generic kernelversion %src-pkg dt_binding_check \ - outputmakefile rustavailable rustfmt rustfmtcheck + outputmakefile rustavailable rustfmt rustfmtcheck \ + dlcvcam-build-version # Installation targets should not require compiler. Unfortunately, vdso_install # is an exception where build artifacts may be updated. This must be fixed. no-compiler-targets := $(no-dot-config-targets) install dtbs_install \ @@ -1687,6 +1703,7 @@ help: @echo ' gtags - Generate GNU GLOBAL index' @echo ' kernelrelease - Output the release version string (use with make -s)' @echo ' kernelversion - Output the version stored in Makefile (use with make -s)' + @echo ' dlcvcam-build-version - Output the date-based DLCVCAM build identifier' @echo ' image_name - Output the image name (use with make -s)' @echo ' headers_install - Install sanitised kernel headers to INSTALL_HDR_PATH'; \ echo ' (default: $(INSTALL_HDR_PATH))'; \ @@ -2095,7 +2112,7 @@ coccicheck: export_report: $(PERL) $(srctree)/scripts/export_report.pl -PHONY += checkstack kernelrelease kernelversion image_name +PHONY += checkstack kernelrelease kernelversion dlcvcam-build-version image_name # UML needs a little special treatment here. It wants to use the host # toolchain, so needs $(SUBARCH) passed to checkstack.pl. Everyone @@ -2116,6 +2133,9 @@ kernelrelease: kernelversion: @echo $(KERNELVERSION) +dlcvcam-build-version: + @echo $(KBUILD_BUILD_VERSION) + image_name: @echo $(KBUILD_IMAGE) diff --git a/build_bootimg.sh b/build_bootimg.sh new file mode 100755 index 0000000000000..a928a6902eea8 --- /dev/null +++ b/build_bootimg.sh @@ -0,0 +1,290 @@ +#!/bin/bash +# DLCVCAM RK3576 内核自动编译脚本 +# - 自动判断初次配置 / 增量编译 +# - 若 DLCVCAM_BUILD_VERSION 对应提交与当前 HEAD 不同,则按当天日期自动 bump +# - 产出带构建编号与短提交的 boot 镜像:boot-rk3576-6.1.99-YYYYMMDDNN-.img +# - 同时生成整包完整性 sidecar(不使用签名,仅 .sha256): +# .sha256 整包 SHA-256(防传错包/截断;FIT 内嵌 hash 另由 verify 检查) +# 板端烧录前:python3 scripts/dlcvcam_verify_bootimg.py verify --require-sidecar +# +# 用法: +# ./build_bootimg.sh # 自动判断配置并编译 +# ./build_bootimg.sh --force-config # 强制重新 defconfig + merge +# ./build_bootimg.sh --no-bump # 不自动更新 DLCVCAM_BUILD_VERSION +# ./build_bootimg.sh -j4 # 指定并行度 +# ./build_bootimg.sh --clean-config # 删除 .config 后走初次配置 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +ARCH=arm64 +CROSS_COMPILE=aarch64-linux-gnu- +DEFCONFIG=lubancat_linux_rk3576_defconfig +CUT_CONFIG=arch/arm64/configs/dlcvcam_rk3576_kernel_cut.config +DTB_NAME=dlcvcam-rk3576.dtb +BUILD_VERSION_FILE=DLCVCAM_BUILD_VERSION +KERNEL_VER_PREFIX="6.1.99" +JOBS="$(nproc 2>/dev/null || echo 8)" +FORCE_CONFIG=0 +NO_BUMP=0 +CLEAN_CONFIG=0 + +log() { printf '\033[1;34m[%s]\033[0m %s\n' "$(date '+%H:%M:%S')" "$*"; } +ok() { printf '\033[1;32m[%s]\033[0m %s\n' "$(date '+%H:%M:%S')" "$*"; } +warn() { printf '\033[1;33m[%s]\033[0m %s\n' "$(date '+%H:%M:%S')" "$*" >&2; } +die() { printf '\033[1;31m[%s]\033[0m %s\n' "$(date '+%H:%M:%S')" "$*" >&2; exit 1; } + +usage() { + sed -n '2,12p' "$0" | sed 's/^# \?//' + exit 0 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) usage ;; + --force-config) FORCE_CONFIG=1; shift ;; + --clean-config) CLEAN_CONFIG=1; shift ;; + --no-bump) NO_BUMP=1; shift ;; + -j*) + if [[ "$1" == "-j" ]]; then + JOBS="${2:?-j 需要并行数}" + shift 2 + else + JOBS="${1#-j}" + shift + fi + ;; + *) + die "未知参数: $1 (见 --help)" + ;; + esac +done + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "缺少命令: $1" +} + +need_cmd make +need_cmd lz4 +need_cmd git +need_cmd "${CROSS_COMPILE}gcc" + +# ---------- 构建编号:相对上次提交该文件的 commit 是否变化 ---------- +# 规则: +# 1. 若 DLCVCAM_BUILD_VERSION 相对 HEAD 有未提交修改,且内容已是合法编号,则沿用工作区值 +# 2. 否则看「最后一次提交该文件的 commit」是否等于当前 HEAD: +# - 相等:说明本提交就是 bump 提交,沿用文件内容 +# - 不等:说明 HEAD 相对上次 bump 又有新提交,按当天日期自动 bump(同日递增 NN) +bump_build_version_if_needed() { + local today current last_bump_commit head_commit date_part seq new_ver + + [[ -f "${BUILD_VERSION_FILE}" ]] || die "缺少 ${BUILD_VERSION_FILE}" + + current="$(tr -d '[:space:]' < "${BUILD_VERSION_FILE}")" + [[ "${current}" =~ ^[0-9]{8}(0[1-9]|[1-9][0-9])$ ]] \ + || die "${BUILD_VERSION_FILE} 格式非法: '${current}'(需要 YYYYMMDDNN,NN=01..99)" + + if [[ "${NO_BUMP}" -eq 1 ]]; then + warn "跳过版本自动 bump(--no-bump),使用: ${current}" + echo "${current}" + return + fi + + # 工作区已改过该文件:尊重用户/上次脚本写好的值 + if ! git diff --quiet -- "${BUILD_VERSION_FILE}" 2>/dev/null \ + || ! git diff --cached --quiet -- "${BUILD_VERSION_FILE}" 2>/dev/null; then + log "${BUILD_VERSION_FILE} 工作区已修改,沿用: ${current}" + echo "${current}" + return + fi + + head_commit="$(git rev-parse HEAD)" + last_bump_commit="$(git log -1 --format='%H' -- "${BUILD_VERSION_FILE}" 2>/dev/null || true)" + + if [[ -n "${last_bump_commit}" && "${last_bump_commit}" == "${head_commit}" ]]; then + log "${BUILD_VERSION_FILE} 已在当前 HEAD 提交,沿用: ${current}" + echo "${current}" + return + fi + + today="$(date +%Y%m%d)" + date_part="${current:0:8}" + seq="${current:8:2}" + # 去掉前导零做算术 + seq=$((10#${seq})) + + if [[ "${date_part}" == "${today}" ]]; then + seq=$((seq + 1)) + if [[ "${seq}" -gt 99 ]]; then + die "当天构建序号已超过 99,请手工处理 ${BUILD_VERSION_FILE}" + fi + new_ver="$(printf '%s%02d' "${today}" "${seq}")" + else + new_ver="${today}01" + fi + + printf '%s\n' "${new_ver}" > "${BUILD_VERSION_FILE}" + ok "HEAD 相对上次 ${BUILD_VERSION_FILE} 提交已变化,自动 bump: ${current} -> ${new_ver}" + echo "${new_ver}" +} + +# ---------- 初次配置 vs 增量 ---------- +need_initial_config() { + [[ "${FORCE_CONFIG}" -eq 1 ]] && return 0 + [[ "${CLEAN_CONFIG}" -eq 1 ]] && return 0 + [[ ! -f .config ]] && return 0 + + # 关键配置标记:本地版本与 ARM64,避免误用其它板级 .config + if ! grep -q '^CONFIG_ARM64=y' .config 2>/dev/null; then + warn ".config 不是 ARM64 配置,将重新配置" + return 0 + fi + if ! grep -q 'CONFIG_LOCALVERSION="-rk3576"' .config 2>/dev/null; then + # 裁剪配置合并后通常带 -rk3576;没有也不强制失败,仅提示 + warn ".config 未包含 CONFIG_LOCALVERSION=\"-rk3576\",仍按增量处理(可用 --force-config)" + fi + return 1 +} + +do_initial_config() { + log "初次配置:${DEFCONFIG} + ${CUT_CONFIG}" + if [[ "${CLEAN_CONFIG}" -eq 1 && -f .config ]]; then + rm -f .config .config.old + fi + + make ARCH="${ARCH}" CROSS_COMPILE="${CROSS_COMPILE}" "${DEFCONFIG}" + [[ -f "${CUT_CONFIG}" ]] || die "缺少裁剪配置: ${CUT_CONFIG}" + ./scripts/kconfig/merge_config.sh -m .config "${CUT_CONFIG}" + make ARCH="${ARCH}" CROSS_COMPILE="${CROSS_COMPILE}" olddefconfig + ok "配置完成" +} + +do_build() { + log "编译 Image / dtbs / modules (-j${JOBS})..." + make ARCH="${ARCH}" CROSS_COMPILE="${CROSS_COMPILE}" \ + Image dtbs modules -j"${JOBS}" + + log "压缩 Image -> Image.lz4 ..." + lz4 -f arch/arm64/boot/Image arch/arm64/boot/Image.lz4 + + log "打包 boot.img (DTB=${DTB_NAME})..." + BOOT_ITS=boot.its ./scripts/mkimg --dtb "${DTB_NAME}" + + [[ -f boot.img ]] || die "未生成 boot.img" +} + +is_worktree_dirty_except_build_version() { + # 忽略 DLCVCAM_BUILD_VERSION 本身(脚本可能刚 bump 过) + # 以及常见编译产物,避免误标 dirty + local line path + while IFS= read -r line; do + [[ -z "${line}" ]] && continue + path="${line:3}" + path="${path#\"}" + path="${path%\"}" + # rename: "old -> new" + if [[ "${path}" == *" -> "* ]]; then + path="${path##* -> }" + fi + case "${path}" in + "${BUILD_VERSION_FILE}") continue ;; + boot.img|resource.img|zboot.img|out|out/*) continue ;; + boot.img.sha256|boot.img.dlcvcam.json) continue ;; + boot-rk3576-*.img|boot-rk3576-*.img.sha256|boot-rk3576-*.img.dlcvcam.json) continue ;; + # legacy *.dlcvcam.json ignored if leftover from older builds + arch/arm64/boot/Image|arch/arm64/boot/Image.lz4) continue ;; + .config|.config.old) continue ;; + bad_packages|bad_packages/*) continue ;; + esac + return 0 + done < <(git status --porcelain --untracked-files=no 2>/dev/null || true) + return 1 +} + +write_bootimg_sha256_sidecar() { + # 只生成整包 .sha256;板端烧录前用 scripts/dlcvcam_verify_bootimg.py 做 + # 整包 SHA + FIT 内嵌 hash(无签名) + local img="$1" + local verify_py="${SCRIPT_DIR}/scripts/dlcvcam_verify_bootimg.py" + + [[ -f "${img}" ]] || die "write_bootimg_sha256_sidecar: 缺少 ${img}" + + # 清理旧版 JSON 清单(若存在),避免误当交付物 + rm -f "${img}.dlcvcam.json" + + if [[ -f "${verify_py}" ]] && command -v python3 >/dev/null 2>&1; then + python3 "${verify_py}" gen-sidecar "${img}" \ + || die "生成 .sha256 失败: ${img}" + # 自检:刚生成的包必须能通过 FIT 内嵌 hash + 整包 sha256 + python3 "${verify_py}" verify "${img}" --require-sidecar \ + || die "打包后自检失败(不应发生): ${img}" + return + fi + + # 兜底:无 python 时只写整包 sha256(无法做 FIT 自检) + warn "python3 或 scripts/dlcvcam_verify_bootimg.py 不可用,仅写入 ${img}.sha256(跳过 FIT 自检)" + need_cmd sha256sum + sha256sum "${img}" | awk -v n="$(basename "${img}")" '{print $1 " " n}' > "${img}.sha256" +} + +package_named_image() { + local build_ver short_sha out_name kernelrelease build_id git_desc + + build_ver="$(tr -d '[:space:]' < "${BUILD_VERSION_FILE}")" + short_sha="$(git rev-parse --short=8 HEAD)" + # 除版本文件/编译产物外还有改动时加 -dirty,避免与干净提交产物混淆 + if is_worktree_dirty_except_build_version; then + short_sha="${short_sha}-dirty" + fi + + out_name="boot-rk3576-${KERNEL_VER_PREFIX}-${build_ver}-${short_sha}.img" + cp -f boot.img "${out_name}" + + if command -v mkimage >/dev/null 2>&1; then + log "FIT 信息:" + mkimage -l boot.img || true + fi + + kernelrelease="$(make -s ARCH="${ARCH}" kernelrelease 2>/dev/null || true)" + build_id="$(make -s ARCH="${ARCH}" dlcvcam-build-version 2>/dev/null || echo "${build_ver}")" + git_desc="$(git rev-parse --short HEAD) ($(git rev-parse --abbrev-ref HEAD))" + + log "生成整包 .sha256 并自检(FIT 内嵌 hash + 整包 sha256)..." + write_bootimg_sha256_sidecar "boot.img" + write_bootimg_sha256_sidecar "${out_name}" + + ok "完成" + echo + echo " kernelrelease : ${kernelrelease:-n/a}" + echo " build version : ${build_id}" + echo " git : ${git_desc}" + echo " boot.img : $(ls -lh boot.img | awk '{print $5}')" + echo " 交付镜像 : ${out_name} ($(ls -lh "${out_name}" | awk '{print $5}'))" + echo " 整包校验 : ${out_name}.sha256" + echo + echo "烧录前在板卡上校验(不验签,仅 hash):" + echo " python3 scripts/dlcvcam_verify_bootimg.py verify ${out_name} --require-sidecar" + echo " # 或只验 FIT 内嵌 sha256(无 .sha256 时):" + echo " python3 scripts/dlcvcam_verify_bootimg.py verify ${out_name}" + echo + echo "下一步:校验通过后再用 RKDevTool / rkdeveloptool 烧录 ${out_name}" + echo " (或直接烧录 boot.img,内容相同)" +} + +# ---------- main ---------- +log "工作目录: ${SCRIPT_DIR}" +log "交叉编译: ${CROSS_COMPILE}gcc ($(${CROSS_COMPILE}gcc -dumpmachine 2>/dev/null || echo '?'))" + +BUILD_VER="$(bump_build_version_if_needed)" +log "本次 KBUILD_BUILD_VERSION / DLCVCAM_BUILD_VERSION = ${BUILD_VER}" + +if need_initial_config; then + do_initial_config +else + log "检测到已有 .config,走增量编译" +fi + +do_build +package_named_image diff --git a/drivers/input/misc/trigger-dev.c b/drivers/input/misc/trigger-dev.c index a87762f4ab486..c31a60ed0ec2d 100644 --- a/drivers/input/misc/trigger-dev.c +++ b/drivers/input/misc/trigger-dev.c @@ -13,6 +13,8 @@ * 2. trigger-path: write to sysfs "echo 1 > ..." - fallback for software trigger * - Optional result LEDs: ok-led-gpios / ng-led-gpios; sysfs "result" (ok/ng/off), * "result_led_enable"; each external trigger clears LEDs before camera action. + * - Runtime gate: sysfs "enable" (0/1). Disabling masks the input IRQ, cancels + * queued work and drops pending triggers before returning. * * When trigger-output-gpios is set, keep a single owner for that output GPIO. * If another node already claims the same pin, remove one side in DT. @@ -87,6 +89,11 @@ struct trigger_dev { bool mode_use_gpio; struct mutex mode_mutex; + /* 外部触发总门闩;默认开启,模式/ROI 切换期间由 sysfs enable 关闭 */ + bool enabled; + /* Serializes IRQ masking and pending-work cancellation. */ + struct mutex enable_mutex; + /* 结果指示灯:DT ok-led-gpios / ng-led-gpios;逻辑 0=inactive(灭) 1=active(亮) */ struct gpio_desc *ok_led_gpiod; struct gpio_desc *ng_led_gpiod; @@ -101,6 +108,7 @@ struct trigger_dev { atomic64_t irq_count; atomic64_t trigger_ok_count; atomic64_t trigger_fail_count; + atomic64_t trigger_suppressed_count; u32 max_pending; u64 last_irq_ns; }; @@ -163,9 +171,13 @@ static int trigger_dev_set_input_mode(struct trigger_dev *tdev, enum trigger_dev_input_mode mode) { unsigned int irq_type = trigger_dev_irq_type_for_mode(tdev, mode); + bool enabled; int ret; - disable_irq(tdev->irq); + mutex_lock(&tdev->enable_mutex); + enabled = READ_ONCE(tdev->enabled); + if (enabled) + disable_irq(tdev->irq); cancel_delayed_work_sync(&tdev->debounce_work); ret = irq_set_irq_type(tdev->irq, irq_type); @@ -175,7 +187,51 @@ static int trigger_dev_set_input_mode(struct trigger_dev *tdev, WRITE_ONCE(tdev->input_mode, mode); } - enable_irq(tdev->irq); + if (enabled) + enable_irq(tdev->irq); + mutex_unlock(&tdev->enable_mutex); + return ret; +} + +static int trigger_dev_set_enabled(struct trigger_dev *tdev, bool enabled) +{ + int dropped; + int ret = 0; + + mutex_lock(&tdev->enable_mutex); + if (enabled == READ_ONCE(tdev->enabled)) + goto out; + + if (!enabled) { + /* Publish the closed gate before waiting for any in-flight handler. */ + WRITE_ONCE(tdev->enabled, false); + disable_irq(tdev->irq); + cancel_delayed_work_sync(&tdev->debounce_work); + cancel_work_sync(&tdev->trigger_work); + + dropped = atomic_xchg(&tdev->trigger_pending, 0); + if (dropped > 0) + atomic64_add(dropped, &tdev->trigger_suppressed_count); + + if (tdev->output_gpiod) + gpiod_set_value_cansleep(tdev->output_gpiod, 0); + + if (tdev->pressed) { + tdev->pressed = false; + input_report_key(tdev->input, tdev->key_code, 0); + input_sync(tdev->input); + } + } else { + atomic_set(&tdev->trigger_pending, 0); + ret = trigger_dev_refresh_pressed_state(tdev); + if (ret) + goto out; + WRITE_ONCE(tdev->enabled, true); + enable_irq(tdev->irq); + } + +out: + mutex_unlock(&tdev->enable_mutex); return ret; } @@ -283,6 +339,11 @@ static void trigger_dev_trigger_work(struct work_struct *work) /* Drain pending triggers (presses) */ while (atomic_dec_if_positive(&tdev->trigger_pending) >= 0) { + if (!READ_ONCE(tdev->enabled)) { + atomic64_inc(&tdev->trigger_suppressed_count); + continue; + } + mutex_lock(&tdev->led_mutex); if (tdev->result_led_enable) trigger_dev_leds_off_locked(tdev); @@ -327,6 +388,9 @@ static void trigger_dev_trigger_work(struct work_struct *work) static void trigger_dev_handle_state(struct trigger_dev *tdev, bool pressed_now) { + if (!READ_ONCE(tdev->enabled)) + return; + if (pressed_now == tdev->pressed) return; @@ -375,6 +439,10 @@ static irqreturn_t trigger_dev_irq(int irq, void *dev_id) tdev->last_irq_ns = ktime_get_ns(); atomic64_inc(&tdev->irq_count); + if (!READ_ONCE(tdev->enabled)) { + atomic64_inc(&tdev->trigger_suppressed_count); + return IRQ_HANDLED; + } /* * Fast edge mode: @@ -516,7 +584,9 @@ static int trigger_dev_probe(struct platform_device *pdev) tdev->dev = dev; platform_set_drvdata(pdev, tdev); mutex_init(&tdev->mode_mutex); + mutex_init(&tdev->enable_mutex); mutex_init(&tdev->led_mutex); + WRITE_ONCE(tdev->enabled, true); /* * Primary DT property: input-gpios (con_id = "input") @@ -555,6 +625,7 @@ static int trigger_dev_probe(struct platform_device *pdev) atomic64_set(&tdev->irq_count, 0); atomic64_set(&tdev->trigger_ok_count, 0); atomic64_set(&tdev->trigger_fail_count, 0); + atomic64_set(&tdev->trigger_suppressed_count, 0); tdev->max_pending = 0; tdev->last_irq_ns = 0; @@ -590,7 +661,7 @@ static int trigger_dev_probe(struct platform_device *pdev) gpio = desc_to_gpio(tdev->input_gpiod); active_low = gpiod_is_active_low(tdev->input_gpiod); - dev_info(dev, "ready: gpio=%d irq=%d active_low=%d input_mode=%s debounce_ms=%u key_code=%u mode=%s\n", + dev_info(dev, "ready: gpio=%d irq=%d active_low=%d input_mode=%s debounce_ms=%u key_code=%u mode=%s enabled=%d\n", gpio, tdev->irq, active_low, @@ -598,7 +669,8 @@ static int trigger_dev_probe(struct platform_device *pdev) tdev->debounce_ms, tdev->key_code, tdev->output_gpiod ? - "direct-gpio-pulse" : "sysfs-write"); + "direct-gpio-pulse" : "sysfs-write", + READ_ONCE(tdev->enabled)); if (tdev->input_mode == TRIGGER_DEV_INPUT_MODE_BUTTON && !tdev->debounce_ms) dev_warn(dev, "button input mode is selected with debounce-ms=0; mechanical keys may still bounce\n"); @@ -666,6 +738,36 @@ static ssize_t input_mode_store(struct device *dev, } static DEVICE_ATTR_RW(input_mode); +/* Sysfs: enable (0=屏蔽外部触发并清空 pending, 1=允许外部触发) */ +static ssize_t enable_show(struct device *dev, + struct device_attribute *attr, char *buf) +{ + struct trigger_dev *tdev = dev_get_drvdata(dev); + + return sysfs_emit(buf, "%d\n", READ_ONCE(tdev->enabled)); +} + +static ssize_t enable_store(struct device *dev, + struct device_attribute *attr, + const char *buf, size_t count) +{ + struct trigger_dev *tdev = dev_get_drvdata(dev); + bool enabled; + int ret; + + ret = kstrtobool(buf, &enabled); + if (ret) + return ret; + + ret = trigger_dev_set_enabled(tdev, enabled); + if (ret) + return ret; + + dev_info(dev, "external trigger %s\n", enabled ? "enabled" : "disabled"); + return count; +} +static DEVICE_ATTR_RW(enable); + /* Sysfs: mode (gpio|sysfs) */ static ssize_t mode_show(struct device *dev, struct device_attribute *attr, char *buf) { @@ -874,10 +976,12 @@ static ssize_t stats_show(struct device *dev, struct device_attribute *attr, cha u64 ago_us = tdev->last_irq_ns ? div_u64(now - tdev->last_irq_ns, 1000) : 0; return sysfs_emit(buf, - "irq=%lld ok=%lld fail=%lld pending=%d max_pending=%u last_irq_ago_us=%llu input_mode=%s debounce_ms=%u\n", + "enabled=%d irq=%lld ok=%lld fail=%lld suppressed=%lld pending=%d max_pending=%u last_irq_ago_us=%llu input_mode=%s debounce_ms=%u\n", + READ_ONCE(tdev->enabled), atomic64_read(&tdev->irq_count), atomic64_read(&tdev->trigger_ok_count), atomic64_read(&tdev->trigger_fail_count), + atomic64_read(&tdev->trigger_suppressed_count), atomic_read(&tdev->trigger_pending), tdev->max_pending, ago_us, @@ -888,6 +992,7 @@ static DEVICE_ATTR_RO(stats); static struct attribute *trigger_dev_attrs[] = { &dev_attr_input_mode.attr, + &dev_attr_enable.attr, &dev_attr_mode.attr, &dev_attr_pulse_count.attr, &dev_attr_pulse_interval_us.attr, @@ -930,5 +1035,3 @@ module_platform_driver(trigger_dev_driver); MODULE_DESCRIPTION("Generic GPIO input trigger device (camera trigger + optional result LEDs)"); MODULE_LICENSE("GPL"); MODULE_IMPORT_NS(VFS_internal_I_am_really_a_filesystem_and_am_NOT_a_driver); - - diff --git a/drivers/media/i2c/imx296.c b/drivers/media/i2c/imx296.c index 0eae71b874623..2edabee194b1c 100644 --- a/drivers/media/i2c/imx296.c +++ b/drivers/media/i2c/imx296.c @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -111,6 +112,7 @@ #define IMX296_GAIN IMX296_REG_16BIT(0x3204) #define IMX296_GAINDLY IMX296_REG_8BIT(0x3212) +#define IMX296_GAINDLY_0FRAME 0x08 #define IMX296_GAINDLY_1FRAME 0x09 #define IMX296_BLKLEVEL IMX296_REG_16BIT(0x3254) @@ -146,6 +148,12 @@ */ #define IMX296_HMAX_DEFAULT 1100U #define IMX296_VBLANK_DEFAULT 1162U +/* + * Fast Trigger does not use the 30 fps free-run blanking budget. Sony's + * timing requirement is VTR = ROIWV1 + 30 for vertical ROI, otherwise the + * ROI frame stays open until the next XTRIG and vb2_done is delayed one shot. + */ +#define IMX296_XTRIG_VBLANK 30U #define IMX296_VBLANK_MAX (1048575U - IMX296_PIXEL_ARRAY_HEIGHT) #define IMX296_EXPOSURE_DEFAULT_LINES 1104U #define IMX296_ANALOG_GAIN_MIN 0U @@ -173,11 +181,58 @@ #define V4L2_CID_IMX296_LIGHT_SOURCE_ADVANCE_US (V4L2_CID_USER_IMX296_BASE + 0x4) #define V4L2_CID_IMX296_LIGHT_SOURCE_OFF_DELAY_US (V4L2_CID_USER_IMX296_BASE + 0x5) +/* Datasheet: changing ROI geometry produces one invalid output frame. */ +#define IMX296_ROI_INVALID_FRAME_WAIT_US 50000U +/* + * After leaving fast-trigger, one short XTRIG is needed so the sensor closes the + * open MIPI frame before free-run stream_on. Without it ISP can latch a green + * or half-written buffer on the next free-run frames. + */ +#define IMX296_XTRIG_CLOSE_FRAME_WAIT_US 50000U +/* + * Enter-FT / ROI-boundary dummies temporarily force this short pulse so drain + * time is bounded by readout, not by a multi-10ms user exposure left in + * trigger_pulse_us from a previous session / CamOS replay (N-1 entry hygiene). + */ +#define IMX296_DUMMY_PULSE_US 800U + enum imx296_op_mode { IMX296_FREE_RUN = 0, IMX296_XTRIG_ONE_SHOT = 1, }; +/* + * RKISP sits behind the RKCIF bridge on this platform, so its active media + * entity is the CIF subdevice rather than the IMX296 subdevice. Keep a small + * read-only mode bridge for RKISP's fast-trigger early-done decision. The + * board has a single IMX296; atomic access also makes the IRQ/start paths + * independent of the sensor mutex. + */ +static atomic_t imx296_fast_trigger_mode = ATOMIC_INIT(0); +/* Monotonic XTRIG counter for N-1 phase correlation (user + dummy pulses). */ +static atomic64_t imx296_xtrig_seq = ATOMIC64_INIT(0); +/* + * Per-frame FT diagnostics (pulse / dummy / mode_switch / stream-state). + * Default off: left on during N-1 bring-up these flooded rsyslog and filled the rootfs. + * Enable: echo 1 > /sys/module/imx296/parameters/n1trace + */ +static bool imx296_n1trace; +module_param_named(n1trace, imx296_n1trace, bool, 0644); +MODULE_PARM_DESC(n1trace, + "IMX296 FT n1trace/dev_info diagnostics (0=off default, 1=verbose)"); + +#define imx296_n1trace_info(dev, fmt, ...) \ + do { \ + if (imx296_n1trace) \ + dev_info((dev), fmt, ##__VA_ARGS__); \ + } while (0) + +bool imx296_is_fast_trigger_active(void) +{ + return atomic_read(&imx296_fast_trigger_mode) != 0; +} +EXPORT_SYMBOL_GPL(imx296_is_fast_trigger_active); + struct imx296_clk_params { unsigned int freq; u8 incksel[4]; @@ -218,6 +273,9 @@ struct imx296 { enum imx296_op_mode active_mode; enum imx296_op_mode pending_mode; u32 trigger_pulse_us; + u64 trigger_pwm_base_period_ns; + /* Transient N-1 trace tag; set only under sensor->mutex by dummy path. */ + const char *pulse_trace_tag; struct gpio_desc *light_source_gpio; struct pinctrl_state *pins_active_high; @@ -239,6 +297,8 @@ struct imx296 { struct v4l2_subdev subdev; struct media_pad pad; struct v4l2_rect crop; + /* Set when ACTIVE crop changes; cleared after ROI invalid-frame discard. */ + bool roi_boundary_pending; struct v4l2_mbus_framefmt format; struct v4l2_ctrl_handler ctrls; @@ -358,20 +418,26 @@ static struct pwm_device *imx296_devm_pwm_get_optional(struct device *dev, return pwm; } -static u64 imx296_pwm_period_ns(struct pwm_device *pwm) +static u64 imx296_trigger_base_period_ns(struct imx296 *sensor) { - struct pwm_state state; struct pwm_args args; - if (!pwm) - return 0; + /* + * Cache the firmware-provided period once. The runtime PWM state may have + * been enlarged for an earlier long pulse and must never become the base + * for later calculations, otherwise a short pulse cannot restore the + * original trigger rate until reboot. + */ + if (!sensor->trigger_pwm) + return IMX296_TRIGGER_PERIOD_NS_DEFAULT; + if (sensor->trigger_pwm_base_period_ns) + return sensor->trigger_pwm_base_period_ns; - pwm_get_state(pwm, &state); - if (state.period) - return state.period; + pwm_get_args(sensor->trigger_pwm, &args); + sensor->trigger_pwm_base_period_ns = args.period ? + args.period : IMX296_TRIGGER_PERIOD_NS_DEFAULT; - pwm_get_args(pwm, &args); - return args.period; + return sensor->trigger_pwm_base_period_ns; } static u64 imx296_trigger_period_ns(u64 pulse_ns, u64 base_period_ns) @@ -384,17 +450,18 @@ static u64 imx296_trigger_period_ns(u64 pulse_ns, u64 base_period_ns) return max(base_period_ns, min_period_ns); } -static int imx296_init_trigger_pwm(struct imx296 *sensor) +static int imx296_apply_trigger_pwm_idle_locked(struct imx296 *sensor) { - struct pwm_state state = { 0 }; + struct pwm_state state; u64 pulse_ns; if (!sensor->trigger_pwm) return 0; pulse_ns = (u64)sensor->trigger_pulse_us * 1000ULL; + pwm_get_state(sensor->trigger_pwm, &state); state.period = imx296_trigger_period_ns( - pulse_ns, imx296_pwm_period_ns(sensor->trigger_pwm)); + pulse_ns, imx296_trigger_base_period_ns(sensor)); state.duty_cycle = 0; state.polarity = PWM_POLARITY_INVERSED; state.enabled = false; @@ -402,6 +469,16 @@ static int imx296_init_trigger_pwm(struct imx296 *sensor) return pwm_apply_state(sensor->trigger_pwm, &state); } +static int imx296_init_trigger_pwm(struct imx296 *sensor) +{ + if (!sensor->trigger_pwm) + return 0; + + imx296_trigger_base_period_ns(sensor); + + return imx296_apply_trigger_pwm_idle_locked(sensor); +} + static int imx296_light_source_value_locked(struct imx296 *sensor, bool on) { /* @@ -482,6 +559,20 @@ static int imx296_trigger_once_locked(struct imx296 *sensor) if (!sensor->trigger_pwm) return -ENODEV; + /* + * Free-run / monitor ignores XTRIG. Emitting a pulse while free_run is + * active was observed to leave the FT entry path one frame out of phase + * (N-1) after CamOS switches to master_fast_trigger — mon-period external + * edges must not poke the PWM. + */ + if (sensor->streaming && + sensor->active_mode != IMX296_XTRIG_ONE_SHOT) { + dev_warn_ratelimited(sensor->dev, + "trigger pulse ignored: active mode is %s (need master_fast_trigger)\n", + imx296_op_mode_name(sensor->active_mode)); + return 0; + } + pulse_us = clamp_t(u32, sensor->trigger_pulse_us, IMX296_TRIGGER_PULSE_US_MIN, IMX296_TRIGGER_PULSE_US_MAX); @@ -506,7 +597,7 @@ static int imx296_trigger_once_locked(struct imx296 *sensor) pwm_get_state(sensor->trigger_pwm, &state); state.period = imx296_trigger_period_ns( - duty_ns, imx296_pwm_period_ns(sensor->trigger_pwm)); + duty_ns, imx296_trigger_base_period_ns(sensor)); state.duty_cycle = duty_ns; state.polarity = PWM_POLARITY_INVERSED; state.enabled = true; @@ -531,18 +622,23 @@ static int imx296_trigger_once_locked(struct imx296 *sensor) if (ret) goto out_light; - dev_info(sensor->dev, - "trigger pulse emitted: width=%u us idle=high active=low mode=%s streaming=%u\n", - pulse_us, - imx296_op_mode_name(sensor->streaming ? - sensor->active_mode : - sensor->pending_mode), - sensor->streaming); - - if (sensor->streaming && sensor->active_mode != IMX296_XTRIG_ONE_SHOT) - dev_warn(sensor->dev, - "trigger pulse was emitted while active mode is %s; switch run_mode to master_fast_trigger for one-shot capture\n", - imx296_op_mode_name(sensor->active_mode)); + { + u64 xseq = (u64)atomic64_inc_return(&imx296_xtrig_seq); + const char *tag = sensor->pulse_trace_tag ? + sensor->pulse_trace_tag : "user"; + const char *mode_name = imx296_op_mode_name( + sensor->streaming ? sensor->active_mode : + sensor->pending_mode); + + /* n1trace: correlate with ISP mi_fe / early-done lines */ + imx296_n1trace_info(sensor->dev, + "trigger pulse emitted: width=%u us idle=high active=low mode=%s streaming=%u\n", + pulse_us, mode_name, sensor->streaming); + imx296_n1trace_info(sensor->dev, + "n1trace pulse seq=%llu width=%u mode=%s streaming=%u tag=%s t_ns=%llu\n", + xseq, pulse_us, mode_name, sensor->streaming, tag, + ktime_get_ns()); + } out_light: if (sensor->light_source_enabled && sensor->light_source_gpio) { @@ -560,6 +656,72 @@ static int imx296_trigger_once_locked(struct imx296 *sensor) return ret; } +/* + * Emit one XTRIG without light-source side effects so an open fast-trigger + * frame can finish readout. Caller must hold sensor->mutex. + * + * Enter-FT / ROI-boundary dummies temporarily force IMX296_DUMMY_PULSE_US so + * drain is not stretched by a leftover multi-10ms user exposure width. + * leave/stream-off keep the current pulse_us (may be long) and always wait + * IMX296_XTRIG_CLOSE_FRAME_WAIT_US for the open frame to close. + */ +static int imx296_dummy_trigger_locked(struct imx296 *sensor, const char *reason) +{ + bool saved_light_en; + bool short_pulse; + u32 saved_pulse_us = 0; + u32 wait_us = IMX296_XTRIG_CLOSE_FRAME_WAIT_US; + u32 done_pulse_us; + int ret; + + if (!sensor->trigger_pwm) { + dev_dbg(sensor->dev, + "dummy trigger skipped (no pwm) reason=%s\n", reason); + return -ENODEV; + } + + if (!sensor->streaming || + sensor->active_mode != IMX296_XTRIG_ONE_SHOT) { + dev_dbg(sensor->dev, + "dummy trigger skipped (streaming=%u active=%s) reason=%s\n", + sensor->streaming, + imx296_op_mode_name(sensor->active_mode), reason); + return 0; + } + + /* Short pulse only for enter-FT / ROI drain — not a latency shortcut. */ + short_pulse = reason && + (!strcmp(reason, "enter-fast-trigger") || + !strcmp(reason, "enter-fast-trigger-2") || + !strcmp(reason, "enter-fast-trigger-3") || + !strcmp(reason, "roi-boundary")); + + saved_light_en = sensor->light_source_enabled; + sensor->light_source_enabled = false; + if (short_pulse) { + saved_pulse_us = sensor->trigger_pulse_us; + sensor->trigger_pulse_us = IMX296_DUMMY_PULSE_US; + } + sensor->pulse_trace_tag = reason; + ret = imx296_trigger_once_locked(sensor); + sensor->pulse_trace_tag = NULL; + done_pulse_us = short_pulse ? IMX296_DUMMY_PULSE_US : sensor->trigger_pulse_us; + if (short_pulse) + sensor->trigger_pulse_us = saved_pulse_us; + sensor->light_source_enabled = saved_light_en; + if (ret) { + dev_warn(sensor->dev, + "dummy trigger failed (%d) reason=%s\n", ret, reason); + return ret; + } + + usleep_range(wait_us, wait_us + 5000U); + imx296_n1trace_info(sensor->dev, + "n1trace dummy done reason=%s wait_us=%u pulse_us=%u t_ns=%llu\n", + reason, wait_us, done_pulse_us, ktime_get_ns()); + return 0; +} + static int imx296_set_ctrl(struct v4l2_ctrl *ctrl); static int imx296_mode_switch(struct imx296 *sensor, enum imx296_op_mode new_mode); @@ -754,6 +916,7 @@ static ssize_t trigger_pulse_us_store(struct device *dev, { struct imx296 *sensor = imx296_from_dev(dev); unsigned int pulse_us; + int ret = 0; if (!sensor) return -ENODEV; @@ -765,12 +928,25 @@ static ssize_t trigger_pulse_us_store(struct device *dev, return -ERANGE; mutex_lock(&sensor->mutex); - sensor->trigger_pulse_us = pulse_us; - mutex_unlock(&sensor->mutex); + if (sensor->trigger_pulse_us != pulse_us) { + u32 old_pulse = sensor->trigger_pulse_us; + + sensor->trigger_pulse_us = pulse_us; + ret = imx296_apply_trigger_pwm_idle_locked(sensor); + if (ret) { + sensor->trigger_pulse_us = old_pulse; + imx296_apply_trigger_pwm_idle_locked(sensor); + goto unlock; + } + dev_info(dev, + "trigger pulse width set to %u us (was %u)\n", + pulse_us, old_pulse); + } - dev_info(dev, "trigger pulse width set to %u us\n", pulse_us); +unlock: + mutex_unlock(&sensor->mutex); - return count; + return ret ? ret : count; } static DEVICE_ATTR_RW(trigger_pulse_us); @@ -826,7 +1002,7 @@ static void imx296_log_stream_state_locked(struct imx296 *sensor, const char *ta #undef IMX296_READBACK - dev_info(sensor->dev, + imx296_n1trace_info(sensor->dev, "stream-state[%s]: pending=%s active=%s test_pattern=%u ctrl00=0x%02x standby=%u ctrl08=0x%02x reghold=%u ctrl0a=0x%02x xmsta=%u ctrl0b=0x%02x trigen=%u syncsel=0x%02x lowlag=0x%02x pgctrl=0x%02x regen=%u clken=%u pg_mode=%u\n", tag, imx296_op_mode_name(sensor->pending_mode), @@ -1325,7 +1501,19 @@ static int imx296_setup(struct imx296 *sensor) imx296_write(sensor, IMX296_FID0_ROIWV1, crop->height, &ret); imx296_write(sensor, IMX296_MIPIC_AREA3W, crop->height, &ret); } else { + /* + * Restore the ROI geometry registers as well as disabling ROI. + * Leaving the previous window programmed is observable after an + * ROI-to-full-frame restart in fast-trigger mode: the frame identity + * is current, but the last output lines can still contain data from + * the preceding buffer. The power-on full-frame state has all four + * geometry registers cleared, so reproduce that state explicitly. + */ imx296_write(sensor, IMX296_FID0_ROI, 0, &ret); + imx296_write(sensor, IMX296_FID0_ROIPH1, 0, &ret); + imx296_write(sensor, IMX296_FID0_ROIPV1, 0, &ret); + imx296_write(sensor, IMX296_FID0_ROIWH1, 0, &ret); + imx296_write(sensor, IMX296_FID0_ROIWV1, 0, &ret); imx296_write(sensor, IMX296_MIPIC_AREA3W, IMX296_PIXEL_ARRAY_HEIGHT, &ret); } @@ -1348,7 +1536,10 @@ static int imx296_setup(struct imx296 *sensor) imx296_write(sensor, IMX296_GTTABLENUM, 0xc5, &ret); imx296_write(sensor, IMX296_CTRL418C, sensor->clk_params->ctrl418c, &ret); - imx296_write(sensor, IMX296_GAINDLY, IMX296_GAINDLY_1FRAME, &ret); + imx296_write(sensor, IMX296_GAINDLY, + sensor->pending_mode == IMX296_XTRIG_ONE_SHOT ? + IMX296_GAINDLY_0FRAME : IMX296_GAINDLY_1FRAME, + &ret); imx296_write(sensor, IMX296_BLKLEVEL, 0x03c, &ret); imx296_write(sensor, IMX296_VINT, IMX296_VINT_EN, &ret); @@ -1369,6 +1560,8 @@ static int imx296_apply_mode_regs_locked(struct imx296 *sensor, imx296_write(sensor, IMX296_SYNCSEL, IMX296_SYNCSEL_NORMAL, &ret); imx296_disable_trigger_outputs_locked(sensor, &ret); + /* Free-run uses next-frame gain application. */ + imx296_write(sensor, IMX296_GAINDLY, IMX296_GAINDLY_1FRAME, &ret); break; case IMX296_XTRIG_ONE_SHOT: @@ -1384,6 +1577,12 @@ static int imx296_apply_mode_regs_locked(struct imx296 *sensor, IMX296_LOWLAGTRG_FAST, &ret); imx296_write(sensor, IMX296_SYNCSEL, IMX296_SYNCSEL_HIZ, &ret); imx296_disable_trigger_outputs_locked(sensor, &ret); + /* + * Fast-trigger gain must take effect on the first XTRIG after a + * control update. setup() already picks 0x08 for pending FT, but + * hot mode_switch does not re-run setup — program it here too. + */ + imx296_write(sensor, IMX296_GAINDLY, IMX296_GAINDLY_0FRAME, &ret); break; default: @@ -1424,7 +1623,8 @@ static int imx296_restore_ctrls_for_mode_locked(struct imx296 *sensor, return imx296_apply_exposure_locked(sensor, sensor->exposure->val); } - return imx296_apply_vblank_locked(sensor, IMX296_VBLANK_DEFAULT); + /* XTRIG exposure is set by pulse width; VMAX must use the VTR baseline. */ + return imx296_apply_vblank_locked(sensor, IMX296_XTRIG_VBLANK); } static int imx296_stream_on(struct imx296 *sensor) @@ -1478,11 +1678,19 @@ static int imx296_quick_stream(struct imx296 *sensor, bool on) static int imx296_mode_switch(struct imx296 *sensor, enum imx296_op_mode new_mode) { + enum imx296_op_mode old_mode; int ret = 0; if (new_mode > IMX296_XTRIG_ONE_SHOT) return -EINVAL; + /* + * Publish the requested mode to RKISP before the sensor is stopped so + * the next isp_start (CamOS PAUSED->READY->PLAYING) programs the matching + * early-done policy for the target mode. + */ + atomic_set(&imx296_fast_trigger_mode, + new_mode == IMX296_XTRIG_ONE_SHOT); sensor->pending_mode = new_mode; imx296_update_ctrl_visibility_locked(sensor, new_mode); @@ -1496,6 +1704,24 @@ static int imx296_mode_switch(struct imx296 *sensor, if (ret < 0) return ret; + old_mode = sensor->active_mode; + + imx296_n1trace_info(sensor->dev, + "n1trace mode_switch begin %s->%s streaming=%u xtrig_seq=%llu t_ns=%llu\n", + imx296_op_mode_name(old_mode), + imx296_op_mode_name(new_mode), + sensor->streaming, + (u64)atomic64_read(&imx296_xtrig_seq), + ktime_get_ns()); + + /* + * Fast-trigger leaves the MIPI frame open until the next XTRIG. Close it + * before STANDBY so CIF/ISP do not keep a half-written buffer that later + * surfaces as a green frame when returning to free-run / monitor. + */ + if (old_mode == IMX296_XTRIG_ONE_SHOT) + imx296_dummy_trigger_locked(sensor, "leave-fast-trigger"); + ret = imx296_stream_off(sensor); if (ret) goto out_pm; @@ -1517,8 +1743,47 @@ static int imx296_mode_switch(struct imx296 *sensor, sensor->active_mode = new_mode; sensor->streaming = true; + /* + * Datasheet: one invalid frame after ROI geometry change. If the crop + * changed while we were still in FT (or ROI was pending across the + * switch), drain it now that free-run is producing frames again. + */ + if (sensor->roi_boundary_pending && + sensor->active_mode == IMX296_FREE_RUN) { + usleep_range(IMX296_ROI_INVALID_FRAME_WAIT_US, + IMX296_ROI_INVALID_FRAME_WAIT_US + 5000U); + sensor->roi_boundary_pending = false; + } + + /* + * Entering FT from free-run (typical CamOS ROI→trigger path): the MIPI + * link / ISP pipeline often publishes one half-open or green first frame + * on the first XTRIG even when MI FE completes. Emit a light-free dummy + * pulse so that invalid frame is closed and discarded before userspace + * triggers. ISP skip_frame also drops the first completed buffers. + */ + if (old_mode != IMX296_XTRIG_ONE_SHOT && + new_mode == IMX296_XTRIG_ONE_SHOT) { + imx296_dummy_trigger_locked(sensor, "enter-fast-trigger"); + /* Extra pulses: first dummy often only closes the free-run tail. */ + imx296_dummy_trigger_locked(sensor, "enter-fast-trigger-2"); + imx296_dummy_trigger_locked(sensor, "enter-fast-trigger-3"); + } + + imx296_n1trace_info(sensor->dev, + "n1trace mode_switch end %s->%s active=%s streaming=%u ret=%d xtrig_seq=%llu t_ns=%llu\n", + imx296_op_mode_name(old_mode), + imx296_op_mode_name(new_mode), + imx296_op_mode_name(sensor->active_mode), + sensor->streaming, ret, + (u64)atomic64_read(&imx296_xtrig_seq), + ktime_get_ns()); + out_pm: if (ret && !sensor->streaming) { + /* Roll the mode bridge back so ISP does not keep FT early-done. */ + atomic_set(&imx296_fast_trigger_mode, + sensor->active_mode == IMX296_XTRIG_ONE_SHOT); pm_runtime_mark_last_busy(sensor->dev); pm_runtime_put_autosuspend(sensor->dev); } else { @@ -1638,6 +1903,13 @@ static int imx296_s_stream(struct v4l2_subdev *sd, int enable) goto unlock; if (!enable) { + /* + * Close any open fast-trigger frame before STANDBY so the last + * ISP buffer is not left half-written (green) across STREAMOFF. + */ + if (sensor->active_mode == IMX296_XTRIG_ONE_SHOT) + imx296_dummy_trigger_locked(sensor, "stream-off"); + ret = imx296_stream_off(sensor); imx296_light_source_set_locked(sensor, false); if (!ret) { @@ -1650,6 +1922,10 @@ static int imx296_s_stream(struct v4l2_subdev *sd, int enable) imx296_update_ctrl_visibility_locked(sensor, sensor->pending_mode); + /* Keep the RKISP early-done bridge aligned with the mode about to start. */ + atomic_set(&imx296_fast_trigger_mode, + sensor->pending_mode == IMX296_XTRIG_ONE_SHOT); + ret = pm_runtime_resume_and_get(sensor->dev); if (ret < 0) goto unlock; @@ -1677,12 +1953,45 @@ static int imx296_s_stream(struct v4l2_subdev *sd, int enable) sensor->active_mode = sensor->pending_mode; sensor->streaming = true; + /* + * The datasheet specifies one invalid frame after ROI geometry changes. + * CamOS restarts the sensor in free-run before switching to fast trigger, + * so let that invalid frame drain before userspace can change run mode. + * If stream-on lands directly in fast-trigger (no free-run settle), close + * the invalid frame with a dummy XTRIG instead. + */ + if (sensor->roi_boundary_pending) { + if (sensor->active_mode == IMX296_FREE_RUN) { + usleep_range(IMX296_ROI_INVALID_FRAME_WAIT_US, + IMX296_ROI_INVALID_FRAME_WAIT_US + 5000U); + sensor->roi_boundary_pending = false; + } else if (sensor->active_mode == IMX296_XTRIG_ONE_SHOT) { + imx296_dummy_trigger_locked(sensor, "roi-boundary"); + sensor->roi_boundary_pending = false; + } + } + + /* + * CamOS run_mode boundary is STREAMOFF → write pending FT → STREAMON. + * imx296_mode_switch() early-returns while !streaming, so its enter-FT + * dummies never run on that path. Without these dummies the first + * userspace XTRIG only closes a half-open frame and phase locks at N-1. + * Always drain three short light-free pulses after any FT stream-on. + */ + if (sensor->active_mode == IMX296_XTRIG_ONE_SHOT) { + imx296_dummy_trigger_locked(sensor, "enter-fast-trigger"); + imx296_dummy_trigger_locked(sensor, "enter-fast-trigger-2"); + imx296_dummy_trigger_locked(sensor, "enter-fast-trigger-3"); + } + if (sensor->active_mode == IMX296_FREE_RUN && sensor->light_source_enabled) imx296_light_source_set_locked(sensor, true); goto unlock; err_pm: + atomic_set(&imx296_fast_trigger_mode, + sensor->active_mode == IMX296_XTRIG_ONE_SHOT); pm_runtime_put_sync(sensor->dev); unlock: mutex_unlock(&sensor->mutex); @@ -1980,6 +2289,12 @@ static int imx296_set_selection(struct v4l2_subdev *sd, format->height = rect.height; } + if (sel->which == V4L2_SUBDEV_FORMAT_ACTIVE && + (rect.left != crop->left || rect.top != crop->top || + rect.width != crop->width || rect.height != crop->height)) { + sensor->roi_boundary_pending = true; + } + *crop = rect; sel->r = rect; diff --git a/drivers/media/platform/rockchip/isp/capture.c b/drivers/media/platform/rockchip/isp/capture.c index dd69891d0df60..a8634a46cdd41 100644 --- a/drivers/media/platform/rockchip/isp/capture.c +++ b/drivers/media/platform/rockchip/isp/capture.c @@ -588,6 +588,8 @@ void rkisp_stream_buf_done_early(struct rkisp_device *dev) if (!dev->cap_dev.is_done_early) return; + /* Buffer drop is applied in mi_frame_end via early_done_drop_left. */ + for (i = 0; i < RKISP_MAX_STREAM; i++) { if (i == RKISP_STREAM_VIR || i == RKISP_STREAM_LUMA || i == RKISP_STREAM_DMATX0 || i == RKISP_STREAM_DMATX1 || @@ -600,6 +602,381 @@ void rkisp_stream_buf_done_early(struct rkisp_device *dev) } } +static void rkisp_early_done_store_deadline(struct rkisp_capture_device *cap_dev, + u64 deadline_ns) +{ + WRITE_ONCE(cap_dev->early_done_deadline_ns_lo, lower_32_bits(deadline_ns)); + WRITE_ONCE(cap_dev->early_done_deadline_ns_hi, upper_32_bits(deadline_ns)); +} + +static u64 rkisp_early_done_load_deadline(struct rkisp_capture_device *cap_dev) +{ + u32 lo = READ_ONCE(cap_dev->early_done_deadline_ns_lo); + u32 hi = READ_ONCE(cap_dev->early_done_deadline_ns_hi); + + return ((u64)hi << 32) | lo; +} + +/* + * Live MI byte progress. On V39 FT the classic OFFS_CNT_SHD often sticks at 0 + * for the whole open frame (logs: y=0/need base=0 saw=1 → permanent + * ceiling-miss → MI FE only on next XTRIG → N-1 phase). Sample both the SHD + * and the START (live) views and take the larger — whichever register is + * actually ticking on this silicon wins. + */ +static u32 rkisp_early_done_read_y_offs(struct rkisp_device *dev) +{ + u32 shd = rkisp_read(dev, CIF_MI_MP_Y_OFFS_CNT_SHD, true); + u32 start = rkisp_read(dev, CIF_MI_MP_Y_OFFS_CNT_START, true); + + return max(shd, start); +} + +static u32 rkisp_early_done_read_cb_offs(struct rkisp_device *dev) +{ + u32 shd = rkisp_read(dev, CIF_MI_MP_CB_OFFS_CNT_SHD, true); + u32 start = rkisp_read(dev, CIF_MI_MP_CB_OFFS_CNT_START, true); + + return max(shd, start); +} + +static void rkisp_early_done_cache_mi_sizes(struct rkisp_device *dev) +{ + struct rkisp_capture_device *cap_dev = &dev->cap_dev; + struct rkisp_stream *mp = &cap_dev->stream[RKISP_STREAM_MP]; + u32 y_size = 0, cb_size = 0; + + /* + * Prefer the shadow programmed size (bytes the MI will write this + * frame). Fall back to plane sizeimage from the MP format. + */ + if (mp->streaming) { + y_size = rkisp_read(dev, CIF_MI_MP_Y_SIZE_SHD, true); + cb_size = rkisp_read(dev, CIF_MI_MP_CB_SIZE_SHD, true); + if (!y_size && mp->out_fmt.num_planes >= 1) + y_size = mp->out_fmt.plane_fmt[0].sizeimage; + if (!cb_size && mp->out_fmt.num_planes >= 2) + cb_size = mp->out_fmt.plane_fmt[1].sizeimage; + /* Single-plane NV12: chroma is the second half. */ + if (y_size && !cb_size && mp->out_fmt.num_planes == 1) + cb_size = y_size / 2; + } + + WRITE_ONCE(cap_dev->early_done_mi_y_size, y_size); + WRITE_ONCE(cap_dev->early_done_mi_cb_size, cb_size); + /* Baseline at arm: detect later decrease/reset as live activity. */ + WRITE_ONCE(cap_dev->early_done_mi_y_base, + rkisp_early_done_read_y_offs(dev)); + WRITE_ONCE(cap_dev->early_done_mi_cb_base, + rkisp_early_done_read_cb_offs(dev)); + WRITE_ONCE(cap_dev->early_done_mi_saw_y, 0); + WRITE_ONCE(cap_dev->early_done_mi_saw_cb, 0); + WRITE_ONCE(cap_dev->early_done_mi_ready, 0); +} + +/* + * MI offs often still hold the previous buffer's final counts across an FT + * SOF, or stay stuck at 0 on SHD while START advances (V39). Require evidence + * of a live frame before trusting full (== need): + * - offs below programmed size (classic ramp), or + * - offs decreased vs the value sampled at arm (counter finally rolled), or + * - offs is zero (counter reset at SOF / new buffer). + * Without this, residual offs==need makes saw_* stuck at 0; with SHD stuck at + * 0, every FT frame ceiling-misses and forces completion onto the next XTRIG + * (N-1 phase). + */ +static bool rkisp_early_done_mi_finished(struct rkisp_device *dev) +{ + struct rkisp_capture_device *cap_dev = &dev->cap_dev; + u32 y_need = READ_ONCE(cap_dev->early_done_mi_y_size); + u32 cb_need = READ_ONCE(cap_dev->early_done_mi_cb_size); + u32 y_base = READ_ONCE(cap_dev->early_done_mi_y_base); + u32 cb_base = READ_ONCE(cap_dev->early_done_mi_cb_base); + u32 y_offs = 0, cb_offs = 0; + bool y_full = true, cb_full = true; + + if (!y_need && !cb_need) + return false; + + if (y_need) { + y_offs = rkisp_early_done_read_y_offs(dev); + if (y_offs < y_need || y_offs < y_base || y_offs == 0) + WRITE_ONCE(cap_dev->early_done_mi_saw_y, 1); + y_full = READ_ONCE(cap_dev->early_done_mi_saw_y) && + (y_offs >= y_need); + } + + if (cb_need) { + cb_offs = rkisp_early_done_read_cb_offs(dev); + if (cb_offs < cb_need || cb_offs < cb_base || cb_offs == 0) + WRITE_ONCE(cap_dev->early_done_mi_saw_cb, 1); + cb_full = READ_ONCE(cap_dev->early_done_mi_saw_cb) && + (cb_offs >= cb_need); + } + + return y_full && cb_full; +} + +/* Snapshot current offs for ceiling-miss diagnostics (IRQ/timer context). */ +static void rkisp_early_done_sample_offs(struct rkisp_device *dev, + u32 *y_offs, u32 *cb_offs) +{ + *y_offs = rkisp_early_done_read_y_offs(dev); + *cb_offs = rkisp_early_done_read_cb_offs(dev); +} + +static u32 rkisp_early_done_post_hold_us(struct rkisp_capture_device *cap_dev, + bool force_path) +{ + u32 left = READ_ONCE(cap_dev->early_done_warmup_left); + u32 warm = READ_ONCE(cap_dev->early_done_warmup_post_mi_us); + u32 steady = READ_ONCE(cap_dev->early_done_post_mi_us); + u32 force = READ_ONCE(cap_dev->early_done_force_post_mi_us); + u32 residual = READ_ONCE(cap_dev->early_done_delay_us); + u32 hold; + + /* Warmup after free-run/ROI→FT is always the most conservative. */ + if (left && warm) + hold = warm; + else + hold = steady; + + /* + * Ceiling-force means MI offs never verified. Board evidence: + * - 1456x592 green bot~8 with residual+16ms + * - 1456x1088 mon→FT head frames still green with force=45ms (2026-08-10) + * Floor at force_post; add most of residual again so short ROI and + * full-height mode-switch cannot under-hold after wait_line. Warmup + * frames already prefer warm post; still raise force floor over them. + */ + if (force_path) { + u32 extra = residual - (residual / 4); /* ~3/4 residual */ + + if (force > hold) + hold = force; + if (extra) { + if (hold < U32_MAX - extra) + hold += extra; + else + hold = U32_MAX; + } + } + return hold; +} + +/* + * Fast-trigger completion after wait_line (OUT_FRM_HALF): + * + * Completing when ISP_OUT_LINE hits the V39 terminal (height - 8) publishes + * buffers with an ~8-row green bottom band — the counter saturates before MI + * finishes the last rows / chroma. MI SHD can also hold the previous buffer's + * final offsets across FT SOF, so require a live ramp before trusting full. + * + * Even after MI reports full, last NV12 chroma may still be in flight → hold + * post_mi_us (longer for the first few FT frames after mode/ROI switch). + * + * On residual ceiling without verified MI: ceiling-force + force_post hold + * (N-1 phase). Still never complete at bare wait_line without that hold. + */ +static enum hrtimer_restart rkisp_early_done_timer(struct hrtimer *timer) +{ + struct rkisp_capture_device *cap_dev = + container_of(timer, struct rkisp_capture_device, early_done_timer); + struct rkisp_device *dev = cap_dev->ispdev; + u64 armed_sof = READ_ONCE(cap_dev->early_done_armed_sof_ns); + u64 cur_sof = dev->isp_sdev.frm_timestamp; + u64 deadline = rkisp_early_done_load_deadline(cap_dev); + u64 now = ktime_get_ns(); + u32 poll_us = READ_ONCE(cap_dev->early_done_poll_us); + u32 post_us; + + if (!armed_sof || (cur_sof && armed_sof != cur_sof)) { + /* Stale arm — next SOF already won. */ + WRITE_ONCE(cap_dev->early_done_armed_sof_ns, 0); + WRITE_ONCE(cap_dev->early_done_mi_ready, 0); + atomic_set(&cap_dev->early_done_pending, 0); + return HRTIMER_NORESTART; + } + + if (!READ_ONCE(cap_dev->early_done_mi_ready)) { + if (rkisp_early_done_mi_finished(dev)) { + WRITE_ONCE(cap_dev->early_done_mi_ready, 1); + post_us = rkisp_early_done_post_hold_us(cap_dev, false); + if (post_us) { + hrtimer_forward_now(timer, + ns_to_ktime((u64)post_us * NSEC_PER_USEC)); + return HRTIMER_RESTART; + } + /* fall through to complete */ + } else if (!deadline || now < deadline) { + if (!poll_us) + poll_us = 50; + hrtimer_forward_now(timer, + ns_to_ktime((u64)poll_us * NSEC_PER_USEC)); + return HRTIMER_RESTART; + } else { + /* + * Ceiling without verified MI. On V39+IMX296 FT the + * MI OFFS_CNT_* views often stay 0 for the whole open + * frame (see y_shd/y_st logs), so the live-ramp check + * can never succeed. Abandoning to MI FE then waits + * for the *next* XTRIG to close the MIPI frame → + * published content is always one shot behind (N-1). + * + * Residual ceiling already covers remaining lines + * after wait_line; fall through the post-MI hold so + * last NV12 chroma can land, then complete. This is + * safer than green-band force-at-wait_line and fixes + * the N-1 phase lock. + */ + WRITE_ONCE(cap_dev->early_done_diag_ceiling, + READ_ONCE(cap_dev->early_done_diag_ceiling) + 1); + { + u32 yo = 0, co = 0; + u32 y_shd, y_st, cb_shd, cb_st; + + rkisp_early_done_sample_offs(dev, &yo, &co); + y_shd = rkisp_read(dev, CIF_MI_MP_Y_OFFS_CNT_SHD, true); + y_st = rkisp_read(dev, CIF_MI_MP_Y_OFFS_CNT_START, true); + cb_shd = rkisp_read(dev, CIF_MI_MP_CB_OFFS_CNT_SHD, true); + cb_st = rkisp_read(dev, CIF_MI_MP_CB_OFFS_CNT_START, true); + rkisp_n1trace_info(&dev->v4l2_dev, + "n1trace early ceiling-force sof=%llu cnt=%u y=%u/%u base=%u saw=%u cb=%u/%u base=%u saw=%u y_shd=%u y_st=%u cb_shd=%u cb_st=%u t_ns=%llu\n", + armed_sof, + READ_ONCE(cap_dev->early_done_diag_ceiling), + yo, READ_ONCE(cap_dev->early_done_mi_y_size), + READ_ONCE(cap_dev->early_done_mi_y_base), + READ_ONCE(cap_dev->early_done_mi_saw_y), + co, READ_ONCE(cap_dev->early_done_mi_cb_size), + READ_ONCE(cap_dev->early_done_mi_cb_base), + READ_ONCE(cap_dev->early_done_mi_saw_cb), + y_shd, y_st, cb_shd, cb_st, + ktime_get_ns()); + } + WRITE_ONCE(cap_dev->early_done_mi_ready, 1); + post_us = rkisp_early_done_post_hold_us(cap_dev, true); + if (post_us) { + hrtimer_forward_now(timer, + ns_to_ktime((u64)post_us * NSEC_PER_USEC)); + return HRTIMER_RESTART; + } + /* fall through to complete */ + } + } + + /* MI ready + post-MI hold elapsed (or post_us was 0). */ + if (READ_ONCE(cap_dev->early_done_warmup_left)) + WRITE_ONCE(cap_dev->early_done_warmup_left, + READ_ONCE(cap_dev->early_done_warmup_left) - 1); + + WRITE_ONCE(cap_dev->early_done_diag_complete, + READ_ONCE(cap_dev->early_done_diag_complete) + 1); + rkisp_n1trace_info(&dev->v4l2_dev, + "n1trace early complete sof=%llu warm_left=%u cnt=%u t_ns=%llu\n", + armed_sof, + READ_ONCE(cap_dev->early_done_warmup_left), + READ_ONCE(cap_dev->early_done_diag_complete), + ktime_get_ns()); + + rkisp_stream_buf_done_early(dev); + WRITE_ONCE(cap_dev->early_done_armed_sof_ns, 0); + WRITE_ONCE(cap_dev->early_done_mi_ready, 0); + rkisp_early_done_store_deadline(cap_dev, 0); + atomic_set(&cap_dev->early_done_pending, 0); + return HRTIMER_NORESTART; +} + +void rkisp_stream_buf_done_early_drain(struct rkisp_device *dev, + u32 ceiling_us) +{ + struct rkisp_capture_device *cap_dev = &dev->cap_dev; + u64 sof_ns = dev->isp_sdev.frm_timestamp; + u32 skip, poll_us; + u64 deadline; + + if (!cap_dev->is_done_early) + return; + + /* First SOFs after free-run/ROI→FT: use real MI FE only. */ + skip = READ_ONCE(cap_dev->early_done_skip_frames); + if (skip) { + WRITE_ONCE(cap_dev->early_done_skip_frames, skip - 1); + WRITE_ONCE(cap_dev->early_done_diag_skip_sof, + READ_ONCE(cap_dev->early_done_diag_skip_sof) + 1); + rkisp_n1trace_info(&dev->v4l2_dev, + "n1trace early skip-sof left=%u cnt=%u sof=%llu t_ns=%llu\n", + skip - 1, + READ_ONCE(cap_dev->early_done_diag_skip_sof), + sof_ns, ktime_get_ns()); + return; + } + + if (atomic_cmpxchg(&cap_dev->early_done_pending, 0, 1)) + return; + + rkisp_early_done_cache_mi_sizes(dev); + WRITE_ONCE(cap_dev->early_done_armed_sof_ns, sof_ns); + WRITE_ONCE(cap_dev->early_done_diag_arm, + READ_ONCE(cap_dev->early_done_diag_arm) + 1); + rkisp_n1trace_info(&dev->v4l2_dev, + "n1trace early arm sof=%llu ceiling_us=%u cnt=%u t_ns=%llu\n", + sof_ns, + ceiling_us ? ceiling_us : READ_ONCE(cap_dev->early_done_delay_us), + READ_ONCE(cap_dev->early_done_diag_arm), + ktime_get_ns()); + + if (!ceiling_us) + ceiling_us = READ_ONCE(cap_dev->early_done_delay_us); + if (!ceiling_us) + ceiling_us = 10000; + deadline = ktime_get_ns() + (u64)ceiling_us * NSEC_PER_USEC; + rkisp_early_done_store_deadline(cap_dev, deadline); + + poll_us = READ_ONCE(cap_dev->early_done_poll_us); + if (!poll_us) + poll_us = 50; + + hrtimer_start(&cap_dev->early_done_timer, + ns_to_ktime((u64)poll_us * NSEC_PER_USEC), + HRTIMER_MODE_REL); +} + +void rkisp_stream_buf_done_early_delayed(struct rkisp_device *dev, + u32 delay_us) +{ + /* Prefer MI-drain path; delay_us is the safety ceiling. */ + rkisp_stream_buf_done_early_drain(dev, delay_us); +} + +void rkisp_early_done_clear_state(struct rkisp_device *dev) +{ + struct rkisp_capture_device *cap_dev = &dev->cap_dev; + + WRITE_ONCE(cap_dev->early_done_armed_sof_ns, 0); + WRITE_ONCE(cap_dev->early_done_mi_y_size, 0); + WRITE_ONCE(cap_dev->early_done_mi_cb_size, 0); + WRITE_ONCE(cap_dev->early_done_mi_saw_y, 0); + WRITE_ONCE(cap_dev->early_done_mi_saw_cb, 0); + WRITE_ONCE(cap_dev->early_done_mi_ready, 0); + rkisp_early_done_store_deadline(cap_dev, 0); + atomic_set(&cap_dev->early_done_pending, 0); +} + +void rkisp_stream_cancel_early_done(struct rkisp_device *dev) +{ + struct rkisp_capture_device *cap_dev = &dev->cap_dev; + int ret; + + if (in_interrupt()) { + ret = hrtimer_try_to_cancel(&cap_dev->early_done_timer); + if (ret < 0) + return; + } else { + hrtimer_cancel(&cap_dev->early_done_timer); + } + rkisp_early_done_clear_state(dev); +} + int rkisp_stream_buf_cnt(struct rkisp_stream *stream) { unsigned long lock_flags = 0; @@ -1911,6 +2288,11 @@ int rkisp_register_stream_vdevs(struct rkisp_device *dev) memset(cap_dev, 0, sizeof(*cap_dev)); cap_dev->ispdev = dev; atomic_set(&cap_dev->refcnt, 0); + atomic_set(&cap_dev->early_done_pending, 0); + cap_dev->early_done_armed_sof_ns = 0; + hrtimer_init(&cap_dev->early_done_timer, CLOCK_MONOTONIC, + HRTIMER_MODE_REL); + cap_dev->early_done_timer.function = rkisp_early_done_timer; if (dev->isp_ver <= ISP_V13) { if (dev->isp_ver == ISP_V12) { @@ -1962,6 +2344,8 @@ int rkisp_register_stream_vdevs(struct rkisp_device *dev) void rkisp_unregister_stream_vdevs(struct rkisp_device *dev) { + rkisp_stream_cancel_early_done(dev); + if (dev->isp_ver <= ISP_V13) rkisp_unregister_stream_v1x(dev); else if (dev->isp_ver == ISP_V20) diff --git a/drivers/media/platform/rockchip/isp/capture.h b/drivers/media/platform/rockchip/isp/capture.h index 82fe5e0d7c66a..da0b1ba30c782 100644 --- a/drivers/media/platform/rockchip/isp/capture.h +++ b/drivers/media/platform/rockchip/isp/capture.h @@ -35,6 +35,7 @@ #ifndef _RKISP_PATH_VIDEO_H #define _RKISP_PATH_VIDEO_H +#include #include #include "common.h" @@ -302,6 +303,7 @@ struct rkisp_stream { bool is_crop_upd; bool is_using_resmem; bool frame_early; + u64 early_done_sof_ns; bool need_scl_upd; bool is_attach_info; wait_queue_head_t done; @@ -334,6 +336,60 @@ struct rkisp_capture_device { struct tasklet_struct rd_tasklet; atomic_t refcnt; u32 wait_line; + /* + * Fast-trigger early-done (V39 + IMX296): + * Arm at wait_line (OUT_FRM_HALF). Complete only when MI MP Y/CB write + * offsets have ramped live and reached the programmed plane sizes, then + * hold post_mi_us so the last NV12 chroma beats land. ISP_OUT_LINE + * saturates ~8 lines early and must not drive completion. + * residual ceiling may ceiling-force when MI OFFS stay 0 (N-1 fix) but + * still applies force_post_mi hold. First skip_frames SOFs use MI FE; + * drop_left discards dirty MP buffers after free-run/ROI→FT. + */ + u32 early_done_delay_us; + struct hrtimer early_done_timer; + atomic_t early_done_pending; + /* SOF ns of the frame for which early-done is armed (0 = none). */ + u64 early_done_armed_sof_ns; + u32 early_done_poll_us; + u32 early_done_terminal_line; + u32 early_done_deadline_ns_lo; + u32 early_done_deadline_ns_hi; + u32 early_done_mi_y_size; + u32 early_done_mi_cb_size; + /* SHD offs sampled when early-done was armed (live-decrease baseline). */ + u32 early_done_mi_y_base; + u32 early_done_mi_cb_base; + /* Observed live MI activity (ramp / decrease / zero) this arm. */ + u32 early_done_mi_saw_y; + u32 early_done_mi_saw_cb; + /* Y+CB live+full; then wait post_mi_us before vb2_done. */ + u32 early_done_mi_ready; + u32 early_done_post_mi_us; + /* Floor hold after ceiling-force (unverified MI); >= post_mi_us. */ + u32 early_done_force_post_mi_us; + u32 early_done_warmup_left; + u32 early_done_warmup_post_mi_us; + /* + * SOFs after FT start that must not arm early-done (use MI FE only). + * Decremented in the drain path; 0 = normal MI-poll early-done. + */ + u32 early_done_skip_frames; + /* Drop next N completed MP buffers (survives stream->skip_frame=0). */ + u32 early_done_drop_left; + /* + * Drop next N MP completes after drop+warm drain (IRQ and WORK). + * 2026-08-10 #1005: warm alone still let ~5 green frames after warm=0. + * 2026-08-12 #1009: must not decrement while warm>0 (IRQ hole in #1008). + */ + u32 early_done_pub_gate_left; + /* N-1 phase diagnostics (FT only): counts published / dropped MP frames. */ + u32 early_done_diag_pub; + u32 early_done_diag_drop; + u32 early_done_diag_skip_sof; + u32 early_done_diag_arm; + u32 early_done_diag_complete; + u32 early_done_diag_ceiling; u32 wrap_width; u32 wrap_line; bool is_done_early; @@ -348,6 +404,13 @@ extern struct rockit_isp_ops rockit_isp_ops; void rkisp_stream_vir_cpy_image(struct work_struct *work); void rkisp_stream_buf_done_early(struct rkisp_device *dev); +void rkisp_stream_buf_done_early_drain(struct rkisp_device *dev, + u32 ceiling_us); +/* Legacy name kept for non-IMX296 call sites. */ +void rkisp_stream_buf_done_early_delayed(struct rkisp_device *dev, + u32 delay_us); +void rkisp_stream_cancel_early_done(struct rkisp_device *dev); +void rkisp_early_done_clear_state(struct rkisp_device *dev); void rkisp_stream_buf_done(struct rkisp_stream *stream, struct rkisp_buffer *buf); void rkisp_unregister_stream_vdev(struct rkisp_stream *stream); diff --git a/drivers/media/platform/rockchip/isp/capture_v39.c b/drivers/media/platform/rockchip/isp/capture_v39.c index 0242e065c488d..e756a0ea813c6 100644 --- a/drivers/media/platform/rockchip/isp/capture_v39.c +++ b/drivers/media/platform/rockchip/isp/capture_v39.c @@ -1033,12 +1033,14 @@ static int isp_frame_end(struct rkisp_stream *stream, u32 irq) * is processing and we should set up buffer for next-next frame, * otherwise it will overflow. */ + static int mi_frame_end(struct rkisp_stream *stream, u32 state) { struct rkisp_device *dev = stream->ispdev; struct capture_fmt *isp_fmt = &stream->out_isp_fmt; unsigned long lock_flags = 0; struct rkisp_buffer *buf = NULL; + u64 sof_ns = dev->isp_sdev.frm_timestamp; u32 i, seq; if (stream->id == RKISP_STREAM_VIR) @@ -1050,12 +1052,26 @@ static int mi_frame_end(struct rkisp_stream *stream, u32 state) if (stream->id == RKISP_STREAM_MP && dev->cap_dev.wrap_line) return 0; spin_lock_irqsave(&stream->vbq_lock, lock_flags); + /* + * wait_line can fire very close to the normal MI frame interrupt. + * If IRQ wins first, FRAME_WORK must not consume the next buffer; + * if FRAME_WORK wins first, IRQ must only advance the queue. The + * SOF timestamp uniquely identifies both callbacks as one frame. + */ + if (sof_ns && stream->early_done_sof_ns == sof_ns) { + spin_unlock_irqrestore(&stream->vbq_lock, lock_flags); + if (state == FRAME_IRQ) + goto end; + return 0; + } if (state == FRAME_IRQ && stream->curr_buf) stream->frame_early = false; else stream->frame_early = true; buf = stream->curr_buf; stream->curr_buf = NULL; + if (buf && sof_ns) + stream->early_done_sof_ns = sof_ns; spin_unlock_irqrestore(&stream->vbq_lock, lock_flags); if ((!stream->frame_early && state == FRAME_WORK) || (stream->frame_early && state == FRAME_IRQ)) @@ -1071,8 +1087,81 @@ static int mi_frame_end(struct rkisp_stream *stream, u32 state) struct vb2_buffer *vb2_buf = &buf->vb.vb2_buf; struct rkisp_stream *vir = &dev->cap_dev.stream[RKISP_STREAM_VIR]; u64 ns = 0; + bool ft_diag = dev->cap_dev.is_done_early && + stream->id == RKISP_STREAM_MP; + if (stream->id == RKISP_STREAM_MP) { + u32 drop = READ_ONCE(dev->cap_dev.early_done_drop_left); + /* + * 2026-08-12 #1009 (fix #1008 IRQ hole, ceiling-force doc §11): + * mon→FT is ~100% ceiling-force. Quarantine order must be + * drop → warm → pub_gate on BOTH early WORK and MI FE IRQ. + * + * #1008 bug: warm_drop required frame_early, so IRQ (early=0) + * with warm>0 fell into gate_drop and burned pub_gate while + * warm was still high; first mi_pub then landed mid-warm with + * gate already 0 (line0 green). CamOS post residual cannot fix. + * + * #1009: + * - warm_drop: any MP complete while warm>0 (IRQ+WORK) + * - gate_drop: only after drop==0 && warm==0 (still covers IRQ) + * CamOS SMARTCAM_TRIGGER_WARMUP must cover skip+drop+warm+gate. + */ + u32 warm = READ_ONCE(dev->cap_dev.early_done_warmup_left); + u32 gate = READ_ONCE(dev->cap_dev.early_done_pub_gate_left); + /* Warm quarantine is path-agnostic (IRQ and early WORK). */ + bool warm_drop = ft_diag && warm; + /* + * Gate starts only after drop+warm are exhausted. IRQ pubs + * after warm still consume gate (#1006 r1); they must NOT + * steal gate during warm (#1007 intent, restored for IRQ). + */ + bool gate_drop = ft_diag && gate && !drop && !warm; + + if (drop || warm_drop || gate_drop) { + if (drop) + WRITE_ONCE(dev->cap_dev.early_done_drop_left, + drop - 1); + /* + * Consume warm on the publish path when early-done did + * not already decrement it (IRQ / non-early completes). + * early complete already did warm-- before mi_frame_end; + * do not double-count those. + */ + if (warm_drop && !stream->frame_early) + WRITE_ONCE(dev->cap_dev.early_done_warmup_left, + warm - 1); + if (gate_drop) + WRITE_ONCE(dev->cap_dev.early_done_pub_gate_left, + gate - 1); + if (ft_diag) { + WRITE_ONCE(dev->cap_dev.early_done_diag_drop, + READ_ONCE(dev->cap_dev.early_done_diag_drop) + 1); + rkisp_n1trace_info(&dev->v4l2_dev, + "n1trace mi_drop state=%s left=%u warm=%u gate=%u cnt=%u early=%d sof=%llu t_ns=%llu\n", + state == FRAME_WORK ? "WORK" : + state == FRAME_IRQ ? "IRQ" : "OTH", + READ_ONCE(dev->cap_dev.early_done_drop_left), + READ_ONCE(dev->cap_dev.early_done_warmup_left), + READ_ONCE(dev->cap_dev.early_done_pub_gate_left), + READ_ONCE(dev->cap_dev.early_done_diag_drop), + stream->frame_early, sof_ns, + ktime_get_ns()); + } + spin_lock_irqsave(&stream->vbq_lock, lock_flags); + list_add_tail(&buf->queue, &stream->buf_queue); + spin_unlock_irqrestore(&stream->vbq_lock, lock_flags); + goto end; + } + } if (dev->skip_frame || stream->skip_frame) { + if (ft_diag) + rkisp_n1trace_info(&dev->v4l2_dev, + "n1trace mi_skip_frame state=%s skip=%u dev_skip=%u sof=%llu t_ns=%llu\n", + state == FRAME_WORK ? "WORK" : + state == FRAME_IRQ ? "IRQ" : "OTH", + stream->skip_frame, dev->skip_frame, + sof_ns, ktime_get_ns()); spin_lock_irqsave(&stream->vbq_lock, lock_flags); list_add_tail(&buf->queue, &stream->buf_queue); spin_unlock_irqrestore(&stream->vbq_lock, lock_flags); @@ -1126,7 +1215,20 @@ static int mi_frame_end(struct rkisp_stream *stream, u32 state) stream->dbg.delay = ns - dev->isp_sdev.frm_timestamp; stream->dbg.timestamp = ns; stream->dbg.id = seq; - + if (ft_diag) { + WRITE_ONCE(dev->cap_dev.early_done_diag_pub, + READ_ONCE(dev->cap_dev.early_done_diag_pub) + 1); + rkisp_n1trace_info(&dev->v4l2_dev, + "n1trace mi_pub state=%s vb_seq=%u early=%d drop_left=%u warm=%u gate=%u pub=%u sof=%llu t_ns=%llu\n", + state == FRAME_WORK ? "WORK" : + state == FRAME_IRQ ? "IRQ" : "OTH", + seq, stream->frame_early, + READ_ONCE(dev->cap_dev.early_done_drop_left), + READ_ONCE(dev->cap_dev.early_done_warmup_left), + READ_ONCE(dev->cap_dev.early_done_pub_gate_left), + READ_ONCE(dev->cap_dev.early_done_diag_pub), + sof_ns, ns); + } if (vir->streaming && vir->conn_id == stream->id) { spin_lock_irqsave(&vir->vbq_lock, lock_flags); list_add_tail(&buf->queue, &dev->cap_dev.vir_cpy.queue); @@ -1490,6 +1592,7 @@ rkisp_start_streaming(struct vb2_queue *queue, unsigned int count) } memset(&stream->dbg, 0, sizeof(stream->dbg)); + stream->early_done_sof_ns = 0; atomic_inc(&dev->cap_dev.refcnt); if (!dev->isp_inp || !stream->linked) { diff --git a/drivers/media/platform/rockchip/isp/common.h b/drivers/media/platform/rockchip/isp/common.h index f77364e922ed9..2d593a4142e5a 100644 --- a/drivers/media/platform/rockchip/isp/common.h +++ b/drivers/media/platform/rockchip/isp/common.h @@ -144,7 +144,16 @@ extern int rkisp_debug; extern bool rkisp_monitor; extern bool rkisp_irq_dbg; extern bool rkisp_buf_dbg; +/* FT N-1 diagnostics; default off (see module_param n1trace in rkisp.c). */ +extern bool rkisp_n1trace; extern u64 rkisp_debug_reg; + +#define rkisp_n1trace_info(v4l2_dev, fmt, ...) \ + do { \ + if (rkisp_n1trace) \ + v4l2_info((v4l2_dev), fmt, ##__VA_ARGS__); \ + } while (0) +extern unsigned int rkisp_wait_line; extern unsigned int rkisp_vicap_buf[DEV_MAX]; extern unsigned int rkisp_hdr_wrap_line[DEV_MAX]; extern struct platform_driver rkisp_plat_drv; diff --git a/drivers/media/platform/rockchip/isp/dev.c b/drivers/media/platform/rockchip/isp/dev.c index f614f4129b080..8e1f0bca41dcc 100644 --- a/drivers/media/platform/rockchip/isp/dev.c +++ b/drivers/media/platform/rockchip/isp/dev.c @@ -91,7 +91,7 @@ u64 rkisp_debug_reg = 0xFFFFFFFFFLL; module_param_named(debug_reg, rkisp_debug_reg, ullong, 0644); MODULE_PARM_DESC(debug_reg, "rkisp debug register"); -static unsigned int rkisp_wait_line; +unsigned int rkisp_wait_line; module_param_named(wait_line, rkisp_wait_line, uint, 0644); MODULE_PARM_DESC(wait_line, "rkisp wait line to buf done early"); diff --git a/drivers/media/platform/rockchip/isp/procfs.c b/drivers/media/platform/rockchip/isp/procfs.c index d52e406d0fd85..edeb97e37dc38 100644 --- a/drivers/media/platform/rockchip/isp/procfs.c +++ b/drivers/media/platform/rockchip/isp/procfs.c @@ -1,9 +1,11 @@ // SPDX-License-Identifier: GPL-2.0 /* Copyright (c) Rockchip Electronics Co., Ltd. */ #include +#include #include #include #include +#include #include "dev.h" #include "procfs.h" @@ -17,6 +19,9 @@ #ifdef CONFIG_PROC_FS +/* Cap userspace proc write size; CamOS WB path only needs a few dozen bytes. */ +#define RKISP_PROC_WRITE_MAX PAGE_SIZE + static void isp20_show(struct rkisp_device *dev, struct seq_file *p) { u32 full_range_flg = CIF_ISP_CTRL_ISP_CSM_Y_FULL_ENA | CIF_ISP_CTRL_ISP_CSM_C_FULL_ENA; @@ -1377,20 +1382,51 @@ static void rkisp_proc_dump_mem(struct rkisp_device *dev) } } +static bool rkisp_proc_hw_ready_for_mmio(struct rkisp_device *dev) +{ + struct rkisp_hw_dev *hw = dev->hw_dev; + + if (!hw || !hw->base_addr) + return false; + if (dev->is_suspend) + return false; + /* + * ISP_START is set while the pipeline is streaming. Writing ISP + * registers (FIL_AIQ/FIL_SW) while clocks/power are off, or during + * teardown thrash, has been observed to raise Asynchronous SError + * (python CamOS WB path: mode=0x100 / 400=0x6197) and panic the SoC. + */ + if (!(dev->isp_state & ISP_START)) + return false; + return true; +} + static ssize_t rkisp_proc_write(struct file *file, const char __user *user_buf, size_t user_len, loff_t *pos) { struct rkisp_device *dev = pde_data(file_inode(file)); - char *tmp, *buf = vmalloc(user_len + 1); + struct device *pm_dev = NULL; + char *tmp, *buf; u32 val, reg; int ret; + int pm_ret = 0; + ssize_t out = user_len; + bool need_pm = false; + + if (!dev) + return -ENODEV; + if (!user_len) + return 0; + if (user_len > RKISP_PROC_WRITE_MAX) + return -E2BIG; + buf = vmalloc(user_len + 1); if (!buf) return -ENOMEM; if (copy_from_user(buf, user_buf, user_len) != 0) { - vfree(buf); - return -EFAULT; + out = -EFAULT; + goto free_buf; } if (buf[user_len - 1] == '\n') buf[user_len - 1] = 0; @@ -1401,8 +1437,10 @@ static ssize_t rkisp_proc_write(struct file *file, if (tmp) { tmp += 5; ret = kstrtou32(tmp, 16, &val); - if (ret) - goto end; + if (ret) { + out = ret; + goto free_buf; + } dev->procfs.mode = val; if (val & RKISP_PROCFS_DUMP_MEM) rkisp_proc_dump_mem(dev); @@ -1410,13 +1448,57 @@ static ssize_t rkisp_proc_write(struct file *file, (RKISP_PROCFS_FIL_AIQ | RKISP_PROCFS_FIL_SW)) { char *p_reg, *p_val; + if (!rkisp_proc_hw_ready_for_mmio(dev)) { + dev_warn_ratelimited(dev->dev, + "%s: skip MMIO (isp_state=0x%x suspend=%d mode=0x%x) cmd='%s'\n", + __func__, dev->isp_state, dev->is_suspend, + dev->procfs.mode, buf); + out = -ENODEV; + goto free_buf; + } + + /* + * Keep the HW powered for the whole register burst. On + * multi-device ISP the vir device's RPM get pulls hw_dev. + * Prefer hw_dev->dev when available (actual MMIO owner). + */ + pm_dev = dev->hw_dev->dev ? dev->hw_dev->dev : dev->dev; + pm_ret = pm_runtime_get_sync(pm_dev); + if (pm_ret < 0) { + pm_runtime_put_noidle(pm_dev); + dev_err_ratelimited(dev->dev, + "%s: pm_runtime_get_sync failed (%d), skip MMIO cmd='%s'\n", + __func__, pm_ret, buf); + out = pm_ret; + goto free_buf; + } + need_pm = true; + + if (!rkisp_proc_hw_ready_for_mmio(dev)) { + dev_warn_ratelimited(dev->dev, + "%s: ISP left ready window after RPM get (state=0x%x), skip\n", + __func__, dev->isp_state); + out = -ENODEV; + goto put_pm; + } + p_reg = buf; tmp = strstr(p_reg, "="); while (tmp) { *tmp = '\0'; ret = kstrtou32(p_reg, 16, ®); - if (ret) - goto end; + if (ret) { + out = ret; + goto put_pm; + } + /* Refuse obviously out-of-range offsets into the ISP SW map. */ + if (reg >= RKISP_ISP_SW_REG_SIZE || (reg & 0x3)) { + dev_warn_ratelimited(dev->dev, + "%s: reject bad reg 0x%x\n", + __func__, reg); + out = -EINVAL; + goto put_pm; + } p_val = tmp + 1; tmp = strstr(p_val, " "); if (tmp) { @@ -1424,8 +1506,10 @@ static ssize_t rkisp_proc_write(struct file *file, p_reg = tmp + 1; } ret = kstrtou32(p_val, 16, &val); - if (ret) - goto end; + if (ret) { + out = ret; + goto put_pm; + } if (dev->procfs.mode & RKISP_PROCFS_FIL_SW) writel(val, dev->hw_dev->base_addr + reg); else @@ -1434,9 +1518,12 @@ static ssize_t rkisp_proc_write(struct file *file, tmp = strstr(p_reg, "="); } } -end: +put_pm: + if (need_pm) + pm_runtime_put_sync(pm_dev); +free_buf: vfree(buf); - return user_len; + return out; } static int isp_open(struct inode *inode, struct file *file) diff --git a/drivers/media/platform/rockchip/isp/rkisp.c b/drivers/media/platform/rockchip/isp/rkisp.c index ca8d8e35bcae7..67fe04b92e127 100644 --- a/drivers/media/platform/rockchip/isp/rkisp.c +++ b/drivers/media/platform/rockchip/isp/rkisp.c @@ -56,6 +56,127 @@ #define ISP_V4L2_EVENT_ELEMS 4 #define ISP_SUBDEV_NAME DRIVER_NAME "-isp-subdev" + +#ifndef V4L2_CID_USER_IMX296_BASE +#define V4L2_CID_USER_IMX296_BASE (V4L2_CID_USER_BASE + 0x10d0) +#endif +#define V4L2_CID_IMX296_OP_MODE (V4L2_CID_USER_IMX296_BASE + 0x1) +#define IMX296_XTRIG_ONE_SHOT 1 +#define ISP39_OUT_LINE_COUNTER_TAIL 8 +/* + * Residual / MI-drain ceiling after wait_line. Prefer live MI offs; when + * OFFS stays 0 on this V39 silicon, capture.c ceiling-forces after this + * residual and still applies force_post_mi hold so N-1 stays aligned + * without publishing at bare wait_line. ROI stream-on re-seeds skip/drop. + */ +#define ISP39_IMX296_TAIL_WAIT_US_DEFAULT 45000U +#define ISP39_IMX296_TAIL_WAIT_US_MAX 100000U +#define ISP39_IMX296_LINE_US_MIN 80U +#define ISP39_IMX296_LINE_US_MAX 250U +#define ISP39_IMX296_EARLY_DONE_POLL_US 50U +#define ISP39_IMX296_POST_MI_US 25000U +/* + * Ceiling-force path floor: MI OFFS stay 0 so we never see live full. + * 2026-08-10 #1005: warm_left drop alone is NOT enough — first ~5 + * mi_pub after warm_left==0 still classic green. Quarantine is + * PUB_GATE_FRAMES MP pubs after drop+warm (see capture_v39 #1009). + * CamOS SMARTCAM_TRIGGER_WARMUP must cover skip_sof + skip_buf + * + warm + pub_gate completes so user 1:1 starts after gate. + */ +#define ISP39_IMX296_FORCE_POST_MI_US 70000U +/* First FT frames after free-run/ROI stream-on (longer hold). */ +#define ISP39_IMX296_WARMUP_POST_MI_US 85000U +#define ISP39_IMX296_WARMUP_FRAMES 2U +/* SOFs that skip early-done (MI FE only) after free-run/ROI->FT. */ +#define ISP39_IMX296_SKIP_EARLY_FRAMES 2U +/* + * Extra half-open drops at FT stream-on (IRQ path / residual). + * Keep tiny — green quarantine is early_done_pub_gate_left. + */ +#define ISP39_IMX296_SKIP_BUF_FRAMES 2U +/* + * Post drop+warm MP pub quarantine (IRQ and WORK). #1007/#1009: gate + * must NOT decrement while warm>0 (IRQ path burned gate in #1008). + * #1009 board (#1201): phase fixed but mon→sw still bot8 green on + * user head 1–5 after gate=6 (same #1005 residual). #1010 gate=12 + * still green and blew CamOS default pulse max 22 → apply hang. + * #1011 gate=8: phase OK, but align n1trace shows first mi_pub at + * warm=0/gate=0 still followed by user head 1–5 classic bot8 green + * (CamOS success at ~20 pulses / appsink_seen≈4 discards only pub1–4; + * user head1=pub5 still dirty). Dirty window ≈5 pubs after quarantine. + * #1012: gate=13 → skip2+drop2+warm8+gate13=25 completes. CamOS + * SMARTCAM_TRIGGER_WARMUP_TOTAL_PULSE_MAX must be ≥28 (default 22 + * is too small). Do not rely on CamOS post residual to erase green. + */ +/* + * #1013–#1016: UV-tail repair on FT pub (capture_v39) replaces long gate. + * #1016: neu-frac only if ref has chroma; mean thr=6; extend last-good. + * Keep tiny drop/warm/gate only for half-open FT entry buffers. + * Budget skip2+drop2+warm2+gate2 ≈ 8 completes → CamOS default pulses OK, + * mon→FT apply target 2–3s without raising TOTAL_PULSE_MAX. + */ +#define ISP39_IMX296_PUB_GATE_FRAMES 2U + +static unsigned int rkisp_imx296_tail_wait_us = ISP39_IMX296_TAIL_WAIT_US_DEFAULT; +module_param_named(imx296_tail_wait_us, rkisp_imx296_tail_wait_us, uint, 0644); +MODULE_PARM_DESC(imx296_tail_wait_us, + "V39 IMX296 FT MI-drain residual ceiling (us); completion prefers MI offs"); + +/* + * Per-frame FT n1trace (early arm/complete/ceiling, mi_pub/drop). Default off: + * bring-up logging filled rsyslog (kern+syslog) and exhausted rootfs. + * Enable: echo 1 > /sys/module/video_rkisp/parameters/n1trace + * (pair with imx296.n1trace=1 for full pulse↔mi correlation). + */ +bool rkisp_n1trace; +module_param_named(n1trace, rkisp_n1trace, bool, 0644); +MODULE_PARM_DESC(n1trace, + "RKISP FT n1trace diagnostics (0=off default, 1=verbose)"); +EXPORT_SYMBOL_GPL(rkisp_n1trace); + +static u32 rkisp_imx296_residual_ceiling_us(u32 width, u32 height, + u32 wait_line) +{ + u32 remain, line_us, tail_us, param; + + if (height <= wait_line) + remain = ISP39_OUT_LINE_COUNTER_TAIL; + else + remain = height - wait_line; + + /* + * Rough line time from width (10-bit / ~74.25 MHz class). Clamp so + * small ROI does not under-estimate and huge width does not explode. + */ + line_us = width / 8; + if (line_us < ISP39_IMX296_LINE_US_MIN) + line_us = ISP39_IMX296_LINE_US_MIN; + if (line_us > ISP39_IMX296_LINE_US_MAX) + line_us = ISP39_IMX296_LINE_US_MAX; + + /* + * Residual after wait_line must cover remaining luma + chroma tail. + * NV12 chroma trails the last Y rows — budget extra lines so + * ceiling-force is not early on short ROI heights (green/tear). + */ + /* + * Budget remaining ISP lines after wait_line PLUS a large chroma + * tail. Observed green band is ~OUT_LINE tail (8–10 rows) of bad UV + * when force completes too early — use ~half-frame chroma time. + */ + tail_us = (remain + ISP39_OUT_LINE_COUNTER_TAIL * 8) * line_us + + (height / 2) * (line_us / 2) + 8000; + /* Full-height mon→FT green still needs a solid residual floor. */ + if (tail_us < 40000) + tail_us = 40000; + + param = rkisp_imx296_tail_wait_us; + if (param < tail_us) + param = tail_us; + if (param > ISP39_IMX296_TAIL_WAIT_US_MAX) + param = ISP39_IMX296_TAIL_WAIT_US_MAX; + return param; +} /* * NOTE: MIPI controller and input MUX are also configured in this file, * because ISP Subdev is not only describe ISP submodule(input size,format, output size, format), @@ -89,6 +210,36 @@ static void rkisp_config_cmsk(struct rkisp_device *dev); static void rkisp_config_aiisp(struct rkisp_device *dev); static void rkisp_config_fpn(struct rkisp_device *dev); +#if IS_REACHABLE(CONFIG_VIDEO_IMX296) +extern bool imx296_is_fast_trigger_active(void); +#endif + +static bool rkisp_imx296_fast_trigger_active(struct rkisp_device *dev) +{ + struct v4l2_subdev *sensor; + struct v4l2_ctrl *mode; + + /* Direct sensor-to-ISP topologies can read the control normally. */ + if (dev->active_sensor && dev->active_sensor->sd) { + sensor = dev->active_sensor->sd; + mode = v4l2_ctrl_find(sensor->ctrl_handler, + V4L2_CID_IMX296_OP_MODE); + if (mode) + return v4l2_ctrl_g_ctrl(mode) == IMX296_XTRIG_ONE_SHOT; + } + + /* + * RK3576 routes IMX296 -> RKCIF -> RKISP across two media devices. + * RKISP therefore sees the CIF bridge as active_sensor and cannot walk + * to the IMX296 control handler. Use the sensor's read-only mode bridge. + */ +#if IS_REACHABLE(CONFIG_VIDEO_IMX296) + return imx296_is_fast_trigger_active(); +#else + return false; +#endif +} + static inline struct rkisp_device *sd_to_isp_dev(struct v4l2_subdev *sd) { return container_of(sd->v4l2_dev, struct rkisp_device, v4l2_dev); @@ -2218,6 +2369,7 @@ static int rkisp_isp_stop(struct rkisp_device *dev) v4l2_dbg(1, rkisp_debug, &dev->v4l2_dev, "%s refcnt:%d\n", __func__, atomic_read(&hw->refcnt)); + rkisp_stream_cancel_early_done(dev); if (atomic_read(&hw->refcnt) > 1) goto end; @@ -2346,12 +2498,96 @@ static int rkisp_isp_stop(struct rkisp_device *dev) static int rkisp_isp_start(struct rkisp_device *dev) { struct rkisp_hw_dev *hw = dev->hw_dev; + u32 height = dev->isp_sdev.out_crop.height; u32 val; v4l2_dbg(1, rkisp_debug, &dev->v4l2_dev, "%s refcnt:%d link_num:%d\n", __func__, atomic_read(&hw->refcnt), hw->dev_link_num); + /* + * IMX296 Fast Trigger keeps the MIPI frame open until the next XTRIG. + * Complete the current ISP buffer at the last observable V39 output line + * so a trigger publishes its own frame instead of N-1. The V39 output + * line counter stops eight counts below the active height, so height - 1 + * never asserts. Program one count before that terminal, then complete + * when MI plane offsets finish (+ post-MI hold). Free-run clears early-done. + * + * Always re-seed wait_line from the module/DT default first: a previous + * fast-trigger session must not leave is_done_early across free-run. + */ + dev->cap_dev.early_done_delay_us = 0; + dev->cap_dev.early_done_poll_us = ISP39_IMX296_EARLY_DONE_POLL_US; + dev->cap_dev.early_done_terminal_line = 0; + dev->cap_dev.early_done_post_mi_us = 0; + dev->cap_dev.early_done_force_post_mi_us = 0; + dev->cap_dev.early_done_warmup_post_mi_us = 0; + dev->cap_dev.early_done_warmup_left = 0; + dev->cap_dev.early_done_skip_frames = 0; + dev->cap_dev.early_done_drop_left = 0; + dev->cap_dev.early_done_pub_gate_left = 0; + dev->cap_dev.early_done_diag_pub = 0; + dev->cap_dev.early_done_diag_drop = 0; + dev->cap_dev.early_done_diag_skip_sof = 0; + dev->cap_dev.early_done_diag_arm = 0; + dev->cap_dev.early_done_diag_complete = 0; + dev->cap_dev.early_done_diag_ceiling = 0; + rkisp_stream_cancel_early_done(dev); + dev->cap_dev.wait_line = rkisp_wait_line; + if (dev->isp_ver == ISP_V39 && height > 1 && + rkisp_imx296_fast_trigger_active(dev)) { + struct rkisp_stream *mp = &dev->cap_dev.stream[RKISP_STREAM_MP]; + u32 terminal = height > ISP39_OUT_LINE_COUNTER_TAIL ? + height - ISP39_OUT_LINE_COUNTER_TAIL : 1; + + dev->cap_dev.early_done_terminal_line = terminal; + dev->cap_dev.wait_line = terminal > 1 ? terminal - 1 : 1; + dev->cap_dev.early_done_delay_us = + rkisp_imx296_residual_ceiling_us(dev->isp_sdev.out_crop.width, + height, + dev->cap_dev.wait_line); + dev->cap_dev.early_done_post_mi_us = ISP39_IMX296_POST_MI_US; + dev->cap_dev.early_done_force_post_mi_us = + ISP39_IMX296_FORCE_POST_MI_US; + dev->cap_dev.early_done_warmup_post_mi_us = + ISP39_IMX296_WARMUP_POST_MI_US; + dev->cap_dev.early_done_warmup_left = ISP39_IMX296_WARMUP_FRAMES; + dev->cap_dev.early_done_skip_frames = ISP39_IMX296_SKIP_EARLY_FRAMES; + /* + * Drop the first FT-completed buffers entirely. After free-run/ROI + * the first published frames are often half-open / invalid even when + * MI FE fires. Use cap_dev.early_done_drop_left so it survives + * stream->skip_frame being cleared in rkisp_start(). + */ + dev->cap_dev.early_done_drop_left = ISP39_IMX296_SKIP_BUF_FRAMES; + dev->cap_dev.early_done_pub_gate_left = ISP39_IMX296_PUB_GATE_FRAMES; + if (mp->streaming) + mp->skip_frame = ISP39_IMX296_SKIP_BUF_FRAMES; + v4l2_info(&dev->v4l2_dev, + "IMX296 fast trigger: early buffer done at line %u/%u terminal %u, MI-drain ceiling %u us post-MI %u/%u force %u us (warmup %u) skip %u drop %u pub_gate %u poll %u us\n", + dev->cap_dev.wait_line, height, + dev->cap_dev.early_done_terminal_line, + dev->cap_dev.early_done_delay_us, + dev->cap_dev.early_done_post_mi_us, + dev->cap_dev.early_done_warmup_post_mi_us, + dev->cap_dev.early_done_force_post_mi_us, + dev->cap_dev.early_done_warmup_left, + dev->cap_dev.early_done_skip_frames, + ISP39_IMX296_SKIP_BUF_FRAMES, + ISP39_IMX296_PUB_GATE_FRAMES, + dev->cap_dev.early_done_poll_us); + rkisp_n1trace_info(&dev->v4l2_dev, + "n1trace isp_ft_arm height=%u wait_line=%u skip=%u drop=%u warmup=%u pub_gate=%u ceiling_us=%u force_post=%u t_ns=%llu\n", + height, dev->cap_dev.wait_line, + dev->cap_dev.early_done_skip_frames, + ISP39_IMX296_SKIP_BUF_FRAMES, + dev->cap_dev.early_done_warmup_left, + ISP39_IMX296_PUB_GATE_FRAMES, + dev->cap_dev.early_done_delay_us, + dev->cap_dev.early_done_force_post_mi_us, + ktime_get_ns()); + } + dev->cap_dev.is_done_early = false; if (dev->cap_dev.wait_line >= dev->isp_sdev.out_crop.height) dev->cap_dev.wait_line = 0; @@ -2371,6 +2607,10 @@ static int rkisp_isp_start(struct rkisp_device *dev) rkisp_unite_clear_bits(dev, CIF_ISP_IMSC, ISP2X_LSC_LUT_ERR, false); dev->rawaf_irq_cnt = 0; } + } else if (dev->isp_ver >= ISP_V32) { + /* Drop a stale OUT_FRM_HALF threshold left by the previous session. */ + rkisp_write(dev, ISP32_ISP_IRQ_CFG0, 0, false); + rkisp_clear_bits(dev, CIF_ISP_IMSC, ISP3X_OUT_FRM_HALF, false); } /* Activate ISP */ @@ -3858,7 +4098,15 @@ static void rkisp_queue_event_aiisp(struct rkisp_device *dev, u32 irq) rd_line += dev->aiisp_cfg.rd_linecnt; if (rd_line > h) rd_line = h - 1; - rkisp_write(dev, ISP32_ISP_IRQ_CFG0, rd_line, true); + /* + * IRQ_CFG0 low 16 bits belong to AIISP's quarter-line + * interrupt; high 16 bits belong to capture early-done. + * Preserve the IMX296 Fast Trigger threshold when AIISP + * advances its read line at runtime. + */ + rkisp_write(dev, ISP32_ISP_IRQ_CFG0, + (rd_line & 0xffff) | + (dev->cap_dev.wait_line << 16), true); } } else { wr_line = ISP39_AIISP_WR_LINECNT(val); @@ -3901,7 +4149,8 @@ static void rkisp_config_aiisp(struct rkisp_device *dev) irq_mask = ISP39_AIISP_LINECNT_DONE | ISP3X_OUT_FRM_QUARTER; en_mask = ISP39_AIISP_EN; - rd_line = dev->aiisp_cfg.rd_linecnt; + rd_line = (dev->aiisp_cfg.rd_linecnt & 0xffff) | + (dev->cap_dev.wait_line << 16); wr_line = dev->aiisp_cfg.wr_linecnt << 16; rkisp_write(dev, ISP32_ISP_IRQ_CFG0, rd_line, false); @@ -5087,7 +5336,14 @@ void rkisp_isp_isr(unsigned int isp_mis, if (isp_mis & ISP3X_OUT_FRM_HALF) { writel(ISP3X_OUT_FRM_HALF, base + CIF_ISP_ICR); rkisp_dvbm_event(dev, ISP3X_OUT_FRM_HALF); - rkisp_stream_buf_done_early(dev); + /* + * V39 exposes the last usable line interrupt before all tail lines + * have reached memory. Arm a high-resolution timer and keep ownership + * until those writes drain; doing vb2_done immediately can expose + * green/stale bottom rows to userspace. The timer avoids a long udelay + * in this hard-IRQ handler. + */ + rkisp_stream_buf_done_early_delayed(dev, dev->cap_dev.early_done_delay_us); } if (isp_mis & ISP3X_OUT_FRM_END) { writel(ISP3X_OUT_FRM_END, base + CIF_ISP_ICR); @@ -5112,4 +5368,3 @@ irqreturn_t rkisp_vs_isr_handler(int irq, void *ctx) return IRQ_HANDLED; } - diff --git a/scripts/dlcvcam_verify_bootimg.py b/scripts/dlcvcam_verify_bootimg.py new file mode 100755 index 0000000000000..c7c4dc9b29386 --- /dev/null +++ b/scripts/dlcvcam_verify_bootimg.py @@ -0,0 +1,498 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0 +""" +DLCVCAM boot.img (FIT) integrity checker — hash only, no signature. + +Verifies: + 1) Optional whole-file SHA-256 against .sha256 or --expect-sha256 + 2) FIT/FDT structure parse + 3) Embedded per-image hash nodes (fdt / kernel / resource, ...) + +Delivery sidecar is only: + .sha256 whole-file SHA-256 (sha256sum format) + +Exit codes: + 0 OK + 1 verification failed + 2 usage / I/O / parse error +""" + +from __future__ import annotations + +import argparse +import hashlib +import hmac +import json +import struct +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + + +FDT_MAGIC = 0xD00DFEED +FDT_BEGIN_NODE = 1 +FDT_END_NODE = 2 +FDT_PROP = 3 +FDT_NOP = 4 +FDT_END = 9 + +SUPPORTED_HASH = { + "sha1": (hashlib.sha1, 20), + "sha256": (hashlib.sha256, 32), + "sha512": (hashlib.sha512, 64), +} + + +class VerifyError(Exception): + pass + + +def eprint(*args: object) -> None: + print(*args, file=sys.stderr) + + +def sha256_file(path: Path, chunk: int = 1024 * 1024) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + while True: + b = f.read(chunk) + if not b: + break + h.update(b) + return h.hexdigest() + + +def parse_sha256_sidecar(text: str) -> str: + """ + Accept: + + + * + """ + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + part = line.split()[0].strip().lower() + if len(part) == 64 and all(c in "0123456789abcdef" for c in part): + return part + raise VerifyError("sidecar 中未找到合法 sha256") + + +def align4(n: int) -> int: + return (n + 3) & ~3 + + +def parse_fdt_tree(data: bytes, fdt_offset: int = 0) -> Dict[str, Dict[str, bytes]]: + if len(data) < fdt_offset + 40: + raise VerifyError("文件过短,无法读取 FDT 头") + + magic, totalsize, off_dt_struct, off_dt_strings, _off_mem, version, _last, _cpu, size_dt_strings, size_dt_struct = struct.unpack_from( + ">10I", data, fdt_offset + ) + if magic != FDT_MAGIC: + raise VerifyError(f"非 FIT/FDT 魔数: 0x{magic:08x} (期望 0xd00dfeed)") + if version < 16: + raise VerifyError(f"不支持的 FDT version: {version}") + if totalsize < 40 or fdt_offset + totalsize > len(data): + # external-data FIT: totalsize 只覆盖 header tree,payload 在后面 —— 允许 totalsize 小于文件 + if totalsize < 40: + raise VerifyError(f"非法 FDT totalsize: {totalsize}") + + struct_base = fdt_offset + off_dt_struct + strings_base = fdt_offset + off_dt_strings + struct_end = struct_base + size_dt_struct + strings_end = strings_base + size_dt_strings + if struct_end > len(data) or strings_end > len(data): + raise VerifyError("FDT struct/strings 超出文件范围") + + def get_string(nameoff: int) -> str: + start = strings_base + nameoff + if start >= strings_end: + raise VerifyError("FDT 字符串偏移越界") + end = data.find(b"\x00", start, strings_end) + if end < 0: + raise VerifyError("FDT 字符串缺少 NUL") + return data[start:end].decode("ascii", "replace") + + pos = struct_base + depth = 0 + stack: List[str] = [] + props: Dict[str, Dict[str, bytes]] = {} + + while pos + 4 <= struct_end: + tag = struct.unpack_from(">I", data, pos)[0] + pos += 4 + if tag == FDT_BEGIN_NODE: + end = data.find(b"\x00", pos, struct_end) + if end < 0: + raise VerifyError("FDT 节点名缺少 NUL") + name = data[pos:end].decode("ascii", "replace") + pos = align4(end + 1) + depth += 1 + if depth == 1: + full = "/" + else: + parent = stack[-1] + full = f"{parent}{name}" if parent.endswith("/") else f"{parent}/{name}" + if parent == "/": + full = f"/{name}" + stack.append(full) + props.setdefault(full, {}) + elif tag == FDT_END_NODE: + if not stack: + raise VerifyError("FDT END_NODE 不匹配") + stack.pop() + depth -= 1 + elif tag == FDT_PROP: + if pos + 8 > struct_end: + raise VerifyError("FDT PROP 头截断") + plen, nameoff = struct.unpack_from(">II", data, pos) + pos += 8 + if pos + plen > struct_end: + raise VerifyError("FDT PROP 值截断") + pname = get_string(nameoff) + pval = data[pos : pos + plen] + pos = align4(pos + plen) + if not stack: + raise VerifyError("FDT PROP 无当前节点") + props[stack[-1]][pname] = pval + elif tag == FDT_NOP: + continue + elif tag == FDT_END: + break + else: + raise VerifyError(f"未知 FDT tag {tag} @ {pos - 4}") + + return props + + +def _cstr(val: bytes) -> str: + return val.split(b"\x00", 1)[0].decode("ascii", "replace") + + +def _u32(val: bytes) -> Optional[int]: + if len(val) != 4: + return None + return struct.unpack(">I", val)[0] + + +def collect_fit_images(props: Dict[str, Dict[str, bytes]]) -> List[Dict[str, Any]]: + images: List[Dict[str, Any]] = [] + for path, p in props.items(): + # image nodes: /images/ (exactly one name segment under /images) + if not path.startswith("/images/"): + continue + rest = path[len("/images/") :] + if not rest or "/" in rest: + continue + name = rest + img: Dict[str, Any] = { + "name": name, + "path": path, + "type": _cstr(p["type"]) if "type" in p else "", + "compression": _cstr(p["compression"]) if "compression" in p else "", + "data_position": _u32(p["data-position"]) if "data-position" in p else None, + "data_offset": _u32(p["data-offset"]) if "data-offset" in p else None, + "data_size": _u32(p["data-size"]) if "data-size" in p else None, + "hashes": [], + } + # inline data property (rare for our external FIT) + if "data" in p: + img["inline_data_len"] = len(p["data"]) + img["inline_data"] = p["data"] + + # hash subnodes: /images//hash or hash@1 ... + prefix = path + "/" + for hpath, hp in props.items(): + if not hpath.startswith(prefix): + continue + sub = hpath[len(prefix) :] + if "/" in sub: + continue + if not (sub == "hash" or sub.startswith("hash@") or sub.startswith("hash-")): + continue + if "algo" not in hp or "value" not in hp: + continue + img["hashes"].append( + { + "node": sub, + "algo": _cstr(hp["algo"]).lower(), + "value": hp["value"], + } + ) + images.append(img) + images.sort(key=lambda x: x["name"]) + return images + + +def resolve_image_blob(data: bytes, img: Dict[str, Any], fdt_totalsize_hint: int) -> bytes: + if "inline_data" in img: + return img["inline_data"] + + size = img.get("data_size") + if size is None: + raise VerifyError(f"镜像 {img['name']}: 缺少 data-size/data") + + pos = img.get("data_position") + if pos is not None: + end = pos + size + if end > len(data): + raise VerifyError( + f"镜像 {img['name']}: data-position/size 越界 ({pos}+{size} > {len(data)})" + ) + return data[pos:end] + + off = img.get("data_offset") + if off is not None: + # external data: offset relative to end of FIT header (aligned external area). + # Our images use absolute data-position; data-offset kept as fallback only. + pos2 = fdt_totalsize_hint + off + end = pos2 + size + if end > len(data): + raise VerifyError( + f"镜像 {img['name']}: data-offset/size 越界 ({pos2}+{size} > {len(data)})" + ) + return data[pos2:end] + + raise VerifyError(f"镜像 {img['name']}: 无 data / data-position / data-offset") + + +def verify_fit_hashes(data: bytes) -> Tuple[bool, List[str], List[Dict[str, Any]]]: + props = parse_fdt_tree(data, 0) + magic, totalsize = struct.unpack_from(">2I", data, 0) + assert magic == FDT_MAGIC + + images = collect_fit_images(props) + if not images: + raise VerifyError("FIT 中未找到 /images/* 节点") + + messages: List[str] = [] + details: List[Dict[str, Any]] = [] + all_ok = True + + for img in images: + entry: Dict[str, Any] = { + "name": img["name"], + "type": img["type"], + "data_size": img.get("data_size"), + "data_position": img.get("data_position"), + "hashes": [], + } + try: + blob = resolve_image_blob(data, img, totalsize) + except VerifyError as ex: + all_ok = False + messages.append(f"[FAIL] {img['name']}: {ex}") + entry["error"] = str(ex) + details.append(entry) + continue + + if not img["hashes"]: + all_ok = False + messages.append(f"[FAIL] {img['name']}: 无内嵌 hash 节点") + entry["error"] = "no hash node" + details.append(entry) + continue + + for h in img["hashes"]: + algo = h["algo"] + val = h["value"] + hinfo: Dict[str, Any] = { + "node": h["node"], + "algo": algo, + "expected": val.hex(), + } + if algo not in SUPPORTED_HASH: + all_ok = False + hinfo["ok"] = False + hinfo["error"] = f"不支持的 hash 算法: {algo}" + messages.append(f"[FAIL] {img['name']}/{h['node']}: 不支持算法 {algo}") + entry["hashes"].append(hinfo) + continue + factory, digest_len = SUPPORTED_HASH[algo] + if len(val) != digest_len: + # some FIT blobs store value with padding + if len(val) > digest_len and all(b == 0 for b in val[digest_len:]): + val = val[:digest_len] + hinfo["expected"] = val.hex() + else: + all_ok = False + hinfo["ok"] = False + hinfo["error"] = f"hash 长度 {len(val)} != {digest_len}" + messages.append( + f"[FAIL] {img['name']}/{h['node']}: hash 长度异常 {len(val)}" + ) + entry["hashes"].append(hinfo) + continue + actual = factory(blob).digest() + hinfo["actual"] = actual.hex() + ok = hmac.compare_digest(actual, val) + hinfo["ok"] = ok + if ok: + messages.append( + f"[OK] {img['name']}/{h['node']} {algo} ({len(blob)} bytes)" + ) + else: + all_ok = False + messages.append( + f"[FAIL] {img['name']}/{h['node']} {algo} mismatch\n" + f" expect {val.hex()}\n" + f" actual {actual.hex()}" + ) + entry["hashes"].append(hinfo) + details.append(entry) + + return all_ok, messages, details + + +def load_expect_sha256(img: Path, explicit: Optional[str], sidecar: Optional[Path]) -> Optional[str]: + if explicit: + v = explicit.strip().lower() + if len(v) != 64 or any(c not in "0123456789abcdef" for c in v): + raise VerifyError("--expect-sha256 不是 64 位 hex") + return v + candidates: List[Path] = [] + if sidecar: + candidates.append(sidecar) + # Preferred: .img.sha256 + candidates.append(Path(str(img) + ".sha256")) + for c in candidates: + if c.is_file(): + return parse_sha256_sidecar(c.read_text(encoding="utf-8", errors="replace")) + return None + + +def cmd_verify(args: argparse.Namespace) -> int: + img = Path(args.image) + if not img.is_file(): + eprint(f"文件不存在: {img}") + return 2 + + size = img.stat().st_size + if size <= 0: + eprint("[FAIL] 空文件") + return 1 + + data = img.read_bytes() + file_sha = hashlib.sha256(data).hexdigest() + print(f"file : {img}") + print(f"size : {size}") + print(f"sha256 : {file_sha}") + + failed = False + + # whole-file hash + try: + expect = load_expect_sha256( + img, args.expect_sha256, Path(args.sha256_file) if args.sha256_file else None + ) + except VerifyError as ex: + eprint(f"[ERR] {ex}") + return 2 + + if expect is None: + if args.require_sidecar: + eprint("[FAIL] 未提供/未找到整包 .sha256(指定了 --require-sidecar)") + return 1 + print("whole-file: (no sidecar / --expect-sha256, skip)") + else: + if hmac.compare_digest(bytes.fromhex(expect), bytes.fromhex(file_sha)): + print("[OK] whole-file sha256 matches") + else: + failed = True + print("[FAIL] whole-file sha256 mismatch") + print(f" expect {expect}") + print(f" actual {file_sha}") + + # FIT internal hashes + try: + ok, messages, details = verify_fit_hashes(data) + except VerifyError as ex: + eprint(f"[FAIL] FIT 解析/校验错误: {ex}") + return 1 + + print("fit-hash :") + for m in messages: + print(f" {m}") + if not ok: + failed = True + + if args.json_out: + Path(args.json_out).write_text( + json.dumps( + { + "file": str(img), + "size": size, + "sha256": file_sha, + "whole_file_ok": (expect is None) or (expect == file_sha), + "fit_ok": ok, + "fit_images": details, + }, + indent=2, + ensure_ascii=False, + ) + + "\n", + encoding="utf-8", + ) + + if failed: + print("RESULT : FAIL") + return 1 + print("RESULT : OK") + return 0 + + +def cmd_gen_sidecar(args: argparse.Namespace) -> int: + """Write whole-file .sha256 only (no JSON manifest).""" + img = Path(args.image) + if not img.is_file(): + eprint(f"文件不存在: {img}") + return 2 + + file_sha = sha256_file(img) + sha_path = Path(args.sha256_file) if args.sha256_file else Path(str(img) + ".sha256") + sha_path.write_text(f"{file_sha} {img.name}\n", encoding="utf-8") + + print(f"wrote {sha_path}") + print(f"sha256 {file_sha}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="DLCVCAM boot.img hash verifier (no signature)") + sub = p.add_subparsers(dest="cmd", required=True) + + v = sub.add_parser("verify", help="校验 boot.img(整包 sha256 + FIT 内嵌 hash)") + v.add_argument("image", help="boot.img / boot-rk3576-*.img 路径") + v.add_argument("--expect-sha256", help="期望的整包 sha256 hex") + v.add_argument("--sha256-file", help="整包 sha256 sidecar 路径") + v.add_argument("--require-sidecar", action="store_true", help="必须存在整包 .sha256") + v.add_argument("--json-out", help="把详细校验结果写到 JSON(调试用,非交付物)") + v.set_defaults(func=cmd_verify) + + g = sub.add_parser("gen-sidecar", help="为 boot.img 生成 .sha256(整包 hash)") + g.add_argument("image", help="boot.img 路径") + g.add_argument("--sha256-file", help="输出 .sha256 路径(默认 .sha256)") + g.set_defaults(func=cmd_gen_sidecar) + + return p + + +def main(argv: Optional[List[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return int(args.func(args)) + except BrokenPipeError: + return 0 + except VerifyError as ex: + eprint(f"[ERR] {ex}") + return 2 + except OSError as ex: + eprint(f"[ERR] {ex}") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/imx296_148_extra.py b/scripts/imx296_148_extra.py new file mode 100644 index 0000000000000..a776dbaf0cad5 --- /dev/null +++ b/scripts/imx296_148_extra.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python3 +"""Extra checks beyond verify_accept_user.py (originally 148). + +Covers panorama §4 / defect E (Y tear at gain=240 1500↔15000), +F idle line0 (no spurious +1), J n1trace default, I pulse restore, +and whole-frame olive (WB/dirty) on head frames. + +CamOS 控制面默认 /ws/cmd;账号用 CAMOS_USER / CAMOS_PASS。 +""" +from __future__ import annotations + +import asyncio +import importlib.util +import json +import os +import sys +import time +from pathlib import Path + +import requests +import websockets + +BASE = os.environ.get("CAMOS_BASE", "http://127.0.0.1:8000") +WS_URL = os.environ.get("CAMOS_WS", "ws://127.0.0.1:8000/ws/cmd") +OUT = Path(os.environ.get("VERIFY_OUT", "/userdata/imx296_accept_148/extra")) +USER = os.environ.get("CAMOS_USER", "admin") +PASS = os.environ.get("CAMOS_PASS", "dlcv2026") +PULSE = Path("/sys/bus/i2c/devices/4-001a/trigger_pulse_us") +RUN_MODE = Path("/sys/bus/i2c/devices/4-001a/run_mode") +N1TRACE = Path("/sys/module/video_rkisp/parameters/n1trace") +TAIL = Path("/sys/module/video_rkisp/parameters/imx296_tail_wait_us") +STATS = Path("/sys/devices/platform/trigger-dev/stats") +UNAME = Path("/proc/version") + +det = None +for cand in ( + Path(__file__).resolve().parent / "imx296_bottom_quality.py", + Path("/userdata/imx296_accept_148/imx296_bottom_quality.py"), + Path("/userdata/imx296_warmup_ab/scripts/imx296_bottom_quality.py"), +): + if cand.is_file(): + spec = importlib.util.spec_from_file_location("imx296_bottom_quality", cand) + det = importlib.util.module_from_spec(spec) + assert spec.loader + spec.loader.exec_module(det) + break +if det is None: + sys.exit("imx296_bottom_quality.py missing") + +OUT.mkdir(parents=True, exist_ok=True) +(OUT / "frames").mkdir(exist_ok=True) + +s = requests.Session() + + +def login(): + s.post(BASE + "/api/auth/login", json={"username": USER, "password": PASS}, timeout=10) + return "; ".join(f"{c.name}={c.value}" for c in s.cookies) + + +cookie = login() + + +def patch(body, timeout=90): + t0 = time.time() + r = s.patch(BASE + "/api/flow_config", json=body, timeout=timeout) + js = r.json() if r.content else {} + ok = r.status_code < 400 + if isinstance(js, dict) and "success" in js: + ok = bool(js["success"]) + return time.time() - t0, js, ok + + +def peek_tag(): + pr = s.get(BASE + "/preview/frame", params={"t": time.time_ns()}, timeout=8) + return int(pr.headers.get("X-Frame-Tag") or pr.headers.get("x-frame-tag") or 0) + + +def wait_new(last, timeout_s=2.2): + end = time.time() + timeout_s + while time.time() < end: + pr = s.get(BASE + "/preview/frame", params={"t": time.time_ns()}, timeout=8) + tn = int(pr.headers.get("X-Frame-Tag") or pr.headers.get("x-frame-tag") or 0) + if pr.status_code == 200 and pr.content and tn != last: + return tn, pr.content + time.sleep(0.03) + return last, b"" + + +def fire_sw(): + s.post(BASE + "/debug/trigger_once", timeout=8) + + +def fire_i2c(): + Path("/sys/bus/i2c/devices/4-001a/trigger").write_text("1\n") + + +def luma_bot_above(a: dict) -> float: + bot = 0.299 * a["br"] + 0.587 * a["bg"] + 0.114 * a["bb"] + above = 0.299 * a["ar"] + 0.587 * a["ag"] + 0.114 * a["ab"] + return bot - above + + +def olive_whole(a: dict) -> bool: + # panorama: whole-frame olive mean often (~73,98,45) + mr, mg, mb = a["mr"], a["mg"], a["mb"] + g_ex = mg - max(mr, mb) + return g_ex >= 18 and 50 <= mg <= 130 and mr < 95 and mb < 70 + + +def rkcam(): + return s.get(BASE + "/debug/status", timeout=10).json()["rkcam"] + + +def parse_stats(): + txt = STATS.read_text() + out = {} + for tok in txt.replace("\n", " ").split(): + if "=" in tok: + k, _, v = tok.partition("=") + try: + out[k] = int(v) + except Exception: + out[k] = v + return out + + +async def ws_cmd(ws, cmd, value=None): + msg = {"cmd": cmd} + if value is not None: + msg["value"] = value + await ws.send(json.dumps(msg)) + for _ in range(4): + try: + await asyncio.wait_for(ws.recv(), timeout=0.3) + except Exception: + break + + +async def set_exp_gain(ws, exp=None, gain=None): + if gain is not None: + await ws_cmd(ws, "rkcam_set_gain", float(gain)) + if exp is not None: + await ws_cmd(ws, "rkcam_set_exposure_time", float(exp)) + # FT 曝光走 PWM pulse;CamOS 应写 trigger_pulse_us。再直写一份兜底。 + try: + pulse = max(1, int(round(float(exp))) - 14) + PULSE.write_text(f"{pulse}\n") + except Exception as e: + print("pulse_sysfs_err", e, flush=True) + # wait until rkcam reports (gain always; exp may stay cached) + deadline = time.time() + 2.5 + while time.time() < deadline: + st = rkcam() + g_ok = gain is None or abs(float(st.get("gain") or -1) - float(gain)) < 0.6 + p_ok = True + if exp is not None and PULSE.exists(): + try: + p_ok = abs(int(PULSE.read_text().strip()) - (int(round(float(exp))) - 14)) <= 2 + except Exception: + p_ok = False + if g_ok and p_ok: + break + time.sleep(0.08) + st = rkcam() + print( + f" set_exp_gain exp={exp} gain={gain} -> rkcam exp={st.get('exposure_us')} " + f"gain={st.get('gain')} pulse={PULSE.read_text().strip()}", + flush=True, + ) + + +def shoot_n(label, n, fire, last=None): + if last is None: + last = peek_tag() + rows = [] + first = None + for i in range(n): + fire() + tn, data = wait_new(last, 2.2) + if not data: + rows.append({"i": i + 1, "miss": True}) + continue + if first is None: + first = i + 1 + a = det.analyze_jpeg(data) + dy = luma_bot_above(a) + rec = { + "i": i + 1, + "tag": tn, + "miss": False, + "dim": a["dim"], + "mean": round(a["mean"], 2), + "dY": round(dy, 2), + "max_row_jump": a["max_row_jump"], + "classic": a["classic_green"], + "seam": a["seam"], + "tear": a["tear"], + "bad": a["bad"], + "olive": olive_whole(a), + "bot": [a["br"], a["bg"], a["bb"]], + "above": [a["ar"], a["ag"], a["ab"]], + "mid": [a["mr"], a["mg"], a["mb"]], + "reasons": a["reasons"], + } + (OUT / "frames" / f"{label}_{i+1:02d}.jpg").write_bytes(data) + rows.append(rec) + last = tn + time.sleep(0.1) + return {"first_hit": first, "rows": rows, "last": last} + + +async def main(): + summary = { + "host": Path("/etc/hostname").read_text().strip() if Path("/etc/hostname").exists() else "", + "kernel": UNAME.read_text().strip(), + "uname_v": os.uname().version, + "n1trace": N1TRACE.read_text().strip() if N1TRACE.exists() else None, + "tail_wait_us": TAIL.read_text().strip() if TAIL.exists() else None, + "run_mode_before": RUN_MODE.read_text().strip() if RUN_MODE.exists() else None, + "pulse_before": PULSE.read_text().strip() if PULSE.exists() else None, + "stats_before": parse_stats(), + "rkcam_before": { + k: rkcam().get(k) + for k in ( + "exposure_us", + "gain", + "trigger_mode", + "frame_w", + "frame_h", + "roi", + "fps", + "frame_tag", + "wb_ratio", + "last_error", + ) + }, + } + print("KERNEL", summary["uname_v"], flush=True) + print("n1trace", summary["n1trace"], "tail", summary["tail_wait_us"], flush=True) + + orig = dict(summary["rkcam_before"]) + orig_flow = s.get(BASE + "/api/flow_config", timeout=10).json() + + ws = await websockets.connect( + WS_URL, + additional_headers={"Cookie": cookie}, + open_timeout=8, + ping_interval=20, + max_size=4 * 1024 * 1024, + ) + + # --- enter software --- + print("=== enter software ===", flush=True) + dt, body, ok = patch({"run_mode": "trigger", "trigger_mode": "software"}) + print(f"apply_sw={dt:.3f} ok={ok}", flush=True) + summary["apply_sw_s"] = round(dt, 3) + summary["apply_sw_ok"] = ok + time.sleep(0.5) + await set_exp_gain(ws, gain=240) + time.sleep(0.3) + print("rkcam after gain240", rkcam().get("gain"), rkcam().get("exposure_us"), flush=True) + + # 1500 → 15000 + print("=== E 1500 -> 15000 gain=240 ===", flush=True) + await set_exp_gain(ws, exp=1500, gain=240) + time.sleep(0.2) + # discard 2 at low + shoot_n("e_lo_disc", 2, fire_sw) + pulse_lo = PULSE.read_text().strip() + await set_exp_gain(ws, exp=15000, gain=240) + time.sleep(0.15) + pulse_hi = PULSE.read_text().strip() + hi = shoot_n("e_lo2hi", 8, fire_sw) + dys = [r["dY"] for r in hi["rows"] if not r.get("miss")] + tears = sum(1 for r in hi["rows"] if r.get("tear")) + bads = sum(1 for r in hi["rows"] if r.get("bad")) + olives = sum(1 for r in hi["rows"] if r.get("olive")) + max_abs_dy = max((abs(x) for x in dys), default=None) + print( + f" lo2hi first={hi['first_hit']} tear={tears} bad={bads} olive={olives} " + f"max|dY|={max_abs_dy} pulse {pulse_lo}->{pulse_hi}", + flush=True, + ) + summary["E_lo2hi"] = { + "pulse_lo": pulse_lo, + "pulse_hi": pulse_hi, + "first_hit": hi["first_hit"], + "tear": tears, + "bad": bads, + "olive": olives, + "max_abs_dY": max_abs_dy, + "dYs": dys, + "rows": hi["rows"], + "pass": tears == 0 and bads == 0 and olives == 0 and (max_abs_dy is not None and max_abs_dy < 10), + } + + # 15000 → 1500 + print("=== E 15000 -> 1500 gain=240 ===", flush=True) + await set_exp_gain(ws, exp=15000, gain=240) + shoot_n("e_hi_disc", 2, fire_sw) + await set_exp_gain(ws, exp=1500, gain=240) + time.sleep(0.15) + lo = shoot_n("e_hi2lo", 8, fire_sw) + dys2 = [r["dY"] for r in lo["rows"] if not r.get("miss")] + tears2 = sum(1 for r in lo["rows"] if r.get("tear")) + bads2 = sum(1 for r in lo["rows"] if r.get("bad")) + olives2 = sum(1 for r in lo["rows"] if r.get("olive")) + max_abs_dy2 = max((abs(x) for x in dys2), default=None) + pulse_after = PULSE.read_text().strip() + print( + f" hi2lo first={lo['first_hit']} tear={tears2} bad={bads2} olive={olives2} " + f"max|dY|={max_abs_dy2} pulse_after={pulse_after}", + flush=True, + ) + summary["E_hi2lo"] = { + "pulse_after": pulse_after, + "first_hit": lo["first_hit"], + "tear": tears2, + "bad": bads2, + "olive": olives2, + "max_abs_dY": max_abs_dy2, + "dYs": dys2, + "rows": lo["rows"], + "pass": tears2 == 0 and bads2 == 0 and olives2 == 0 and (max_abs_dy2 is not None and max_abs_dy2 < 10), + } + + # I: after short exp, pulse should track current (not stuck at short) + await set_exp_gain(ws, exp=15000, gain=0) + time.sleep(0.5) + pulse_restore = PULSE.read_text().strip() + try: + pv = int(pulse_restore) + except Exception: + pv = -1 + summary["I_pulse_restore"] = { + "pulse": pulse_restore, + "pass": pv > 10000, # 15000-14 ≈ 14986 + } + print(f"I pulse after 15000={pulse_restore} pass={summary['I_pulse_restore']['pass']}", flush=True) + + # restore milder gain for remaining + await set_exp_gain(ws, exp=orig.get("exposure_us") or 3000, gain=0) + + # --- F: mon → line0 idle, no fire, tag must not +1 --- + print("=== F idle line0 no-fire ===", flush=True) + dtm, _, okm = patch({"run_mode": "monitor", "trigger_mode": "close"}) + time.sleep(5.5) + tag0 = peek_tag() + stats0 = parse_stats() + rk0 = { + k: rkcam().get(k) + for k in ("frame_tag", "frame_seq", "native_frame_seq", "preview_seq") + } + dtl, _, okl = patch({"run_mode": "trigger", "trigger_mode": "line0"}) + time.sleep(2.5) + tag1 = peek_tag() + stats1 = parse_stats() + rk1 = { + k: rkcam().get(k) + for k in ("frame_tag", "frame_seq", "native_frame_seq", "preview_seq") + } + idle_delta = (tag1 or 0) - (tag0 or 0) + irq_delta = (stats1.get("irq") or 0) - (stats0.get("irq") or 0) + # /preview/frame 的 X-Frame-Tag 来自 encoded hold-last-frame(切换后仍显示 + # 最后一张 monitor JPEG 的 raw_seq)。用户路径以 raw/frame_seq 为准: + # quarantine 后应为 0,且无 echo 时不得再 put。 + user_seq = int(rk1.get("frame_seq") or rk1.get("frame_tag") or 0) + print( + f" apply_l0={dtl:.3f} ok={okl} preview_tag {tag0}->{tag1} delta={idle_delta} " + f"irq_delta={irq_delta} user_seq={user_seq} rk {rk0}->{rk1}", + flush=True, + ) + # 再等 2s:warmup 可能刚好吃掉 1 个 tag;稳态不应继续涨 + time.sleep(2.0) + tag2 = peek_tag() + late_delta = (tag2 or 0) - (tag1 or 0) + rk2 = { + k: rkcam().get(k) + for k in ("frame_tag", "frame_seq", "native_frame_seq", "preview_seq") + } + user_seq2 = int(rk2.get("frame_seq") or rk2.get("frame_tag") or 0) + print( + f" idle+2s preview_tag {tag1}->{tag2} late_delta={late_delta} " + f"user_seq {user_seq}->{user_seq2}", + flush=True, + ) + summary["F_idle_line0"] = { + "apply_s": round(dtl, 3), + "apply_ok": okl, + "tag0": tag0, + "tag1": tag1, + "tag2": tag2, + "tag_delta": idle_delta, + "late_delta": late_delta, + "irq_delta": irq_delta, + "rk0": rk0, + "rk1": rk1, + "rk2": rk2, + "user_seq": user_seq, + "user_seq2": user_seq2, + "stats0": stats0, + "stats1": stats1, + # preview tag 在 apply 期间可能被 monitor hold 编码推高,不等于入模。 + "enter_spurious": user_seq != 0 and irq_delta == 0, + "pass": okl and late_delta == 0 and user_seq == 0 and user_seq2 == 0, + } + + # first i2c fire after idle (mtime fallback, not true GPIO) + last = peek_tag() + i2c = shoot_n("f_i2c", 6, fire_i2c, last=last) + stats2 = parse_stats() + olives_i = sum(1 for r in i2c["rows"] if r.get("olive")) + bads_i = sum(1 for r in i2c["rows"] if r.get("bad")) + print( + f" i2c first={i2c['first_hit']} got={sum(1 for r in i2c['rows'] if not r.get('miss'))} " + f"bad={bads_i} olive={olives_i} irq {stats1.get('irq')}->{stats2.get('irq')}", + flush=True, + ) + summary["F_i2c_line0"] = { + "first_hit": i2c["first_hit"], + "got": sum(1 for r in i2c["rows"] if not r.get("miss")), + "bad": bads_i, + "olive": olives_i, + "irq_before": stats1.get("irq"), + "irq_after": stats2.get("irq"), + "note": "i2c echo does not increment trigger-dev irq; mtime fallback", + "rows": i2c["rows"], + "pass": i2c["first_hit"] == 1 and bads_i == 0 and olives_i == 0, + } + + # J + summary["J_n1trace_off"] = { + "value": N1TRACE.read_text().strip(), + "pass": N1TRACE.read_text().strip() in ("N", "0", "n"), + } + + # restore monitor + original exp/gain + print("=== restore ===", flush=True) + patch({"run_mode": orig_flow.get("run_mode", "monitor"), "trigger_mode": orig_flow.get("trigger_mode", "close")}) + await set_exp_gain(ws, exp=orig.get("exposure_us"), gain=orig.get("gain")) + await ws.close() + + summary["E_pass"] = bool(summary["E_lo2hi"]["pass"] and summary["E_hi2lo"]["pass"]) + summary["F_idle_pass"] = bool(summary["F_idle_line0"]["pass"]) + summary["extra_pass"] = bool( + summary["E_pass"] + and summary["F_idle_pass"] + and summary["I_pulse_restore"]["pass"] + and summary["J_n1trace_off"]["pass"] + ) + (OUT / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + print("BEGIN_EXTRA_JSON", flush=True) + slim = {k: summary[k] for k in summary if k not in ("E_lo2hi", "E_hi2lo", "F_i2c_line0", "F_idle_line0")} + slim["E_lo2hi"] = {k: summary["E_lo2hi"][k] for k in summary["E_lo2hi"] if k != "rows"} + slim["E_hi2lo"] = {k: summary["E_hi2lo"][k] for k in summary["E_hi2lo"] if k != "rows"} + slim["F_idle_line0"] = {k: summary["F_idle_line0"][k] for k in summary["F_idle_line0"] if k not in ("stats0", "stats1")} + slim["F_i2c_line0"] = {k: summary["F_i2c_line0"][k] for k in summary["F_i2c_line0"] if k != "rows"} + print(json.dumps(slim, ensure_ascii=False), flush=True) + print("END_EXTRA_JSON", flush=True) + print("EXTRA", "PASS" if summary["extra_pass"] else "FAIL", flush=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/imx296_bottom_quality.py b/scripts/imx296_bottom_quality.py new file mode 100644 index 0000000000000..3ec2c32cd2eab --- /dev/null +++ b/scripts/imx296_bottom_quality.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""IMX296 bottom-band quality check (classic green / chroma seam / tear). + +Designed to catch: + - classic G-dominant green band (incomplete UV green cast) + - #1013-style flat neutral-grey seam (UV forced 0x80) + - bottom tear / row discontinuity (abrupt band vs scene) + +Pure stdlib + Pillow (no numpy required) so it runs on board CamOS images. +""" +from __future__ import annotations + +from io import BytesIO +from typing import Any + +try: + from PIL import Image +except ImportError: # pragma: no cover + Image = None # type: ignore + +BOT_ROWS = 8 +# Skip transition / UV-footprint zone above bot when sampling reference. +REF_SKIP = 8 +REF_ROWS = 8 + +# classic green +G_DELTA = 20.0 +G_ABS = 80.0 +LAST_G_DELTA = 25.0 + +# seam: flat achromatic bot vs colored scene +GRAY_CH_MAX = 7.0 +FLAT_STD_MAX = 5.5 +SEAM_L1_MIN = 3.0 +SEAM_SAT_MIN = 5.5 +SEAM_LUMA_MAX = 90.0 + +# tear: abrupt discontinuity even if not grey +TEAR_L1_MIN = 10.0 +TEAR_ROW_JUMP = 12.0 # mean |row_i - row_{i-1}| across RGB +TEAR_BOT_STD_MAX = 18.0 # tear band itself not wildly textured noise + + +def _mean_std(vals: list[float]) -> tuple[float, float]: + n = len(vals) or 1 + m = sum(vals) / n + if n < 2: + return m, 0.0 + var = sum((v - m) ** 2 for v in vals) / n + return m, var ** 0.5 + + +def _band_stats(pix, w: int, y0: int, y1: int, x_step: int = 2) -> dict[str, float]: + rs: list[float] = [] + gs: list[float] = [] + bs: list[float] = [] + y0 = max(0, y0) + y1 = max(y0, y1) + for y in range(y0, y1): + for x in range(0, w, x_step): + r, g, b = pix[x, y][:3] + rs.append(float(r)) + gs.append(float(g)) + bs.append(float(b)) + if not rs: + return {"r": 0.0, "g": 0.0, "b": 0.0, "std": 0.0, "sat": 0.0, "luma": 0.0} + mr, sr = _mean_std(rs) + mg, sg = _mean_std(gs) + mb, sb = _mean_std(bs) + sat = max(abs(mr - mg), abs(mg - mb), abs(mr - mb)) + luma = 0.299 * mr + 0.587 * mg + 0.114 * mb + return { + "r": mr, + "g": mg, + "b": mb, + "std": max(sr, sg, sb), + "sat": sat, + "luma": luma, + } + + +def _row_mean(pix, w: int, y: int, x_step: int = 4) -> tuple[float, float, float]: + rs, gs, bs = [], [], [] + for x in range(0, w, x_step): + r, g, b = pix[x, y][:3] + rs.append(float(r)) + gs.append(float(g)) + bs.append(float(b)) + n = len(rs) or 1 + return sum(rs) / n, sum(gs) / n, sum(bs) / n + + +def analyze_image(im: "Image.Image") -> dict[str, Any]: + """Analyze bottom quality of an RGB PIL image.""" + im = im.convert("RGB") + w, h = im.size + pix = im.load() + n = min(BOT_ROWS, h) + + bot = _band_stats(pix, w, h - n, h) + skip = min(REF_SKIP, max(0, h - 2 * n)) + a1 = h - n - skip + a0 = max(0, a1 - REF_ROWS) + above = _band_stats(pix, w, a0, a1) if a1 > a0 else bot + mid = _band_stats(pix, w, h // 2, h // 2 + 1) + + br, bg, bb = bot["r"], bot["g"], bot["b"] + ar, ag, ab = above["r"], above["g"], above["b"] + mr, mg, mb = mid["r"], mid["g"], mid["b"] + + # --- classic green (bottom band vs scene; ignore global green cast) --- + classic = False + reasons: list[str] = [] + mean_l1 = (abs(br - ar) + abs(bg - ag) + abs(bb - ab)) / 3.0 + mid_l1 = (abs(br - mr) + abs(bg - mg) + abs(bb - mb)) / 3.0 + bot_g_ex = bg - max(br, bb) + above_g_ex = ag - max(ar, ab) + mid_g_ex = mg - max(mr, mb) + g_dom = bg > br + G_DELTA and bg > bb + G_DELTA + # True incomplete-UV stripe: bot more G-dominant than above/mid. + if g_dom and bot_g_ex >= above_g_ex + 12 and bot_g_ex >= mid_g_ex + 8: + classic = True + reasons.append("g_delta_band") + if ( + bg > G_ABS + and br < 55 + and bb < 55 + and (mean_l1 >= 3.0 or bot_g_ex >= above_g_ex + 15) + ): + classic = True + reasons.append("pure_g") + lr, lg, lb = _row_mean(pix, w, h - 1) + if ( + lg > lr + LAST_G_DELTA + and lg > lb + LAST_G_DELTA + and (lg - max(lr, lb)) >= (ag - max(ar, ab)) + 10 + ): + classic = True + reasons.append("last_row_g") + + # --- seam: flat grey bot vs colored scene --- + bot_gray = ( + abs(br - bg) <= GRAY_CH_MAX + and abs(bg - bb) <= GRAY_CH_MAX + and abs(br - bb) <= GRAY_CH_MAX + ) + bot_flat = bot["std"] <= FLAT_STD_MAX + scene_sat = max(above["sat"], mid["sat"]) + seam = False + if ( + bot_flat + and bot_gray + and scene_sat >= SEAM_SAT_MIN + and mean_l1 >= SEAM_L1_MIN + and bot["luma"] <= SEAM_LUMA_MAX + ): + seam = True + reasons.append("seam_above") + if ( + not seam + and bot_flat + and bot_gray + and mid["sat"] >= SEAM_SAT_MIN + and mid_l1 >= SEAM_L1_MIN + and bot["luma"] <= SEAM_LUMA_MAX + ): + seam = True + reasons.append("seam_mid") + + # --- tear: abrupt discontinuity (not necessarily grey) --- + # 1) band L1 vs above + # 2) max adjacent-row jump inside last 16 rows + max_jump = 0.0 + y_start = max(0, h - max(16, 2 * n)) + prev = _row_mean(pix, w, y_start) + for y in range(y_start + 1, h): + cur = _row_mean(pix, w, y) + jump = (abs(cur[0] - prev[0]) + abs(cur[1] - prev[1]) + abs(cur[2] - prev[2])) / 3.0 + if jump > max_jump: + max_jump = jump + prev = cur + + tear = False + if ( + mean_l1 >= TEAR_L1_MIN + and bot["std"] <= TEAR_BOT_STD_MAX + and scene_sat >= 4.0 + and max_jump >= TEAR_ROW_JUMP + ): + # avoid flagging classic green twice; still record tear if not classic-only + tear = True + reasons.append("tear_jump") + # strong L1 discontinuity without needing jump (sanitize hard edge) + if ( + not tear + and mean_l1 >= TEAR_L1_MIN + 4 + and bot_flat + and scene_sat >= SEAM_SAT_MIN + and not classic + ): + tear = True + reasons.append("tear_l1") + + bad = bool(classic or seam or tear) + # severity score 0..100 for ranking dumps + score = 0.0 + if classic: + score += 40 + min(40.0, max(0.0, bg - max(br, bb))) + if seam: + score += 35 + min(25.0, mean_l1 * 3) + if tear: + score += 25 + min(20.0, max_jump) + score = min(100.0, score) + + # center luma for phase (endurance compatibility) + ch = max(8, int(h * 0.25)) + cw = max(8, int(w * 0.25)) + y0 = (h - ch) // 2 + x0 = (w - cw) // 2 + center = _band_stats(pix, w, y0, y0 + ch, x_step=4) + # approximate center luma already in center['luma'] but band_stats uses full width step; + # recompute tighter: + cs = [] + for y in range(y0, y0 + ch): + for x in range(x0, x0 + cw, 4): + r, g, b = pix[x, y][:3] + cs.append(0.299 * r + 0.587 * g + 0.114 * b) + mean = sum(cs) / len(cs) if cs else center["luma"] + + return { + "dim": f"{w}x{h}", + "mean": float(mean), + "br": round(br, 1), + "bg": round(bg, 1), + "bb": round(bb, 1), + "mr": round(mr, 1), + "mg": round(mg, 1), + "mb": round(mb, 1), + "ar": round(ar, 1), + "ag": round(ag, 1), + "ab": round(ab, 1), + "bot_std": round(bot["std"], 2), + "above_sat": round(above["sat"], 2), + "mid_sat": round(mid["sat"], 2), + "mean_l1": round(mean_l1, 2), + "mid_l1": round(mid_l1, 2), + "max_row_jump": round(max_jump, 2), + "classic_green": bool(classic), + "seam": bool(seam), + "tear": bool(tear), + "flat_gray": bool(bot_flat and bot_gray), + "reasons": reasons, + "score": round(score, 1), + # endurance / legacy + "green": bad, + "bad": bad, + } + + +def analyze_jpeg(data: bytes) -> dict[str, Any]: + if Image is None: + raise RuntimeError("Pillow required") + return analyze_image(Image.open(BytesIO(data))) + + +def analyze_path(path: str) -> dict[str, Any]: + if Image is None: + raise RuntimeError("Pillow required") + return analyze_image(Image.open(path)) diff --git a/scripts/imx296_day_endurance.py b/scripts/imx296_day_endurance.py new file mode 100644 index 0000000000000..9b145db5497cc --- /dev/null +++ b/scripts/imx296_day_endurance.py @@ -0,0 +1,1507 @@ +#!/usr/bin/env python3 +"""IMX296 24h endurance via CamOS frontend/backend path (not grabber direct). + +Runs on board against local CamOS (server.py + preview/WS): + login cookie → PATCH /api/flow_config (monitor/trigger × close/software/line0) + → WS rkcam_set_roi / rkcam_reset_roi / rkcam_set_exposure_time / rkcam_trigger_once + → GET /preview/frame + X-Frame-Tag + → LO/HI 曝光交替判 N-1;JPEG 底行判绿线 + +Requires CamOS already up (prefer SMARTCAM_NO_VITE_DEV=1 python server.py, +or systemctl start camos). Do NOT recover via 0_测试运行_camos.sh (rebuilds frontend). +Does not own /dev/video11 directly. + +Outputs under OUT_DIR (default /tmp/imx296_day_endurance): + console.log, events.jsonl, summary.json, summary.txt, bugs.jsonl, green/* +""" +from __future__ import annotations + +import argparse +import asyncio +import http.cookiejar +import json +import os +import random +import signal +import subprocess +import sys +import time +import traceback +import urllib.error +import urllib.request +from datetime import datetime, timezone +from io import BytesIO +from pathlib import Path + +import numpy as np + +try: + import websockets +except ImportError: + print("FATAL: websockets missing", file=sys.stderr) + raise SystemExit(2) + +try: + from PIL import Image +except ImportError: + print("FATAL: Pillow missing", file=sys.stderr) + raise SystemExit(2) + +BASE = os.environ.get("CAMOS_BASE", "http://127.0.0.1:8000") +WS_URL = os.environ.get("CAMOS_WS", "ws://127.0.0.1:8000/ws/cmd") +USER = os.environ.get("CAMOS_USER", "admin") +PASS = os.environ.get("CAMOS_PASS", "dlcv2026") + +RUN_MODE_SYSFS = Path("/sys/bus/i2c/devices/4-001a/run_mode") +TRIGGER_SYSFS = Path("/sys/bus/i2c/devices/4-001a/trigger") +PULSE_SYSFS = Path("/sys/bus/i2c/devices/4-001a/trigger_pulse_us") + +LO_US = 100.0 +HI_US = 16000.0 +CENTER_FRAC = 0.25 +BOT_ROWS = 8 +GREEN_UV_THR = 8.0 # unused for JPEG path; kept for summary compatibility +GREEN_G_DELTA = 20.0 +GREEN_G_ABS = 80.0 +# Bottom quality: classic / seam / tear — see scripts/imx296_bottom_quality.py +# 暗场 JPEG 下 span 常 <2;低于此阈值的相位结果标 INCONCLUSIVE,不记 n1/mix bug +PHASE_SPAN_MIN = 2.0 +PHASE_N1_SPAN_MIN = 5.0 # 仅 span 达标才记 hard N1 +# 0.5h 轮 MIX 主因:曝光切换后 1~2 枪仍是旧亮度 / 单帧 outlier 把 mid 拉飞 +PHASE_DROP_HEAD = 2 # 有效样本前再丢 head(切换残留) +PHASE_OUTLIER_MAD_K = 4.0 # 组内 |x-med| > k*MAD(或 >0.45*组间span)剔除 +EXP_SETTLE_S = 0.45 # set_exposure 后等待 +EXP_DISCARD_EACH = 1 # 每次改曝光后先丢枪再采样(trigger 路径) +RUN_MODE_COOLDOWN_S = 5.5 # CamOS RUN_MODE_SWITCH_COOLDOWN_S = 5.0 +PATCH_FLOW_TIMEOUT_S = 55.0 +ROI_SETTLE_S = 4.0 +MON_SETTLE_S = 3.5 +TRIG_SETTLE_S = 4.5 +DISCARD_AFTER_SWITCH = 3 +# ROI 最小高度:240 边界在 HW crop/ISP 上曾导致 restart 后 streaming=0 +ROI_MIN_W = 320 +ROI_MIN_H = 256 + +SENSOR_W = 1456 +SENSOR_H = 1088 +ROIS = [ + (0, 0, 640, 480), + (200, 200, 640, 480), + (100, 100, 800, 600), + (50, 80, 1024, 768), + (0, 0, 1200, 560), + (300, 200, 640, 360), + (0, 100, 1456, 400), + (400, 100, 512, 384), + (0, 0, 1456, 1088), + (120, 60, 960, 540), + (80, 200, 720, 400), + (16, 16, 1280, 720), + (256, 128, 800, 480), + (0, 0, 400, 400), + (500, 300, 640, 480), +] + +STOP = False + + +def align_down(v: int, a: int = 2) -> int: + return max(0, (int(v) // a) * a) + + +def random_roi() -> tuple[int, int, int, int]: + if random.random() < 0.55: + x, y, w, h = random.choice(ROIS) + else: + w = align_down(random.randint(320, SENSOR_W), 16) or 320 + h = align_down(random.randint(240, SENSOR_H), 8) or 240 + x = align_down(random.randint(0, max(0, SENSOR_W - w)), 2) + y = align_down(random.randint(0, max(0, SENSOR_H - h)), 2) + w = min(align_down(w, 2) or 320, SENSOR_W) + h = min(align_down(h, 2) or 240, SENSOR_H) + x = min(align_down(x, 2), max(0, SENSOR_W - w)) + y = min(align_down(y, 2), max(0, SENSOR_H - h)) + if w < ROI_MIN_W: + w = ROI_MIN_W + if h < ROI_MIN_H: + h = ROI_MIN_H + # 对齐到 16x8,降低 subdev/VB2 对齐失败概率 + w = max(ROI_MIN_W, align_down(w, 16) or ROI_MIN_W) + h = max(ROI_MIN_H, align_down(h, 8) or ROI_MIN_H) + if x + w > SENSOR_W: + x = max(0, SENSOR_W - w) + if y + h > SENSOR_H: + y = max(0, SENSOR_H - h) + return int(x), int(y), int(w), int(h) + + +def _on_sig(_s, _f): + global STOP + STOP = True + + +signal.signal(signal.SIGTERM, _on_sig) +signal.signal(signal.SIGINT, _on_sig) + + +def now_local() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def now_iso() -> str: + return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + + +def run_mode_text() -> str: + try: + return RUN_MODE_SYSFS.read_text(encoding="utf-8", errors="ignore").replace("\n", " | ") + except Exception as e: + return f"ERR {e}" + + +def sysfs_pulse(us: int | None = None) -> None: + if us is not None: + try: + PULSE_SYSFS.write_text(f"{int(us)}\n", encoding="utf-8") + except Exception: + pass + try: + TRIGGER_SYSFS.write_text("1\n", encoding="utf-8") + except Exception: + pass + + +class Sink: + def __init__(self, out: Path): + self.out = out + self.out.mkdir(parents=True, exist_ok=True) + (self.out / "green").mkdir(exist_ok=True) + self.console = self.out / "console.log" + self.events = self.out / "events.jsonl" + self.bugs = self.out / "bugs.jsonl" + self.summary_json = self.out / "summary.json" + self.summary_txt = self.out / "summary.txt" + self.stats = { + "started_at": now_iso(), + "updated_at": now_iso(), + "path": "camos", + "cycles": 0, + "cases": 0, + "frames": 0, + "valid_frames": 0, + "green_frames": 0, + "n1_cases": 0, + "mix_cases": 0, + "low_contrast_cases": 0, + "inconclusive_cases": 0, + "too_few_cases": 0, + "no_frame": 0, + "switch_fail": 0, + "switch_warn": 0, + "roi_fail": 0, + "recoveries": 0, + "crashes": 0, + "bugs": 0, + "by_case": {}, + "last_bug": None, + "kernel": None, + "build": None, + } + + def log(self, msg: str, alert: bool = False): + line = f"[{now_local()}] {'ALERT ' if alert else ''}{msg}" + # 仅 print;由 nohup 重定向写入 console.log,避免双重 write + print(line, flush=True) + + def event(self, kind: str, **kw): + ev = {"ts": now_iso(), "kind": kind, **kw} + with self.events.open("a", encoding="utf-8") as f: + f.write(json.dumps(ev, ensure_ascii=False) + "\n") + return ev + + def bug(self, title: str, **kw): + self.stats["bugs"] += 1 + b = {"ts": now_iso(), "title": title, "cycle": self.stats["cycles"], **kw} + self.stats["last_bug"] = b + with self.bugs.open("a", encoding="utf-8") as f: + f.write(json.dumps(b, ensure_ascii=False) + "\n") + self.log(f"BUG {title} {json.dumps(kw, ensure_ascii=False)[:300]}", alert=True) + self.event("bug", title=title, **kw) + return b + + def touch_summary(self): + self.stats["updated_at"] = now_iso() + try: + self.stats["uptime_s"] = int( + time.time() - datetime.fromisoformat(self.stats["started_at"]).timestamp() + ) + except Exception: + self.stats["uptime_s"] = 0 + self.summary_json.write_text( + json.dumps(self.stats, indent=2, ensure_ascii=False), encoding="utf-8" + ) + s = self.stats + lines = [ + f"updated={s['updated_at']}", + f"started={s['started_at']}", + f"uptime_s={s.get('uptime_s', 0)}", + f"path={s.get('path')}", + f"cycles={s['cycles']} cases={s['cases']} frames={s['frames']} valid={s['valid_frames']}", + f"green={s['green_frames']} n1={s['n1_cases']} mix={s['mix_cases']} " + f"lowc={s['low_contrast_cases']} incon={s.get('inconclusive_cases', 0)} " + f"toofew={s['too_few_cases']} noframe={s['no_frame']}", + f"switch_fail={s['switch_fail']} switch_warn={s.get('switch_warn', 0)} " + f"roi_fail={s['roi_fail']} recoveries={s['recoveries']} " + f"crashes={s['crashes']} bugs={s['bugs']}", + f"build={s.get('build')} kernel={s.get('kernel')}", + f"last_bug={json.dumps(s.get('last_bug'), ensure_ascii=False)[:240] if s.get('last_bug') else None}", + f"by_case={json.dumps(s.get('by_case'), ensure_ascii=False)}", + f"run_mode={run_mode_text()}", + ] + self.summary_txt.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _mad(vals: list[float]) -> float: + if not vals: + return 0.0 + med = float(np.median(np.asarray(vals, dtype=np.float64))) + return float(np.median(np.abs(np.asarray(vals, dtype=np.float64) - med))) + + +def filter_phase_samples( + means: list[float], expects: list[str] +) -> tuple[list[float], list[str], dict]: + """Drop head samples + within-expect outliers that inflate MIX false positives.""" + meta: dict = {"in_n": len(means), "dropped_head": 0, "dropped_outlier": 0} + mm = list(means) + ee = list(expects) + head = min(PHASE_DROP_HEAD, max(0, len(mm) - 3)) + if head: + mm = mm[head:] + ee = ee[head:] + meta["dropped_head"] = head + if len(mm) < 2: + return mm, ee, meta + + lo_vals = [m for m, e in zip(mm, ee) if e == "LO"] + hi_vals = [m for m, e in zip(mm, ee) if e == "HI"] + if lo_vals and hi_vals: + lo_med = float(np.median(lo_vals)) + hi_med = float(np.median(hi_vals)) + gap = abs(hi_med - lo_med) + thr_gap = max(3.0, 0.45 * gap) if gap > 0 else 1e9 + else: + lo_med = hi_med = 0.0 + thr_gap = 1e9 + + keep_m: list[float] = [] + keep_e: list[str] = [] + for m, e in zip(mm, ee): + group = lo_vals if e == "LO" else hi_vals + med = float(np.median(group)) if group else float(m) + mad = _mad(group) if len(group) >= 3 else 0.0 + thr = max(thr_gap, PHASE_OUTLIER_MAD_K * mad) if mad > 1e-6 else thr_gap + if abs(float(m) - med) > thr: + meta["dropped_outlier"] += 1 + continue + keep_m.append(float(m)) + keep_e.append(e) + # 剔太狠则回退到仅 drop-head 的序列,避免 TOO_FEW + if len(keep_m) < 3: + return mm, ee, {**meta, "outlier_reverted": True} + return keep_m, keep_e, meta + + +def classify_phase(means: list[float], expects: list[str]) -> dict: + if len(means) < 2: + return { + "verdict": "TOO_FEW", + "span": 0.0, + "labels": [], + "expects": expects, + "means": means, + } + arr = np.asarray(means, dtype=np.float64) + # 用 expect 分组中位数做阈值,避免单帧 outlier 把 min/max mid 拉飞 + lo_vals = [float(m) for m, e in zip(means, expects) if e == "LO"] + hi_vals = [float(m) for m, e in zip(means, expects) if e == "HI"] + if lo_vals and hi_vals: + lo_ref = float(np.median(lo_vals)) + hi_ref = float(np.median(hi_vals)) + if hi_ref < lo_ref: + lo_ref, hi_ref = hi_ref, lo_ref + mid = 0.5 * (lo_ref + hi_ref) + span = float(hi_ref - lo_ref) + # 仍报告全序列极值 span,便于看 outlier + span_ext = float(arr.max() - arr.min()) + else: + lo_m, hi_m = float(arr.min()), float(arr.max()) + span = hi_m - lo_m + span_ext = span + mid = 0.5 * (lo_m + hi_m) + labels = ["HI" if m >= mid else "LO" for m in means] + # 先按 LO/HI 标签与 expect 对齐判相位;暗场 JPEG 下 span 可能 <1 但仍交替正确 + cur = sum(g == e for g, e in zip(labels, expects)) + n1 = 0 + for i in range(1, len(labels)): + if labels[i] == expects[i - 1] and labels[i] != expects[i]: + n1 += 1 + # 允许 1 枪噪声:匹配率够高仍算 CUR(收 MIX 假阳) + if cur == len(labels) or (len(labels) >= 5 and cur >= len(labels) - 1): + verdict = "CUR" + elif span < PHASE_SPAN_MIN and span_ext < PHASE_SPAN_MIN: + verdict = "INCONCLUSIVE" + elif n1 >= max(2, (len(labels) - 1) // 2): + verdict = "N1" + else: + verdict = "MIX" + return { + "verdict": verdict, + "span": round(span_ext if span_ext > span else span, 3), + "span_ref": round(span, 3), + "mid": round(mid, 3), + "labels": labels, + "expects": expects, + "means": [round(m, 3) for m in means], + "cur_matches": cur, + "n1_hits": sum( + 1 + for i in range(1, len(labels)) + if labels[i] == expects[i - 1] and labels[i] != expects[i] + ), + } + + +def analyze_jpeg(data: bytes) -> dict: + """Multi-signal bottom quality (classic green / chroma seam / tear). + + Implementation lives in imx296_bottom_quality.py (Pillow-only). Falls back + to an inlined import path next to this file for board deploys. + """ + try: + from imx296_bottom_quality import analyze_jpeg as _aq + return _aq(data) + except Exception: + pass + import importlib.util + from pathlib import Path as _P + + for cand in ( + _P(__file__).resolve().parent / "imx296_bottom_quality.py", + _P("/userdata/imx296_warmup_ab/scripts/imx296_bottom_quality.py"), + ): + if cand.is_file(): + spec = importlib.util.spec_from_file_location("imx296_bottom_quality", cand) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod.analyze_jpeg(data) + raise RuntimeError("imx296_bottom_quality.py not found") + + +class CamOSClient: + def __init__(self, sink: Sink): + self.sink = sink + self.cj = http.cookiejar.CookieJar() + self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.cj)) + self.ws = None + self._last_run_mode_switch = 0.0 + self._current_flow = {"run_mode": None, "trigger_mode": None} + + @property + def cookie_header(self) -> str: + return "; ".join(f"{c.name}={c.value}" for c in self.cj) + + def login(self) -> bool: + req = urllib.request.Request( + f"{BASE}/api/auth/login", + data=json.dumps({"username": USER, "password": PASS}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with self.opener.open(req, timeout=10) as r: + body = r.read() + ok = b'"success":true' in body or b'"username"' in body + if ok: + self.sink.log(f"login ok cookies={len(list(self.cj))}") + return ok + except Exception as e: + self.sink.log(f"login fail {e}", alert=True) + return False + + def get_flow(self) -> dict: + req = urllib.request.Request( + f"{BASE}/api/flow_config", + headers={"Cookie": self.cookie_header}, + ) + with self.opener.open(req, timeout=8) as r: + return json.loads(r.read().decode()) + + def _cooldown_wait(self, target_run: str): + cur = self._current_flow.get("run_mode") + if cur is None or cur == target_run: + return + elapsed = time.monotonic() - self._last_run_mode_switch + if elapsed < RUN_MODE_COOLDOWN_S: + time.sleep(RUN_MODE_COOLDOWN_S - elapsed + 0.15) + + def _refresh_flow_cache(self) -> dict: + try: + cfg = self.get_flow() + self._current_flow = { + "run_mode": cfg.get("run_mode"), + "trigger_mode": cfg.get("trigger_mode"), + } + return cfg + except Exception: + return {} + + def preview_healthy(self, retries: int = 4, gap_s: float = 0.35) -> tuple[bool, str]: + """True only if preview returns JPEG body (login-alone is not enough). + + line0/FT 下 ROI restart 后常见短暂 204/空 body,streaming 仍为 1;多探几次再判死。 + """ + last = "no_attempt" + for i in range(max(1, retries)): + st, tag, data = self._get_preview_raw() + if st == 200 and data and len(data) > 800: + return True, f"ok tag={tag} sz={len(data)} try={i}" + last = f"st={st} tag={tag} sz={len(data) if data else 0}" + time.sleep(gap_s) + return False, last + + def stream_and_preview_ok( + self, *, poke_line0: bool = False, retries: int = 4 + ) -> tuple[bool, str]: + rm = run_mode_text() + if "streaming=1" not in rm: + return False, f"no_stream rm={rm}" + # line0 需要外部脉冲才刷新 preview;健康检查前轻推几枪避免假死 + if poke_line0 or "master_fast_trigger" in rm: + try: + for _ in range(2): + sysfs_pulse() + time.sleep(0.08) + except Exception: + pass + ok, info = self.preview_healthy(retries=retries) + if not ok: + # 再等 ROI encoder settle 一次 + time.sleep(0.6) + try: + if poke_line0 or "master_fast_trigger" in rm: + sysfs_pulse() + except Exception: + pass + ok, info = self.preview_healthy(retries=3) + if not ok: + return False, f"preview_bad {info} rm={rm}" + return True, f"{info} | {rm}" + + def patch_flow(self, run_mode: str, trigger_mode: str, retries: int = 12) -> tuple[bool, str]: + """PATCH /api/flow_config; respect cooldown; short-circuit only if healthy.""" + cur_rm = self._current_flow.get("run_mode") + cur_tm = self._current_flow.get("trigger_mode") + if cur_rm is None or cur_tm is None: + self._refresh_flow_cache() + cur_rm = self._current_flow.get("run_mode") + cur_tm = self._current_flow.get("trigger_mode") + if cur_rm == run_mode and cur_tm == trigger_mode: + healthy, hinfo = self.stream_and_preview_ok() + if healthy: + return True, f"already {run_mode}/{trigger_mode}" + # 死流时禁止短路径,强制再走硬件/后续 recover + self.sink.log( + f"patch short-circuit blocked (unhealthy): {hinfo}", alert=True + ) + + self._cooldown_wait(run_mode) + last_err = "" + for attempt in range(retries): + if STOP: + return False, "stopped" + payload = {"run_mode": run_mode, "trigger_mode": trigger_mode} + req = urllib.request.Request( + f"{BASE}/api/flow_config", + data=json.dumps(payload).encode(), + headers={ + "Content-Type": "application/json", + "Cookie": self.cookie_header, + }, + method="PATCH", + ) + t0 = time.monotonic() + try: + with self.opener.open(req, timeout=PATCH_FLOW_TIMEOUT_S) as r: + body = r.read() + patch_ms = int((time.monotonic() - t0) * 1000) + data = json.loads(body.decode()) + if data.get("ok"): + cfg = data.get("config") or {} + prev = self._current_flow.get("run_mode") + self._current_flow = { + "run_mode": cfg.get("run_mode", run_mode), + "trigger_mode": cfg.get("trigger_mode", trigger_mode), + } + if prev != self._current_flow["run_mode"]: + self._last_run_mode_switch = time.monotonic() + return True, ( + f"ok patch_ms={patch_ms} " + + json.dumps(cfg, ensure_ascii=False)[:180] + ) + last_err = str(data.get("error") or body[:200]) + if "冷却" in last_err or "cooldown" in last_err.lower(): + time.sleep(RUN_MODE_COOLDOWN_S) + continue + except urllib.error.HTTPError as e: + patch_ms = int((time.monotonic() - t0) * 1000) + err_body = "" + try: + err_body = e.read().decode(errors="ignore")[:200] + except Exception: + pass + last_err = f"HTTP {e.code} patch_ms={patch_ms} {err_body}" + if e.code in (401, 403): + self.login() + time.sleep(1.0) + except Exception as e: + patch_ms = int((time.monotonic() - t0) * 1000) + msg = str(e) + kind = "error" + if "timed out" in msg.lower() or "timeout" in msg.lower(): + kind = "client_timeout" + cfg = self._refresh_flow_cache() + if ( + cfg.get("run_mode") == run_mode + and cfg.get("trigger_mode") == trigger_mode + ): + return True, f"timeout_but_cfg_ok patch_ms={patch_ms}" + last_err = f"{kind} patch_ms={patch_ms} {msg}" + time.sleep(1.0) + return False, last_err + + def wait_stream( + self, + timeout: float = 18.0, + need_ft: bool = False, + need_free: bool = False, + strict_mode: bool = False, + ) -> tuple[bool, str, dict]: + """Wait streaming=1. Default soft: mode mismatch still ok (meta.mode_mismatch).""" + t0 = time.time() + last_rm = "" + saw_stream = False + while time.time() - t0 < timeout and not STOP: + rm = run_mode_text() + last_rm = rm + streaming = "streaming=1" in rm + if streaming: + saw_stream = True + mode_ok = True + if need_ft: + mode_ok = mode_ok and "active=master_fast_trigger" in rm + if need_free: + mode_ok = mode_ok and "active=free_run" in rm + if streaming and (mode_ok or not strict_mode): + return True, rm, { + "wait_ms": int((time.time() - t0) * 1000), + "mode_mismatch": bool(streaming and not mode_ok), + "streaming": True, + } + time.sleep(0.25) + meta = { + "wait_ms": int((time.time() - t0) * 1000), + "mode_mismatch": True, + "streaming": saw_stream or ("streaming=1" in last_rm), + } + if not strict_mode and "streaming=1" in last_rm: + meta["mode_mismatch"] = ( + (need_ft and "active=master_fast_trigger" not in last_rm) + or (need_free and "active=free_run" not in last_rm) + ) + return True, last_rm, meta + return False, last_rm or run_mode_text(), meta + + async def open_ws(self): + if self.ws is not None: + try: + await self.ws.close() + except Exception: + pass + self.ws = None + # websockets lib: pass cookie header + self.ws = await websockets.connect( + WS_URL, + additional_headers={"Cookie": self.cookie_header}, + open_timeout=8, + ping_interval=20, + ping_timeout=20, + max_size=4 * 1024 * 1024, + ) + return self.ws + + async def ensure_ws(self): + if self.ws is None: + return await self.open_ws() + try: + # cheap liveness + if getattr(self.ws, "closed", False): + return await self.open_ws() + except Exception: + return await self.open_ws() + return self.ws + + async def ws_cmd(self, cmd: str, value=None, drain: int = 3): + ws = await self.ensure_ws() + msg = {"cmd": cmd} + if value is not None: + msg["value"] = value + try: + await ws.send(json.dumps(msg)) + except Exception: + ws = await self.open_ws() + await ws.send(json.dumps(msg)) + # drain a few status pushes so buffer doesn't grow forever + for _ in range(drain): + try: + await asyncio.wait_for(ws.recv(), timeout=0.25) + except Exception: + break + return ws + + def _get_preview_raw(self): + req = urllib.request.Request( + f"{BASE}/preview/frame?t={time.time_ns()}", + headers={"Cookie": self.cookie_header}, + ) + try: + resp = self.opener.open(req, timeout=6) + tag = resp.headers.get("X-Frame-Tag") + data = resp.read() + return resp.status, tag, data + except urllib.error.HTTPError as e: + if e.code in (401, 403): + self.login() + return e.code, None, b"" + except Exception as e: + return str(e), None, b"" + + def peek_tag(self): + st, tag, data = self._get_preview_raw() + if st == 200 and data and len(data) > 800: + return tag + return tag + + async def fire_trigger(self, mode: str): + """software: WS+debug; line0: sysfs XTRIG only.""" + m = str(mode or "").strip().lower() + if m == "line0": + sysfs_pulse() + return + try: + await self.ws_cmd("rkcam_trigger_once", drain=1) + except Exception: + pass + try: + req = urllib.request.Request( + f"{BASE}/debug/trigger_once", + data=b"{}", + headers={ + "Content-Type": "application/json", + "Cookie": self.cookie_header, + }, + method="POST", + ) + self.opener.open(req, timeout=3).read() + except Exception: + pass + + async def set_exposure(self, us: float): + try: + await self.ws_cmd("rkcam_set_exposure_time", float(us), drain=1) + except Exception: + try: + await self.ws_cmd("set_exposure_us", float(us), drain=1) + except Exception: + pass + + async def set_gain(self, gain_db: float = 0.0): + """固定模拟增益。判 N-1 前应置 0,避免默认高增益把 LO/HI 对比压扁。""" + try: + await self.ws_cmd("rkcam_set_gain", float(gain_db), drain=1) + except Exception: + try: + await self.ws_cmd("set_gain", float(gain_db), drain=1) + except Exception: + pass + + async def set_roi(self, x: int, y: int, w: int, h: int): + if w >= SENSOR_W and h >= SENSOR_H and x == 0 and y == 0: + await self.ws_cmd("rkcam_reset_roi") + else: + await self.ws_cmd( + "rkcam_set_roi", {"x": int(x), "y": int(y), "w": int(w), "h": int(h)} + ) + + async def reset_roi(self): + await self.ws_cmd("rkcam_reset_roi") + + async def grab_after_trigger( + self, mode: str, timeout_s: float = 3.0, poll_s: float = 0.05 + ) -> tuple[bytes | None, str | None, bool, dict]: + """Return (data, tag, is_new, meta).""" + before = self.peek_tag() + await self.fire_trigger(mode) + t0 = time.time() + last_tag = before + last_data = b"" + last_st: object = None + miss_reason = "no_response" + while time.time() - t0 < timeout_s and not STOP: + st, tag, data = self._get_preview_raw() + last_st = st + if st == 200 and data and len(data) > 800: + last_tag = tag + last_data = data + if before is None or tag is None: + if time.time() - t0 > 0.12: + return data, tag, True, {"reason": "ok", "before": before} + elif str(tag) != str(before): + return data, tag, True, { + "reason": "ok", + "before": before, + "tag": tag, + } + else: + miss_reason = "old_tag" + elif st != 200: + miss_reason = f"http_{st}" + elif not data or len(data) <= 800: + miss_reason = "empty_body" + await asyncio.sleep(poll_s) + await self.fire_trigger(mode) + t0 = time.time() + while time.time() - t0 < timeout_s and not STOP: + st, tag, data = self._get_preview_raw() + last_st = st + if st == 200 and data and len(data) > 800: + last_tag = tag + last_data = data + if before is None or (tag is not None and str(tag) != str(before)): + return data, tag, True, { + "reason": "ok_retry", + "before": before, + "tag": tag, + } + miss_reason = "old_tag" + elif st != 200: + miss_reason = f"http_{st}" + else: + miss_reason = "empty_body" + await asyncio.sleep(poll_s) + meta = { + "reason": miss_reason, + "before": before, + "tag": last_tag, + "http_st": last_st, + } + if last_data and len(last_data) > 800: + return last_data, last_tag, False, meta + return None, last_tag, False, meta + + async def grab_free_run(self, timeout_s: float = 2.0) -> tuple[bytes | None, str | None]: + before = self.peek_tag() + t0 = time.time() + while time.time() - t0 < timeout_s and not STOP: + st, tag, data = self._get_preview_raw() + if st == 200 and data and len(data) > 800: + if before is None or tag is None or str(tag) != str(before): + return data, tag + await asyncio.sleep(0.05) + st, tag, data = self._get_preview_raw() + if st == 200 and data and len(data) > 800: + return data, tag + return None, tag + + async def close(self): + if self.ws is not None: + try: + await self.ws.close() + except Exception: + pass + self.ws = None + + +async def switch_to( + client: CamOSClient, sink: Sink, run_mode: str, trigger_mode: str, label: str +) -> bool: + ok, info = client.patch_flow(run_mode, trigger_mode) + sink.event("switch", label=label, ok=ok, info=info, rm=run_mode_text()) + if not ok: + sink.stats["switch_fail"] += 1 + sink.bug(f"switch_fail:{label}", info=info, rm=run_mode_text()) + return False + settle = MON_SETTLE_S if run_mode == "monitor" else TRIG_SETTLE_S + await asyncio.sleep(settle) + need_ft = run_mode == "trigger" + need_free = run_mode in ("monitor", "manual") + ok_s, rm, meta = client.wait_stream( + 18.0, need_ft=need_ft, need_free=need_free, strict_mode=False + ) + if not ok_s: + ok_s2, rm2, meta2 = client.wait_stream( + 10.0, need_ft=False, need_free=False, strict_mode=False + ) + if not ok_s2: + sink.stats["switch_fail"] += 1 + sink.bug( + f"stream_fail:{label}", + rm=rm2 or rm, + wait_ms=meta2.get("wait_ms"), + info=info, + ) + return False + rm, meta = rm2, meta2 + if meta.get("mode_mismatch"): + sink.stats["switch_warn"] += 1 + sink.event( + "switch_warn", + label=label, + rm=rm, + wait_ms=meta.get("wait_ms"), + need_ft=need_ft, + need_free=need_free, + ) + sink.log(f"switch {label} WARN mode_mismatch rm={rm}", alert=True) + else: + sink.log(f"switch {label} ok wait_ms={meta.get('wait_ms')} rm={rm}") + try: + await client.ensure_ws() + except Exception as e: + sink.log(f"ws after switch: {e}") + return True + +async def run_alt_exp_case( + sink: Sink, + client: CamOSClient, + name: str, + mode: str, + n: int = 8, + free_run: bool = False, +) -> dict: + means: list[float] = [] + expects: list[str] = [] + greens: list[bool] = [] + details: list[dict] = [] + # 相位判定前固定增益=0:默认用户集常为 ~12dB,暗场下 LO/HI 均值挤在一起会误判 MIX + await client.set_gain(0.0) + await asyncio.sleep(0.2) + # ROI/模式切换后多 discard,减轻首帧相位污染 + if not free_run: + for _ in range(DISCARD_AFTER_SWITCH): + try: + await client.grab_after_trigger(mode, timeout_s=2.8) + except Exception: + pass + for i in range(n): + if STOP: + break + exp = LO_US if (i % 2 == 0) else HI_US + expects.append("LO" if exp == LO_US else "HI") + await client.set_exposure(exp) + await asyncio.sleep(EXP_SETTLE_S) + grab_meta: dict = {} + if free_run: + # free_run:多等一帧时间再取,减少曝光未生效 + await asyncio.sleep(0.08) + data, tag = await client.grab_free_run(timeout_s=2.0) + is_new = data is not None + else: + # 每次改曝光后先丢 EXP_DISCARD_EACH 枪,再采统计样本 + for _ in range(EXP_DISCARD_EACH): + try: + await client.grab_after_trigger(mode, timeout_s=2.5) + except Exception: + pass + data, tag, is_new, grab_meta = await client.grab_after_trigger( + mode, timeout_s=3.5 + ) + sink.stats["frames"] += 1 + if not data or not is_new: + means.append(float("nan")) + greens.append(False) + sink.stats["no_frame"] += 1 + details.append( + { + "i": i, + "err": "no_frame", + "tag": tag, + "is_new": is_new, + "miss": grab_meta, + } + ) + # 连续 503 / 无流:熔断本 case,避免空转 + reason = str((grab_meta or {}).get("reason") or "") + if reason.startswith("http_503") or reason.startswith("http_5"): + consec = sum( + 1 + for d in details[-3:] + if str((d.get("miss") or {}).get("reason") or "").startswith("http_5") + ) + if consec >= 2: + sink.log( + f"ABORT {name} consecutive preview 5xx, break case", + alert=True, + ) + break + continue + try: + a = analyze_jpeg(data) + means.append(float(a["mean"])) + greens.append(bool(a["green"])) + sink.stats["valid_frames"] += 1 + if a["green"]: + sink.stats["green_frames"] += 1 + gpath = sink.out / "green" / f"c{sink.stats['cycles']:04d}_{name}_{i:02d}.jpg" + gpath.write_bytes(data) + gpath.with_suffix(".json").write_text( + json.dumps( + {"case": name, "i": i, "tag": tag, **a, "rm": run_mode_text()}, + indent=2, + ensure_ascii=False, + ), + encoding="utf-8", + ) + details.append( + { + "i": i, + "exp": exp, + "mean": round(float(a["mean"]), 3), + "green": a["green"], + "classic_green": a.get("classic_green", False), + "seam": a.get("seam", False), + "tear": a.get("tear", False), + "score": a.get("score", 0), + "reasons": a.get("reasons", []), + "tag": tag, + "dim": a["dim"], + "bot": [a["br"], a["bg"], a["bb"]], + } + ) + except Exception as e: + means.append(float("nan")) + greens.append(False) + details.append({"i": i, "err": str(e), "tag": tag}) + + mm, ee = [], [] + for m, e in zip(means, expects): + if m == m: # not nan + mm.append(m) + ee.append(e) + filt_m, filt_e, filt_meta = filter_phase_samples(mm, ee) + if len(filt_m) >= 2: + phase = classify_phase(filt_m, filt_e) + else: + phase = classify_phase(mm, ee) + phase_all = classify_phase(mm, ee) if len(mm) >= 2 else phase + phase = dict(phase) + phase["filter"] = filt_meta + + result = { + "name": name, + "phase_drop_first": phase, + "phase_all": phase_all, + "green_frames": int(sum(1 for x in greens if x)), + "total": len(means), + "valid": len(mm), + "details": details, + "run_mode": run_mode_text(), + "phase_filter": filt_meta, + } + + sink.stats["cases"] += 1 + bc = sink.stats["by_case"].setdefault( + name, + { + "n": 0, + "green": 0, + "n1": 0, + "mix": 0, + "cur": 0, + "lowc": 0, + "incon": 0, + "toofew": 0, + "fail": 0, + }, + ) + bc["n"] += 1 + v = phase["verdict"] + span = float(phase.get("span") or 0.0) + if v == "CUR": + bc["cur"] += 1 + elif v == "N1": + pass # hard count after span gate + elif v == "MIX": + bc["mix"] += 1 + sink.stats["mix_cases"] += 1 + elif v in ("LOW_CONTRAST", "INCONCLUSIVE"): + bc["incon"] = bc.get("incon", 0) + 1 + bc["lowc"] += 1 + sink.stats["inconclusive_cases"] += 1 + sink.stats["low_contrast_cases"] += 1 + v = "INCONCLUSIVE" + elif v == "TOO_FEW": + bc["toofew"] += 1 + sink.stats["too_few_cases"] += 1 + gcnt = result["green_frames"] + bc["green"] += gcnt + + bad = False + reasons = [] + is_free = "FREERUN" in name or free_run + if v == "N1": + if span >= PHASE_N1_SPAN_MIN and not is_free: + bad = True + reasons.append("phase=N1") + bc["n1"] += 1 + sink.stats["n1_cases"] += 1 + else: + v = "INCONCLUSIVE" + bc["incon"] = bc.get("incon", 0) + 1 + sink.stats["inconclusive_cases"] += 1 + sink.log( + f"INCONCLUSIVE {name} weak_N1 span={span} (need>={PHASE_N1_SPAN_MIN})" + ) + elif v == "TOO_FEW": + bad = True + reasons.append(f"phase={v}") + elif v == "MIX" and not is_free: + if span >= PHASE_SPAN_MIN: + bad = True + reasons.append(f"phase={v}") + else: + bc["mix"] = max(0, bc.get("mix", 0) - 1) + sink.stats["mix_cases"] = max(0, sink.stats["mix_cases"] - 1) + v = "INCONCLUSIVE" + bc["incon"] = bc.get("incon", 0) + 1 + sink.stats["inconclusive_cases"] += 1 + elif v == "INCONCLUSIVE": + pass + if gcnt > 0: + bad = True + reasons.append(f"green={gcnt}") + if result["valid"] < max(2, n // 2): + bad = True + reasons.append(f"valid={result['valid']}/{n}") + + result["phase_final"] = v + result["span"] = span + if bad: + bc["fail"] += 1 + sink.bug( + f"{name}:{','.join(reasons)}", + case=name, + phase=v, + phase_all=phase_all.get("verdict"), + green=gcnt, + labels=phase.get("labels"), + expects=phase.get("expects"), + means=phase.get("means"), + span=phase.get("span"), + run_mode=result["run_mode"], + sample_details=details[:4], + ) + else: + sink.log( + f"OK {name} phase={v} green={gcnt} valid={result['valid']}/{n} " + f"span={phase.get('span')} rm={result['run_mode'][:60]}" + ) + sink.event("case", **{k: result[k] for k in result if k != "details"}, details_n=len(details)) + sink.touch_summary() + return result + + + +def _kill_camos_devs() -> None: + """Stop all CamOS server paths (dev + systemd unit process).""" + subprocess.call( + ["systemctl", "stop", "camos"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + subprocess.call( + ["pkill", "-9", "-f", "python server.py"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + # packaged entry may appear as .../camos/camos + subprocess.call( + ["pkill", "-9", "-f", "site-packages/camos/camos"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + subprocess.call( + ["pkill", "-9", "-f", "[v]ite"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + time.sleep(1.0) + # release video node if still held + subprocess.call( + ["fuser", "-k", "/dev/video11"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + time.sleep(0.5) + + +def _start_camos_light(sink: Sink) -> None: + """Always single-instance: NO_VITE python server.py from /root/smartcam_bs. + + Do NOT systemctl start camos while a dev tree is the test target — it races + the existing server and leaves streaming=0 with login still 200. + """ + try: + RUN_MODE_SYSFS.write_text("free_run\n", encoding="utf-8") + except Exception: + pass + + _kill_camos_devs() + time.sleep(1) + logf = open("/tmp/camos_boot_endurance.log", "a") + env = os.environ.copy() + env["SMARTCAM_NO_VITE_DEV"] = "1" + env["PYTHONUNBUFFERED"] = "1" + subprocess.Popen( + ["python", "server.py", "--host", "0.0.0.0", "--port", "8000", "80"], + cwd="/root/smartcam_bs", + stdout=logf, + stderr=subprocess.STDOUT, + start_new_session=True, + env=env, + ) + sink.log("camos start via python server.py (NO_VITE_DEV=1, single instance)") + + +async def ensure_camos_up(sink: Sink, client: CamOSClient) -> bool: + """Login + preview body required. Restart single instance if unhealthy.""" + if client.login(): + ok, info = client.preview_healthy() + if ok: + # also prefer streaming=1 but preview body is the hard gate + sink.log(f"camos up preview {info}") + return True + sink.log(f"camos login ok but preview unhealthy: {info}", alert=True) + else: + sink.log("camos login failed", alert=True) + + sink.log("camos not healthy, light restart (single instance)", alert=True) + sink.stats["recoveries"] += 1 + _start_camos_light(sink) + for i in range(90): + if STOP: + return False + time.sleep(2) + if not client.login(): + continue + ok, info = client.preview_healthy() + if ok: + # clear stale flow cache after restart + client._current_flow = {"run_mode": None, "trigger_mode": None} + try: + client._refresh_flow_cache() + except Exception: + pass + try: + await client.open_ws() + except Exception: + pass + sink.log(f"camos restarted ok i={i} {info}") + return True + sink.bug("camos_restart_fail") + return False + + +async def one_cycle(sink: Sink, client: CamOSClient, cycle: int): + sink.stats["cycles"] = cycle + sink.log(f"===== cycle {cycle} begin path=camos =====") + + # 1) monitor / free_run smoke (few frames, no hard N-1) + if not await switch_to(client, sink, "monitor", "close", "MON"): + await client_recover(sink, client) + if not await switch_to(client, sink, "monitor", "close", "MON_retry"): + return + await run_alt_exp_case(sink, client, "MON_FREERUN", mode="close", n=4, free_run=True) + + # 2) software trigger full + ROI set/reset + if not await switch_to(client, sink, "trigger", "software", "SW"): + await client_recover(sink, client) + if not await switch_to(client, sink, "trigger", "software", "SW_retry"): + return + await run_alt_exp_case(sink, client, "SW_BASE", mode="software", n=8) + + async def _roi_then_case(label_prefix: str, xx, yy, ww, hh, mode: str, n: int) -> bool: + """set ROI, require stream+preview healthy, then run case. False => need recover.""" + try: + await client.set_roi(xx, yy, ww, hh) + await asyncio.sleep(ROI_SETTLE_S) + sink.event("roi_set", x=xx, y=yy, w=ww, h=hh, rm=run_mode_text()) + except Exception as e: + sink.stats["roi_fail"] += 1 + sink.bug("roi_set_fail", err=str(e), roi=[xx, yy, ww, hh]) + return False + # software 也给 ROI 后 encoder 一点缓冲,避免瞬时 204 假死 + healthy, hinfo = client.stream_and_preview_ok(retries=5) + if not healthy: + sink.stats["roi_fail"] += 1 + sink.bug( + f"roi_dead_after_set:{label_prefix}", + info=hinfo, + roi=[xx, yy, ww, hh], + rm=run_mode_text(), + ) + return False + result = await run_alt_exp_case( + sink, client, f"{label_prefix}_{ww}x{hh}", mode=mode, n=n + ) + # case itself may have hit 503 storm + if result.get("valid", 0) == 0 and result.get("total", 0) > 0: + healthy2, hinfo2 = client.stream_and_preview_ok(retries=5) + if not healthy2: + sink.bug( + f"roi_dead_after_case:{label_prefix}", + info=hinfo2, + roi=[xx, yy, ww, hh], + rm=run_mode_text(), + ) + return False + return True + + x, y, w, h = random_roi() + if not await _roi_then_case("SW_ROI", x, y, w, h, "software", 8): + await client_recover(sink, client) + if not await switch_to(client, sink, "trigger", "software", "SW_after_roi_fail"): + return + else: + try: + await client.reset_roi() + await asyncio.sleep(ROI_SETTLE_S) + sink.event("roi_reset", rm=run_mode_text()) + healthy, hinfo = client.stream_and_preview_ok() + if not healthy: + sink.bug("roi_dead_after_reset", info=hinfo, rm=run_mode_text()) + await client_recover(sink, client) + if not await switch_to( + client, sink, "trigger", "software", "SW_after_reset_fail" + ): + return + else: + await run_alt_exp_case(sink, client, "SW_ROI_RESET", mode="software", n=8) + except Exception as e: + sink.stats["roi_fail"] += 1 + sink.bug("roi_reset_fail_sw", err=str(e)) + + # second random ROI in software + x2, y2, w2, h2 = random_roi() + if not await _roi_then_case("SW_ROI2", x2, y2, w2, h2, "software", 6): + await client_recover(sink, client) + if not await switch_to(client, sink, "trigger", "software", "SW_after_roi2_fail"): + return + else: + try: + await client.reset_roi() + await asyncio.sleep(ROI_SETTLE_S) + healthy, hinfo = client.stream_and_preview_ok() + if not healthy: + sink.bug("roi2_dead_after_reset", info=hinfo, rm=run_mode_text()) + await client_recover(sink, client) + except Exception as e: + sink.stats["roi_fail"] += 1 + sink.bug("roi2_fail_sw", err=str(e)) + + # 3) flip software → line0 (same run_mode=trigger, only trigger_mode change — no cooldown) + ok, info = client.patch_flow("trigger", "line0") + sink.event("switch", label="LINE0", ok=ok, info=info, rm=run_mode_text()) + if ok: + await asyncio.sleep(TRIG_SETTLE_S) + ok_l0, rm_l0, meta_l0 = client.wait_stream( + 14.0, need_ft=True, strict_mode=False + ) + sink.event( + "switch_wait", + label="LINE0", + ok=ok_l0, + rm=rm_l0, + wait_ms=meta_l0.get("wait_ms"), + mode_mismatch=meta_l0.get("mode_mismatch"), + ) + await run_alt_exp_case(sink, client, "LINE0_BASE", mode="line0", n=8) + x3, y3, w3, h3 = random_roi() + try: + await client.set_roi(x3, y3, w3, h3) + await asyncio.sleep(ROI_SETTLE_S) + # line0:先 XTRIG 唤醒 preview,再健康检查(避免 204 假死触发 recover) + try: + for _ in range(3): + sysfs_pulse() + await asyncio.sleep(0.12) + except Exception: + pass + healthy, hinfo = client.stream_and_preview_ok(poke_line0=True, retries=6) + if not healthy: + sink.bug("roi_dead_line0_set", info=hinfo, roi=[x3, y3, w3, h3]) + await client_recover(sink, client) + else: + await run_alt_exp_case( + sink, client, f"LINE0_ROI_{w3}x{h3}", mode="line0", n=8 + ) + await client.reset_roi() + await asyncio.sleep(ROI_SETTLE_S) + try: + for _ in range(3): + sysfs_pulse() + await asyncio.sleep(0.12) + except Exception: + pass + healthy2, hinfo2 = client.stream_and_preview_ok( + poke_line0=True, retries=6 + ) + if not healthy2: + sink.bug("roi_dead_line0_reset", info=hinfo2) + await client_recover(sink, client) + else: + await run_alt_exp_case( + sink, client, "LINE0_ROI_RESET", mode="line0", n=6 + ) + except Exception as e: + sink.stats["roi_fail"] += 1 + sink.bug("roi_fail_line0", err=str(e)) + else: + sink.stats["switch_fail"] += 1 + sink.bug("switch_fail:LINE0", info=info) + + # 4) back to monitor + await switch_to(client, sink, "monitor", "close", "MON_END") + sink.touch_summary() + sink.log( + f"===== cycle {cycle} end bugs={sink.stats['bugs']} " + f"green={sink.stats['green_frames']} n1={sink.stats['n1_cases']} =====" + ) + + +async def client_recover(sink: Sink, client: CamOSClient): + sink.stats["recoveries"] += 1 + sink.log("recover begin", alert=True) + # soft path first + try: + # force patch even if cache says monitor (dead stream blocks short-circuit) + client._current_flow = {"run_mode": None, "trigger_mode": None} + client.patch_flow("monitor", "close") + await asyncio.sleep(2) + await client.ensure_ws() + await client.reset_roi() + await asyncio.sleep(ROI_SETTLE_S) + except Exception as e: + sink.log(f"recover soft {e}") + ok_s, rm, _meta = client.wait_stream(8.0, strict_mode=False) + ok_p, pinfo = client.preview_healthy() + if ok_s and ok_p: + sink.log(f"recover soft ok rm={rm} {pinfo}") + sink.event("recover", kind_soft=True, rm=rm) + return + sink.log( + f"recover soft failed stream={ok_s} preview={ok_p}/{pinfo} -> hard restart", + alert=True, + ) + await ensure_camos_up(sink, client) + try: + client._current_flow = {"run_mode": None, "trigger_mode": None} + client.patch_flow("monitor", "close") + await asyncio.sleep(2) + await client.open_ws() + await client.reset_roi() + except Exception as e: + sink.log(f"recover hard followup {e}") + sink.event("recover", kind_soft=False, rm=run_mode_text()) + + +async def amain(hours: float, out: Path) -> int: + sink = Sink(out) + try: + uname = subprocess.check_output(["uname", "-a"], text=True).strip() + except Exception: + uname = "?" + sink.stats["kernel"] = uname + # build id from uname #YYYYMMDDNN + for part in uname.split(): + if part.startswith("#20"): + sink.stats["build"] = part + break + sink.log(f"start CamOS endurance hours={hours} out={out} kernel={uname}") + sink.touch_summary() + + client = CamOSClient(sink) + if not await ensure_camos_up(sink, client): + sink.log("FATAL camos not up", alert=True) + sink.touch_summary() + return 2 + try: + await client.open_ws() + except Exception as e: + sink.log(f"ws open fail {e}, continue with HTTP-only + retry") + + # seed flow state + try: + cfg = client.get_flow() + client._current_flow = { + "run_mode": cfg.get("run_mode"), + "trigger_mode": cfg.get("trigger_mode"), + } + sink.log(f"flow_config={json.dumps(cfg, ensure_ascii=False)[:240]}") + except Exception as e: + sink.log(f"get_flow {e}") + + t_end = time.time() + hours * 3600.0 + cycle = 0 + while time.time() < t_end and not STOP: + cycle += 1 + try: + await one_cycle(sink, client, cycle) + except Exception as e: + sink.stats["crashes"] += 1 + sink.bug("cycle_crash", err=str(e), tb=traceback.format_exc()[-800:]) + sink.touch_summary() + try: + await client_recover(sink, client) + except Exception: + pass + await asyncio.sleep(2) + # brief pause between cycles + for _ in range(10): + if STOP or time.time() >= t_end: + break + await asyncio.sleep(0.5) + + # leave camera in monitor/free_run + try: + client.patch_flow("monitor", "close") + except Exception: + pass + await client.close() + sink.log(f"done cycles={cycle} bugs={sink.stats['bugs']}") + sink.touch_summary() + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--hours", type=float, default=24.0) + ap.add_argument("--out", default="/tmp/imx296_day_endurance") + args = ap.parse_args() + out = Path(args.out) + return asyncio.run(amain(args.hours, out)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/imx296_day_watchdog.py b/scripts/imx296_day_watchdog.py new file mode 100644 index 0000000000000..7408092b0ee84 --- /dev/null +++ b/scripts/imx296_day_watchdog.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +"""Host-side watchdog for board IMX296 day endurance. + +SSH to root@192.168.1.180, pull summary/bugs, append local markdown report, +restart board test if dead (optional). +""" +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import time +from datetime import datetime +from pathlib import Path + +HOST = "root@192.168.1.180" +BOARD_OUT = "/tmp/imx296_day_endurance" +BOARD_SCRIPT = "/tmp/imx296_day_endurance.py" +SSH_BASE = [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=8", + "-o", + "BatchMode=yes", + "-o", + "ServerAliveInterval=5", + "-o", + "ServerAliveCountMax=2", + HOST, +] + + +def ts() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def ssh(script: str, timeout: int = 60) -> str: + try: + r = subprocess.run( + SSH_BASE + ["bash", "-s"], + input=script, + text=True, + capture_output=True, + timeout=timeout, + ) + out = r.stdout or "" + if r.stderr: + out += "\n" + r.stderr + return out + except subprocess.TimeoutExpired: + return "SSH_TIMEOUT" + except Exception as e: + return f"SSH_ERR:{e}" + + +def scp_pull(remote: str, local: Path, timeout: int = 45) -> bool: + local.parent.mkdir(parents=True, exist_ok=True) + try: + r = subprocess.run( + [ + "scp", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=8", + "-o", + "BatchMode=yes", + f"{HOST}:{remote}", + str(local), + ], + capture_output=True, + text=True, + timeout=timeout, + ) + return r.returncode == 0 and local.is_file() + except Exception: + return False + + +def _parse_summary_txt(txt: str) -> dict: + """Parse key=value lines from summary.txt into a compact dict.""" + s: dict = {} + for ln in (txt or "").splitlines(): + if "=" not in ln: + continue + k, v = ln.split("=", 1) + k, v = k.strip(), v.strip() + if k in ( + "cycles", + "bugs", + "green_frames", + "n1_cases", + "mix_cases", + "low_contrast_cases", + "too_few_cases", + "no_frame", + "switch_fail", + "roi_fail", + "recoveries", + "crashes", + "cases", + "frames", + "valid_frames", + "uptime_s", + ): + # lines may pack multiple keys; handle single-key first + try: + s[k] = int(re.sub(r"[^\d-]", "", v.split()[0]) or 0) + except Exception: + pass + elif k in ("started_at", "updated_at", "build", "kernel", "last_bug", "run_mode", "by_case"): + s[k] = v + elif k == "green": + # green=0 n1=0 mix=0 ... packed line from summary.txt + for part in ln.replace(",", " ").split(): + if "=" in part: + pk, pv = part.split("=", 1) + try: + if pk == "green": + s["green_frames"] = int(pv) + elif pk == "n1": + s["n1_cases"] = int(pv) + elif pk == "mix": + s["mix_cases"] = int(pv) + elif pk == "lowc": + s["low_contrast_cases"] = int(pv) + elif pk == "toofew": + s["too_few_cases"] = int(pv) + elif pk == "noframe": + s["no_frame"] = int(pv) + elif pk in ( + "cycles", + "cases", + "frames", + "bugs", + "switch_fail", + "roi_fail", + "recoveries", + "crashes", + ): + s[pk if pk != "frames" else "frames"] = int(pv) + elif pk == "valid": + s["valid_frames"] = int(pv) + except Exception: + pass + elif ln.startswith("cycles=") or " cycles=" in f" {ln}": + for part in ln.replace(",", " ").split(): + if "=" in part: + pk, pv = part.split("=", 1) + try: + if pk == "cycles": + s["cycles"] = int(pv) + elif pk == "cases": + s["cases"] = int(pv) + elif pk == "frames": + s["frames"] = int(pv) + elif pk == "valid": + s["valid_frames"] = int(pv) + except Exception: + pass + elif ln.startswith("switch_fail=") or ln.startswith("green="): + for part in ln.replace(",", " ").split(): + if "=" in part: + pk, pv = part.split("=", 1) + try: + mapping = { + "switch_fail": "switch_fail", + "roi_fail": "roi_fail", + "recoveries": "recoveries", + "crashes": "crashes", + "bugs": "bugs", + "green": "green_frames", + "n1": "n1_cases", + "mix": "mix_cases", + "lowc": "low_contrast_cases", + "toofew": "too_few_cases", + "noframe": "no_frame", + } + if pk in mapping: + s[mapping[pk]] = int(pv) + except Exception: + pass + return s + + +def collect() -> dict: + out = ssh( + f""" +set +e +pid=$(pgrep -f '{BOARD_SCRIPT}' | head -1) +echo PID=$pid +if [ -f {BOARD_OUT}/summary.txt ]; then + echo '---SUMMARY---' + cat {BOARD_OUT}/summary.txt + echo '---END_SUMMARY---' +fi +bugs_n=$(wc -l < {BOARD_OUT}/bugs.jsonl 2>/dev/null || echo 0) +echo BUGS_N=$bugs_n +if [ -f {BOARD_OUT}/bugs.jsonl ]; then + echo '---BUGS_TAIL---' + tail -n 8 {BOARD_OUT}/bugs.jsonl + echo '---END_BUGS---' +fi +echo '---RMODE---' +cat /sys/bus/i2c/devices/4-001a/run_mode 2>/dev/null | tr '\\n' ' ' +echo +echo '---UNAME---' +uname -a +echo '---DMESG---' +dmesg 2>/dev/null | grep -E 'SError|Oops|BUG:|n1trace early ceiling-force|ignored XTRIG' | tail -n 8 +echo '---END---' +""", + timeout=50, + ) + d: dict = {"_raw": out, "ok": "SSH_" not in out[:20]} + m = re.search(r"^PID=(.*)$", out, re.M) + d["pid"] = (m.group(1) if m else "").strip() + m = re.search(r"^BUGS_N=(.*)$", out, re.M) + d["bugs_n"] = int(re.sub(r"\D", "", (m.group(1) if m else "0")) or 0) + sm = re.search(r"---SUMMARY---\n(.*?)\n---END_SUMMARY---", out, re.S) + d["summary_txt"] = sm.group(1).strip() if sm else "" + # Prefer scp'd summary.json (avoids SSH stream mangling large JSON) + d["summary"] = None + tmp_json = Path("/tmp/imx296_day_watchdog/_pull_summary.json") + if scp_pull(f"{BOARD_OUT}/summary.json", tmp_json, timeout=30): + try: + d["summary"] = json.loads(tmp_json.read_text(encoding="utf-8")) + except Exception: + d["summary"] = None + if d["summary"] is None and d["summary_txt"]: + d["summary"] = _parse_summary_txt(d["summary_txt"]) + bm = re.search(r"---BUGS_TAIL---\n(.*?)\n---END_BUGS---", out, re.S) + d["bugs_tail"] = bm.group(1).strip() if bm else "" + rm = re.search(r"---RMODE---\n(.*)", out) + d["run_mode"] = rm.group(1).strip() if rm else "" + um = re.search(r"---UNAME---\n(.*)", out) + d["uname"] = um.group(1).strip() if um else "" + dm = re.search(r"---DMESG---\n(.*?)\n---END---", out, re.S) + d["dmesg"] = dm.group(1).strip() if dm else "" + return d + + +def restart_board_test() -> str: + return ssh( + f""" +set +e +pkill -f '{BOARD_SCRIPT}' 2>/dev/null || true +sleep 1 +# free camera +echo free_run > /sys/bus/i2c/devices/4-001a/run_mode 2>/dev/null || true +mkdir -p {BOARD_OUT} +# keep previous bugs +if [ -f {BOARD_OUT}/bugs.jsonl ]; then + cp {BOARD_OUT}/bugs.jsonl {BOARD_OUT}/bugs.jsonl.bak.$(date +%s) 2>/dev/null || true +fi +nohup /root/dlcv/bin/python3 -u {BOARD_SCRIPT} --hours 24 --out {BOARD_OUT} \ + >>{BOARD_OUT}/console.log 2>&1 & +sleep 2 +pgrep -af '{BOARD_SCRIPT}' | head -3 +test -f {BOARD_OUT}/summary.txt && head -5 {BOARD_OUT}/summary.txt +""", + timeout=40, + ) + + +def ensure_report(path: Path, meta: dict): + if path.is_file(): + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + f"""# IMX296 24h 稳定性连续测试记录 + +- **开始时间**: {ts()} +- **板卡**: `{HOST}` +- **内核**: `{meta.get('uname', 'unknown')}` +- **测试脚本**: `{BOARD_SCRIPT}` → 输出 `{BOARD_OUT}` +- **主机 watchdog**: `scripts/imx296_day_watchdog.py` +- **关注点**: 模式切换(close/software/line0)、ROI set/reset、底部绿线/串帧、N-1 相位 + +## 汇总 + +| 时间 | 状态 | cycles | bugs | green | n1 | mix | 备注 | +|------|------|--------|------|-------|----|-----|------| +""", + encoding="utf-8", + ) + + +def append_report(path: Path, d: dict, note: str = ""): + s = d.get("summary") or {} + cycles = s.get("cycles", "?") + bugs = s.get("bugs", d.get("bugs_n", "?")) + green = s.get("green_frames", "?") + n1 = s.get("n1_cases", "?") + mix = s.get("mix_cases", "?") + pid = d.get("pid") or "-" + alive = "ALIVE" if pid and pid not in ("",) else "DEAD" + if not d.get("ok"): + alive = "SSH_FAIL" + row = ( + f"| {ts()} | {alive} pid={pid} | {cycles} | {bugs} | {green} | {n1} | {mix} | " + f"{note.replace('|', '/')} |\n" + ) + with path.open("a", encoding="utf-8") as f: + f.write(row) + if d.get("bugs_tail"): + f.write(f"\n### {ts()} bugs tail\n\n```\n{d['bugs_tail'][:2000]}\n```\n\n") + if note.startswith("ALERT") or alive != "ALIVE": + f.write(f"\n### {ts()} detail\n\n") + f.write(f"- run_mode: `{d.get('run_mode', '')}`\n") + if d.get("summary_txt"): + f.write(f"\n```\n{d['summary_txt'][:1500]}\n```\n\n") + if d.get("dmesg"): + f.write(f"\n```\n{d['dmesg'][:1500]}\n```\n\n") + + +def write_snapshot(snap_dir: Path, d: dict): + snap_dir.mkdir(parents=True, exist_ok=True) + (snap_dir / "last_collect.json").write_text( + json.dumps( + { + "ts": ts(), + "pid": d.get("pid"), + "bugs_n": d.get("bugs_n"), + "summary": d.get("summary"), + "run_mode": d.get("run_mode"), + "uname": d.get("uname"), + "bugs_tail": d.get("bugs_tail"), + "dmesg": d.get("dmesg"), + }, + indent=2, + ensure_ascii=False, + ), + encoding="utf-8", + ) + if d.get("summary_txt"): + (snap_dir / "summary.txt").write_text(d["summary_txt"] + "\n", encoding="utf-8") + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument( + "--report", + default=str(Path("/tmp/imx296_day_watchdog/report.md")), + ) + ap.add_argument("--state-dir", default="/tmp/imx296_day_watchdog") + ap.add_argument("--interval", type=int, default=120) + ap.add_argument("--once", action="store_true") + ap.add_argument("--restart-if-dead", action="store_true", default=True) + ap.add_argument("--no-restart", action="store_true") + args = ap.parse_args() + + report = Path(args.report) + state = Path(args.state_dir) + state.mkdir(parents=True, exist_ok=True) + logf = state / "watchdog.log" + + def wlog(msg: str): + line = f"[{ts()}] {msg}" + print(line, flush=True) + with logf.open("a", encoding="utf-8") as f: + f.write(line + "\n") + + last_bugs = -1 + dead_streak = 0 + + while True: + d = collect() + write_snapshot(state, d) + s = d.get("summary") or {} + bugs = int(s.get("bugs", d.get("bugs_n") or 0) or 0) + pid = (d.get("pid") or "").strip() + alive = bool(pid) + + ensure_report(report, d) + note = "" + if not d.get("ok"): + note = "ALERT SSH problem" + dead_streak += 1 + elif not alive: + note = "ALERT test process dead" + dead_streak += 1 + else: + dead_streak = 0 + if last_bugs >= 0 and bugs > last_bugs: + note = f"ALERT new bugs +{bugs - last_bugs}" + else: + note = "ok" + + append_report(report, d, note=note) + wlog( + f"alive={alive} pid={pid or '-'} bugs={bugs} cycles={s.get('cycles')} " + f"green={s.get('green_frames')} n1={s.get('n1_cases')} note={note}" + ) + + # pull bugs file periodically + scp_pull(f"{BOARD_OUT}/bugs.jsonl", state / "bugs.jsonl") + scp_pull(f"{BOARD_OUT}/summary.json", state / "summary.json") + + if ( + (not args.no_restart) + and args.restart_if_dead + and dead_streak >= 2 + and d.get("ok") + ): + wlog("restarting board endurance") + out = restart_board_test() + wlog(out[:500].replace("\n", " | ")) + append_report(report, d, note="ALERT restarted board test") + dead_streak = 0 + + last_bugs = bugs + if args.once: + break + time.sleep(max(30, args.interval)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_accept_user.py b/scripts/verify_accept_user.py new file mode 100644 index 0000000000000..fa530437ece3b --- /dev/null +++ b/scripts/verify_accept_user.py @@ -0,0 +1,646 @@ +#!/usr/bin/env python3 +"""Real-user acceptance on CamOS (smartcam_bs defaults). + +Scenarios: + - mon → software trigger (apply latency + quality + first_hit) + - mon → line0 / XTRIG (sysfs) quality + first_hit + - software ROI set/reset via WS + - N-1 / phase: successive triggers must each produce a NEW frame tag; + optional center-luma monotonicity under controlled light (soft check) + - multi-signal bottom quality: classic / seam / tear + +Does not override CamOS warmup/gate. Kernel #2026082001 + imx296_bottom_quality. +CamOS 控制面默认 /ws/cmd(/ws 只读);可用 CAMOS_WS 覆盖。 +""" +from __future__ import annotations + +import asyncio +import importlib.util +import json +import os +import statistics +import sys +import time +from pathlib import Path + +import requests +import websockets + +BASE = os.environ.get("CAMOS_BASE", "http://127.0.0.1:8000") +WS_URL = os.environ.get("CAMOS_WS", "ws://127.0.0.1:8000/ws/cmd") +OUT = Path(os.environ.get("VERIFY_OUT", "/userdata/imx296_warmup_ab/verify_accept_user")) +SW_ROUNDS = int(os.environ.get("VERIFY_SW_ROUNDS", "6")) +LINE0_ROUNDS = int(os.environ.get("VERIFY_LINE0_ROUNDS", "4")) +HEAD = int(os.environ.get("VERIFY_HEAD", "12")) +N1_SEQ = int(os.environ.get("VERIFY_N1_SEQ", "16")) +ROI_HEAD = int(os.environ.get("VERIFY_ROI_HEAD", "8")) +COOLDOWN = float(os.environ.get("VERIFY_COOLDOWN", "5.5")) +USER = os.environ.get("CAMOS_USER", "admin") +PASS = os.environ.get("CAMOS_PASS", "dlcv2026") +XTRIG = Path(os.environ.get("XTRIG_PATH", "/sys/bus/i2c/devices/4-001a/trigger")) +N1TRACE = Path("/sys/module/video_rkisp/parameters/n1trace") + +_det = None +for cand in ( + Path(__file__).resolve().parent / "imx296_bottom_quality.py", + Path("/userdata/imx296_warmup_ab/scripts/imx296_bottom_quality.py"), +): + if cand.is_file(): + spec = importlib.util.spec_from_file_location("imx296_bottom_quality", cand) + _det = importlib.util.module_from_spec(spec) + assert spec.loader + spec.loader.exec_module(_det) + break +if _det is None: + sys.exit("imx296_bottom_quality.py missing") + +if OUT.exists(): + import shutil + + shutil.rmtree(OUT) +OUT.mkdir(parents=True) +(OUT / "frames").mkdir() +(OUT / "bad").mkdir() +(OUT / "n1").mkdir() + +s = requests.Session() + + +def login(): + s.post(BASE + "/api/auth/login", json={"username": USER, "password": PASS}, timeout=10) + return "; ".join(f"{c.name}={c.value}" for c in s.cookies) + + +cookie = login() + + +def n1trace(on: bool): + try: + N1TRACE.write_text("Y\n" if on else "N\n") + except Exception as e: + print("n1trace", e, flush=True) + + +def patch(body, timeout=90): + t0 = time.time() + try: + r = s.patch(BASE + "/api/flow_config", json=body, timeout=timeout) + js = r.json() if r.content else {} + ok = r.status_code < 400 + if isinstance(js, dict): + if "success" in js: + ok = bool(js["success"]) + elif "ok" in js: + ok = bool(js["ok"]) + return time.time() - t0, js, None, ok + except Exception as e: + return time.time() - t0, {}, str(e), False + + +def peek_tag(): + try: + pr = s.get(BASE + "/preview/frame", params={"t": time.time_ns()}, timeout=8) + return int(pr.headers.get("x-frame-tag") or pr.headers.get("X-Frame-Tag") or 0) + except Exception: + return 0 + + +def fire(mode: str): + try: + if mode == "line0": + XTRIG.write_text("1\n") + else: + s.post(BASE + "/debug/trigger_once", timeout=8) + return True + except Exception as e: + print(f" fire_{mode}_err={e}", flush=True) + return False + + +def wait_new_frame(last_tag: int, timeout_s: float = 2.0): + end = time.time() + timeout_s + while time.time() < end: + try: + pr = s.get(BASE + "/preview/frame", params={"t": time.time_ns()}, timeout=8) + except Exception: + time.sleep(0.03) + continue + h = pr.headers.get("x-frame-tag") or pr.headers.get("X-Frame-Tag") or "" + try: + tn = int(h) if str(h).isdigit() else -1 + except Exception: + tn = -1 + if pr.status_code == 200 and pr.content and tn != last_tag and tn >= 0: + return tn, pr.content, None + time.sleep(0.03) + return last_tag, b"", "timeout" + + +def shoot(label: str, n: int, mode: str = "software"): + last = peek_tag() + first = None + stats = { + "got": 0, + "miss": 0, + "bad": 0, + "classic": 0, + "seam": 0, + "tear": 0, + "idx": [], + "bots": [], + "dims": [], + "tags": [], + "means": [], + "dup_tag": 0, + "tag_non_increasing": 0, + } + prev_tag = last + for i in range(n): + fire(mode) + tn, data, err = wait_new_frame(last, 2.0) + if err or not data: + stats["miss"] += 1 + time.sleep(0.08) + continue + if first is None: + first = i + 1 + if tn == prev_tag: + stats["dup_tag"] += 1 + if tn < prev_tag and prev_tag > 0: + stats["tag_non_increasing"] += 1 + last = tn + prev_tag = tn + stats["got"] += 1 + stats["tags"].append(tn) + (OUT / "frames" / f"{label}_s{i+1:02d}.jpg").write_bytes(data) + a = _det.analyze_jpeg(data) + stats["dims"].append(a.get("dim")) + stats["means"].append(a.get("mean")) + if len(stats["bots"]) < 3: + stats["bots"].append({"i": i + 1, "tag": tn, **a}) + if a.get("green") or a.get("bad"): + stats["bad"] += 1 + stats["classic"] += int(bool(a.get("classic_green"))) + stats["seam"] += int(bool(a.get("seam"))) + stats["tear"] += int(bool(a.get("tear"))) + stats["idx"].append(i + 1) + (OUT / "bad" / f"{label}_s{i+1:02d}.jpg").write_bytes(data) + (OUT / "bad" / f"{label}_s{i+1:02d}.json").write_text( + json.dumps({"tag": tn, **a}, ensure_ascii=False, indent=2), encoding="utf-8" + ) + time.sleep(0.1) + stats["first_hit"] = first + # unique tags == got → no N-1 reuse of same published frame + stats["unique_tags"] = len(set(stats["tags"])) + stats["tag_unique_ok"] = stats["unique_tags"] == stats["got"] and stats["got"] > 0 + return stats + + +def n1_sequence(label: str, n: int, mode: str = "software"): + """Strict 1-trigger → 1-new-tag sequence; detect stalls / repeats (N-1 symptom).""" + last = peek_tag() + rows = [] + miss = 0 + bad = 0 + classic = seam = tear = 0 + tags = [] + for i in range(n): + t_fire = time.time() + fire(mode) + tn, data, err = wait_new_frame(last, 2.2) + dt = time.time() - t_fire + if err or not data: + miss += 1 + rows.append({"i": i + 1, "miss": True, "dt": round(dt, 3)}) + time.sleep(0.12) + continue + a = _det.analyze_jpeg(data) + row = { + "i": i + 1, + "tag": tn, + "prev": last, + "delta_tag": tn - last if last else None, + "dt": round(dt, 3), + "mean": a.get("mean"), + "dim": a.get("dim"), + "bad": bool(a.get("bad")), + "classic": bool(a.get("classic_green")), + "seam": bool(a.get("seam")), + "tear": bool(a.get("tear")), + "reasons": a.get("reasons"), + } + rows.append(row) + tags.append(tn) + (OUT / "n1" / f"{label}_{i+1:02d}.jpg").write_bytes(data) + if a.get("bad"): + bad += 1 + classic += int(bool(a.get("classic_green"))) + seam += int(bool(a.get("seam"))) + tear += int(bool(a.get("tear"))) + (OUT / "bad" / f"{label}_n1_{i+1:02d}.jpg").write_bytes(data) + last = tn + time.sleep(0.15) + # N-1 failure modes: + # - miss on first trigger but hit on second with stale content (hard without scene change) + # - repeated tags + # - first_hit > 1 (first fire produced no new frame) + first_hit = next((r["i"] for r in rows if not r.get("miss")), None) + unique = len(set(tags)) + # delta_tag should be >0 for each hit; if always 0 impossible since wait_new_frame + deltas = [r["delta_tag"] for r in rows if r.get("delta_tag") is not None] + # soft: mean should not be identical for all (frozen frame) if n large + means = [r["mean"] for r in rows if r.get("mean") is not None] + frozen = False + if len(means) >= 6: + # if all means within 0.05 → suspicious freeze + if max(means) - min(means) < 0.05: + frozen = True + return { + "label": label, + "mode": mode, + "n": n, + "got": len(tags), + "miss": miss, + "first_hit": first_hit, + "bad": bad, + "classic": classic, + "seam": seam, + "tear": tear, + "unique_tags": unique, + "tag_unique_ok": unique == len(tags) and len(tags) > 0, + "first_is_1": first_hit == 1, + "frozen_mean": frozen, + "delta_min": min(deltas) if deltas else None, + "delta_max": max(deltas) if deltas else None, + "rows": rows, + # pass: every fire gets new tag, first=1, no quality bad, not frozen + "n1_pass": ( + miss == 0 + and first_hit == 1 + and unique == len(tags) + and len(tags) == n + and bad == 0 + and not frozen + ), + } + + +async def ws_cmd(ws, cmd, value=None): + msg = {"cmd": cmd} + if value is not None: + msg["value"] = value + await ws.send(json.dumps(msg)) + for _ in range(3): + try: + await asyncio.wait_for(ws.recv(), timeout=0.25) + except Exception: + break + + +def lat_stats(vals): + vals = [v for v in vals if v is not None] + if not vals: + return {"n": 0} + return { + "n": len(vals), + "min": round(min(vals), 3), + "max": round(max(vals), 3), + "avg": round(sum(vals) / len(vals), 3), + "p50": round(statistics.median(vals), 3), + "all": [round(v, 3) for v in vals], + } + + +async def main(): + n1trace(False) + summary = { + "kernel": Path("/proc/version").read_text().strip(), + "detector": "imx296_bottom_quality", + "camos": "smartcam_bs/0_测试运行_camos.sh defaults", + "sw": [], + "line0": [], + "n1": [], + "roi": [], + "targets": {"apply_hard_s": 5.0, "apply_goal_s": [2.0, 3.0]}, + } + + def to_mon(): + # CamOS uses trigger_mode=close for monitor + return patch({"run_mode": "monitor", "trigger_mode": "close"}) + + dt, _, err, ok = to_mon() + print(f"init mon dt={dt:.3f} ok={ok} err={err}", flush=True) + time.sleep(COOLDOWN) + + real_applies = [] + + # --- mon → software --- + for r in range(SW_ROUNDS): + print(f"=== SW R{r} mon->software ===", flush=True) + to_mon() + time.sleep(COOLDOWN) + dt, body, err, ok = patch({"run_mode": "trigger", "trigger_mode": "software"}, timeout=90) + real = bool(ok) and dt >= 0.5 and err is None + if real: + real_applies.append(dt) + print(f" apply={dt:.3f}s ok={ok} real={real} err={err}", flush=True) + time.sleep(0.35) + sh = shoot(f"sw_r{r}", HEAD, "software") + print( + f" first={sh['first_hit']} got={sh['got']} miss={sh['miss']} bad={sh['bad']} " + f"c/s/t={sh['classic']}/{sh['seam']}/{sh['tear']} " + f"uniq={sh['unique_tags']}/{sh['got']} idx={sh['idx']}", + flush=True, + ) + if sh["bots"]: + b = sh["bots"][0] + print( + f" head bot=({b.get('br')},{b.get('bg')},{b.get('bb')}) " + f"above=({b.get('ar')},{b.get('ag')},{b.get('ab')}) reasons={b.get('reasons')}", + flush=True, + ) + summary["sw"].append( + { + "r": r, + "apply_s": round(dt, 3), + "apply_ok": ok, + "real_switch": real, + "apply_err": err, + **{ + k: sh[k] + for k in ( + "first_hit", + "got", + "miss", + "bad", + "classic", + "seam", + "tear", + "idx", + "unique_tags", + "tag_unique_ok", + "dup_tag", + ) + }, + "bots_head": sh["bots"][:2], + "tags_head": sh["tags"][:6], + } + ) + dt2, _, err2, _ = to_mon() + print(f" back mon dt={dt2:.3f} err={err2}", flush=True) + time.sleep(0.3) + + # --- N-1 sequence under software (after fresh enter) --- + print("=== N1 SEQ software ===", flush=True) + to_mon() + time.sleep(COOLDOWN) + dt, _, err, ok = patch({"run_mode": "trigger", "trigger_mode": "software"}) + print(f" enter sw apply={dt:.3f} ok={ok}", flush=True) + time.sleep(0.5) + n1s = n1_sequence("n1_sw", N1_SEQ, "software") + print( + f" n1_sw got={n1s['got']}/{n1s['n']} miss={n1s['miss']} first={n1s['first_hit']} " + f"bad={n1s['bad']} uniq_ok={n1s['tag_unique_ok']} frozen={n1s['frozen_mean']} " + f"pass={n1s['n1_pass']}", + flush=True, + ) + summary["n1"].append({k: v for k, v in n1s.items() if k != "rows"}) + (OUT / "n1_sw_rows.json").write_text( + json.dumps(n1s["rows"], ensure_ascii=False, indent=2), encoding="utf-8" + ) + to_mon() + time.sleep(COOLDOWN) + + # --- mon → line0 --- + for r in range(LINE0_ROUNDS): + print(f"=== LINE0 R{r} mon->line0 ===", flush=True) + to_mon() + time.sleep(COOLDOWN) + # hardware / external line0 mode if supported + dt, body, err, ok = patch( + {"run_mode": "trigger", "trigger_mode": "line0"}, timeout=90 + ) + if not ok: + # fallback common aliases + for alt in ( + {"run_mode": "trigger", "trigger_mode": "hardware"}, + {"run_mode": "trigger", "trigger_mode": "external"}, + {"run_mode": "trigger", "trigger_mode": "xtrig"}, + ): + dt, body, err, ok = patch(alt, timeout=90) + if ok: + print(f" alt_mode={alt} apply={dt:.3f}", flush=True) + break + real = bool(ok) and dt >= 0.5 and err is None + if real: + real_applies.append(dt) + print(f" apply={dt:.3f}s ok={ok} real={real} err={err} body_keys={list(body)[:6] if isinstance(body, dict) else body}", flush=True) + time.sleep(0.4) + # fire via sysfs XTRIG regardless of CamOS mode name + sh = shoot(f"l0_r{r}", HEAD, "line0") + print( + f" first={sh['first_hit']} got={sh['got']} miss={sh['miss']} bad={sh['bad']} " + f"c/s/t={sh['classic']}/{sh['seam']}/{sh['tear']} " + f"uniq={sh['unique_tags']}/{sh['got']} idx={sh['idx']}", + flush=True, + ) + summary["line0"].append( + { + "r": r, + "apply_s": round(dt, 3), + "apply_ok": ok, + "real_switch": real, + "apply_err": err, + **{ + k: sh[k] + for k in ( + "first_hit", + "got", + "miss", + "bad", + "classic", + "seam", + "tear", + "idx", + "unique_tags", + "tag_unique_ok", + ) + }, + "bots_head": sh["bots"][:2], + } + ) + to_mon() + time.sleep(0.3) + + # N-1 under line0 + print("=== N1 SEQ line0 ===", flush=True) + to_mon() + time.sleep(COOLDOWN) + dt, _, err, ok = patch({"run_mode": "trigger", "trigger_mode": "line0"}) + if not ok: + for alt in ( + {"run_mode": "trigger", "trigger_mode": "hardware"}, + {"run_mode": "trigger", "trigger_mode": "external"}, + ): + dt, _, err, ok = patch(alt) + if ok: + break + print(f" enter l0 apply={dt:.3f} ok={ok}", flush=True) + time.sleep(0.5) + n1l = n1_sequence("n1_l0", N1_SEQ, "line0") + print( + f" n1_l0 got={n1l['got']}/{n1l['n']} miss={n1l['miss']} first={n1l['first_hit']} " + f"bad={n1l['bad']} uniq_ok={n1l['tag_unique_ok']} frozen={n1l['frozen_mean']} " + f"pass={n1l['n1_pass']}", + flush=True, + ) + summary["n1"].append({k: v for k, v in n1l.items() if k != "rows"}) + (OUT / "n1_l0_rows.json").write_text( + json.dumps(n1l["rows"], ensure_ascii=False, indent=2), encoding="utf-8" + ) + + # --- ROI under software --- + print("=== ROI WS ===", flush=True) + to_mon() + time.sleep(COOLDOWN) + dt, _, err, ok = patch({"run_mode": "trigger", "trigger_mode": "software"}) + print(f"enter sw for ROI apply={dt:.3f} ok={ok} err={err}", flush=True) + time.sleep(COOLDOWN) + cookie2 = login() + ws = await websockets.connect( + WS_URL, + additional_headers={"Cookie": cookie2}, + open_timeout=8, + ping_interval=20, + max_size=4 * 1024 * 1024, + ) + steps = [ + ("base", None), + ("roi640", {"x": 100, "y": 100, "w": 640, "h": 480}), + ("roi800", {"x": 200, "y": 150, "w": 800, "h": 600}), + ("reset", "reset"), + ("roi512", {"x": 50, "y": 80, "w": 512, "h": 384}), + ("reset2", "reset"), + ] + for name, roi in steps: + t0 = time.time() + try: + if roi == "reset": + await ws_cmd(ws, "rkcam_reset_roi") + elif isinstance(roi, dict): + await ws_cmd(ws, "rkcam_set_roi", roi) + except Exception as e: + print(f" {name} ws_err={e}", flush=True) + roi_dt = time.time() - t0 + time.sleep(4.0) + try: + sh = shoot(f"roi_{name}", ROI_HEAD, "software") + except Exception as e: + print(f" {name} shoot_err={e}", flush=True) + login() + summary["roi"].append({"name": name, "error": str(e), "bad": -1}) + continue + print( + f" {name} roi_cmd={roi_dt:.3f}s dim={sh['dims'][:1]} first={sh['first_hit']} " + f"got={sh['got']} bad={sh['bad']} c/s/t={sh['classic']}/{sh['seam']}/{sh['tear']} " + f"uniq_ok={sh['tag_unique_ok']}", + flush=True, + ) + summary["roi"].append( + { + "name": name, + "roi": roi, + "roi_cmd_s": round(roi_dt, 3), + **{ + k: sh[k] + for k in ( + "first_hit", + "got", + "miss", + "bad", + "classic", + "seam", + "tear", + "idx", + "dims", + "unique_tags", + "tag_unique_ok", + ) + }, + "bots_head": sh["bots"][:2], + } + ) + await ws.close() + to_mon() + + def _sum_bad(rows): + return sum(x.get("bad") or 0 for x in rows if (x.get("bad") or 0) >= 0) + + bad_sw = _sum_bad(summary["sw"]) + bad_l0 = _sum_bad(summary["line0"]) + bad_roi = _sum_bad(summary["roi"]) + bad_n1 = sum(x.get("bad") or 0 for x in summary["n1"]) + all_blocks = summary["sw"] + summary["line0"] + summary["roi"] + firsts = [x.get("first_hit") for x in all_blocks if x.get("first_hit") is not None] + firsts += [x.get("first_hit") for x in summary["n1"] if x.get("first_hit") is not None] + uniq_oks = [ + x.get("tag_unique_ok") + for x in all_blocks + summary["n1"] + if "tag_unique_ok" in x + ] + n1_pass_all = all(x.get("n1_pass") for x in summary["n1"]) if summary["n1"] else False + lat = lat_stats(real_applies) + within_3 = all(v <= 3.0 for v in real_applies) if real_applies else False + within_5 = all(v <= 5.0 for v in real_applies) if real_applies else False + quality_ok = (bad_sw + bad_l0 + bad_roi + bad_n1) == 0 + first_ok = all(f == 1 for f in firsts) if firsts else False + tag_ok = all(uniq_oks) if uniq_oks else False + + summary["totals"] = { + "bad_sw": bad_sw, + "bad_line0": bad_l0, + "bad_roi": bad_roi, + "bad_n1": bad_n1, + "bad_total": bad_sw + bad_l0 + bad_roi + bad_n1, + "classic": sum( + (x.get("classic") or 0) for x in all_blocks + summary["n1"] + ), + "seam": sum((x.get("seam") or 0) for x in all_blocks + summary["n1"]), + "tear": sum((x.get("tear") or 0) for x in all_blocks + summary["n1"]), + "first_all_1": first_ok, + "tag_unique_all": tag_ok, + "n1_pass_all": n1_pass_all, + "apply_latency": lat, + "apply_all_le_3s": within_3, + "apply_all_le_5s": within_5, + "quality_pass": quality_ok and first_ok, + "n1_phase_pass": n1_pass_all and tag_ok, + "latency_pass_hard": within_5 and bool(real_applies), + "latency_pass_goal": within_3 and bool(real_applies), + "dim_changed": any( + (x.get("dims") or [None])[0] not in (None, "1456x1088") + for x in summary["roi"] + if str(x.get("name", "")).startswith("roi") + and not str(x.get("name")).startswith("reset") + ), + "accept_pass": ( + quality_ok + and first_ok + and tag_ok + and n1_pass_all + and within_5 + and bool(real_applies) + ), + } + (OUT / "summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8" + ) + print("BEGIN_SUMMARY_JSON", flush=True) + print(json.dumps(summary["totals"], ensure_ascii=False), flush=True) + print("END_SUMMARY_JSON", flush=True) + print("OUT", OUT, flush=True) + print("ACCEPT", "PASS" if summary["totals"]["accept_pass"] else "FAIL", flush=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/verify_k1301_lat.py b/scripts/verify_k1301_lat.py new file mode 100644 index 0000000000000..44e979ed8a0a5 --- /dev/null +++ b/scripts/verify_k1301_lat.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""#1301 latency + multi-signal quality matrix. + +Measures mon→software apply wall-clock (real switches only: apply_s>=0.5). +Hard limit 5s target 2–3s. Does not lengthen warmup/gate. +CamOS 控制面默认 /ws/cmd(/ws 只读);可用 CAMOS_WS 覆盖。 +""" +from __future__ import annotations + +import asyncio +import importlib.util +import json +import os +import statistics +import sys +import time +from pathlib import Path + +import requests +import websockets + +BASE = os.environ.get("CAMOS_BASE", "http://127.0.0.1:8000") +WS_URL = os.environ.get("CAMOS_WS", "ws://127.0.0.1:8000/ws/cmd") +OUT = Path(os.environ.get("VERIFY_OUT", "/userdata/imx296_warmup_ab/verify_k1301_lat")) +SW_ROUNDS = int(os.environ.get("VERIFY_SW_ROUNDS", "8")) +HEAD = int(os.environ.get("VERIFY_HEAD", "10")) +ROI_HEAD = int(os.environ.get("VERIFY_ROI_HEAD", "8")) +COOLDOWN = float(os.environ.get("VERIFY_COOLDOWN", "5.5")) +USER = os.environ.get("CAMOS_USER", "admin") +PASS = os.environ.get("CAMOS_PASS", "dlcv2026") + +_det = None +for cand in ( + Path(__file__).resolve().parent / "imx296_bottom_quality.py", + Path("/userdata/imx296_warmup_ab/scripts/imx296_bottom_quality.py"), +): + if cand.is_file(): + spec = importlib.util.spec_from_file_location("imx296_bottom_quality", cand) + _det = importlib.util.module_from_spec(spec) + assert spec.loader + spec.loader.exec_module(_det) + break +if _det is None: + sys.exit("imx296_bottom_quality.py missing") + +if OUT.exists(): + import shutil + + shutil.rmtree(OUT) +OUT.mkdir(parents=True) +(OUT / "frames").mkdir() +(OUT / "bad").mkdir() + +s = requests.Session() + + +def login(): + s.post(BASE + "/api/auth/login", json={"username": USER, "password": PASS}, timeout=10) + return "; ".join(f"{c.name}={c.value}" for c in s.cookies) + + +cookie = login() + + +def patch(body, timeout=90): + t0 = time.time() + try: + r = s.patch(BASE + "/api/flow_config", json=body, timeout=timeout) + js = r.json() if r.content else {} + ok = r.status_code < 400 + if isinstance(js, dict): + if "success" in js: + ok = bool(js["success"]) + elif "ok" in js: + ok = bool(js["ok"]) + return time.time() - t0, js, None, ok + except Exception as e: + return time.time() - t0, {}, str(e), False + + +def peek_tag(): + try: + pr = s.get(BASE + "/preview/frame", params={"t": time.time_ns()}, timeout=8) + return int(pr.headers.get("x-frame-tag") or pr.headers.get("X-Frame-Tag") or 0) + except Exception: + return 0 + + +def shoot(label, n, mode="software"): + last = peek_tag() + first = None + stats = { + "got": 0, + "bad": 0, + "classic": 0, + "seam": 0, + "tear": 0, + "idx": [], + "bots": [], + "dims": [], + } + for i in range(n): + try: + if mode == "line0": + Path("/sys/bus/i2c/devices/4-001a/trigger").write_text("1\n") + else: + s.post(BASE + "/debug/trigger_once", timeout=8) + except Exception: + pass + end = time.time() + 2.0 + hit = False + data = b"" + while time.time() < end: + try: + pr = s.get(BASE + "/preview/frame", params={"t": time.time_ns()}, timeout=8) + except Exception: + time.sleep(0.04) + continue + h = pr.headers.get("x-frame-tag") or pr.headers.get("X-Frame-Tag") or "" + try: + tn = int(h) if str(h).isdigit() else -1 + except Exception: + tn = -1 + if pr.status_code == 200 and pr.content and tn != last and tn >= 0: + hit = True + last = tn + data = pr.content + break + time.sleep(0.03) + if first is None and hit: + first = i + 1 + if not hit: + continue + stats["got"] += 1 + (OUT / "frames" / f"{label}_s{i+1:02d}.jpg").write_bytes(data) + a = _det.analyze_jpeg(data) + stats["dims"].append(a.get("dim")) + if len(stats["bots"]) < 3: + stats["bots"].append({"i": i + 1, **a}) + if a.get("green") or a.get("bad"): + stats["bad"] += 1 + stats["classic"] += int(bool(a.get("classic_green"))) + stats["seam"] += int(bool(a.get("seam"))) + stats["tear"] += int(bool(a.get("tear"))) + stats["idx"].append(i + 1) + (OUT / "bad" / f"{label}_s{i+1:02d}.jpg").write_bytes(data) + (OUT / "bad" / f"{label}_s{i+1:02d}.json").write_text( + json.dumps(a, ensure_ascii=False, indent=2), encoding="utf-8" + ) + time.sleep(0.1) + stats["first_hit"] = first + return stats + + +async def ws_cmd(ws, cmd, value=None): + msg = {"cmd": cmd} + if value is not None: + msg["value"] = value + await ws.send(json.dumps(msg)) + for _ in range(3): + try: + await asyncio.wait_for(ws.recv(), timeout=0.25) + except Exception: + break + + +def lat_stats(vals): + vals = [v for v in vals if v is not None] + if not vals: + return {"n": 0} + return { + "n": len(vals), + "min": round(min(vals), 3), + "max": round(max(vals), 3), + "avg": round(sum(vals) / len(vals), 3), + "p50": round(statistics.median(vals), 3), + "all": [round(v, 3) for v in vals], + } + + +async def main(): + summary = { + "kernel": Path("/proc/version").read_text().strip(), + "detector": "imx296_bottom_quality", + "sw": [], + "roi": [], + "targets": {"apply_hard_s": 5.0, "apply_goal_s": [2.0, 3.0]}, + } + + # ensure mon + dt, _, err, ok = patch({"run_mode": "monitor", "trigger_mode": "none"}) + print(f"init mon dt={dt:.3f} ok={ok} err={err}", flush=True) + time.sleep(COOLDOWN) + + real_applies = [] + for r in range(SW_ROUNDS): + print(f"=== SW R{r} mon->software ===", flush=True) + # force mon first if previous left us in trigger + patch({"run_mode": "monitor", "trigger_mode": "none"}) + time.sleep(COOLDOWN) + dt, body, err, ok = patch({"run_mode": "trigger", "trigger_mode": "software"}, timeout=90) + real = bool(ok) and dt >= 0.5 and err is None + if real: + real_applies.append(dt) + print( + f" apply={dt:.3f}s ok={ok} real_switch={real} err={err}", + flush=True, + ) + time.sleep(0.35) + sh = shoot(f"sw_r{r}", HEAD) + print( + f" first={sh['first_hit']} got={sh['got']} bad={sh['bad']} " + f"c/s/t={sh['classic']}/{sh['seam']}/{sh['tear']} idx={sh['idx']}", + flush=True, + ) + if sh["bots"]: + b = sh["bots"][0] + print( + f" head bot=({b.get('br')},{b.get('bg')},{b.get('bb')}) " + f"above=({b.get('ar')},{b.get('ag')},{b.get('ab')}) " + f"reasons={b.get('reasons')}", + flush=True, + ) + summary["sw"].append( + { + "r": r, + "apply_s": round(dt, 3), + "apply_ok": ok, + "real_switch": real, + "apply_err": err, + **{k: sh[k] for k in ("first_hit", "got", "bad", "classic", "seam", "tear", "idx")}, + "bots_head": sh["bots"][:2], + } + ) + dt2, _, err2, _ = patch({"run_mode": "monitor", "trigger_mode": "none"}) + print(f" back mon dt={dt2:.3f} err={err2}", flush=True) + time.sleep(0.3) + + # ROI under software + print("=== ROI WS ===", flush=True) + dt, _, err, ok = patch({"run_mode": "trigger", "trigger_mode": "software"}) + print(f"enter sw for ROI apply={dt:.3f} ok={ok} err={err}", flush=True) + time.sleep(COOLDOWN) + cookie2 = login() + ws = await websockets.connect( + WS_URL, + additional_headers={"Cookie": cookie2}, + open_timeout=8, + ping_interval=20, + max_size=4 * 1024 * 1024, + ) + steps = [ + ("base", None), + ("roi640", {"x": 100, "y": 100, "w": 640, "h": 480}), + ("roi800", {"x": 200, "y": 150, "w": 800, "h": 600}), + ("reset", "reset"), + ("roi512", {"x": 50, "y": 80, "w": 512, "h": 384}), + ("reset2", "reset"), + ] + for name, roi in steps: + t0 = time.time() + try: + if roi == "reset": + await ws_cmd(ws, "rkcam_reset_roi") + elif isinstance(roi, dict): + await ws_cmd(ws, "rkcam_set_roi", roi) + except Exception as e: + print(f" {name} ws_err={e}", flush=True) + roi_dt = time.time() - t0 + time.sleep(4.0) + try: + sh = shoot(f"roi_{name}", ROI_HEAD) + except Exception as e: + print(f" {name} shoot_err={e}", flush=True) + login() + summary["roi"].append({"name": name, "error": str(e), "bad": -1}) + continue + print( + f" {name} roi_cmd={roi_dt:.3f}s dim={sh['dims'][:1]} first={sh['first_hit']} " + f"got={sh['got']} bad={sh['bad']} c/s/t={sh['classic']}/{sh['seam']}/{sh['tear']}", + flush=True, + ) + summary["roi"].append( + { + "name": name, + "roi": roi, + "roi_cmd_s": round(roi_dt, 3), + **{k: sh[k] for k in ("first_hit", "got", "bad", "classic", "seam", "tear", "idx", "dims")}, + "bots_head": sh["bots"][:2], + } + ) + await ws.close() + patch({"run_mode": "monitor", "trigger_mode": "none"}) + + bad_sw = sum(x.get("bad") or 0 for x in summary["sw"] if (x.get("bad") or 0) >= 0) + bad_roi = sum(x.get("bad") or 0 for x in summary["roi"] if (x.get("bad") or 0) >= 0) + firsts = [ + x.get("first_hit") + for x in summary["sw"] + summary["roi"] + if x.get("first_hit") is not None + ] + lat = lat_stats(real_applies) + within_3 = all(v <= 3.0 for v in real_applies) if real_applies else False + within_5 = all(v <= 5.0 for v in real_applies) if real_applies else False + summary["totals"] = { + "bad_sw": bad_sw, + "bad_roi": bad_roi, + "bad_total": bad_sw + bad_roi, + "classic": sum(x.get("classic") or 0 for x in summary["sw"] + summary["roi"]), + "seam": sum(x.get("seam") or 0 for x in summary["sw"] + summary["roi"]), + "tear": sum(x.get("tear") or 0 for x in summary["sw"] + summary["roi"]), + "first_all_1": all(f == 1 for f in firsts) if firsts else False, + "apply_latency": lat, + "apply_all_le_3s": within_3, + "apply_all_le_5s": within_5, + "quality_pass": (bad_sw + bad_roi) == 0 + and (all(f == 1 for f in firsts) if firsts else False), + "latency_pass_hard": within_5 and bool(real_applies), + "latency_pass_goal": within_3 and bool(real_applies), + "dim_changed": any( + (x.get("dims") or [None])[0] not in (None, "1456x1088") + for x in summary["roi"] + if str(x.get("name", "")).startswith("roi") and not str(x.get("name")).startswith("reset") + ), + } + (OUT / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + print("BEGIN_SUMMARY_JSON", flush=True) + print(json.dumps(summary["totals"], ensure_ascii=False), flush=True) + print("END_SUMMARY_JSON", flush=True) + print("OUT", OUT, flush=True) + + +if __name__ == "__main__": + asyncio.run(main())