feat: visual parity with the original Ghostty (pixel-diff pipeline)
Adds scripts/visual-parity.sh (`just visual-parity`): launches the installed Ghostty 1.3.1 and the Rust build in an isolated headless nested weston (GL renderer, Mesa forced, weston-screenshooter via --debug), with identical isolated config, a deterministic test page (ASCII, ligatures, all SGR styles and underline kinds, 16/256/truecolor, box/block/braille/powerline/nerd icons, CJK, emoji, wide chars, cursor) and a tabs+splits scenario driven through Xwayland, then diffs the captures (terminal area and chrome separately, heatmaps). Fixes found by comparing against the original: - A pane stayed blank after a nested split: the GTK render path called draw_frame(false) where Ghostty uses drawFrame(true) (a re-realized GLArea has a new swap chain that nothing else redraws). - Sprite glyph antialiasing now ports z2d's default 4x4 multisample rasterizer instead of tiny-skia's analytic coverage (pixels differing from Ghostty's reference sprite sheets: 5.76% -> 1.20%, none off by more than 96 levels); the sheet test tolerance is tightened to 0.1%. Result vs Ghostty 1.3.1: test page 0.020% of pixels differ (196 px at curly-underline ends and diagonal box strokes, max delta 86 levels); tabs+splits and headerbar/tab bar are bit-identical. Details in docs/rewrite/notes/visual-parity-VP.md. Test Plan: - just visual-parity (needs weston, weston-screenshooter, xdotool) - cargo test -p ghostty-font (sprite sheet comparison)
This commit is contained in:
1 parent
19ea627bed
commit
64aa8b6793
9 files changed
+598
-44
No files matched your search
@@ -2125,7 +2125,10 @@ impl CoreSurface for Surface {
|
|||||||
fn draw_frame(&self, _default_framebuffer: u32) -> Result<(), CoreError> {
|
fn draw_frame(&self, _default_framebuffer: u32) -> Result<(), CoreError> {
|
||||||
// GTK's GLArea binds its own FBO before `render`; the renderer
|
// GTK's GLArea binds its own FBO before `render`; the renderer
|
||||||
// queries GL_FRAMEBUFFER_BINDING itself.
|
// queries GL_FRAMEBUFFER_BINDING itself.
|
||||||
self.renderer.draw_frame(false)?;
|
// Zig: apprt gtk `glRender` calls `renderer.drawFrame(true)`. Sync
|
||||||
|
// must be true: after a GLArea (re)realize (e.g. a split rebuild) the
|
||||||
|
// swap chain is new and nothing else would redraw an unchanged pane.
|
||||||
|
self.renderer.draw_frame(true)?;
|
||||||
self.debug_screenshot();
|
self.debug_screenshot();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
//! This exposes primitives to draw 2D graphics and export the graphic to
|
//! This exposes primitives to draw 2D graphics and export the graphic to
|
||||||
//! a font atlas. Port of `font/sprite/canvas.zig`.
|
//! a font atlas. Port of `font/sprite/canvas.zig`.
|
||||||
//!
|
//!
|
||||||
//! Ghostty uses the `z2d` library for antialiased path drawing; this port
|
//! Ghostty uses the `z2d` library for antialiased path drawing. This port
|
||||||
//! uses `tiny-skia` to rasterize path coverage (see [`Canvas::fill_path`]).
|
//! reproduces z2d's default `multisample_4x` rasterizer (4x4 samples per
|
||||||
|
//! pixel, scanline spans, alpha `coverage * 16 - 1`) in [`Canvas::rasterize`];
|
||||||
|
//! stroke outlines come from `tiny-skia`'s stroker.
|
||||||
|
|
||||||
use std::ops::Sub;
|
use std::ops::Sub;
|
||||||
|
|
||||||
use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform};
|
use tiny_skia::{LineCap, LineJoin, PathBuilder, PathSegment, Stroke};
|
||||||
|
|
||||||
use crate::atlas::{self, Atlas, Format, Region};
|
use crate::atlas::{self, Atlas, Format, Region};
|
||||||
|
|
||||||
@@ -248,8 +250,8 @@ pub struct Canvas {
|
|||||||
pub clip_right: u32,
|
pub clip_right: u32,
|
||||||
pub clip_bottom: u32,
|
pub clip_bottom: u32,
|
||||||
|
|
||||||
/// Scratch pixmap used to rasterize path coverage.
|
/// Coverage mask (alpha8, same layout as `sfc`) of the last rasterized path.
|
||||||
scratch: Pixmap,
|
cov: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Canvas {
|
impl Canvas {
|
||||||
@@ -267,9 +269,7 @@ impl Canvas {
|
|||||||
clip_left: 0,
|
clip_left: 0,
|
||||||
clip_right: 0,
|
clip_right: 0,
|
||||||
clip_bottom: 0,
|
clip_bottom: 0,
|
||||||
// tiny-skia can't make zero sized pixmaps; sprites are never empty
|
cov: vec![0; w as usize * h as usize],
|
||||||
// but be defensive.
|
|
||||||
scratch: Pixmap::new(w.max(1), h.max(1)).expect("non-zero pixmap size"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,22 +465,15 @@ impl Canvas {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rasterize `path` (filled or stroked) into the scratch pixmap and
|
/// Rasterize `path` (filled or stroked) into the coverage mask `self.cov`
|
||||||
/// return nothing; the coverage is then in `self.scratch`'s alpha.
|
/// with z2d's default multisample anti-aliasing.
|
||||||
fn rasterize(&mut self, path: &Path, stroke: Option<StrokeOptions>) -> bool {
|
fn rasterize(&mut self, path: &Path, stroke: Option<StrokeOptions>) -> bool {
|
||||||
self.scratch.fill(tiny_skia::Color::TRANSPARENT);
|
self.cov.fill(0);
|
||||||
let Some(p) = path.to_skia(0.0, 0.0) else {
|
let Some(p) = path.to_skia(f64::from(self.padding_x), f64::from(self.padding_y)) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let mut paint = Paint::default();
|
let p = match stroke {
|
||||||
paint.set_color_rgba8(0, 0, 0, 255);
|
None => p,
|
||||||
paint.anti_alias = true;
|
|
||||||
let ts = Transform::from_translate(self.padding_x as f32, self.padding_y as f32);
|
|
||||||
match stroke {
|
|
||||||
None => {
|
|
||||||
self.scratch
|
|
||||||
.fill_path(&p, &paint, FillRule::Winding, ts, None);
|
|
||||||
}
|
|
||||||
Some(opts) => {
|
Some(opts) => {
|
||||||
let s = Stroke {
|
let s = Stroke {
|
||||||
width: opts.line_width as f32,
|
width: opts.line_width as f32,
|
||||||
@@ -493,22 +486,28 @@ impl Canvas {
|
|||||||
line_join: LineJoin::Miter,
|
line_join: LineJoin::Miter,
|
||||||
dash: None,
|
dash: None,
|
||||||
};
|
};
|
||||||
self.scratch.stroke_path(&p, &paint, &s, ts, None);
|
match p.stroke(&s, 1.0) {
|
||||||
|
Some(o) => o,
|
||||||
|
None => return false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
let edges = flatten_to_edges(&p);
|
||||||
|
multisample_fill(
|
||||||
|
&edges,
|
||||||
|
self.sfc_width as usize,
|
||||||
|
self.sfc_height as usize,
|
||||||
|
&mut self.cov,
|
||||||
|
);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Composite the scratch coverage onto the canvas using `src_over`
|
/// Composite the coverage mask onto the canvas using `src_over`
|
||||||
/// with an opaque pattern of alpha `color`.
|
/// with an opaque pattern of alpha `color`.
|
||||||
fn composite_scratch(&mut self, color: Color) {
|
fn composite_scratch(&mut self, color: Color) {
|
||||||
let a = u32::from(color.0);
|
let a = u32::from(color.0);
|
||||||
for (dst, px) in self
|
for (dst, px) in self.sfc.iter_mut().zip(self.cov.iter()) {
|
||||||
.sfc
|
let cov = u32::from(*px);
|
||||||
.iter_mut()
|
|
||||||
.zip(self.scratch.data().as_chunks::<4>().0.iter())
|
|
||||||
{
|
|
||||||
let cov = u32::from(px[3]);
|
|
||||||
if cov == 0 {
|
if cov == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -547,11 +546,8 @@ impl Canvas {
|
|||||||
|
|
||||||
let mut fill = vec![0u8; self.sfc.len()];
|
let mut fill = vec![0u8; self.sfc.len()];
|
||||||
if self.rasterize(&closed, None) {
|
if self.rasterize(&closed, None) {
|
||||||
for (d, px) in fill
|
for (d, px) in fill.iter_mut().zip(self.cov.iter()) {
|
||||||
.iter_mut()
|
*d = *px;
|
||||||
.zip(self.scratch.data().as_chunks::<4>().0.iter())
|
|
||||||
{
|
|
||||||
*d = px[3];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,11 +557,8 @@ impl Canvas {
|
|||||||
mut_opts.line_width *= 2.0;
|
mut_opts.line_width *= 2.0;
|
||||||
if self.rasterize(path, Some(mut_opts)) {
|
if self.rasterize(path, Some(mut_opts)) {
|
||||||
let a = u32::from(color.0);
|
let a = u32::from(color.0);
|
||||||
for (d, px) in stroke
|
for (d, px) in stroke.iter_mut().zip(self.cov.iter()) {
|
||||||
.iter_mut()
|
*d = ((u32::from(*px) * a + 127) / 255) as u8;
|
||||||
.zip(self.scratch.data().as_chunks::<4>().0.iter())
|
|
||||||
{
|
|
||||||
*d = ((u32::from(px[3]) * a + 127) / 255) as u8;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,6 +607,258 @@ impl Canvas {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// z2d multisample rasterizer port (`z2d/src/internal/raster/multisample.zig`,
|
||||||
|
// `tess/Polygon.zig`, `tess/Spline.zig`).
|
||||||
|
|
||||||
|
const MS_SCALE: i32 = 4;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Edge {
|
||||||
|
y0: f64,
|
||||||
|
y1: f64,
|
||||||
|
x_start: f64,
|
||||||
|
x_inc: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Edge {
|
||||||
|
fn top(&self) -> f64 {
|
||||||
|
if self.y0 < self.y1 { self.y0 } else { self.y1 }
|
||||||
|
}
|
||||||
|
fn bottom(&self) -> f64 {
|
||||||
|
if self.y0 < self.y1 { self.y1 } else { self.y0 }
|
||||||
|
}
|
||||||
|
fn dir(&self) -> i32 {
|
||||||
|
if self.y0 < self.y1 { -1 } else { 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_edge(edges: &mut Vec<Edge>, p0: (f64, f64), p1: (f64, f64)) {
|
||||||
|
let s = f64::from(MS_SCALE);
|
||||||
|
let (x0, y0, x1, y1) = (p0.0 * s, p0.1 * s, p1.0 * s, p1.1 * s);
|
||||||
|
if y0 < y1 {
|
||||||
|
edges.push(Edge {
|
||||||
|
y0,
|
||||||
|
y1,
|
||||||
|
x_start: x0,
|
||||||
|
x_inc: (x1 - x0) / (y1 - y0),
|
||||||
|
});
|
||||||
|
} else if y0 > y1 {
|
||||||
|
edges.push(Edge {
|
||||||
|
y0,
|
||||||
|
y1,
|
||||||
|
x_start: x1,
|
||||||
|
x_inc: (x0 - x1) / (y0 - y1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lerp_half(a: (f64, f64), b: (f64, f64)) -> (f64, f64) {
|
||||||
|
(a.0 + (b.0 - a.0) / 2.0, a.1 + (b.1 - a.1) / 2.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Knots = [(f64, f64); 4];
|
||||||
|
|
||||||
|
#[allow(clippy::float_cmp)] // exact comparison, like z2d
|
||||||
|
fn knots_error_sq(k: &Knots) -> f64 {
|
||||||
|
let [a, b, c, d] = *k;
|
||||||
|
let (mut bx, mut by) = (b.0 - a.0, b.1 - a.1);
|
||||||
|
let (mut cx, mut cy) = (c.0 - a.0, c.1 - a.1);
|
||||||
|
if a.0 != d.0 || a.1 != d.1 {
|
||||||
|
let (dx, dy) = (d.0 - a.0, d.1 - a.1);
|
||||||
|
let dd = dx * dx + dy * dy;
|
||||||
|
let bd = bx * dx + by * dy;
|
||||||
|
if bd >= dd {
|
||||||
|
bx -= dx;
|
||||||
|
by -= dy;
|
||||||
|
} else {
|
||||||
|
bx -= bd / dd * dx;
|
||||||
|
by -= bd / dd * dy;
|
||||||
|
}
|
||||||
|
let cd = cx * dx + cy * dy;
|
||||||
|
if cd >= dd {
|
||||||
|
cx -= dx;
|
||||||
|
cy -= dy;
|
||||||
|
} else {
|
||||||
|
cx -= cd / dd * dx;
|
||||||
|
cy -= cd / dd * dy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(bx * bx + by * by).max(cx * cx + cy * cy)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn de_casteljau(k: &mut Knots) -> Knots {
|
||||||
|
let [a, b, c, d] = *k;
|
||||||
|
let ab = lerp_half(a, b);
|
||||||
|
let bc = lerp_half(b, c);
|
||||||
|
let cd = lerp_half(c, d);
|
||||||
|
let abbc = lerp_half(ab, bc);
|
||||||
|
let bccd = lerp_half(bc, cd);
|
||||||
|
let fin = lerp_half(abbc, bccd);
|
||||||
|
*k = [a, ab, abbc, fin];
|
||||||
|
[fin, bccd, cd, d]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decompose_into(k: &mut Knots, start: (f64, f64), tol_sq: f64, out: &mut Vec<(f64, f64)>) {
|
||||||
|
if knots_error_sq(k) < tol_sq {
|
||||||
|
if k[0] != start {
|
||||||
|
out.push(k[0]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut s2 = de_casteljau(k);
|
||||||
|
decompose_into(k, start, tol_sq, out);
|
||||||
|
decompose_into(&mut s2, start, tol_sq, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flatten a cubic (z2d spline, tolerance 0.1), pushing every point after `a`.
|
||||||
|
fn flatten_cubic(
|
||||||
|
a: (f64, f64),
|
||||||
|
b: (f64, f64),
|
||||||
|
c: (f64, f64),
|
||||||
|
d: (f64, f64),
|
||||||
|
out: &mut Vec<(f64, f64)>,
|
||||||
|
) {
|
||||||
|
if a == b && c == d {
|
||||||
|
out.push(d);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut k: Knots = [a, b, c, d];
|
||||||
|
decompose_into(&mut k, a, 0.1 * 0.1, out);
|
||||||
|
out.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flatten a tiny-skia path into scaled polygon edges. Every subpath is
|
||||||
|
/// implicitly closed (fill semantics).
|
||||||
|
fn flatten_to_edges(p: &tiny_skia::Path) -> Vec<Edge> {
|
||||||
|
let mut edges = Vec::new();
|
||||||
|
let mut pts: Vec<(f64, f64)> = Vec::new();
|
||||||
|
let flush = |pts: &mut Vec<(f64, f64)>, edges: &mut Vec<Edge>| {
|
||||||
|
if pts.len() > 1 {
|
||||||
|
for w in pts.windows(2) {
|
||||||
|
add_edge(edges, w[0], w[1]);
|
||||||
|
}
|
||||||
|
add_edge(edges, pts[pts.len() - 1], pts[0]);
|
||||||
|
}
|
||||||
|
pts.clear();
|
||||||
|
};
|
||||||
|
let f = |p: tiny_skia::Point| (f64::from(p.x), f64::from(p.y));
|
||||||
|
let mut cur = (0.0, 0.0);
|
||||||
|
for seg in p.segments() {
|
||||||
|
match seg {
|
||||||
|
PathSegment::MoveTo(q) => {
|
||||||
|
flush(&mut pts, &mut edges);
|
||||||
|
cur = f(q);
|
||||||
|
pts.push(cur);
|
||||||
|
}
|
||||||
|
PathSegment::LineTo(q) => {
|
||||||
|
cur = f(q);
|
||||||
|
pts.push(cur);
|
||||||
|
}
|
||||||
|
PathSegment::QuadTo(c, q) => {
|
||||||
|
let (c, q) = (f(c), f(q));
|
||||||
|
// Elevate to a cubic.
|
||||||
|
let c1 = (
|
||||||
|
cur.0 + 2.0 / 3.0 * (c.0 - cur.0),
|
||||||
|
cur.1 + 2.0 / 3.0 * (c.1 - cur.1),
|
||||||
|
);
|
||||||
|
let c2 = (q.0 + 2.0 / 3.0 * (c.0 - q.0), q.1 + 2.0 / 3.0 * (c.1 - q.1));
|
||||||
|
flatten_cubic(cur, c1, c2, q, &mut pts);
|
||||||
|
cur = q;
|
||||||
|
}
|
||||||
|
PathSegment::CubicTo(c1, c2, q) => {
|
||||||
|
let q = f(q);
|
||||||
|
flatten_cubic(cur, f(c1), f(c2), q, &mut pts);
|
||||||
|
cur = q;
|
||||||
|
}
|
||||||
|
PathSegment::Close => {
|
||||||
|
flush(&mut pts, &mut edges);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(&mut pts, &mut edges);
|
||||||
|
edges
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Port of `multisample.run` with a non-zero fill rule and an opaque source:
|
||||||
|
/// writes the per-pixel alpha mask (`coverage * 16 - 1`, full = 255).
|
||||||
|
fn multisample_fill(edges: &[Edge], width: usize, height: usize, cov: &mut [u8]) {
|
||||||
|
if edges.is_empty() || width == 0 || height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let scale = MS_SCALE as usize;
|
||||||
|
let full = (scale * scale) as u32;
|
||||||
|
let mut counts = vec![0u32; width];
|
||||||
|
let mut xs: Vec<(i32, i32)> = Vec::new();
|
||||||
|
let max_x = (width * scale) as i32;
|
||||||
|
for y in 0..height {
|
||||||
|
counts.fill(0);
|
||||||
|
let mut any = false;
|
||||||
|
for sub in 0..scale {
|
||||||
|
let ys = (y * scale + sub) as i32;
|
||||||
|
let mid = f64::from(ys) + 0.5;
|
||||||
|
xs.clear();
|
||||||
|
for e in edges {
|
||||||
|
if e.top() < mid && e.bottom() >= mid {
|
||||||
|
let x = (e.x_start + e.x_inc * (mid - e.top())).round() as i32;
|
||||||
|
xs.push((x, e.dir()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if xs.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Stable sort by x like pdqsort on x (ties are order-insensitive
|
||||||
|
// for the winding filter outcome in practice).
|
||||||
|
xs.sort_by_key(|v| v.0);
|
||||||
|
// Non-zero filter.
|
||||||
|
let mut winding = 0;
|
||||||
|
let mut filtered: Vec<i32> = Vec::with_capacity(xs.len());
|
||||||
|
for &(x, d) in &xs {
|
||||||
|
if winding == 0 {
|
||||||
|
winding += d;
|
||||||
|
filtered.push(x);
|
||||||
|
} else {
|
||||||
|
winding += d;
|
||||||
|
if winding == 0 {
|
||||||
|
filtered.push(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut x_min = 0;
|
||||||
|
for pair in filtered.as_chunks::<2>().0 {
|
||||||
|
let start = pair[0].max(x_min);
|
||||||
|
if start >= max_x {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let end = pair[1].clamp(start, max_x);
|
||||||
|
if end > start {
|
||||||
|
any = true;
|
||||||
|
// Count samples per pixel.
|
||||||
|
let (mut sx, ex) = (start, end);
|
||||||
|
while sx < ex {
|
||||||
|
let px = (sx / MS_SCALE) as usize;
|
||||||
|
let next = ((px as i32 + 1) * MS_SCALE).min(ex);
|
||||||
|
counts[px] += (next - sx) as u32;
|
||||||
|
sx = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
x_min = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if any {
|
||||||
|
let row = &mut cov[y * width..(y + 1) * width];
|
||||||
|
for (o, &c) in row.iter_mut().zip(&counts) {
|
||||||
|
let c = c.min(full);
|
||||||
|
*o = match c {
|
||||||
|
0 => 0,
|
||||||
|
c if c == full => 255,
|
||||||
|
c => (c * (256 / full) - 1).min(255) as u8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -316,12 +316,17 @@ mod tests {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let d = diff_sheet(atlas, &reference);
|
let d = diff_sheet(atlas, &reference);
|
||||||
|
eprintln!(
|
||||||
|
"sprite-sheet {name}: ink={} differing={} big={} max={}",
|
||||||
|
d.ink, d.differing, d.big, d.max
|
||||||
|
);
|
||||||
|
|
||||||
// Tolerances: antialiasing differences between z2d and tiny-skia
|
// Tolerances: the multisample rasterizer matches z2d's coverage;
|
||||||
// are fine, but the geometry must match: very few pixels may be
|
// remaining differences come from stroke geometry (tiny-skia's
|
||||||
// off by a lot, relative to the amount of ink on the sheet.
|
// stroker) and are small. Practically no pixel may be off by a
|
||||||
|
// lot, relative to the amount of ink on the sheet.
|
||||||
let big_ratio = d.big as f64 / d.ink.max(1) as f64;
|
let big_ratio = d.big as f64 / d.ink.max(1) as f64;
|
||||||
let ok = big_ratio <= 0.04;
|
let ok = big_ratio <= 0.001;
|
||||||
if !ok || d.differing > 0 {
|
if !ok || d.differing > 0 {
|
||||||
// Write a visual diff (red = reference, green = ours).
|
// Write a visual diff (red = reference, green = ours).
|
||||||
let mut rgb = Vec::with_capacity(atlas.len() * 3);
|
let mut rgb = Vec::with_capacity(atlas.len() * 3);
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Visual parity vs original Ghostty 1.3.1 (agent VP)
|
||||||
|
|
||||||
|
## Method (`scripts/visual-parity.sh`, `just visual-parity [scenario...]`)
|
||||||
|
|
||||||
|
- A private headless `weston` (`--backend=headless --renderer=gl`, 1200x800, `desktop-shell`, no
|
||||||
|
panel) per run, on its own socket in `$XDG_RUNTIME_DIR=/tmp/vp-runtime-$UID` (socket paths must be
|
||||||
|
<108 bytes). The user's desktop is never touched. Mesa is forced for weston (`__EGL_VENDOR_LIBRARY_FILENAMES`
|
||||||
|
-> 50_mesa.json); the NVIDIA EGL crashes Xwayland's glamor. `XWAYLAND_NO_GLAMOR=1`.
|
||||||
|
- `weston --debug` is what authorizes `weston-screenshooter` (without it: "Output capture error: unauthorized").
|
||||||
|
- Both apps run with `env -i`, `GSETTINGS_BACKEND=memory`, no D-Bus, isolated `XDG_CONFIG_HOME` with the
|
||||||
|
same explicit config (JetBrains Mono 13, bg/fg, padding 8, `maximize = true` so the window geometry
|
||||||
|
is deterministic, `cursor-style-blink = false`, `title = Ghostty`, no shell integration) and
|
||||||
|
`LANG=C.UTF-8` (otherwise `printf '\U..'` in the test page prints literally).
|
||||||
|
- Scenarios: `page` (block cursor), `page-bar` (bar cursor), `tabs` (new tab + split right + split down,
|
||||||
|
driven with `xdotool` through Xwayland since the original Ghostty has no input replay; Rust is driven
|
||||||
|
the same way so both apps are treated identically).
|
||||||
|
Test page: `scripts/visual-parity/testpage.sh` (ASCII, ligatures, bold/italic/faint/inverse/strike/
|
||||||
|
overline, all underline styles + colored, 16/256/truecolor, box drawing, blocks, braille, powerline,
|
||||||
|
nerd font icons, CJK, emoji, wide chars, math, cursor). Not covered: mouse selection (no input
|
||||||
|
injection into the original on Wayland; could be added through the Xwayland path).
|
||||||
|
- `scripts/visual-parity/compare.py`: per-pixel max-channel delta, % differing, % > 8 / > 32 levels, max,
|
||||||
|
mean, red heat-map PNG; run on the full frame, the terminal area and the headerbar strip.
|
||||||
|
`scripts/visual-parity/zoom.py`: ref | rust | diff crops, upscaled.
|
||||||
|
- Output (not committed): `target/visual-parity/<scenario>/{ref,rust,diff,diff-term,diff-chrome}.png`,
|
||||||
|
`target/visual-parity/stats.txt`. `RUST_BIN` defaults to `target/production/ghostty`.
|
||||||
|
Iteration build: `CARGO_TARGET_DIR=target/vp CARGO_PROFILE_PRODUCTION_LTO=off
|
||||||
|
CARGO_PROFILE_PRODUCTION_CODEGEN_UNITS=16 CARGO_PROFILE_PRODUCTION_INCREMENTAL=true cargo build --profile production`.
|
||||||
|
|
||||||
|
## Results (% of pixels differing at all; max channel delta)
|
||||||
|
|
||||||
|
| scenario | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| page (full frame) | 0.130% (1250 px, max 93) | 0.020% (196 px, max 86; 0.001% > 32 levels) |
|
||||||
|
| page-bar | same as page | 0.020% |
|
||||||
|
| tabs + splits | 44.6% (a whole pane blank, bug) | 0.000% (bit-identical) |
|
||||||
|
| chrome (headerbar, tab bar) | 0.000% | 0.000% |
|
||||||
|
|
||||||
|
Sprite sheets (`ghostty-font` tests, Zig reference PNGs): differing pixels 5.76% -> 1.20%, pixels off by
|
||||||
|
more than 96 levels 74 -> 0 (test tolerance tightened from 4% to 0.1% big diffs).
|
||||||
|
|
||||||
|
Font metrics, cell size, baseline, glyph rasterization, colors, cursor, underline positions, padding,
|
||||||
|
GTK CSS/headerbar/tab bar were already pixel-identical; no change was needed there.
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
1. **Split rebuild blanked an unchanged pane** (`ghostty-core/src/surface/mod.rs`, `draw_frame`): the GTK
|
||||||
|
`render` handler called `renderer.draw_frame(false)`; Zig's `glRender` uses `drawFrame(true)`. With
|
||||||
|
`sync = false` and nothing dirty the renderer only re-presents the last target, which is cleared when
|
||||||
|
the GLArea is re-realized (nested split = tree rebuild = unrealize/realize of every surface whose size
|
||||||
|
did not change), so that pane stayed empty until something changed. Now `true` like Zig.
|
||||||
|
2. **Sprite antialiasing** (`ghostty-font/src/sprite/canvas.rs`): replaced tiny-skia's coverage with a port of
|
||||||
|
z2d 0.10's default `multisample_4x` rasterizer (4x4 samples, scanline spans with edge x rounded at
|
||||||
|
sample row centers, non-zero filter, alpha = `coverage * 16 - 1`, full = 255, z2d's Bezier
|
||||||
|
flattening with tolerance 0.1). Stroke outlines still come from tiny-skia's stroker.
|
||||||
|
|
||||||
|
## Remaining differences (page: 196 px, all <= 86 levels)
|
||||||
|
|
||||||
|
- Curly underline: first/last pixels and peaks differ slightly. z2d builds its own stroke polygon (pen/joins)
|
||||||
|
for curves; tiny-skia's stroker offsets the Bezier differently.
|
||||||
|
- Diagonal box glyphs (`U+2571..2573`) have a few edge pixels off by up to ~38 levels (same cause).
|
||||||
|
- Porting z2d's stroker (`tess/stroke_plotter`, `Pen`) would remove the rest; judged not worth it
|
||||||
|
(0.02% of pixels, imperceptible).
|
||||||
@@ -290,3 +290,7 @@ check-wasm:
|
|||||||
check-all: clippy test check-wasm check-windows
|
check-all: clippy test check-wasm check-windows
|
||||||
scripts/macos-crosscheck.sh clippy aarch64-apple-darwin
|
scripts/macos-crosscheck.sh clippy aarch64-apple-darwin
|
||||||
scripts/macos-crosscheck.sh clippy x86_64-apple-darwin
|
scripts/macos-crosscheck.sh clippy x86_64-apple-darwin
|
||||||
|
|
||||||
|
# Capture original Ghostty vs the Rust build in a headless weston and diff them.
|
||||||
|
visual-parity *scenarios:
|
||||||
|
RUST_BIN="${RUST_BIN:-target/vp/production/ghostty}" scripts/visual-parity.sh {{scenarios}}
|
||||||
Executable
+127
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Visual parity: capture original Ghostty and the Rust build in an isolated,
|
||||||
|
# headless nested weston and compare pixel-wise.
|
||||||
|
#
|
||||||
|
# scripts/visual-parity.sh [scenario...] (default: page page-bar tabs)
|
||||||
|
#
|
||||||
|
# env: RUST_BIN (default target/production/ghostty), REF_BIN (default
|
||||||
|
# /usr/bin/ghostty), OUT (default target/visual-parity), W/H output size.
|
||||||
|
# Output: $OUT/<scenario>/{ref,rust,diff}.png (+ chrome/term crops) and stats.txt.
|
||||||
|
set -u
|
||||||
|
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
RUST_BIN=${RUST_BIN:-$ROOT/target/production/ghostty}
|
||||||
|
REF_BIN=${REF_BIN:-/usr/bin/ghostty}
|
||||||
|
OUT=${OUT:-$ROOT/target/visual-parity}
|
||||||
|
W=${W:-1200}; H=${H:-800}
|
||||||
|
VP=$ROOT/scripts/visual-parity
|
||||||
|
SCEN=${*:-page page-bar tabs}
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
export XDG_RUNTIME_DIR=${VP_RUNTIME_DIR:-/tmp/vp-runtime-$UID}
|
||||||
|
mkdir -p -m700 "$XDG_RUNTIME_DIR"
|
||||||
|
WORK=$(mktemp -d "$OUT/work.XXXX")
|
||||||
|
SOCK=vp$$
|
||||||
|
|
||||||
|
XWL=
|
||||||
|
case " $SCEN " in *" tabs "*) XWL=xwayland=true; mkdir -p /tmp/.X11-unix ;; esac
|
||||||
|
cat >"$WORK/weston.ini" <<INI
|
||||||
|
[core]
|
||||||
|
shell=desktop-shell.so
|
||||||
|
$XWL
|
||||||
|
[output]
|
||||||
|
name=headless
|
||||||
|
mode=${W}x${H}
|
||||||
|
[shell]
|
||||||
|
panel-position=none
|
||||||
|
background-color=0xff202020
|
||||||
|
INI
|
||||||
|
|
||||||
|
__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/50_mesa.json XWAYLAND_NO_GLAMOR=1 weston --debug --backend=headless --renderer=gl --socket=$SOCK -c "$WORK/weston.ini" \
|
||||||
|
>"$WORK/weston.log" 2>&1 &
|
||||||
|
WPID=$!
|
||||||
|
trap 'kill $WPID 2>/dev/null; wait 2>/dev/null' EXIT
|
||||||
|
for _ in $(seq 50); do [ -S "$XDG_RUNTIME_DIR/$SOCK" ] && break; sleep 0.1; done
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
write_cfg() { # dir, extra
|
||||||
|
mkdir -p "$1/ghostty"
|
||||||
|
cat >"$1/ghostty/config" <<CFG
|
||||||
|
font-family = JetBrains Mono
|
||||||
|
font-size = 13
|
||||||
|
background = 1e1e2e
|
||||||
|
foreground = cdd6f4
|
||||||
|
maximize = true
|
||||||
|
window-padding-x = 8
|
||||||
|
window-padding-y = 8
|
||||||
|
cursor-style = block
|
||||||
|
cursor-style-blink = false
|
||||||
|
window-theme = dark
|
||||||
|
title = Ghostty
|
||||||
|
confirm-close-surface = false
|
||||||
|
shell-integration = none
|
||||||
|
quit-after-last-window-closed = true
|
||||||
|
$2
|
||||||
|
CFG
|
||||||
|
}
|
||||||
|
|
||||||
|
shoot() { # outfile
|
||||||
|
local d; d=$(mktemp -d "$WORK/shot.XXXX")
|
||||||
|
(cd "$d" && WAYLAND_DISPLAY=$SOCK timeout 20 weston-screenshooter >/dev/null 2>&1)
|
||||||
|
mv "$d"/*.png "$1" && rmdir "$d"
|
||||||
|
}
|
||||||
|
|
||||||
|
# run BIN LABEL SCENARIO OUTPNG
|
||||||
|
run_one() {
|
||||||
|
local bin=$1 label=$2 scen=$3 out=$4 cfgx=""
|
||||||
|
local cfg="$WORK/cfg-$label-$scen"
|
||||||
|
case $scen in page-bar) cfgx="cursor-style = bar" ;; esac
|
||||||
|
write_cfg "$cfg" "$cfgx"
|
||||||
|
local env=(LANG=C.UTF-8 XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR WAYLAND_DISPLAY=$SOCK XDG_CONFIG_HOME=$cfg GSETTINGS_BACKEND=memory
|
||||||
|
DBUS_SESSION_BUS_ADDRESS=unix:path=/nonexistent GDK_BACKEND=wayland
|
||||||
|
HOME=$WORK/home-$label)
|
||||||
|
mkdir -p "$WORK/home-$label"
|
||||||
|
case $scen in
|
||||||
|
tabs)
|
||||||
|
# Needs input: run under Xwayland and drive with xdotool.
|
||||||
|
env=("${env[@]/GDK_BACKEND=wayland/GDK_BACKEND=x11}")
|
||||||
|
env+=(DISPLAY="$(xwl_display)")
|
||||||
|
env -i PATH="$PATH" "${env[@]}" timeout 60 "$bin" --gtk-single-instance=false \
|
||||||
|
-e "$VP/testpage.sh" >"$WORK/$label-$scen.log" 2>&1 &
|
||||||
|
local pid=$!
|
||||||
|
sleep 6
|
||||||
|
xdotool_keys
|
||||||
|
sleep 3
|
||||||
|
shoot "$out"
|
||||||
|
kill $pid 2>/dev/null; wait $pid 2>/dev/null
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
env -i PATH="$PATH" "${env[@]}" timeout 60 "$bin" --gtk-single-instance=false \
|
||||||
|
-e "$VP/testpage.sh" >"$WORK/$label-$scen.log" 2>&1 &
|
||||||
|
local pid=$!
|
||||||
|
sleep 7
|
||||||
|
shoot "$out"
|
||||||
|
kill $pid 2>/dev/null; wait $pid 2>/dev/null
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
xwl_display() { grep -o "display :[0-9]*" "$WORK/weston.log" | tail -1 | sed 's/display //'; }
|
||||||
|
xdotool_keys() {
|
||||||
|
export DISPLAY=$(xwl_display)
|
||||||
|
xdotool key --clearmodifiers ctrl+shift+t; sleep 1
|
||||||
|
xdotool key --clearmodifiers ctrl+shift+o; sleep 1
|
||||||
|
xdotool key --clearmodifiers ctrl+shift+e; sleep 1
|
||||||
|
}
|
||||||
|
|
||||||
|
: >"$OUT/stats.txt"
|
||||||
|
for s in $SCEN; do
|
||||||
|
mkdir -p "$OUT/$s"
|
||||||
|
run_one "$REF_BIN" ref "$s" "$OUT/$s/ref.png"
|
||||||
|
run_one "$RUST_BIN" rust "$s" "$OUT/$s/rust.png"
|
||||||
|
python3 "$VP/compare.py" "$OUT/$s/ref.png" "$OUT/$s/rust.png" "$OUT/$s/diff.png" --name "$s/full" | tee -a "$OUT/stats.txt"
|
||||||
|
# terminal area: window content below the headerbar; chrome: the headerbar strip.
|
||||||
|
# Window geometry is deterministic under desktop-shell; override with TERM_CROP / CHROME_CROP.
|
||||||
|
python3 "$VP/compare.py" "$OUT/$s/ref.png" "$OUT/$s/rust.png" "$OUT/$s/diff-term.png" \
|
||||||
|
--crop "${TERM_CROP:-0,48,1200,752}" --name "$s/terminal" | tee -a "$OUT/stats.txt"
|
||||||
|
python3 "$VP/compare.py" "$OUT/$s/ref.png" "$OUT/$s/rust.png" "$OUT/$s/diff-chrome.png" \
|
||||||
|
--crop "${CHROME_CROP:-0,0,1200,48}" --name "$s/chrome" | tee -a "$OUT/stats.txt"
|
||||||
|
done
|
||||||
|
[ -n "${KEEP:-}" ] || rm -rf "$WORK"
|
||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pixel compare two PNG captures: stats + diff heatmap.
|
||||||
|
|
||||||
|
usage: compare.py REF.png RUST.png DIFF.png [--crop x,y,w,h] [--name NAME]
|
||||||
|
Prints one line: name, size, differing pixels (%), >8 / >32 levels, max delta, mean delta.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
a = sys.argv[1:]
|
||||||
|
crop = None
|
||||||
|
name = "all"
|
||||||
|
pos = []
|
||||||
|
i = 0
|
||||||
|
while i < len(a):
|
||||||
|
if a[i] == "--crop":
|
||||||
|
crop = tuple(int(v) for v in a[i + 1].split(","))
|
||||||
|
i += 2
|
||||||
|
elif a[i] == "--name":
|
||||||
|
name = a[i + 1]
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
pos.append(a[i])
|
||||||
|
i += 1
|
||||||
|
ref = Image.open(pos[0]).convert("RGB")
|
||||||
|
rs = Image.open(pos[1]).convert("RGB")
|
||||||
|
if crop:
|
||||||
|
x, y, w, h = crop
|
||||||
|
ref, rs = ref.crop((x, y, x + w, y + h)), rs.crop((x, y, x + w, y + h))
|
||||||
|
if ref.size != rs.size:
|
||||||
|
print(f"{name}: SIZE MISMATCH {ref.size} vs {rs.size}")
|
||||||
|
w, h = min(ref.size[0], rs.size[0]), min(ref.size[1], rs.size[1])
|
||||||
|
ref, rs = ref.crop((0, 0, w, h)), rs.crop((0, 0, w, h))
|
||||||
|
r = np.asarray(ref).astype(int)
|
||||||
|
s = np.asarray(rs).astype(int)
|
||||||
|
d = np.abs(r - s).max(axis=2)
|
||||||
|
n = d.size
|
||||||
|
diff = int((d > 0).sum())
|
||||||
|
print(
|
||||||
|
f"{name}: {ref.size[0]}x{ref.size[1]} differing={diff} ({100*diff/n:.3f}%) "
|
||||||
|
f">8={100*(d>8).sum()/n:.3f}% >32={100*(d>32).sum()/n:.3f}% max={d.max()} mean={d.mean():.4f}"
|
||||||
|
)
|
||||||
|
# heatmap: dimmed reference with differences in red (intensity ~ delta)
|
||||||
|
heat = (r * 0.25).astype(np.uint8)
|
||||||
|
m = np.clip(d * 4, 0, 255).astype(np.uint8)
|
||||||
|
heat[..., 0] = np.maximum(heat[..., 0], m)
|
||||||
|
heat[..., 1] = np.where(m > 0, heat[..., 1] // 2, heat[..., 1])
|
||||||
|
Image.fromarray(heat).save(pos[2])
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deterministic test page for visual-parity captures. No prompt, no variance.
|
||||||
|
E=$'\e'
|
||||||
|
printf '\e[?25l\e[2J\e[H'
|
||||||
|
printf '%s\n' 'ASCII: The quick brown fox jumps over the lazy dog 0123456789 {}[]()<>'
|
||||||
|
printf '%s\n' 'Ligs: -> => == != >= <= :: |> <| www fi fl ffi'
|
||||||
|
printf '%s\n' 'Sym: !"#$%&'"'"'()*+,-./:;<=>?@[\]^_`{|}~ gjpqy ÀÉÎÕÜ ñ ß'
|
||||||
|
printf '\e[1mBold\e[0m \e[3mItalic\e[0m \e[1;3mBoldItalic\e[0m \e[2mFaint\e[0m \e[7mInverse\e[0m \e[9mStrike\e[0m \e[53mOverline\e[0m \e[8mhidden\e[0m|\n'
|
||||||
|
printf 'UL: \e[4mSingle\e[0m \e[4:2mDouble\e[0m \e[4:3mCurly\e[0m \e[4:4mDotted\e[0m \e[4:5mDashed\e[0m \e[4:3;58;2;255;0;0mRedCurly\e[0m \e[4;9mUL+Strike\e[0m\n'
|
||||||
|
printf '16: '
|
||||||
|
for i in 0 1 2 3 4 5 6 7; do printf '\e[4%dm \e[0m' $i; done; printf ' '
|
||||||
|
for i in 0 1 2 3 4 5 6 7; do printf '\e[10%dm \e[0m' $i; done; printf '\n'
|
||||||
|
printf 'FG: '
|
||||||
|
for i in 0 1 2 3 4 5 6 7; do printf '\e[3%dmAb\e[0m' $i; done; printf ' '
|
||||||
|
for i in 0 1 2 3 4 5 6 7; do printf '\e[9%dmAb\e[0m' $i; done; printf '\n'
|
||||||
|
printf '256: '
|
||||||
|
for i in $(seq 16 87); do printf '\e[48;5;%dm \e[0m' $i; done; printf '\n '
|
||||||
|
for i in $(seq 88 159); do printf '\e[48;5;%dm \e[0m' $i; done; printf '\n '
|
||||||
|
for i in $(seq 160 231); do printf '\e[48;5;%dm \e[0m' $i; done; printf '\n '
|
||||||
|
for i in $(seq 232 255); do printf '\e[48;5;%dm \e[0m' $i; done; printf '\n'
|
||||||
|
printf 'RGB: '
|
||||||
|
for i in $(seq 0 4 255); do printf '\e[48;2;%d;%d;%dm \e[0m' $i $((255-i)) 128; done; printf '\n'
|
||||||
|
printf 'FGRGB: \e[38;2;255;128;0mOrange\e[0m \e[38;2;0;200;255;48;2;40;40;90mCyanOnBlue\e[0m \e[38;5;196mRed196\e[0m \e[38;5;21;48;5;226mBlueOnYellow\e[0m\n'
|
||||||
|
printf 'Box: ┌─┬─┐ ╔═╦═╗ ╭─╮ ┏━┳━┓ │ │ │ ║ ║ ║ ├─┼─┤ ╠═╬═╣ ╰─╯ ┗━┻━┛ ╱╲╳ ┈┉┊┋ ╌╍╎╏\n'
|
||||||
|
printf ' └─┴─┘ ╚═╩═╝ ┐┘└┌ ╒╓╕╖ ╞╟╡╢ ╤╥╧╨ ╪╫ ╴╵╶╷ ╸╹╺╻ ╼╽╾╿\n'
|
||||||
|
printf 'Blk: ▀▁▂▃▄▅▆▇█▉▊▋▌▍▎▏▐░▒▓▔▕▖▗▘▙▚▛▜▝▞▟ ◢◣◤◥ ⬛\n'
|
||||||
|
printf 'Brl: ⠀⠁⠂⠃⠄⠅⠆⠇⡀⡁⣀⣿⠿⣤⢕⡪ ⣾⣽⣻⢿⡿⣟⣯⣷\n'
|
||||||
|
printf 'Pwr: \n'
|
||||||
|
printf '\e[30;44m \e[34;42m\e[30m main \e[32;40m\e[0m\n'
|
||||||
|
printf 'Nerd: \U000f0219 \n'
|
||||||
|
printf 'CJK: 日本語のテキスト 中文字符 한국어 full ABC\n'
|
||||||
|
printf 'Emoji: 😀 🎉 🚀 ❤️ 👍🏽 👨👩👧 🇩🇪 ✅ ⭐ ☀\n'
|
||||||
|
printf 'Wide: |日|本|a|語|\n'
|
||||||
|
printf 'Math: ∑ ∫ ∂ √ ∞ ≠ ≤ ≥ ← → ↑ ↓ ⇒ λ π Ω αβγ \n'
|
||||||
|
printf 'Curs: '
|
||||||
|
printf '\e[1 q\e[?25hX'
|
||||||
|
printf '\e[?25l\e[0 q'
|
||||||
|
printf '\n'
|
||||||
|
printf '\e[7mReverse\e[27m \e[38;5;240;48;5;238mdim on dim\e[0m \e[31;41mred on red\e[0m\n'
|
||||||
|
printf '\e[5;1H'
|
||||||
|
printf '\e[24;1H'
|
||||||
|
printf '\e[1 q\e[?25h'
|
||||||
|
exec sleep 100000
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""zoom.py DIR x,y,w,h scale OUT : side-by-side ref | rust | diff crop, upscaled (nearest)."""
|
||||||
|
import sys
|
||||||
|
from PIL import Image
|
||||||
|
d, box, sc, out = sys.argv[1], [int(v) for v in sys.argv[2].split(",")], int(sys.argv[3]), sys.argv[4]
|
||||||
|
x, y, w, h = box
|
||||||
|
ims = [Image.open(f"{d}/{n}.png").convert("RGB").crop((x, y, x + w, y + h)).resize((w * sc, h * sc), Image.NEAREST) for n in ("ref", "rust", "diff")]
|
||||||
|
o = Image.new("RGB", (w * sc, h * sc * 3 + 8), (255, 0, 255))
|
||||||
|
for i, im in enumerate(ims):
|
||||||
|
o.paste(im, (0, i * (h * sc + 4)))
|
||||||
|
o.save(out)
|
||||||
Reference in new issue
Block a user