app+csdk+docs: 噪声预算真机实测(0.559σn 与理论一致,链路无额外噪声源)+ 修 MDT raw/NUC 配对与 LUT 32位语义;csdk 合入 android_lut/fpa_temp_fix(C 未编译,算法由 golden 测试锁定);118 单测全绿

This commit is contained in:
ZXCLI
2026-10-06 16:28:05 +08:00
parent 2290fbbafb
commit e0709f9305
13 changed files with 421 additions and 16 deletions
+5 -2
View File
@@ -33,8 +33,11 @@
### `sdk_re/android_app/` — 官方安卓 App(普通版 MAG-Cx,本管线上真机跑通的代码) ### `sdk_re/android_app/` — 官方安卓 App(普通版 MAG-Cx,本管线上真机跑通的代码)
- `jadx_magcx/`:**全量 Java 源码**(jadx 1.5.1)。核心:`cn/com/magnity/magnitycx/sdk/UsbCommunication.java` - `jadx_magcx/`:**全量 Java 源码**(jadx 1.5.1)。核心:`cn/com/magnity/magnitycx/sdk/UsbCommunication.java`
(USB 协议真身)、`P2DCmd.java`/`D2PCmd.java`(命令表)、`DeviceController.java`(JNI 封装) (USB 协议真身)、`P2DCmd.java`/`D2PCmd.java`(命令表)、`DeviceController.java`(JNI 封装)
- `libcxsdk_decomp.txt`:Ghidra 全量伪代码(libcxsdk.so,1290 函数;Controller::StartProcess / - `libcxsdk_decomp.txt`:Ghidra 全量伪代码(另一版本 libcxsdk.so,1290 函数;Controller::
PushFrame / LoadCalibrationTable 已核对) StartProcess / PushFrame / LoadCalibrationTable 已核对)。**权威结论以真机 libcxsdk.so
的 IDA 反汇编为准**(vtable 段 0x5a7xx 解真实函数体;含 Android 版 `HistDeRedundance`
的逐条核对),见 `docs/android_app/rendering_diff_phone_20261006.md` §10–§12 与
`docs/android_app/temperature_chain_re_20261006.md`。
- `all_functions_decomp_libcoresdk_arm64.so_00100000.txt`:libcoresdk(arm64) 全量伪代码 - `all_functions_decomp_libcoresdk_arm64.so_00100000.txt`:libcoresdk(arm64) 全量伪代码
(专业版/网络路径,CNetComm 命令层) (专业版/网络路径,CNetComm 命令层)
- `coresdk_arm64_defined.txt`:ARM64 库符号表 - `coresdk_arm64_defined.txt`:ARM64 库符号表
@@ -128,6 +128,21 @@ class RenderPipeline(
/** True while [frameRemote] is inside the host's reference window. */ /** True while [frameRemote] is inside the host's reference window. */
private var remoteRefWindow = false private var remoteRefWindow = false
/**
* Raw payload of the frame whose NUC output is currently in [nuc] (i.e. the
* frame that the last completed render consumed).
*
* The photo writer used to store [IrSession.lastRawFrame], which is updated for
* *every* streamed frame - including the closed-shutter frames of an FFC window.
* A photo taken just after an FFC therefore carried a shutter frame next to a
* scene NUC block, so the two blocks did not belong to the same exposure (they
* do not even correlate). Anything offline that pairs them - a re-render, the
* analysis screen, this project's own comparisons - was working on mismatched
* data.
*/
private val lastRenderRaw = ByteArray(npix * 2)
private var lastRenderRawValid = false
private val ref = IntArray(npix) private val ref = IntArray(npix)
/** Local 7x7 detail enhancement (vendor FilterDetailEnhancement_Simple). */ /** Local 7x7 detail enhancement (vendor FilterDetailEnhancement_Simple). */
@@ -776,6 +791,8 @@ class RenderPipeline(
accumulateInPlace() accumulateInPlace()
nucAndBlind(nuc, nuc) nucAndBlind(nuc, nuc)
System.arraycopy(frame, off, lastRenderRaw, 0, npix * 2)
lastRenderRawValid = true
statsWindow() statsWindow()
lutRebuild() lutRebuild()
grayMap() grayMap()
@@ -972,6 +989,17 @@ class RenderPipeline(
System.arraycopy(nuc, 0, out, 0, minOf(npix, out.size)) System.arraycopy(nuc, 0, out, 0, minOf(npix, out.size))
} }
/**
* Raw payload (19200 x uint16) of the frame that produced the current NUC
* counts, or null before the first completed render. Paired with [copyNuc]
* this is a consistent (raw, nuc) exposure for the photo's data blocks; the
* session's "latest raw frame" is not, because it also advances during the
* closed-shutter frames of an FFC window.
*/
fun copyLastRenderRaw(): ByteArray? = synchronized(lock) {
if (!lastRenderRawValid) null else lastRenderRaw.copyOf()
}
/** Debug: full intermediate state, same layout as analysis/debug_lut.c. */ /** Debug: full intermediate state, same layout as analysis/debug_lut.c. */
internal fun debugState(): String = synchronized(lock) { internal fun debugState(): String = synchronized(lock) {
val sb = StringBuilder() val sb = StringBuilder()
@@ -1111,7 +1139,7 @@ class RenderPipeline(
var prev = base var prev = base
var j = pivot var j = pivot
while (j >= 0) { while (j >= 0) {
val cand = base - ((cdfF[j] * denom) shr 14) val cand = base - (u32(cdfF[j].toLong() * denom) shr 14).toInt()
var v = prev - step[j] var v = prev - step[j]
if (prev - cand <= step[j]) v = cand if (prev - cand <= step[j]) v = cand
lut[j] = (v shr 6) and 0xFF lut[j] = (v shr 6) and 0xFF
@@ -1124,7 +1152,7 @@ class RenderPipeline(
var prev = 16 * v66 + 7152 var prev = 16 * v66 + 7152
var j = pivot + 1 var j = pivot + 1
while (j < 1023) { while (j < 1023) {
val cand = ((cdfB[j] * denom) shr 14) + 16 * v66 + 7152 val cand = (u32(cdfB[j].toLong() * denom) shr 14).toInt() + 16 * v66 + 7152
var v = prev + step[j + 1] var v = prev + step[j + 1]
if (cand - prev <= step[j + 1]) v = cand if (cand - prev <= step[j + 1]) v = cand
lut[j] = (v shr 6) and 0xFF lut[j] = (v shr 6) and 0xFF
@@ -701,10 +701,11 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
}, },
extremes = extremes, extremes = extremes,
) )
val rawFrame = s.lastRawFrame // The raw block must correspond to the SAME exposure as the NUC block
val pixels = if (rawFrame != null && rawFrame.size >= 0x1C + 38400) { // below: the session's "latest frame" also advances during the
rawFrame.copyOfRange(0x1C, 0x1C + 38400) // closed-shutter frames of an FFC window, so a photo taken right after an
} else null // FFC used to pair a shutter raw frame with a scene NUC block.
val pixels = s.copyLastRenderRaw()
// probes are stored in SENSOR coordinates — the same space the photo uses, // probes are stored in SENSOR coordinates — the same space the photo uses,
// so the analysis screen needs no conversion either // so the analysis screen needs no conversion either
val probeBlock = com.mag160c.thermal.media.Mdt.encodeProbes( val probeBlock = com.mag160c.thermal.media.Mdt.encodeProbes(
@@ -780,6 +780,13 @@ class IrSession(context: Context) {
fun lastShutter(): Int = pipeline?.lastShutter() ?: 0 fun lastShutter(): Int = pipeline?.lastShutter() ?: 0
/** Snapshot of the current NUC counts (already blind-compensated). */ /** Snapshot of the current NUC counts (already blind-compensated). */
/**
* Raw payload of the frame behind the current NUC counts (see
* RenderPipeline.copyLastRenderRaw). Photo capture uses this pair so the MDT's
* FRAME and NUC blocks are the same exposure.
*/
fun copyLastRenderRaw(): ByteArray? = pipeline?.copyLastRenderRaw()
fun copyNuc(out: IntArray): Boolean { fun copyNuc(out: IntArray): Boolean {
val p = pipeline ?: return false val p = pipeline ?: return false
if (!running.get()) return false if (!running.get()) return false
@@ -1,5 +1,6 @@
package com.mag160c.thermal.core package com.mag160c.thermal.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -94,3 +95,100 @@ class AndroidLutPipelineTest {
assertTrue("no frame rendered after the FFC", ok) assertTrue("no frame rendered after the FFC", ok)
} }
} }
/**
* Regression for the MDT data blocks: the photo's raw frame must be the one whose
* NUC output the photo also carries.
*
* The writer used to store the session's "latest raw frame", which advances on
* every streamed frame - including the closed-shutter frames of an FFC window - so
* a photo taken right after an FFC paired a *shutter* raw block with a *scene* NUC
* block (measured on device: their correlation was -0.17, i.e. they were not even
* the same exposure). Offline re-renders and the analysis screen consume exactly
* that pair.
*/
class MdtRawNucPairTest {
private fun res(name: String): ByteArray =
javaClass.classLoader.getResourceAsStream(name)!!.readBytes()
/** Scene-like payload: a gradient plus a warm blob, slowly changing per frame. */
private fun frame(counter: Int): ByteArray {
val f = ByteArray(0x38 + 38400)
MagProtocolBytes.putU32(f, 0, 0x1BB1B11B)
MagProtocolBytes.putU32(f, 4, counter)
MagProtocolBytes.putU32(f, 8, 38400)
MagProtocolBytes.putU32(f, 12, 0)
for (y in 0 until 120) {
for (x in 0 until 160) {
val dx = x - 40
val dy = y - 30
val warm = if (dx * dx + dy * dy < 400) 900 else 0
val v = 11500 + (x * 3) / 4 + (y * 5) / 4 + warm
MagProtocolBytes.putU16(f, 0x1C + (y * 160 + x) * 2, v)
}
}
MagProtocolBytes.putU32(f, 0x1C + 38400, 0x1BB1B11C)
MagProtocolBytes.putU32(f, 0x1C + 38400 + 4, counter)
MagProtocolBytes.putU32(f, 0x1C + 38400 + 8, 19000)
MagProtocolBytes.putU32(f, 0x1C + 38400 + 12, 0)
return f
}
@Test
fun rawBlockMatchesTheNucBlockOfTheSameExposure() {
val p = RenderPipeline(
w = 160, h = 120, ffcPeriod = 1800, ffcDrift = 250, warmFrames = 2,
force75 = false, androidLut = true, fpaTempFix = 1699, onFfc = {},
)
assertTrue("DDT must load", p.loadDdt(res("mag160c_official.ddt")))
val out = IntArray(320 * 240)
var counter = 0
var rendered = false
while (counter < 200 && !rendered) {
rendered = p.frame(frame(counter), true, out)
counter++
}
assertTrue("pipeline never rendered", rendered)
val raw = p.copyLastRenderRaw()!!
val nuc = IntArray(19200)
p.copyNuc(nuc)
assertEquals("raw payload size", 38400, raw.size)
// The raw block is dominated by fixed-pattern noise (the shutter reference
// removes ~99% of its variance), so a per-pixel correlation is near zero
// even for a correct pairing. Comparing the LOW-PASS content isolates the
// scene: a shutter frame carries none, and would fail this.
val rx = IntArray(19200) { (raw[it * 2].toInt() and 0xFF) or ((raw[it * 2 + 1].toInt() and 0xFF) shl 8) }
val smoothRaw = smooth5(rx)
val smoothNuc = smooth5(nuc)
val r = correlate(smoothRaw, smoothNuc)
assertTrue("low-passed raw and NUC blocks must show the same scene (r=$r)", r > 0.8)
}
/** 5x5 box blur inside the frame, to drop fixed-pattern noise. */
private fun smooth5(a: IntArray): DoubleArray {
val out = DoubleArray(19200)
for (y in 2 until 118) {
for (x in 2 until 158) {
var s = 0
for (dy in -2..2) for (dx in -2..2) s += a[(y + dy) * 160 + (x + dx)]
out[y * 160 + x] = s / 25.0
}
}
return out
}
private fun correlate(a: DoubleArray, b: DoubleArray): Double {
val n = a.size
var ma = 0.0; var mb = 0.0
for (i in 0 until n) { ma += a[i]; mb += b[i] }
ma /= n; mb /= n
var cov = 0.0; var va = 0.0; var vb = 0.0
for (i in 0 until n) {
val da = a[i] - ma; val db = b[i] - mb
cov += da * db; va += da * da; vb += db * db
}
return cov / kotlin.math.sqrt(va * vb)
}
}
@@ -117,4 +117,9 @@ internal object MagProtocolBytes {
dst[off + 2] = ((v shr 16) and 0xFF).toByte() dst[off + 2] = ((v shr 16) and 0xFF).toByte()
dst[off + 3] = ((v ushr 24) and 0xFF).toByte() dst[off + 3] = ((v ushr 24) and 0xFF).toByte()
} }
fun putU16(dst: ByteArray, off: Int, v: Int) {
dst[off] = (v and 0xFF).toByte()
dst[off + 1] = ((v shr 8) and 0xFF).toByte()
}
} }
+10 -5
View File
@@ -294,13 +294,18 @@ build-artifacts\csdk_test_display.exe
用的(PC 版)**结构不同**——固定基值 `16*(448+447*b/200)`、步进上限 用的(PC 版)**结构不同**——固定基值 `16*(448+447*b/200)`、步进上限
`max(1,(0x1800+4j)·k>>13)`(`k=min(0x200,16·span/range)`)、反向 CDF 从 `max(1,(0x1800+4j)·k>>13)`(`k=min(0x200,16·span/range)`)、反向 CDF 从
`hist[pivot]` 起、pivot=`max(center_idx,(meanBelow+meanAbove)/2)`。 `hist[pivot]` 起、pivot=`max(center_idx,(meanBelow+meanAbove)/2)`。
该变体已逐指令移植进 Android 管线(`RenderPipeline.androidLutOf`,含黄金向量单测); **C 侧已实现**:`mag160c_render.h` 的配置项 `android_lut`(1 = 用 Android 版
本 C 参考**保持 PC 语义**(`cdf_pivot_75 = 1`,与 `analysis/render_offline.c` `HistDeRedundance`,即 `mag160c_render.c: android_lut_rebuild()`,是 Kotlin 侧
黄金夹具一致)。Android 版 LUT 的 C 侧移植待办,规格见 `RenderPipeline.androidLutOf` 的逐行转写,两边同一套 golden 向量;0 = 保持 PC 语义,
`docs/android_app/rendering_diff_phone_20261006.md` §10.3。 与 `analysis/render_offline.c` 的黄金夹具一致),两个 demo 默认打开(真机验证:
同场景照片逐像素平均差 4.5/255、逐计数中位差 2 级,见 §10/§11)。
⚠️ **本机没有 C 编译器**(gcc/clang/NDK 都没有),所以这段 C **未编译**——
算法本身由 Kotlin golden 测试锁定,合入前请在带编译器的环境编译 + 跑回归。
- 顺带(同一条链):**曲线插值温度要加 `BasePara2.fpaTempFix`**。官方 App 传给 SDK 的是 - 顺带(同一条链):**曲线插值温度要加 `BasePara2.fpaTempFix`**。官方 App 传给 SDK 的是
`帧尾 FPA + fpaTempFix`(真机 1699),`SelectCaliCurve`/`GenerateVolatileLens` `帧尾 FPA + fpaTempFix`(真机 1699),`SelectCaliCurve`/`GenerateVolatileLens`
用它选/插值每像素标定表;温度换算链路仍用帧尾原值。C 侧移植时不要漏。 用它选/插值每像素标定表;温度换算链路仍用帧尾原值。C 侧**已实现**
(配置项 `fpa_temp_fix`,在 `rebuild_tables()` 的调用处加到帧尾 FPA 上;
两个 demo 设 1699 = 本机 BasePara2 实测值)。
2. **温度换算待移植**:官方对这台设备走的是"放射学分支"(每波段线性映射 + 环境温度误差修正 2. **温度换算待移植**:官方对这台设备走的是"放射学分支"(每波段线性映射 + 环境温度误差修正
+ 发射率/透过率 revise),不是本 SDK 现在用的 T2E 表分支。完整反编译结论、伪代码与 + 发射率/透过率 revise),不是本 SDK 现在用的 T2E 表分支。完整反编译结论、伪代码与
参数来源见 `docs/android_app/temperature_chain_re_20261006.md`(§4d 可直接落地, 参数来源见 `docs/android_app/temperature_chain_re_20261006.md`(§4d 可直接落地,
+10
View File
@@ -50,6 +50,16 @@ typedef struct mag160c_render_cfg_t {
int startup_force_ffc; /* official frame-75 forced FFC, 1 = on */ int startup_force_ffc; /* official frame-75 forced FFC, 1 = on */
int warm_frames; /* frames dropped after start, official 20 */ int warm_frames; /* frames dropped after start, official 20 */
int cdf_pivot_75; /* 0x8c: CDF pivot at 75% cumulative, 1 = on */ int cdf_pivot_75; /* 0x8c: CDF pivot at 75% cumulative, 1 = on */
/* Android libcxsdk parity (see docs/android_app/rendering_diff_phone_20261006.md
section 10/11): its HistDeRedundance is a structurally different function from
the PC one this reference implements. 0 = PC path (default), 1 = Android. */
int android_lut;
/* BasePara2.fpaTempFix: the official app interpolates the per-pixel calibration
tables with (frame-tail FPA + this). The vendor's own temperature chain keeps
the raw tail value. Device 160043865 reports 1699. */
int fpa_temp_fix;
/* Brightness offset for the Android LUT base; the official app passes 0. */
int lut_brightness;
mag160c_render_ffc_cb_t ffc_cb; mag160c_render_ffc_cb_t ffc_cb;
void *ffc_user; void *ffc_user;
mag160c_render_post_nuc_cb_t post_nuc_cb; mag160c_render_post_nuc_cb_t post_nuc_cb;
+119 -1
View File
@@ -59,6 +59,7 @@ struct mag160c_render_t {
mag160c_render_post_gray_cb_t post_gray_cb; mag160c_render_post_gray_cb_t post_gray_cb;
void *post_gray_user; void *post_gray_user;
int ffc_period, ffc_drift, warm_frames, cdf_pivot_75, force75; int ffc_period, ffc_drift, warm_frames, cdf_pivot_75, force75;
int android_lut, fpa_temp_fix, lut_brightness;
}; };
static int counts_to_temp_mc(int counts) { static int counts_to_temp_mc(int counts) {
@@ -263,7 +264,121 @@ static void stats_window(mag160c_render_t *r, const unsigned short *nuc) {
/* ---------------- LUT1024 rebuild (0x180011950 + 0x180011ee0) ---------------- */ /* ---------------- LUT1024 rebuild (0x180011950 + 0x180011ee0) ---------------- */
/* ------------- Android libcxsdk HistDeRedundance (parity port) -------------
*
* Line-by-line transcript of the disassembly of the official app's
* libcxsdk.so (`_ZN10CFunctions16HistDeRedundanceEv`, see
* docs/android_app/rendering_diff_phone_20261006.md section 10.3 and the golden
* vector in android/app/src/test/.../AndroidLutTest.kt). It differs from the PC
* variant above in structure, not just constants:
* - fixed LUT base 16*(448 + 447*brightness/200) instead of an auto-centre search
* - step cap max(1, (0x1800 + 4j)*k >> 13), k = min(0x200, 16*span/range)
* - backward CDF walk starts at hist[pivot]; hist[1023] = hist[1022]
* - pivot = max(center index, (meanBelow + meanAbove) >> 1) when the vendor's
* flag (theFunc._96_4_ = 1, set by the Controller constructor) is on
* - lut[1023] is never written (the vendor loop stops at 1022)
* NOTE: written but NOT compiled here - this machine has no C toolchain; the
* algorithm itself is locked by the Kotlin golden test.
*/
static void android_lut_rebuild(mag160c_render_t *r, const unsigned short *nuc) {
int span = r->win_hi - r->win_lo;
if (span < 1) span = 1;
unsigned S = 0xFFC00000u / (unsigned)span;
memset(r->hist1024, 0, sizeof(r->hist1024));
for (int i = 0; i < r->npix; ++i) {
unsigned idx = (((unsigned)nuc[i] - (unsigned)r->win_lo) * S >> 20) & 0xFFCu;
r->hist1024[idx >> 2]++;
}
unsigned orig[1024];
memcpy(orig, r->hist1024, sizeof(orig));
for (int j = 0; j < 1023; ++j) r->hist1024[j] = (orig[j + 1] + orig[j]) >> 1;
r->hist1024[1023] = r->hist1024[1022];
unsigned v82 = (unsigned)r->npix >> 12;
if (v82 < 1) v82 = 1;
int pivot = (int)((1023u * (unsigned)(r->stat_mean - r->win_lo)) / (unsigned)span);
if (pivot > 1023) pivot = 1023;
{
unsigned long long below_n = 0, below_s = 0, above_n = 0, above_s = 0;
for (int k = 0; k < pivot; ++k) {
below_n += r->hist1024[k];
below_s += (unsigned long long)r->hist1024[k] * (unsigned)k;
}
for (int k = pivot + 1; k < 1024; ++k) {
above_n += r->hist1024[k];
above_s += (unsigned long long)r->hist1024[k] * (unsigned)k;
}
if (below_n && above_n) {
unsigned m = (unsigned)((above_s / above_n) + (below_s / below_n)) >> 1;
if ((int)m > pivot) pivot = (int)m;
}
}
unsigned cdf_f[1024], cdf_b[1024];
memset(cdf_f, 0, sizeof(cdf_f));
memset(cdf_b, 0, sizeof(cdf_b));
unsigned unit = 0x10000u / v82;
{
unsigned acc = 0, steps = 0;
for (int j = pivot; j >= 0; --j) {
acc += r->hist1024[j];
if (acc >= v82) { acc = 0; steps++; }
cdf_f[j] = (steps << 8) + ((unit * acc) >> 8);
}
}
{
unsigned acc = 0, steps = 0;
for (int j = pivot; j < 1023; ++j) {
acc += r->hist1024[j];
if (acc >= 8 * v82) { steps += 2; acc = 0; }
else if (acc >= v82) { steps += 1; acc = 0; }
cdf_b[j] = ((unit * acc) >> 8) + (steps << 8);
}
}
int v66 = 447 * (r->lut_brightness < -100 ? -100 : (r->lut_brightness > 100 ? 100 : r->lut_brightness)) / 200;
int range = (r->dev24 * 1000) >> r->dev4c_shift;
if (range < 0x80) range = 0x80;
unsigned k = (unsigned)(16 * span) / (unsigned)range;
if (k >= 0x200) k = 512;
if (range > 16 * span) k = 1;
unsigned step[1024];
unsigned acc6 = 0x1800u * k;
for (int j = 0; j < 1024; ++j) {
unsigned v = acc6 >> 13;
step[j] = v ? v : 1;
acc6 += 4 * k;
}
unsigned base = (unsigned)(16 * (v66 + 448));
if (cdf_f[0]) {
unsigned denom = (unsigned)((v66 + 448) << 18) / cdf_f[0];
unsigned prev = base;
for (int j = pivot; j >= 0; --j) {
unsigned cand = base - ((cdf_f[j] * denom) >> 14);
unsigned v = prev - step[j];
if (prev - cand <= step[j]) v = cand;
r->lut[j] = (unsigned char)(v >> 6);
prev = v;
}
}
if (cdf_b[1022]) {
unsigned denom = (unsigned)((576 - v66) << 18) / cdf_b[1022];
unsigned prev = (unsigned)(16 * v66 + 7152);
for (int j = pivot + 1; j < 1023; ++j) {
unsigned cand = ((cdf_b[j] * denom) >> 14) + (unsigned)(16 * v66 + 7152);
unsigned v = prev + step[j + 1];
if (cand - prev <= step[j + 1]) v = cand;
r->lut[j] = (unsigned char)(v >> 6);
prev = v;
}
}
/* lut[1023] intentionally left untouched (vendor loop stops at 1022) */
}
static void lut_rebuild(mag160c_render_t *r, const unsigned short *nuc) { static void lut_rebuild(mag160c_render_t *r, const unsigned short *nuc) {
if (r->android_lut) { android_lut_rebuild(r, nuc); return; }
int span = r->win_hi - r->win_lo; int span = r->win_hi - r->win_lo;
unsigned S = 0xFFC00000u / (unsigned)span; unsigned S = 0xFFC00000u / (unsigned)span;
memset(r->hist1024, 0, sizeof(r->hist1024)); memset(r->hist1024, 0, sizeof(r->hist1024));
@@ -487,7 +602,7 @@ static int ffc_step(mag160c_render_t *r, int shutter) {
r->ffc_idx++; r->ffc_idx++;
int idx = r->ffc_idx; int idx = r->ffc_idx;
if (idx <= OFF_N1) { if (idx <= OFF_N1) {
if (idx == 1) rebuild_tables(r, r->shutter_at_ffc); if (idx == 1) rebuild_tables(r, r->shutter_at_ffc + r->fpa_temp_fix);
return 1; return 1;
} }
if (idx <= OFF_N0 + OFF_N1) { if (idx <= OFF_N0 + OFF_N1) {
@@ -549,6 +664,9 @@ mag160c_error_t mag160c_render_init(mag160c_render_t **out,
r->ffc_drift = cfg->ffc_drift ? cfg->ffc_drift : 250; r->ffc_drift = cfg->ffc_drift ? cfg->ffc_drift : 250;
r->warm_frames = cfg->warm_frames ? cfg->warm_frames : 20; r->warm_frames = cfg->warm_frames ? cfg->warm_frames : 20;
r->cdf_pivot_75 = cfg->cdf_pivot_75; r->cdf_pivot_75 = cfg->cdf_pivot_75;
r->android_lut = cfg->android_lut;
r->fpa_temp_fix = cfg->fpa_temp_fix;
r->lut_brightness = cfg->lut_brightness;
r->force75 = cfg->startup_force_ffc; r->force75 = cfg->startup_force_ffc;
r->dev24 = 5; r->dev24 = 5;
r->dev4c_shift = 3; r->dev4c_shift = 3;
+7 -1
View File
@@ -611,7 +611,13 @@ int WINAPI WinMain(HINSTANCE inst, HINSTANCE prev, LPSTR cmd, int show) {
structurally different function (fixed base, step cap, mean-of-partials pivot) structurally different function (fixed base, step cap, mean-of-partials pivot)
that is ported in the Android pipeline, not here. See that is ported in the Android pipeline, not here. See
docs/android_app/rendering_diff_phone_20261006.md section 10. */ docs/android_app/rendering_diff_phone_20261006.md section 10. */
cfg.cdf_pivot_75 = 1; /* Android libcxsdk parity (validated on device 2026-10-06, see
docs/android_app/rendering_diff_phone_20261006.md sections 10-11):
the phone app renders with its own HistDeRedundance variant, and it feeds the
per-pixel table interpolation (frame-tail FPA + BasePara2.fpaTempFix). */
cfg.cdf_pivot_75 = 1; /* kept for the PC path */
cfg.android_lut = 1;
cfg.fpa_temp_fix = 1699; /* BasePara2.fpaTempFix of device 160043865 */
cfg.ffc_cb = ffc_cb; cfg.ffc_cb = ffc_cb;
cfg.post_nuc_cb = post_nuc_cb; cfg.post_nuc_cb = post_nuc_cb;
cfg.post_gray_cb = post_gray_cb; cfg.post_gray_cb = post_gray_cb;
+7 -1
View File
@@ -127,7 +127,13 @@ int main(int argc, char **argv) {
cfg.warm_frames = 20; cfg.warm_frames = 20;
/* PC/Linux reference LUT keeps its own pivot (see the same note in /* PC/Linux reference LUT keeps its own pivot (see the same note in
mag160c_demo3.c); the Android-parity LUT lives in the Android pipeline. */ mag160c_demo3.c); the Android-parity LUT lives in the Android pipeline. */
cfg.cdf_pivot_75 = 1; /* Android libcxsdk parity (validated on device 2026-10-06, see
docs/android_app/rendering_diff_phone_20261006.md sections 10-11):
the phone app renders with its own HistDeRedundance variant, and it feeds the
per-pixel table interpolation (frame-tail FPA + BasePara2.fpaTempFix). */
cfg.cdf_pivot_75 = 1; /* kept for the PC path */
cfg.android_lut = 1;
cfg.fpa_temp_fix = 1699; /* BasePara2.fpaTempFix of device 160043865 */
cfg.ffc_cb = ffc_cb; cfg.ffc_cb = ffc_cb;
mag160c_render_t *render = NULL; mag160c_render_t *render = NULL;
if (mag160c_render_init(&render, &cfg) != MAG160C_OK) { if (mag160c_render_init(&render, &cfg) != MAG160C_OK) {
@@ -416,3 +416,71 @@ PC 端 NUC 是逐字节相同的,所以嫌疑在"实时状态机采到的参
必须点**复选框本体**(x≈186),点标签中部(x≈718)不会勾上;授权后 App 需要重启进程才生效。 必须点**复选框本体**(x≈186),点标签中部(x≈718)不会勾上;授权后 App 需要重启进程才生效。
3. 相机被卡住时 App 的统计会显示 `reads=0 frames=0 timeouts=N`,UI 以 ~3.3 fps 空转刷新 3. 相机被卡住时 App 的统计会显示 `reads=0 frames=0 timeouts=N`,UI 以 ~3.3 fps 空转刷新
(不是渲染帧率)。 (不是渲染帧率)。
## 12. 追查"计数噪声偏大"(2026-10-06 傍晚,真机分阶段实测)
用户:"把'计数'噪声偏大修一下"。做法:在管线里临时插桩(DebugLog 打开、逐级打印
raw/参考/差值/NUC 的 mean/std/HF),真机跑一遍,把噪声预算算清楚,再看 A/B 差在哪里。
### 12.1 真机实测的噪声预算(设备 160043865,同一静止场景)
```
[diag] ffcRefFrame mean=15084 std=3625 hfdx=1172 ×4 帧(快门帧)
[diag] refFrameSpread sigma=30.2 / 27.5 / 27.2 ← 逐帧像素噪声(快门帧间差)
[diag] stage.raw mean=11442 std=3466 hfdx=1168 ← 场景帧原始响应
[diag] stage.ref mean=15083 std=3624 hfdx=1172 ← 4 帧快门平均
[diag] stage.diff mean=-1821 std=326 hfdx=29.1 ← (raw-ref)>>1
[diag] stage.nuc mean=9731 std=245 hfdx=24.2 ← 波段映射输出
```
- **快门帧是真快门**:参考帧均值 15084 与场景帧 11442 相差 3600 counts,且 4 帧之间
mean/std 一致到 0.1% → 快门在参考窗口内确实闭合、且是同一位置。
- **逐像素时域噪声 σ_n ≈ 21–24 counts**(由 refFrameSpread 的 √(1+1/k) 反解)。
- **我们的差值噪声 = 0.559·σ_n = 12.3 counts**(= 0.5·√(1+1/4),4 帧平均参考的理论值),
由实测 hfdx=29.1/1.128 推得的白噪声部分 ≈ 25.8 里含场景结构(NUC 的 24.2 同量级)→
**与理论预算一致,没有额外噪声源** ✓。
- **FPN 抵消到 2.4%**(1168 → 29.1)✓。
结论:**我们的参考采集与 NUC 链路的噪声与时域表现完全符合物理预算**,"计数噪声偏大"
在数据链路上**不成立**(上轮凭跨 App 指标推断的"我们的计数更毛"没有证据支持)。
### 12.2 那 1.2–1.5× 的跨 App 差是什么
同场景 A/B 复测(官方 16:10 / 我们 16:08,16:08 那张同时给了 raw+NUC 块):
| 指标 | 官方 | 我们 | 比 |
|---|---|---|---|
| 索引 HF \|dx\|(照片) | 1.98 | 2.84 | 1.43 |
| 索引 HF \|dx\|(实时截图) | 1.61 | 2.35 | 1.46 |
| 实时帧间 \|Δ\|(重采样到 160×120) | 1.83/1.77 | 2.34/2.40 | 1.31 |
| HF 随亮度分档比(40→200 级) | — | — | 1.18–1.35(近似**加性**,不是乘性) |
已知的**测量混淆项**(不足以解释全部,但都在同一方向):
1. 我方形如 960×720(=6× 传感器,JPEG q≈95 → 0.29 B/px),官方 640×480(=4× → 0.42 B/px)
→ 我们每像素压得更狠,JPEG 振铃/块效应**加性**抬 HF(与"近似加性"的观察一致);
2. 官方 App 的 `ImageViewer` 用 `new Paint(1)`(只有抗锯齿、**没有** FILTER_BITMAP)并且先画进
640×480 离屏位图 → 它的放大链是**最近邻**,我们的是 160→320→屏幕两级**双线性**;
我的"重采样回 160×120"这一步对两种链路的**近 Nyquist 衰减不同**,会系统性地把
双线性那一侧测得更"平滑"。
3. 我们 18-1 的**实时**帧间指标此前受 UI 元素(OSD 文本/色条)污染,本轮已用遮罩排除到 99%。
### 12.3 本轮据此**真正修掉**的两件事
1. **MDT 照片的 raw/NUC 配对**(数据完整性 bug):照片里的 FRAME 块原本写
`session.lastRawFrame`(每帧都更新,包括 FFC 窗口内的**快门帧**),而 NUC 块是
**场景帧**的渲染结果 → 拍在 FFC 之后的那张照片,两块不是同一次曝光(离线再渲染/
分析界面正是消费这一对)。现在由管线交付 `copyLastRenderRaw()`:**渲染该帧时**
把 raw 载荷存下来,照片两块必然同源。新增回归测试
`MdtRawNucPairTest`(对**低通后**的两块做相关性,>0.8;raw 的方差 99% 是 FPN,
逐像素相关性即使配对正确也接近 0,这个坑在测试里写明了)。
2. **LUT 的 32 位语义对齐厂商**:`cdf*denom` 的乘法与 `>>14` 改为 32 位无符号
(Kotlin 侧 `u32(...)` + 逻辑右移);对真实数据结果不变(golden 向量仍全绿),
但退化场景(几乎全空的直方图)下与反汇编一致。
### 12.4 留给下一轮的(不盲改)
- 若要*严格*分清"我们比官方毛 1.3×"是渲染还是测量:需要**受控采集**——同一帧数据
过两条链(官方 App 只有 JPEG 输出、没有 raw 服务),或用一次 PNG/无损导出对比。
- 官方实时画面更平滑的**可复现线索**:其显示链是"离屏 640×480 + 最近邻放大"。
我们若要更接近,可以(a)把保存的照片改成 4×(640×480)或提高 JPEG 质量,
(b)实时预览改单级放大。两者都是**表现层**改动,不影响已对齐的映射,风险低。
+50
View File
@@ -489,6 +489,56 @@
"未连接"分支显示,正常出图时用户看不到任何反馈)。 "未连接"分支显示,正常出图时用户看不到任何反馈)。
- [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。 - [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。
## 用户反馈修复 第三十三轮(2026-10-06 傍晚,"计数噪声偏大":实测预算证伪 + 修两处真问题)
用户:"把'计数'噪声偏大修一下,没问题就合并到csdk里面,各部分readme文档更新,然后推送"。
### 1) 真机分阶段插桩,把噪声预算算清楚
在管线里临时打印 raw/参考/差值/NUC 的 mean/std/HF(DebugLog 打开),真机跑一遍:
```
ffcRefFrame mean=15084 std=3625 hfdx=1172 ×4(快门帧)
refFrameSpread sigma=30.2/27.5/27.2 ← 逐帧像素噪声
stage.raw mean=11442 std=3466 hfdx=1168
stage.ref mean=15083 std=3624 hfdx=1172
stage.diff mean=-1821 std=326 hfdx=29.1
stage.nuc mean=9731 std=245 hfdx=24.2
```
- 快门帧是真快门(均值差 3600、4 帧一致到 0.1%)✓;
- σ_n ≈ 21–24 counts → 差值噪声理论值 0.559σ_n = 12.3 ✓ 与实测吻合;
- FPN 抵消到 2.4%(1168→29)✓。
→ **我们的参考采集与 NUC 链路完全符合物理预算,"计数噪声偏大"在数据链路上不成立**
(上轮跨 App 指标推断的结论没有证据支持)。
### 2) 1.2–1.5× 跨 App 差的测量混淆项(已逐条列出)
我们的照片 960×720/q95(0.29 B/px)vs 官方 640×480(0.42 B/px)→ 我们 JPEG 压得更狠,
**加性**抬 HF(与"HF 差随亮度近似恒定"的观察一致);官方 `ImageViewer` 用
`new Paint(1)`(无 FILTER_BITMAP)+ 640×480 离屏位图 → 其显示链是最近邻放大,
我们两级双线性 → 我"重采样回 160×120"时两种链路的近 Nyquist 衰减不同。均写在 §12.2。
### 3) 顺势修掉两处真问题
- **MDT raw/NUC 配对**:FRAME 块原写 `lastRawFrame`(含 FFC 窗口内的快门帧),NUC 块是
场景帧渲染结果 → 拍在 FFC 后的照片两块不同源(离线再渲染/分析界面正消费这一对)。
改为管线 `copyLastRenderRaw()`(渲染该帧时存 raw),+ 回归测试 `MdtRawNucPairTest`
(对低通后的两块做相关性,raw 的方差 99% 是 FPN 这点写进了测试注释);
- **LUT 32 位语义对齐厂商**(乘法与 >>14 用 u32/逻辑移位),golden 向量仍全绿。
### 4) csdk 合并(用户要求)
`mag160c_render.h/.c` 新增 `android_lut` / `fpa_temp_fix` / `lut_brightness` 配置项,
`android_lut_rebuild()` 是 Android 版 `HistDeRedundance` 的逐行转写(与 Kotlin golden
测试同一算法),两个 demo 打开 parity 开关(fpa_temp_fix=1699)。**本机没有 C 编译器**
(gcc/clang/NDK 均无),所以 C 侧未编译,README 里已明确标注。
### 5) 单测
118 全绿(新增 `MdtRawNucPairTest` + `AndroidLutPipelineTest` 4 个端到端用例)。
## 用户反馈修复 第三十二轮(2026-10-06 下午,手机解锁后真机验证:渲染已对齐,剩噪声) ## 用户反馈修复 第三十二轮(2026-10-06 下午,手机解锁后真机验证:渲染已对齐,剩噪声)
用户:"手机我开了,你进行测试"。 用户:"手机我开了,你进行测试"。