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:
+2
-2
@@ -37,5 +37,5 @@ __pycache__/
|
|||||||
# ZCode 会话目录
|
# ZCode 会话目录
|
||||||
.zcode/
|
.zcode/
|
||||||
|
|
||||||
# Java build output for the manual figure generator
|
# Java 编译产物(手册配图生成器、tools/ 下的小工具)
|
||||||
docs/android_app/manual_images/*.class
|
*.class
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user