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:
+5
-2
@@ -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
@@ -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 可直接落地,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)实时预览改单级放大。两者都是**表现层**改动,不影响已对齐的映射,风险低。
|
||||||
|
|||||||
@@ -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 下午,手机解锁后真机验证:渲染已对齐,剩噪声)
|
||||||
|
|
||||||
用户:"手机我开了,你进行测试"。
|
用户:"手机我开了,你进行测试"。
|
||||||
|
|||||||
Reference in New Issue
Block a user