From 4a60011ed9c420660a65d060ed222c29e1b3f53d Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Mon, 5 Oct 2026 23:01:31 +0800 Subject: [PATCH] =?UTF-8?q?tools:=20=E6=96=B0=E5=A2=9E=20MdtInspect=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=AF=B9=E6=AF=94=E6=9C=AC=20App=20?= =?UTF-8?q?=E4=B8=8E=E5=AE=98=E6=96=B9=E7=85=A7=E7=89=87=E7=9A=84=E5=B7=AE?= =?UTF-8?q?=E5=BC=82=E5=AE=9A=E4=BD=8D=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈"这个 App 拍出来的照片和官方软件还是有区别",即将提供两张对照照片。 先把这个工具准备好,文件到手即可定位差异出在哪一层,而不是凭肉眼猜。 按三层从外到内报告: 1. 容器层:是否厂商 MDT(JPEG + DDT 块 + 尾)、有哪些块、各多大、RENDER 参数 (翻转/旋转)——先排除结构性差异;校准文件(magic 5AA5000x)会被识别并说明 它描述的是传感器而非场景。 2. 数据层:FRAME(原始测量帧)与 NUC 块的 min/mean/max/spread。**这一层若不同, 差异来自采集(传感器状态/FFC/标定),不是我们的配色映射**。 3. 图像层:JPEG 亮度分位数、均值、Laplacian 锐度。两个文件尺寸一致时还给出 逐项对比(亮度差、对比宽度差、锐度比)。 实测校验:对仓库内两张真机照片输出正常(960x720 rot=0 与 720x960 rot=90, 各块与极值/测点都能读出);对两个标定文件正确识别为"非照片"并跳过对比。 .class 加入 .gitignore(原来是 manual_images 专用的)。 --- .gitignore | 4 +- tools/MdtInspect.java | 262 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 tools/MdtInspect.java diff --git a/.gitignore b/.gitignore index b04a856..6274a86 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,5 @@ __pycache__/ # ZCode 会话目录 .zcode/ -# Java build output for the manual figure generator -docs/android_app/manual_images/*.class +# Java 编译产物(手册配图生成器、tools/ 下的小工具) +*.class diff --git a/tools/MdtInspect.java b/tools/MdtInspect.java new file mode 100644 index 0000000..da4749c --- /dev/null +++ b/tools/MdtInspect.java @@ -0,0 +1,262 @@ +import java.awt.image.BufferedImage; +import java.io.File; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import javax.imageio.ImageIO; + +/** + * Inspect MAG160C photo files — ours and the official app's — and print what is + * needed to explain why two photos of the same scene differ. + * + * It reports three layers, from the outside in: + * 1. CONTAINER — is it the vendor MDT (JPEG + DDT blocks + tail)? which blocks, + * how big. A structural difference is the first thing to rule out. + * 2. DATA — the raw measurement frame and the NUC block: counts min/mean/max. + * If the two files disagree HERE, the difference is upstream of + * rendering (sensor state, FFC, calibration), not our colour mapping. + * 3. IMAGE — JPEG luma percentiles, mean and a Laplacian sharpness figure. + * That is what "the photos look different" usually means. + * + * Usage: + * javac -encoding UTF-8 MdtInspect.java + * java MdtInspect ours.jpg official.jpg + * (one file: prints the report; two files: also prints the comparison) + */ +public class MdtInspect { + + // ---- MDT container magics (little-endian, as stored) ---- + static final int SEC_DDT = 0x5BB5B55B; + static final int SEC_TAIL = 0x5BB5B57B; + static final int B_INFO0 = 0x5BB5B55B; + static final int B_INFO1 = 0x5BB5B55C; + static final int B_FRAME = 0x5BB5B55D; + static final int B_TXT = 0x5BB5B55E; + static final int B_PROBES = 0x5BB5B55F; + static final int B_NUC = 0x5BB5B560; + static final int B_RENDER = 0x5BB5B561; + static final int B_EXTREMES = 0x5BB5B562; + /** Calibration file (not a photo) — worth naming explicitly when handed one. */ + static final int CALI_MAGIC_3 = 0x5AA50003; + static final int CALI_MAGIC_4 = 0x5AA50004; + + public static void main(String[] args) throws Exception { + if (args.length == 0) { + System.out.println("usage: java MdtInspect [second file to compare]"); + return; + } + Report[] reports = new Report[args.length]; + for (int i = 0; i < args.length; i++) { + reports[i] = inspect(new File(args[i])); + System.out.println(reports[i]); + } + if (reports.length == 2) compare(reports[0], reports[1]); + } + + // ------------------------------------------------------------------ model + + static class Report { + String name; + long bytes; + boolean isCali; + boolean isMdt; + int jpegW, jpegH; + int tailOffset = -1; + java.util.List blocks = new java.util.ArrayList<>(); + String probes, text, render, extremes; + int[] rawCounts; // FRAME block, 38400 B as 19200 u16 + int[] nucCounts; // NUC block, same layout + double p1, p25, p50, p75, p99, meanLuma, sharpness; + String note = ""; + + @Override public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("== ").append(name).append(" (").append(bytes).append(" bytes) ==\n"); + if (isCali) { + sb.append(" calibration file (magic 5AA5000x) — not a photo\n"); + return sb.toString(); + } + sb.append(" container: ").append(isMdt ? "vendor MDT" : "plain JPEG (no DDT tail)") + .append(note.isEmpty() ? "" : " [" + note + "]").append('\n'); + sb.append(" JPEG : ").append(jpegW).append("x").append(jpegH).append('\n'); + for (String b : blocks) sb.append(" block : ").append(b).append('\n'); + if (probes != null) sb.append(" probes : ").append(probes).append('\n'); + if (extremes != null) sb.append(" extremes : ").append(extremes).append('\n'); + if (text != null && !text.isBlank()) sb.append(" note : ").append(text).append('\n'); + if (render != null) sb.append(" render : ").append(render).append('\n'); + if (rawCounts != null) sb.append(" raw frame: ").append(stats(rawCounts)).append('\n'); + if (nucCounts != null) sb.append(" NUC data : ").append(stats(nucCounts)).append('\n'); + sb.append(String.format(" luma : p1=%.0f p25=%.0f p50=%.0f p75=%.0f p99=%.0f mean=%.1f sharpness=%.2f%n", + p1, p25, p50, p75, p99, meanLuma, sharpness)); + return sb.toString(); + } + } + + // ---------------------------------------------------------------- parsing + + static Report inspect(File f) throws Exception { + Report r = new Report(); + r.name = f.getName(); + byte[] b = Files.readAllBytes(f.toPath()); + r.bytes = b.length; + if (b.length < 8) { r.note = "too small"; return r; } + + ByteBuffer bb = ByteBuffer.wrap(b).order(ByteOrder.LITTLE_ENDIAN); + int magic = bb.getInt(0); + if (magic == CALI_MAGIC_3 || magic == CALI_MAGIC_4) { + r.isCali = true; + return r; + } + + // --- DDT tail (last 152 bytes) --- + int ddt = -1; + if (b.length >= 152) { + int t = b.length - 152; + if (bb.getInt(t) == SEC_TAIL) { + int off = bb.getInt(t + 4); + if (off > 0 && off + 0x88 <= b.length && bb.getInt(off) == SEC_DDT) { + ddt = off; + r.isMdt = true; + r.tailOffset = t; + } + } + } + if (ddt < 0) { + r.note = "no MDT tail — official files may store the raw frame elsewhere"; + } else { + int bodySize = bb.getInt(ddt + 4); + int p = ddt + 0x88, end = Math.min(b.length - 152, p + bodySize); + while (p + 8 <= end) { + int m = bb.getInt(p), len = bb.getInt(p + 4); + if (len < 0 || p + 8 + len > end) break; + byte[] data = Arrays.copyOfRange(b, p + 8, p + 8 + len); + switch (m) { + case B_INFO0 -> r.blocks.add("INFO0 " + len + " B"); + case B_INFO1 -> r.blocks.add("INFO1 " + len + " B"); + case B_FRAME -> { + r.blocks.add("FRAME(raw) " + len + " B"); + r.rawCounts = toU16(data); + } + case B_TXT -> r.blocks.add("TXT " + len + " B"); + case B_PROBES -> { + r.blocks.add("PROBES " + len + " B"); + r.probes = new String(data, StandardCharsets.UTF_8).trim(); + } + case B_NUC -> { + r.blocks.add("NUC " + len + " B"); + r.nucCounts = toU16(data); + } + case B_RENDER -> { + int flags = len >= 8 ? bb.getInt(p + 8 + 4) : 0; + int rot = len >= 12 ? bb.getInt(p + 8 + 8) : 0; + r.blocks.add("RENDER v" + (len >= 12 ? 2 : 1) + " flags=" + flags + " rot=" + rot); + r.render = "flipH=" + ((flags & 1) != 0) + " flipV=" + ((flags & 2) != 0) + + " rot=" + rot; + } + case B_EXTREMES -> { + r.blocks.add("EXTREMES " + len + " B"); + r.extremes = new String(data, StandardCharsets.UTF_8).trim(); + } + default -> r.blocks.add(String.format("unknown 0x%08X %d B", m, len)); + } + p += 8 + len; + } + } + + // --- the JPEG itself + image statistics --- + BufferedImage im = ImageIO.read(f); + if (im == null) { r.note += " (JPEG not decodable)"; return r; } + r.jpegW = im.getWidth(); + r.jpegH = im.getHeight(); + imageStats(im, r); + return r; + } + + static int[] toU16(byte[] d) { + int n = d.length / 2; + int[] out = new int[n]; + for (int i = 0; i < n; i++) out[i] = (d[2 * i] & 0xFF) | ((d[2 * i + 1] & 0xFF) << 8); + return out; + } + + static String stats(int[] v) { + if (v.length == 0) return "(empty)"; + long sum = 0; int mn = Integer.MAX_VALUE, mx = Integer.MIN_VALUE; + for (int x : v) { sum += x; if (x < mn) mn = x; if (x > mx) mx = x; } + return String.format("%d samples min=%d mean=%.1f max=%d spread=%d", + v.length, mn, sum / (double) v.length, mx, mx - mn); + } + + static void imageStats(BufferedImage im, Report r) { + int w = im.getWidth(), h = im.getHeight(); + int[] lum = new int[w * h]; + long sum = 0; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int c = im.getRGB(x, y); + int l = (((c >> 16) & 0xFF) * 299 + ((c >> 8) & 0xFF) * 587 + (c & 0xFF) * 114) / 1000; + lum[y * w + x] = l; + sum += l; + } + } + r.meanLuma = sum / (double) lum.length; + int[] sorted = lum.clone(); + Arrays.sort(sorted); + r.p1 = pct(sorted, 1); r.p25 = pct(sorted, 25); r.p50 = pct(sorted, 50); + r.p75 = pct(sorted, 75); r.p99 = pct(sorted, 99); + // Laplacian energy: comparable across images of the SAME size, which is how a + // "ours looks softer/sharper" claim gets settled + double acc = 0; long n = 0; + for (int y = 1; y < h - 1; y++) { + for (int x = 1; x < w - 1; x++) { + int i = y * w + x; + int lap = 4 * lum[i] - lum[i - 1] - lum[i + 1] - lum[i - w] - lum[i + w]; + acc += Math.abs(lap); n++; + } + } + r.sharpness = acc / n; + } + + static double pct(int[] sorted, int p) { + return sorted[Math.min(sorted.length - 1, Math.max(0, p * sorted.length / 100))]; + } + + // ------------------------------------------------------------ comparison + + static void compare(Report a, Report b) { + // Nothing to compare unless BOTH are photos that actually decoded: a pair of + // calibration files (or an undecodable JPEG) has no luma figures, and printing + // zeroes would look like a real result. + if (a.isCali || b.isCali || a.jpegW == 0 || b.jpegW == 0) { + System.out.println("---- comparison skipped: not two decodable photos ----"); + if (a.isCali || b.isCali) { + System.out.println(" calibration files describe the SENSOR, not a scene;"); + System.out.println(" compare them byte-wise or with the block report above."); + } + return; + } + System.out.println("---- comparison " + a.name + " vs " + b.name + " ----"); + if (a.jpegW != b.jpegW || a.jpegH != b.jpegH) { + System.out.printf(" size differs: %dx%d vs %dx%d — different framing/resolution,%n" + + " so a pixel-wise diff is meaningless; compare the numbers above instead.%n", + a.jpegW, a.jpegH, b.jpegW, b.jpegH); + } + System.out.printf(" mean luma : %.1f vs %.1f (Δ %+.1f) -> brightness%n", + a.meanLuma, b.meanLuma, b.meanLuma - a.meanLuma); + System.out.printf(" p25/p75 : %.0f/%.0f vs %.0f/%.0f -> contrast width %+.0f%n", + a.p25, a.p75, b.p25, b.p75, (b.p75 - b.p25) - (a.p75 - a.p25)); + System.out.printf(" sharpness : %.2f vs %.2f (ratio %.2f) -> detail/softness%n", + a.sharpness, b.sharpness, b.sharpness / Math.max(1e-9, a.sharpness)); + if (a.rawCounts != null && b.rawCounts != null) { + System.out.println(" raw frame : " + stats(a.rawCounts) + "\n " + stats(b.rawCounts)); + System.out.println(" ^ if these differ, the two captures saw a different sensor state/FFC,"); + System.out.println(" so part of the visual gap is scene/camera, not rendering."); + } else { + System.out.println(" raw frame : " + (a.rawCounts == null ? a.name + " has none; " : "") + + (b.rawCounts == null ? b.name + " has none" : "")); + System.out.println(" ^ without the raw frame of BOTH we cannot separate rendering from capture."); + } + } +}