mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 09:36:59 +03:00
Add comparison sheets for masked comp references
Batch affected crops beside their actual matcher references in paginated, provenance-marked images. Expose the sheets in CLI output, JSON and the inspector, and direct shared mapping work to inspect them together. Matcher pixels and approval rules are unchanged. Validation: 580 Rust tests, default Bun/Node suite against rebuilt engine, provider build, browser links, and unchanged raster references for the saved hotel map. AI assistance: implemented and verified with Codex.
This commit is contained in:
@@ -51,6 +51,8 @@
|
||||
h1 { font-size: 20px; line-height: 1.3; margin: 0; }
|
||||
header p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
header a { margin-left: auto; font-size: 12px; }
|
||||
#sheets { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 18px; font-size: 12px; }
|
||||
#sheets:empty { display: none; }
|
||||
.layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); height: calc(100dvh - 68px); }
|
||||
nav { background: var(--panel); border-right: 1px solid var(--line); overflow: auto; padding: 14px 12px; }
|
||||
.filters { display: flex; gap: 4px; margin-bottom: 14px; }
|
||||
@@ -157,6 +159,7 @@
|
||||
<div><h2 id="title">Check the region map</h2><p id="explanation"></p><div id="related"></div></div>
|
||||
<button id="next">Next check →</button>
|
||||
</div>
|
||||
<div id="sheets" aria-label="Comparison sheets"></div>
|
||||
<div id="inspection" class="inspection">
|
||||
<section>
|
||||
<div class="section-title"><h3>In the comp</h3><button id="context-toggle" aria-pressed="false">Show whole comp</button></div>
|
||||
@@ -337,6 +340,11 @@
|
||||
$('context-toggle').onclick = () => { wholeComp = !wholeComp; renderContext(); };
|
||||
$('zoom').onchange = () => { if (selected) { renderCrop($('original'), false); renderCrop($('ignored'), true); } };
|
||||
$('summary').textContent = `${data.inputRegionCount} regions · ${data.issues.filter(i => i.severity === 'error').length} errors · ${partialMasks.length} partial masks to inspect`;
|
||||
for (const [index, sheet] of (data.comparisonSheets || []).entries()) {
|
||||
const link = element('a', `Comparison sheet ${index + 1}`);
|
||||
link.href = sheet.path; link.target = '_blank'; link.rel = 'noopener';
|
||||
$('sheets').append(link);
|
||||
}
|
||||
const otherIssues = data.issues.filter(i => i.code !== 'fully-masked' && i.code !== 'foreground-mask');
|
||||
$('diagnostics-label').textContent = `Other map findings (${otherIssues.length})`;
|
||||
for (const i of otherIssues) {
|
||||
|
||||
@@ -291,6 +291,41 @@ fn save_reference(path: &Path, image: &Image, source: &str) -> Result<(), String
|
||||
std::fs::write(path, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A bounded overview, not a new matcher: both panes use the same scale and
|
||||
/// preserve aspect ratio. Individual full-resolution crops remain authoritative.
|
||||
fn comparison_sheet(comp: &Image, spec: &Value, regions: &[&Value], page: usize, pages: usize) -> Image {
|
||||
const PANEL_W: usize = 512;
|
||||
const PANEL_H: usize = 320;
|
||||
const PAD: usize = 24;
|
||||
let rows = regions.len().div_ceil(2);
|
||||
let mut sheet = r::create_image(PANEL_W * 2 + PAD * 3, 96 + rows * PANEL_H + PAD, [246,247,245,255]);
|
||||
let ink = [32.,38.,35.,255.];
|
||||
let muted = [91.,101.,95.,255.];
|
||||
r::draw_text(&mut sheet, &format!("MASKED REFERENCES {page}/{pages}"), 24., 20., ink, 3.);
|
||||
r::draw_text(&mut sheet, "ORIGINAL AND ACTUAL CHECKER REFERENCE. REFERENCE ONLY.", 24., 54., muted, 2.);
|
||||
for (i, region) in regions.iter().enumerate() {
|
||||
let x = (PAD + (i % 2) * (PANEL_W + PAD)) as f64;
|
||||
let y = (96 + (i / 2) * PANEL_H) as f64;
|
||||
let label = format!("#{} {}", region["number"], region["id"].as_str().unwrap());
|
||||
let label = if label.chars().count() > 42 { format!("{}...", label.chars().take(39).collect::<String>()) } else { label };
|
||||
r::draw_text(&mut sheet, &label, x, y, ink, 2.);
|
||||
r::draw_text(&mut sheet, &format!("{:.1}% EXCLUDED - {}X{} PX",
|
||||
region["reference"]["excludedFraction"].as_f64().unwrap() * 100., region["px"]["w"], region["px"]["h"]), x, y+23., muted, 2.);
|
||||
let original = r::crop(comp, coord(region,"x"), coord(region,"y"), coord(region,"w"), coord(region,"h"));
|
||||
let reference = comp_spec::prepare_plate_reference(comp, spec, region);
|
||||
let scale = (244. / original.width as f64).min(216. / original.height as f64).min(3.);
|
||||
let width = (original.width as f64 * scale).round().max(1.);
|
||||
let height = (original.height as f64 * scale).round().max(1.);
|
||||
for (pane, (name, image)) in [("ORIGINAL", &original), ("CHECKER", &reference.image)].iter().enumerate() {
|
||||
let px = x + pane as f64 * 268.;
|
||||
r::draw_text(&mut sheet, name, px, y+48., muted, 2.);
|
||||
r::fill_rect(&mut sheet, px, y+72., 244., 216., [233.,237.,232.,255.]);
|
||||
r::blit(&mut sheet, &r::resize(image, width, height), px+(244.-width)/2., y+72.+(216.-height)/2.);
|
||||
}
|
||||
}
|
||||
sheet
|
||||
}
|
||||
|
||||
fn write_report(dir: &Path, comp: &Image, report: &mut Value) -> Result<(), String> {
|
||||
// An inspection owns a new directory. Never overwrite an input, spec, or receipt.
|
||||
if let Some(parent) = dir.parent() {
|
||||
@@ -345,6 +380,18 @@ fn write_report(dir: &Path, comp: &Image, report: &mut Value) -> Result<(), Stri
|
||||
);
|
||||
}
|
||||
save_reference(&dir.join("overlay.png"), &overlay, &source)?;
|
||||
let mut affected: Vec<&Value> = report["regions"].as_array().unwrap().iter()
|
||||
.filter(|r| r.pointer("/reference/excludedPixels").and_then(Value::as_u64).unwrap_or(0) > 0).collect();
|
||||
affected.sort_by(|a,b| b["reference"]["excludedFraction"].as_f64().unwrap()
|
||||
.total_cmp(&a["reference"]["excludedFraction"].as_f64().unwrap()));
|
||||
let pages = affected.len().div_ceil(6);
|
||||
let mut sheets = Vec::new();
|
||||
for (i, regions) in affected.chunks(6).enumerate() {
|
||||
let path = format!("comparison-{}.png", i+1);
|
||||
save_reference(&dir.join(&path), &comparison_sheet(comp, &spec, regions, i+1, pages), &source)?;
|
||||
sheets.push(json!({"path":path,"regionIds":regions.iter().map(|r| &r["id"]).collect::<Vec<_>>()}));
|
||||
}
|
||||
report["comparisonSheets"] = json!(sheets);
|
||||
let data = serde_json::to_string_pretty(report).map_err(|e| e.to_string())?;
|
||||
std::fs::write(dir.join("report.json"), &data).map_err(|e| e.to_string())?;
|
||||
// JSON in a script element is data; escape HTML delimiters to prevent closing it.
|
||||
@@ -398,6 +445,9 @@ pub fn run(argv: &[String], io: &mut Io, comp: &Image, comp_path: &str) -> i32 {
|
||||
} else {
|
||||
let partial_masks = report["issues"].as_array().unwrap().iter().filter(|i| i["code"] == "foreground-mask").count();
|
||||
io.out(&format!("MAP {}/index.html\nOVERLAY {}/overlay.png\n{} regions, {errors} errors, {partial_masks} partial masks to inspect. Zero errors does not certify crop accuracy. Reference only; no build state or approvals changed.\n",report["outputDir"].as_str().unwrap(),report["outputDir"].as_str().unwrap(),report["inputRegionCount"]));
|
||||
for sheet in report["comparisonSheets"].as_array().unwrap() {
|
||||
io.out(&format!("COMPARE {}/{}\n", report["outputDir"].as_str().unwrap(), sheet["path"].as_str().unwrap()));
|
||||
}
|
||||
for i in report["issues"].as_array().unwrap() {
|
||||
io.out(&format!(
|
||||
"{} {}: {}\n",
|
||||
@@ -421,6 +471,51 @@ mod tests {
|
||||
use super::*;
|
||||
use impeccable_comp::raster::create_image;
|
||||
|
||||
#[test]
|
||||
fn sheet_panes_match_original_and_real_reference_at_the_same_scale() {
|
||||
let mut comp = create_image(100, 100, [240,240,240,255]);
|
||||
r::fill_rect(&mut comp, 10., 10., 10., 10., [150.,40.,20.,255.]);
|
||||
let mut region = json!({"id":"art","number":1,"kind":"image","px":{"x":10,"y":10,"w":20,"h":10},"palette":[{"hex":"#f0f0f0"}]});
|
||||
let spec = json!({"regions":[region,{"id":"label","kind":"text","px":{"x":10,"y":10,"w":5,"h":10}}]});
|
||||
let reference = comp_spec::prepare_plate_reference(&comp, &spec, ®ion);
|
||||
region["reference"] = reference.audit();
|
||||
let sheet = comparison_sheet(&comp, &spec, &[®ion], 1, 1);
|
||||
let expected_original = r::resize(&r::crop(&comp,10.,10.,20.,10.),60.,30.);
|
||||
let expected_reference = r::resize(&reference.image,60.,30.);
|
||||
assert_ne!(expected_original.data, expected_reference.data);
|
||||
assert_eq!(r::crop(&sheet,116.,261.,60.,30.).data, expected_original.data);
|
||||
assert_eq!(r::crop(&sheet,384.,261.,60.,30.).data, expected_reference.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comparison_sheets_paginate_masked_assets_and_keep_reference_provenance() {
|
||||
let root = std::env::temp_dir().join(format!("impeccable-sheet-test-{}-{}", std::process::id(),
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()));
|
||||
let image = create_image(300, 100, [240, 240, 240, 255]);
|
||||
let mut regions: Vec<Value> = (0..7).map(|n| json!({"id":format!("art-{n}"),"kind":"image",
|
||||
"note":"Distinct illustration", "pixelBox":{"x":5+n*25,"y":20,"w":20,"h":20}})).collect();
|
||||
regions.push(json!({"id":"foreground","kind":"text","note":"Overlapping text line",
|
||||
"pixelBox":{"x":0,"y":20,"w":200,"h":4}}));
|
||||
regions.push(json!({"id":"unmasked","kind":"image","note":"Unobscured photograph",
|
||||
"pixelBox":{"x":230,"y":20,"w":20,"h":20}}));
|
||||
let mut report = inspect(&image, &json!({"regions":regions}), "comp.png");
|
||||
write_report(&root, &image, &mut report).unwrap();
|
||||
let sheets = report["comparisonSheets"].as_array().expect("sheets are discoverable in JSON");
|
||||
assert_eq!(sheets.len(), 2);
|
||||
let ids: Vec<&str> = sheets.iter().flat_map(|s| s["regionIds"].as_array().unwrap()).map(|id|id.as_str().unwrap()).collect();
|
||||
assert_eq!(ids, (0..7).map(|n|format!("art-{n}")).collect::<Vec<_>>());
|
||||
for sheet in sheets {
|
||||
let png = png_io::decode_png(&std::fs::read(root.join(sheet["path"].as_str().unwrap())).unwrap()).unwrap();
|
||||
assert_eq!(png.text.get("impeccable:crop-of").map(String::as_str), Some("comp.png"));
|
||||
assert!(sheet["regionIds"].as_array().unwrap().len() <= 6);
|
||||
}
|
||||
assert_eq!(report["regions"].as_array().unwrap().len(), 9);
|
||||
let mut clean = inspect(&image, &json!({"regions":[regions[8].clone()]}), "comp.png");
|
||||
write_report(&root.join("clean"), &image, &mut clean).unwrap();
|
||||
assert_eq!(clean["comparisonSheets"], json!([]));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_masks_need_attention_even_without_geometry_errors() {
|
||||
let image = create_image(100, 100, [240, 240, 240, 255]);
|
||||
|
||||
@@ -449,7 +449,7 @@ retain their local-development trust behavior. See [bundle signing](BUNDLE-SIGNI
|
||||
|
||||
Ported from the former `skill/scripts/{comp-spec,comp-diff,font-match,build-phase}.mjs` (+ `lib/{png,raster,image-metrics,font-fingerprint,font-index,hero-checks}.mjs`) into the engine; invoked as `{{scripts_path}}/impeccable <verb>`. All four resolve paths against the process cwd. Printed commands spell the launcher via `IMPECCABLE_SELF` (default `impeccable`), so they name `{{scripts_path}}/impeccable <verb>`, never `node …mjs`. ISO `createdAt`/`startedAt` timestamps in stdout and written JSON are the only run-dependent output.
|
||||
|
||||
- **`comp-spec`** — turns an approved comp into a measured build spec. `--schema` prints the region-map JSON schema without reading or writing a project. `--comp <png> --regions <json> --inspect-map` writes reference-only crops, overlay and report in a new directory; exit 2 means diagnosed hard errors, exit 1 means command/input failure. Partial foreground masks are warnings naming the excluding regions and remain in the inspector’s main inspection queue even with zero hard errors. The CLI summary counts them separately; they do not imply an asset failure or visual approval. Code rectangles exclude their full overlap, so separate text elements need separate bounds. Mapping metadata (`parentId`, `reviewGroup`) survives measurement. Review groups must contain peer code regions of one kind and container status; mixed groups and grouped raster assets are refused both at measurement and at component-review preparation. `--comp <png> --grid` writes `.impeccable/build/comp-grid.png` (10x10 labeled grid) and prints PALETTE/BANDS/NEXT; `--comp <png> --regions <json>` measures regions into `.impeccable/build/spec.json` (region box, sampled palette, medium, aspect, detail energy, plate path for raster kinds) and prints the spec; `--comp <png> --auto [--out <draft.json>]` writes approximate bands to a new draft file (default `.impeccable/build/regions.draft.json`), without modifying the measured spec or build state; `--print` prints the compact spec; `--crop <id> [--out f] [--scale n] [--raw]` writes a reference crop; `--plate-prompt <id>` prints the regeneration prompt. `--spec <path>` overrides the spec path (default `.impeccable/build/spec.json`). Validation refusals (stderr, exit 1) are the JS strings verbatim: a region with no id / duplicate id / no note, a code-kind region whose note names painted material, a code region over 25% of the comp, a grid span that is not `<colrow>:<colrow>`, uncovered ink cells without `allowUncovered`. spec.json is byte-identical to the JS output.
|
||||
- **`comp-spec`** — turns an approved comp into a measured build spec. `--schema` prints the region-map JSON schema without reading or writing a project. `--comp <png> --regions <json> --inspect-map` writes reference-only crops, overlay and report in a new directory; exit 2 means diagnosed hard errors, exit 1 means command/input failure. Masked assets also produce paginated, comp-derived `comparison-N.png` sheets (six original/checker pairs per image, matched scale and aspect ratio), listed by `COMPARE` lines and the JSON `comparisonSheets` array. Every affected instance is retained; sheets do not change matcher inputs or approvals. Partial foreground masks are warnings naming the excluding regions and remain in the inspector’s main inspection queue even with zero hard errors. The CLI summary counts them separately; they do not imply an asset failure or visual approval. Code rectangles exclude their full overlap, so separate text elements need separate bounds. Mapping metadata (`parentId`, `reviewGroup`) survives measurement. Review groups must contain peer code regions of one kind and container status; mixed groups and grouped raster assets are refused both at measurement and at component-review preparation. `--comp <png> --grid` writes `.impeccable/build/comp-grid.png` (10x10 labeled grid) and prints PALETTE/BANDS/NEXT; `--comp <png> --regions <json>` measures regions into `.impeccable/build/spec.json` (region box, sampled palette, medium, aspect, detail energy, plate path for raster kinds) and prints the spec; `--comp <png> --auto [--out <draft.json>]` writes approximate bands to a new draft file (default `.impeccable/build/regions.draft.json`), without modifying the measured spec or build state; `--print` prints the compact spec; `--crop <id> [--out f] [--scale n] [--raw]` writes a reference crop; `--plate-prompt <id>` prints the regeneration prompt. `--spec <path>` overrides the spec path (default `.impeccable/build/spec.json`). Validation refusals (stderr, exit 1) are the JS strings verbatim: a region with no id / duplicate id / no note, a code-kind region whose note names painted material, a code region over 25% of the comp, a grid span that is not `<colrow>:<colrow>`, uncovered ink cells without `allowUncovered`. spec.json is byte-identical to the JS output.
|
||||
Automatic drafts require decomposition into actual visible elements before measurement; a draft flag blocks accidental submission, and existing draft files are never overwritten. Successful `--regions` measurements record the source path and SHA-256. Spec and plate gates reject changed or missing source files, so a failed region edit cannot silently reuse the previous measurements. Legacy specs without source metadata remain readable.
|
||||
Region inputs support three coordinate representations: inclusive `grid` cells, normalized `box: {x,y,w,h}`, or `pixelBox: {x,y,w,h}` in whole original-comp pixels. Pixel boxes cannot be combined with the other formats and must be positive-sized and contained in the comp; they avoid snapping an asset boundary to a neighbouring grid cell. Foreground UI excluded from a plate reference is excluded at the same aligned coordinates from the candidate during scoring; unmasked asset bytes still undergo provenance checks.
|
||||
- **`comp-diff`** — `--comp <png> --build <png> [--spec spec.json] [--out-dir dir] [--align top|stretch|cover] [--label name] [--threshold t] [--json] [--no-files]`. Scores structure / color / detail / bands and per-region verdicts (`match`/`drift`/`missing`/`contradicted`); writes `side-by-side.png`, `heatmap.png`, `regions/<id>.png`, and `report.json` under `--out-dir` (unless `--no-files`); prints the text summary or, with `--json`, the report. Exit 0 measured, 1 usage/unreadable input, 3 below `--threshold`. The JSON report and text summary are byte-identical to the JS.
|
||||
|
||||
@@ -4,8 +4,8 @@ A region map names what is actually visible in the approved comp before asset pr
|
||||
|
||||
1. Run `{{scripts_path}}/impeccable comp-spec --comp <comp.png> --grid` and open the original and gridded images.
|
||||
2. Run `{{scripts_path}}/impeccable comp-spec --schema` for the JSON fields. Write `regions.json` with a `regions` array. Each region needs a stable `id`, `kind`, `note`, and exactly one of `pixelBox`, normalized `box`, or `grid`. Use the original comp’s dimensions.
|
||||
3. Run `{{scripts_path}}/impeccable comp-spec --comp <comp.png> --regions regions.json --inspect-map`. The output points to a report, overlay and exact crops. The default prints findings; `--json` prints the entire report.
|
||||
4. Open the crops and compare their bounds with the original. Inspect excluded foreground pixels as well as geometry errors. Every overlapping non-container code box is masked in full, including empty space inside it. Bound separate text elements separately so artwork in the gaps stays visible; a container describes their layout extent and never replaces its children. Correct the map and inspect again; use a new output directory each time. Zero errors does not certify crop accuracy. Coverage warnings are hints, not proof of completeness.
|
||||
3. Run `{{scripts_path}}/impeccable comp-spec --comp <comp.png> --regions regions.json --inspect-map`. The output points to a report, overlay, exact crops and `COMPARE` sheets of masked references. The default prints findings; `--json` prints the entire report, with sheet paths in `comparisonSheets`.
|
||||
4. Open the comparison sheets first to inspect affected crops together, then open individual crops where more detail is needed. Compare their bounds with the original. Inspect excluded foreground pixels as well as geometry errors. Every overlapping non-container code box is masked in full, including empty space inside it. Bound separate text elements separately so artwork in the gaps stays visible; a container describes their layout extent and never replaces its children. Correct the map and inspect again; use a new output directory each time. Zero errors does not certify crop accuracy. Coverage warnings are hints, not proof of completeness.
|
||||
|
||||
If the request ends at mapping, stop with the map, inspection report and unresolved findings. To continue a build, measure the inspected map with `comp-spec --comp <comp.png> --regions regions.json` and follow [new-work.md](new-work.md).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user