Files
issue-hub-ai/scripts/visual-diff.mjs
T
2026-07-07 07:27:13 +08:00

444 lines
15 KiB
JavaScript

import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { deflateSync, inflateSync } from "node:zlib";
const designDir = path.resolve("docs/design/images2");
const implementationDir = path.resolve("docs/design/implementation-screenshots");
const outputDir = path.resolve("docs/design/visual-diff");
const threshold = 24;
const gridColumns = 12;
const gridRows = 8;
const pageNames = [
"dispatcher-01-center-dashboard.png",
"dispatcher-02-issue-pool.png",
"dispatcher-03-department-queue.png",
"dispatcher-04-escalation-center.png",
"dispatcher-05-knowledge-review.png",
"dispatcher-06-rule-config.png",
"dispatcher-07-analytics-board.png",
"client-01-workbench.png",
"client-02-submit-issue.png",
"client-03-issue-detail-handler.png",
"client-04-submitter-tracking.png",
"client-05-mobile-submit.png"
];
function main() {
mkdirSync(outputDir, { recursive: true });
const availableNames = pageNames.filter((fileName) => existsSync(path.join(designDir, fileName)) && existsSync(path.join(implementationDir, fileName)));
const missingNames = pageNames.filter((fileName) => !availableNames.includes(fileName));
const results = availableNames.map((fileName) => comparePage(fileName));
writeReport(results, missingNames);
const failed = results.filter((result) => result.status === "needs-polish").length;
const review = results.filter((result) => result.status === "review").length;
console.log(`Visual diff complete. Compared ${results.length} pages. ${failed} need polish, ${review} need review.`);
console.log(`Report: ${path.relative(process.cwd(), path.join(outputDir, "report.md"))}`);
}
function comparePage(fileName) {
const designPath = path.join(designDir, fileName);
const implementationPath = path.join(implementationDir, fileName);
const expected = readPng(designPath);
let actual = readPng(implementationPath);
const resized = expected.width !== actual.width || expected.height !== actual.height;
if (resized) {
actual = resizeImage(actual, expected.width, expected.height);
}
const diff = Buffer.alloc(expected.width * expected.height * 4);
const grid = createGrid();
let changedPixels = 0;
let deltaTotal = 0;
let deltaSquaredTotal = 0;
let maxDelta = 0;
for (let y = 0; y < expected.height; y += 1) {
for (let x = 0; x < expected.width; x += 1) {
const offset = (y * expected.width + x) * 4;
const dr = expected.data[offset] - actual.data[offset];
const dg = expected.data[offset + 1] - actual.data[offset + 1];
const db = expected.data[offset + 2] - actual.data[offset + 2];
const delta = Math.sqrt((dr * dr + dg * dg + db * db) / 3);
const changed = delta > threshold;
if (changed) changedPixels += 1;
deltaTotal += delta;
deltaSquaredTotal += delta * delta;
if (delta > maxDelta) maxDelta = delta;
const cell = gridCellFor(x, y, expected.width, expected.height);
grid[cell].deltaTotal += delta;
grid[cell].changedPixels += changed ? 1 : 0;
grid[cell].pixels += 1;
writeDiffPixel(diff, offset, actual.data, delta, changed);
}
}
const pixelCount = expected.width * expected.height;
const meanDelta = deltaTotal / pixelCount;
const rmsDelta = Math.sqrt(deltaSquaredTotal / pixelCount);
const changedRatio = changedPixels / pixelCount;
const diffFileName = fileName.replace(/\.png$/, "-diff.png");
writePng(path.join(outputDir, diffFileName), {
width: expected.width,
height: expected.height,
data: diff
});
return {
fileName,
width: expected.width,
height: expected.height,
resized,
meanDelta,
rmsDelta,
changedRatio,
maxDelta,
status: classify(meanDelta, changedRatio),
diffFileName,
hotspots: topHotspots(grid)
};
}
function readPng(filePath) {
const bytes = readFileSync(filePath);
const signature = "89504e470d0a1a0a";
if (bytes.subarray(0, 8).toString("hex") !== signature) {
throw new Error(`Not a PNG file: ${filePath}`);
}
let offset = 8;
let header;
const idatChunks = [];
while (offset < bytes.length) {
const length = bytes.readUInt32BE(offset);
const type = bytes.subarray(offset + 4, offset + 8).toString("ascii");
const data = bytes.subarray(offset + 8, offset + 8 + length);
offset += 12 + length;
if (type === "IHDR") {
header = {
width: data.readUInt32BE(0),
height: data.readUInt32BE(4),
bitDepth: data[8],
colorType: data[9],
compression: data[10],
filter: data[11],
interlace: data[12]
};
} else if (type === "IDAT") {
idatChunks.push(data);
} else if (type === "IEND") {
break;
}
}
if (!header) throw new Error(`PNG is missing IHDR: ${filePath}`);
if (header.bitDepth !== 8 || (header.colorType !== 2 && header.colorType !== 6)) {
throw new Error(`Unsupported PNG format in ${filePath}. Expected 8-bit RGB/RGBA, got bit=${header.bitDepth}, color=${header.colorType}.`);
}
if (header.compression !== 0 || header.filter !== 0 || header.interlace !== 0) {
throw new Error(`Unsupported PNG encoding in ${filePath}. Interlaced images are not supported.`);
}
const channels = header.colorType === 6 ? 4 : 3;
const stride = header.width * channels;
const raw = inflateSync(Buffer.concat(idatChunks));
const pixels = Buffer.alloc(header.width * header.height * 4);
let inputOffset = 0;
let previous = Buffer.alloc(stride);
for (let y = 0; y < header.height; y += 1) {
const filterType = raw[inputOffset];
inputOffset += 1;
const encoded = raw.subarray(inputOffset, inputOffset + stride);
inputOffset += stride;
const decoded = unfilterScanline(encoded, previous, filterType, channels);
for (let x = 0; x < header.width; x += 1) {
const source = x * channels;
const target = (y * header.width + x) * 4;
pixels[target] = decoded[source];
pixels[target + 1] = decoded[source + 1];
pixels[target + 2] = decoded[source + 2];
pixels[target + 3] = channels === 4 ? decoded[source + 3] : 255;
}
previous = decoded;
}
return {
width: header.width,
height: header.height,
data: pixels
};
}
function unfilterScanline(encoded, previous, filterType, bytesPerPixel) {
const decoded = Buffer.alloc(encoded.length);
for (let index = 0; index < encoded.length; index += 1) {
const left = index >= bytesPerPixel ? decoded[index - bytesPerPixel] : 0;
const up = previous[index] || 0;
const upLeft = index >= bytesPerPixel ? previous[index - bytesPerPixel] || 0 : 0;
let value;
switch (filterType) {
case 0:
value = encoded[index];
break;
case 1:
value = encoded[index] + left;
break;
case 2:
value = encoded[index] + up;
break;
case 3:
value = encoded[index] + Math.floor((left + up) / 2);
break;
case 4:
value = encoded[index] + paethPredictor(left, up, upLeft);
break;
default:
throw new Error(`Unsupported PNG filter type: ${filterType}`);
}
decoded[index] = value & 255;
}
return decoded;
}
function paethPredictor(left, up, upLeft) {
const estimate = left + up - upLeft;
const leftDistance = Math.abs(estimate - left);
const upDistance = Math.abs(estimate - up);
const upLeftDistance = Math.abs(estimate - upLeft);
if (leftDistance <= upDistance && leftDistance <= upLeftDistance) return left;
if (upDistance <= upLeftDistance) return up;
return upLeft;
}
function writePng(filePath, image) {
const rowLength = image.width * 4;
const raw = Buffer.alloc((rowLength + 1) * image.height);
let rawOffset = 0;
for (let y = 0; y < image.height; y += 1) {
raw[rawOffset] = 0;
rawOffset += 1;
image.data.copy(raw, rawOffset, y * rowLength, y * rowLength + rowLength);
rawOffset += rowLength;
}
const chunks = [
pngChunk("IHDR", createIhdr(image.width, image.height)),
pngChunk("IDAT", deflateSync(raw)),
pngChunk("IEND", Buffer.alloc(0))
];
writeFileSync(filePath, Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), ...chunks]));
}
function createIhdr(width, height) {
const data = Buffer.alloc(13);
data.writeUInt32BE(width, 0);
data.writeUInt32BE(height, 4);
data[8] = 8;
data[9] = 6;
data[10] = 0;
data[11] = 0;
data[12] = 0;
return data;
}
function pngChunk(type, data) {
const typeBytes = Buffer.from(type, "ascii");
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 0);
return Buffer.concat([length, typeBytes, data, crc]);
}
const crcTable = Array.from({ length: 256 }, (_, index) => {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
return value >>> 0;
});
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function resizeImage(image, targetWidth, targetHeight) {
const output = Buffer.alloc(targetWidth * targetHeight * 4);
const xScale = image.width / targetWidth;
const yScale = image.height / targetHeight;
for (let y = 0; y < targetHeight; y += 1) {
const sourceY = (y + 0.5) * yScale - 0.5;
const y0 = clamp(Math.floor(sourceY), 0, image.height - 1);
const y1 = clamp(y0 + 1, 0, image.height - 1);
const yMix = sourceY - y0;
for (let x = 0; x < targetWidth; x += 1) {
const sourceX = (x + 0.5) * xScale - 0.5;
const x0 = clamp(Math.floor(sourceX), 0, image.width - 1);
const x1 = clamp(x0 + 1, 0, image.width - 1);
const xMix = sourceX - x0;
const target = (y * targetWidth + x) * 4;
for (let channel = 0; channel < 4; channel += 1) {
const a = image.data[(y0 * image.width + x0) * 4 + channel];
const b = image.data[(y0 * image.width + x1) * 4 + channel];
const c = image.data[(y1 * image.width + x0) * 4 + channel];
const d = image.data[(y1 * image.width + x1) * 4 + channel];
const top = a + (b - a) * xMix;
const bottom = c + (d - c) * xMix;
output[target + channel] = clamp(Math.round(top + (bottom - top) * yMix), 0, 255);
}
}
}
return {
width: targetWidth,
height: targetHeight,
data: output
};
}
function writeDiffPixel(diff, offset, actualData, delta, changed) {
if (changed) {
diff[offset] = 255;
diff[offset + 1] = clamp(Math.round(220 - delta), 20, 190);
diff[offset + 2] = clamp(Math.round(80 + delta), 120, 255);
diff[offset + 3] = 255;
return;
}
const gray = Math.round(actualData[offset] * 0.299 + actualData[offset + 1] * 0.587 + actualData[offset + 2] * 0.114);
diff[offset] = gray;
diff[offset + 1] = gray;
diff[offset + 2] = gray;
diff[offset + 3] = 255;
}
function createGrid() {
return Array.from({ length: gridColumns * gridRows }, () => ({
deltaTotal: 0,
changedPixels: 0,
pixels: 0
}));
}
function gridCellFor(x, y, width, height) {
const column = Math.min(gridColumns - 1, Math.floor((x / width) * gridColumns));
const row = Math.min(gridRows - 1, Math.floor((y / height) * gridRows));
return row * gridColumns + column;
}
function topHotspots(grid) {
return grid
.map((cell, index) => ({
index,
row: Math.floor(index / gridColumns) + 1,
column: (index % gridColumns) + 1,
meanDelta: cell.deltaTotal / Math.max(1, cell.pixels),
changedRatio: cell.changedPixels / Math.max(1, cell.pixels)
}))
.sort((a, b) => b.meanDelta - a.meanDelta)
.slice(0, 3)
.map((cell) => `${zoneLabel(cell.column, cell.row)} ${formatNumber(cell.meanDelta)} / ${formatPercent(cell.changedRatio)}`);
}
function zoneLabel(column, row) {
const horizontal = column <= 4 ? "left" : column <= 8 ? "center" : "right";
const vertical = row <= 3 ? "top" : row <= 5 ? "middle" : "bottom";
return `${vertical}-${horizontal}`;
}
function classify(meanDelta, changedRatio) {
if (meanDelta <= 8 && changedRatio <= 0.1) return "close";
if (meanDelta <= 18 && changedRatio <= 0.25) return "review";
return "needs-polish";
}
function writeReport(results, missingNames) {
const lines = [
"# Issue Hub AI 视觉差异报告",
"",
`生成时间:${new Date().toISOString()}`,
"",
"## 方法",
"",
`- 对比对象:\`docs/design/images2/\` 高保真设计图 vs \`docs/design/implementation-screenshots/\` 当前实现截图。`,
`- 差异阈值:单像素 RGB 均方差大于 ${threshold} 记为变更像素。`,
"- 输出:每页一张高亮差异图,灰色代表低差异区域,粉色代表明显差异区域。",
"- 用途:这是视觉打磨诊断工具,不替代人工设计评审;AI 生成稿中的文案、图标细节和截图动态数据会天然造成差异。",
"",
"## 汇总",
"",
`- 对比页面:${results.length} 张。`,
`- 接近设计稿:${results.filter((item) => item.status === "close").length} 张。`,
`- 需要人工复核:${results.filter((item) => item.status === "review").length} 张。`,
`- 需要继续视觉打磨:${results.filter((item) => item.status === "needs-polish").length} 张。`,
"",
"| 页面 | 尺寸 | 状态 | 平均差异 | RMS | 变更像素 | 最大差异 | 差异图 | 热点区域 |",
"| --- | --- | --- | ---: | ---: | ---: | ---: | --- | --- |"
];
for (const result of results) {
lines.push(
`| ${result.fileName} | ${result.width}x${result.height}${result.resized ? " resized" : ""} | ${statusLabel(result.status)} | ${formatNumber(result.meanDelta)} | ${formatNumber(result.rmsDelta)} | ${formatPercent(result.changedRatio)} | ${formatNumber(result.maxDelta)} | [diff](${result.diffFileName}) | ${result.hotspots.join("<br>")} |`
);
}
if (missingNames.length > 0) {
lines.push("", "## 缺失文件", "");
for (const fileName of missingNames) {
lines.push(`- ${fileName}`);
}
}
lines.push(
"",
"## 判读建议",
"",
"- 优先处理 `needs-polish` 页面中热点集中在 top-left/top-center 的差异,这通常对应侧栏、顶部工具区和首屏结构。",
"- 如果热点集中在 right-middle/right-bottom,优先核对右侧检查器、AI 建议面板、操作区密度和滚动状态。",
"- 如果热点集中在 center-middle,优先核对表格密度、卡片边界、图表形态和主要工作区信息层级。",
"- 当平均差异下降但变更像素仍高,通常说明整体布局接近但文字、图标、间距和局部状态还需要人工修正。"
);
writeFileSync(path.join(outputDir, "report.md"), `${lines.join("\n")}\n`);
}
function statusLabel(status) {
if (status === "close") return "接近";
if (status === "review") return "复核";
return "需打磨";
}
function formatNumber(value) {
return value.toFixed(2);
}
function formatPercent(value) {
return `${(value * 100).toFixed(1)}%`;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
main();