386 lines
20 KiB
Rust
386 lines
20 KiB
Rust
//! Compare our composite with Photoshop's merged image for one PSD (rendering-fidelity work).
|
|
//!
|
|
//! ```sh
|
|
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd [N] # layers + first N pixels
|
|
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 png out.png # ours | Photoshop | diff heatmap
|
|
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 col [x] # column samples
|
|
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 row y x0 x1 # row samples
|
|
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 worst [n] # n worst pixels
|
|
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 grid x0 y0 x1 y1 [ch] # value grids
|
|
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 layerpx x y # each layer's pixel
|
|
//! cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd # every file: max err, bad %, PASS/DIFF
|
|
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 dump prefix # raw f32 planes for offline fitting
|
|
//! HIDE_ADJ=1 … # adjustment layers hidden
|
|
//! DUMP_FX=1 … # raw effects descriptors
|
|
//! ```
|
|
fn main() {
|
|
let path = std::env::args().nth(1).expect("path");
|
|
if std::path::Path::new(&path).is_dir() {
|
|
corpus_summary(std::path::Path::new(&path));
|
|
return;
|
|
}
|
|
let bytes = std::fs::read(&path).unwrap();
|
|
let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap();
|
|
let mut imp = photocraft_io::import(&path, &bytes).unwrap();
|
|
if std::env::var_os("HIDE_ADJ").is_some() {
|
|
// Composite without adjustment layers (their input, for fitting transfer curves).
|
|
fn hide(ls: &mut [photocraft_doc::Layer]) {
|
|
for l in ls {
|
|
if matches!(l.content, photocraft_doc::LayerContent::Adjustment(_)) {
|
|
l.visible = false;
|
|
}
|
|
if let photocraft_doc::LayerContent::Group(g) = &mut l.content {
|
|
hide(&mut g.children);
|
|
}
|
|
}
|
|
}
|
|
hide(&mut imp.document.layers);
|
|
}
|
|
let doc = &imp.document;
|
|
println!("mode {:?} depth {:?} layers {} warnings {:?} light {:?}", doc.mode, doc.depth, doc.layer_count(), imp.warnings, doc.global_light);
|
|
for l in doc.walk() {
|
|
let l = l.2;
|
|
println!(
|
|
" layer {:?} {:?} blend {:?} op {} fill {} fill_cache {} visible {}",
|
|
l.name,
|
|
l.content.kind_name(),
|
|
l.blend,
|
|
l.opacity,
|
|
l.fill_opacity,
|
|
l.fill_cache.is_some(),
|
|
l.visible
|
|
);
|
|
if let photocraft_doc::LayerContent::Adjustment(a) = &l.content {
|
|
println!(" {}", format!("{a:?}").chars().take(800).collect::<String>());
|
|
}
|
|
if let photocraft_doc::LayerContent::Fill(f) = &l.content {
|
|
println!(" {f:?}");
|
|
}
|
|
if let Some(m) = &l.mask {
|
|
println!(" mask default {:?} bounds {:?} enabled {}", m.surface.default_pixel(), m.surface.content_bounds(), m.enabled);
|
|
let b = m.surface.content_bounds();
|
|
let cx = (b.x0 + b.x1) / 2;
|
|
let col: Vec<String> = (b.y0..b.y0 + 6).chain(b.y1 - 6..b.y1).map(|y| format!("{y}:{:.2}", m.surface.pixel(cx, y)[0])).collect();
|
|
println!(" mask column x={cx}: {}", col.join(" "));
|
|
let row: Vec<String> = (b.x0..b.x0 + 4).chain(b.x1 - 4..b.x1).map(|x| format!("{x}:{:.2}", m.surface.pixel(x, (b.y0 + b.y1) / 2)[0])).collect();
|
|
println!(" mask row: {}", row.join(" "));
|
|
}
|
|
if let Some(sf) = l.surface() {
|
|
let b = sf.content_bounds();
|
|
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
|
for y in b.y0..b.y1 {
|
|
for x in b.x0..b.x1 {
|
|
let p = sf.pixel(x, y);
|
|
if p[p.len() - 1] > 0.0 {
|
|
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
|
|
}
|
|
}
|
|
}
|
|
println!(" alpha bounds ({x0},{y0})-({x1},{y1})");
|
|
}
|
|
if let photocraft_doc::LayerContent::Shape(sh) = &l.content {
|
|
println!(" shape path bounds {:?}", sh.path.control_bounds());
|
|
}
|
|
if let Some(vm) = &l.vector_mask {
|
|
println!(" vector mask bounds {:?}", vm.path.control_bounds());
|
|
}
|
|
println!(" clipped {} frame {:?} bounds {:?}", l.clipped, photocraft_compose::fill_frame(l, doc.bounds()), l.surface().map(|s| s.content_bounds()));
|
|
for e in &l.effects.items {
|
|
if let photocraft_doc::Effect::GradientOverlay { common, gradient, .. } = e
|
|
&& common.enabled
|
|
{
|
|
let g = format!("{gradient:?}");
|
|
println!(" gradient overlay: {g}");
|
|
} else if format!("{e:?}").contains("enabled: true") {
|
|
println!(" fx {}", format!("{e:?}").chars().take(if std::env::var_os("FULL").is_some() { 100_000 } else { 200 }).collect::<String>());
|
|
}
|
|
}
|
|
println!(" blocks {:?}", l.psd_blocks.iter().map(|(k, v)| format!("{}({})", String::from_utf8_lossy(k), v.len())).collect::<Vec<_>>());
|
|
for (k, v) in &l.psd_blocks {
|
|
if k == b"lfx2" || k == b"lmfx" {
|
|
// Recursively list descriptor keys (effects), skipping colour stop lists.
|
|
fn walk(d: &photocraft_psd::descriptor::Descriptor, depth: usize, out: &mut Vec<String>) {
|
|
for (k, v) in &d.items {
|
|
let key = match k {
|
|
photocraft_psd::descriptor::Id::Code(c) => String::from_utf8_lossy(c).to_string(),
|
|
other => String::from_utf8_lossy(other.as_bytes()).to_string(),
|
|
};
|
|
let vs = format!("{v:?}");
|
|
match v {
|
|
photocraft_psd::descriptor::Value::Descriptor(sub) => {
|
|
out.push(format!("{}{key}:", " ".repeat(depth)));
|
|
walk(sub, depth + 1, out);
|
|
}
|
|
_ => out.push(format!("{}{key} = {}", " ".repeat(depth), vs.chars().take(90).collect::<String>())),
|
|
}
|
|
}
|
|
}
|
|
if let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(&v[4..]) {
|
|
let mut out = Vec::new();
|
|
walk(&vd.descriptor, 3, &mut out);
|
|
for l in out.iter().filter(|l| !l.trim_start().starts_with("Clrs") && !l.trim_start().starts_with("Trns")) {
|
|
println!("{l}");
|
|
}
|
|
}
|
|
}
|
|
if k == b"GdFl" {
|
|
// Skip the 4-byte version before the descriptor.
|
|
if let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(v) {
|
|
println!(
|
|
" GdFl keys: {:?}",
|
|
vd.descriptor
|
|
.items
|
|
.iter()
|
|
.filter(|(k, _)| !format!("{k:?}").contains("71, 114, 97, 100"))
|
|
.map(|(k, v)| format!(
|
|
"{:?}={}",
|
|
match k {
|
|
photocraft_psd::descriptor::Id::Code(c) => String::from_utf8_lossy(c).to_string(),
|
|
other => format!("{other:?}"),
|
|
},
|
|
format!("{v:?}").chars().take(120).collect::<String>()
|
|
))
|
|
.collect::<Vec<_>>()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if std::env::var_os("DUMP_FX").is_some() {
|
|
fn walk(d: &photocraft_psd::descriptor::Descriptor, depth: usize) {
|
|
for (k, v) in &d.items {
|
|
let key = String::from_utf8_lossy(k.as_bytes()).to_string();
|
|
if key == "Clrs" || key == "Trns" {
|
|
continue;
|
|
}
|
|
match v {
|
|
photocraft_psd::descriptor::Value::Descriptor(sub) => {
|
|
println!("{}{key}:", " ".repeat(depth));
|
|
walk(sub, depth + 1);
|
|
}
|
|
photocraft_psd::descriptor::Value::Text(t) => println!("{}{key} = {:?}", " ".repeat(depth), t.to_string_lossy()),
|
|
photocraft_psd::descriptor::Value::List(items) if items.iter().all(|i| matches!(i, photocraft_psd::descriptor::Value::Descriptor(_))) => {
|
|
println!("{}{key} = [{} items]", " ".repeat(depth), items.len());
|
|
for (i, it) in items.iter().enumerate() {
|
|
if let photocraft_psd::descriptor::Value::Descriptor(sub) = it {
|
|
println!("{}[{i}]:", " ".repeat(depth + 1));
|
|
walk(sub, depth + 2);
|
|
}
|
|
}
|
|
}
|
|
_ => println!("{}{key} = {}", " ".repeat(depth), format!("{v:?}").chars().take(600).collect::<String>()),
|
|
}
|
|
}
|
|
}
|
|
for rec in file.layers() {
|
|
println!("-- layer {:?}", String::from_utf8_lossy(&rec.name));
|
|
if let Some(b) = rec.block(b"lmfx").or(rec.block(b"lfx2")).or(rec.block(b"lfxs"))
|
|
&& let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(&b.data[4..])
|
|
{
|
|
walk(&vd.descriptor, 1);
|
|
}
|
|
}
|
|
}
|
|
let ours = photocraft_compose::flatten(doc).px;
|
|
let merged = photocraft_io::merged_composite(&file).unwrap();
|
|
if std::env::args().nth(3).as_deref() == Some("png") {
|
|
// ours | photoshop | diff heatmap, side by side (over white).
|
|
let out = std::env::args().nth(4).expect("out.png");
|
|
let (w, h) = (doc.size.width as usize, doc.size.height as usize);
|
|
let mut img = vec![0u8; w * 3 * h * 4];
|
|
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
|
for y in 0..h {
|
|
for x in 0..w {
|
|
let (a, b) = (ours[y * w + x], merged[y * w + x]);
|
|
let over = |p: [f32; 4]| [p[0] * p[3] + 1.0 - p[3], p[1] * p[3] + 1.0 - p[3], p[2] * p[3] + 1.0 - p[3]];
|
|
let (oa, ob) = (over(a), over(b));
|
|
let d = (0..3).map(|c| (oa[c] - ob[c]).abs()).fold(0.0f32, f32::max);
|
|
let heat = [q((d * 4.0).min(1.0)), q((1.0 - d * 4.0).max(0.0) * 0.3), 0];
|
|
for (k, px) in [[q(oa[0]), q(oa[1]), q(oa[2])], [q(ob[0]), q(ob[1]), q(ob[2])], heat].iter().enumerate() {
|
|
let o = (y * w * 3 + k * w + x) * 4;
|
|
img[o..o + 3].copy_from_slice(px);
|
|
img[o + 3] = 255;
|
|
}
|
|
}
|
|
}
|
|
let image =
|
|
photocraft_codecs::Image::from_raw((w * 3) as u32, h as u32, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, img)
|
|
.unwrap();
|
|
std::fs::write(&out, photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
|
|
println!("wrote {out}");
|
|
return;
|
|
}
|
|
if std::env::args().nth(3).as_deref() == Some("dump") {
|
|
// dump prefix: raw little-endian f32 RGBA (straight) planes for offline fitting:
|
|
// prefix_ours.f32, prefix_ps.f32, prefix_L<i>.f32 (each top-level layer's own pixels,
|
|
// masks not applied) and prefix.txt (width, height, layer names).
|
|
let out = std::env::args().nth(4).expect("prefix");
|
|
let wr = |name: String, px: &[[f32; 4]]| {
|
|
let bytes: Vec<u8> = px.iter().flat_map(|p| p.iter().flat_map(|v| v.to_le_bytes())).collect();
|
|
std::fs::write(name, bytes).unwrap();
|
|
};
|
|
wr(format!("{out}_ours.f32"), &ours);
|
|
wr(format!("{out}_ps.f32"), &merged);
|
|
let mut meta = format!("{} {}\n", doc.size.width, doc.size.height);
|
|
for (i, l) in doc.layers.iter().enumerate() {
|
|
let b = photocraft_compose::surface_to_buffer(
|
|
l.surface().unwrap_or(&photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8)),
|
|
doc.bounds(),
|
|
);
|
|
wr(format!("{out}_L{i}.f32"), &b.px);
|
|
meta += &format!("{i} {}\n", l.name);
|
|
}
|
|
std::fs::write(format!("{out}.txt"), meta).unwrap();
|
|
return;
|
|
}
|
|
if std::env::args().nth(3).as_deref() == Some("layerpx") {
|
|
// layerpx x y: each layer's own pixel (0-255).
|
|
let x: i32 = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
let y: i32 = std::env::args().nth(5).and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
for l in doc.walk() {
|
|
if let Some(s) = l.2.surface() {
|
|
println!("{:?}: {:?}", l.2.name, s.pixel(x, y).iter().map(|v| (v * 255.0 * 100.0).round() / 100.0).collect::<Vec<_>>());
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if std::env::args().nth(3).as_deref() == Some("grid") {
|
|
// grid x0 y0 x1 y1 [channel]: ours / photoshop values (0-255, premultiplied colour).
|
|
let w = doc.size.width as usize;
|
|
let a: Vec<usize> = (4..8).map(|i| std::env::args().nth(i).and_then(|s| s.parse().ok()).unwrap_or(0)).collect();
|
|
let ch: usize = std::env::args().nth(8).and_then(|s| s.parse().ok()).unwrap_or(3);
|
|
for (label, img) in [("ours", &ours), ("ps", &merged)] {
|
|
println!("{label} (channel {ch}):");
|
|
for y in a[1]..a[3] {
|
|
let row: Vec<String> = (a[0]..a[2])
|
|
.map(|x| {
|
|
let p = img[y * w + x];
|
|
let v = if ch == 3 { p[3] } else { p[ch] * p[3] };
|
|
format!("{:4}", (v * 255.0).round())
|
|
})
|
|
.collect();
|
|
println!(" y={y:4} {}", row.join(""));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if std::env::args().nth(3).as_deref() == Some("worst") {
|
|
// The N worst pixels (premultiplied max channel error), with coordinates.
|
|
let w = doc.size.width as usize;
|
|
let n: usize = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(20);
|
|
let mut v: Vec<(f32, usize)> = ours
|
|
.iter()
|
|
.zip(&merged)
|
|
.enumerate()
|
|
.map(|(i, (a, b))| ((0..4).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold((a[3] - b[3]).abs(), f32::max), i))
|
|
.collect();
|
|
v.sort_by(|a, b| b.0.total_cmp(&a.0));
|
|
for (d, i) in v.into_iter().take(n) {
|
|
let (a, b) = (ours[i], merged[i]);
|
|
println!("({:4},{:4}) d={:5.1} ours {:?} ps {:?}", i % w, i / w, d * 255.0, a.map(|v| (v * 255.0).round()), b.map(|v| (v * 255.0).round()));
|
|
}
|
|
return;
|
|
}
|
|
if std::env::args().nth(3).as_deref() == Some("row") {
|
|
let w = doc.size.width as usize;
|
|
let y: usize = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
let (x0, x1): (usize, usize) =
|
|
(std::env::args().nth(5).and_then(|s| s.parse().ok()).unwrap_or(0), std::env::args().nth(6).and_then(|s| s.parse().ok()).unwrap_or(w));
|
|
for x in (x0..x1).step_by(((x1 - x0) / 20).max(1)) {
|
|
let (a, b) = (ours[y * w + x], merged[y * w + x]);
|
|
println!("x={x:5} ours {:?} ps {:?}", a.map(|v| (v * 1000.0).round() / 1000.0), b.map(|v| (v * 1000.0).round() / 1000.0));
|
|
}
|
|
return;
|
|
}
|
|
if std::env::args().nth(3).as_deref() == Some("col") {
|
|
let (w, h) = (doc.size.width as usize, doc.size.height as usize);
|
|
let x = std::env::args().nth(4).and_then(|s| s.parse().ok()).unwrap_or(w / 2);
|
|
for k in 0..=16 {
|
|
let y = ((h - 1) * k / 16).min(h - 1);
|
|
let (a, b) = (ours[y * w + x], merged[y * w + x]);
|
|
println!("y={y:5} ours {:?} ps {:?}", a.map(|v| (v * 1000.0).round() / 1000.0), b.map(|v| (v * 1000.0).round() / 1000.0));
|
|
}
|
|
return;
|
|
}
|
|
let n = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(8usize);
|
|
for (i, (a, b)) in ours.iter().zip(&merged).enumerate().take(n) {
|
|
println!("{i:4}: ours {:?}\n ps {:?}", a.map(|v| (v * 1000.0).round() / 1000.0), b.map(|v| (v * 1000.0).round() / 1000.0));
|
|
}
|
|
}
|
|
|
|
/// The corpus oracle table (as `tests/corpus.rs`), files in parallel, without a test build.
|
|
fn corpus_summary(root: &std::path::Path) {
|
|
fn collect(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
|
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
|
for e in rd.flatten() {
|
|
let p = e.path();
|
|
if p.is_dir() {
|
|
collect(&p, out);
|
|
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("psd") || e.eq_ignore_ascii_case("psb")) {
|
|
out.push(p);
|
|
}
|
|
}
|
|
}
|
|
let mut files = Vec::new();
|
|
collect(root, &mut files);
|
|
files.sort();
|
|
const TOL: f32 = 2.0 / 255.0;
|
|
type Row = (String, Option<(f32, f32)>);
|
|
let row = |p: &std::path::PathBuf| -> Row {
|
|
let name = p.strip_prefix(root).unwrap_or(p).display().to_string();
|
|
let Ok(bytes) = std::fs::read(p) else { return (name, None) };
|
|
let Ok(file) = photocraft_psd::PsdFile::from_bytes(&bytes) else { return (name, None) };
|
|
let Ok(imp) = photocraft_io::import(&name, &bytes) else { return (name, None) };
|
|
if file.has_real_merged_data() == Some(false) || file.layers().is_empty() {
|
|
return (name, None);
|
|
}
|
|
let Ok(merged) = photocraft_io::merged_composite(&file) else { return (name, None) };
|
|
let ours = photocraft_compose::flatten(&imp.document).px;
|
|
let mut m = 0.0f32;
|
|
let mut bad = 0usize;
|
|
for (a, b) in ours.iter().zip(&merged) {
|
|
let d = (0..4).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold((a[3] - b[3]).abs(), f32::max);
|
|
m = m.max(d);
|
|
if (0..4).any(|c| (a[c] * a[3] - b[c] * b[3]).abs() > TOL) {
|
|
bad += 1;
|
|
}
|
|
}
|
|
(name, Some((m, 100.0 * bad as f32 / ours.len().max(1) as f32)))
|
|
};
|
|
// Files in parallel (a few threads; flatten itself is tile-parallel).
|
|
let next = std::sync::atomic::AtomicUsize::new(0);
|
|
let mut rows: Vec<(usize, Row)> = std::thread::scope(|sc| {
|
|
let hs: Vec<_> = (0..4)
|
|
.map(|_| {
|
|
sc.spawn(|| {
|
|
let mut out = Vec::new();
|
|
loop {
|
|
let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let Some(p) = files.get(i) else { break };
|
|
out.push((i, row(p)));
|
|
}
|
|
out
|
|
})
|
|
})
|
|
.collect();
|
|
hs.into_iter().flat_map(|h| h.join().unwrap()).collect()
|
|
});
|
|
rows.sort_by_key(|r| r.0);
|
|
let rows: Vec<Row> = rows.into_iter().map(|r| r.1).collect();
|
|
let (mut pass, mut diff, mut skip) = (0, 0, 0);
|
|
for (name, r) in &rows {
|
|
match r {
|
|
Some((m, pct)) if *m <= TOL => {
|
|
pass += 1;
|
|
println!("{name:<60} {m:>9.4} {pct:>7.2}% PASS");
|
|
}
|
|
Some((m, pct)) => {
|
|
diff += 1;
|
|
println!("{name:<60} {m:>9.4} {pct:>7.2}% DIFF");
|
|
}
|
|
None => skip += 1,
|
|
}
|
|
}
|
|
println!("{} files: {pass} pass, {diff} differ, {skip} skipped/errors", rows.len());
|
|
}
|