tools: 新增 MdtInspect —— 对比本 App 与官方照片的差异定位工具

用户反馈"这个 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 专用的)。
This commit is contained in:
ZXCLI
2026-10-05 23:01:31 +08:00
parent ee6859877a
commit 4a60011ed9
2 changed files with 264 additions and 2 deletions
+262
View File
@@ -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 <photo.jpg|.ddt> [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<String> 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.");
}
}
}