///|
/// Pixelmatch - Fast pixel-level image comparison library
/// Port of mapbox/pixelmatch to MoonBit
///|
/// RGBA color representation
pub(all) struct Color {
r : Int
g : Int
b : Int
a : Int
} derive(Show, Eq)
///|
pub fn Color::rgba(r : Int, g : Int, b : Int, a : Int) -> Color {
{ r, g, b, a }
}
///|
pub fn Color::rgb(r : Int, g : Int, b : Int) -> Color {
{ r, g, b, a: 255 }
}
///|
/// Image data (RGBA pixels in row-major order)
pub(all) struct Image {
width : Int
height : Int
data : FixedArray[Int] // [r, g, b, a, r, g, b, a, ...]
}
///|
pub fn Image::new(width : Int, height : Int) -> Image {
let size = width * height * 4
{ width, height, data: FixedArray::make(size, 0) }
}
///|
pub fn Image::from_pixels(
width : Int,
height : Int,
pixels : Array[Color],
) -> Image {
let data = FixedArray::make(width * height * 4, 0)
for i, pixel in pixels {
let base = i * 4
data[base] = pixel.r
data[base + 1] = pixel.g
data[base + 2] = pixel.b
data[base + 3] = pixel.a
}
{ width, height, data }
}
///|
pub fn Image::get_pixel(self : Image, x : Int, y : Int) -> Color {
let idx = (y * self.width + x) * 4
{
r: self.data[idx],
g: self.data[idx + 1],
b: self.data[idx + 2],
a: self.data[idx + 3],
}
}
///|
pub fn Image::set_pixel(self : Image, x : Int, y : Int, c : Color) -> Unit {
let idx = (y * self.width + x) * 4
self.data[idx] = c.r
self.data[idx + 1] = c.g
self.data[idx + 2] = c.b
self.data[idx + 3] = c.a
}
///|
/// Pixelmatch options
pub(all) struct Options {
/// Matching threshold (0 to 1). Smaller = more sensitive.
threshold : Double
/// Include anti-aliased pixels in diff
include_aa : Bool
/// Blending factor of unchanged pixels (0 to 1)
alpha : Double
/// Color of anti-aliased pixels in diff
aa_color : Color
/// Color of different pixels in diff
diff_color : Color
/// Detect dark on light differences
diff_color_alt : Color?
/// Mask mode - draw only changed pixels
diff_mask : Bool
}
///|
pub fn Options::default() -> Options {
{
threshold: 0.1,
include_aa: false,
alpha: 0.1,
aa_color: Color::rgba(255, 255, 0, 255), // Yellow
diff_color: Color::rgba(255, 0, 0, 255), // Red
diff_color_alt: None,
diff_mask: false,
}
}
///|
/// Compare two images and return the number of different pixels
/// Optionally writes diff image to output
pub fn pixelmatch(
img1 : Image,
img2 : Image,
output : Image?,
options : Options,
) -> Int {
if img1.width != img2.width || img1.height != img2.height {
abort("Image dimensions must match")
}
let width = img1.width
let height = img1.height
// Maximum delta based on threshold
// 35215 is the maximum possible delta (see YIQ calculation)
let max_delta = 35215.0 *
options.threshold *
options.threshold *
options.threshold *
options.threshold
let mut diff_count = 0
for y in 0.. max_delta {
// Check for anti-aliasing
if !options.include_aa &&
(is_antialiased(img1, img2, x, y) || is_antialiased(img2, img1, x, y)) {
// Anti-aliased pixel
match output {
Some(out) =>
if !options.diff_mask {
out.set_pixel(x, y, options.aa_color)
}
None => ()
}
} else {
// Different pixel
diff_count += 1
match output {
Some(out) => {
let color = if delta < 0.0 {
options.diff_color_alt.unwrap_or(options.diff_color)
} else {
options.diff_color
}
out.set_pixel(x, y, color)
}
None => ()
}
}
} else {
// Similar pixel - draw blended grayscale
match output {
Some(out) =>
if !options.diff_mask {
let gray = blend_gray(img1, x, y, options.alpha)
out.set_pixel(x, y, gray)
}
None => ()
}
}
}
}
diff_count
}
///|
/// Calculate color delta between two pixels using YIQ color space
/// Optimized: inline alpha blending
fn color_delta(
r1 : Int,
g1 : Int,
b1 : Int,
a1 : Int,
r2 : Int,
g2 : Int,
b2 : Int,
a2 : Int,
y_only : Bool,
) -> Double {
// Handle alpha - need to blend both when either is non-opaque or they differ
let (rf1, gf1, bf1, rf2, gf2, bf2) : (
Double,
Double,
Double,
Double,
Double,
Double,
) = if a1 == a2 && a1 == 255 {
(
r1.to_double(),
g1.to_double(),
b1.to_double(),
r2.to_double(),
g2.to_double(),
b2.to_double(),
)
} else {
let alpha1 = a1.to_double() / 255.0
let white1 = 255.0 * (1.0 - alpha1)
let alpha2 = a2.to_double() / 255.0
let white2 = 255.0 * (1.0 - alpha2)
(
r1.to_double() * alpha1 + white1,
g1.to_double() * alpha1 + white1,
b1.to_double() * alpha1 + white1,
r2.to_double() * alpha2 + white2,
g2.to_double() * alpha2 + white2,
b2.to_double() * alpha2 + white2,
)
}
// Calculate YIQ components
let dr = rf1 - rf2
let dg = gf1 - gf2
let db = bf1 - bf2
// Y (luminance)
let y = 0.29889531 * dr + 0.58662247 * dg + 0.11448223 * db
if y_only {
return y
}
// I and Q (chrominance)
let i = 0.59597799 * dr - 0.27417610 * dg - 0.32180189 * db
let q = 0.21147017 * dr - 0.52261711 * dg + 0.31114694 * db
// Weighted delta (sign indicates lightening/darkening)
let delta = 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q
if y < 0.0 {
-delta
} else {
delta
}
}
///|
fn color_delta_at(
img1 : Image,
img2 : Image,
x : Int,
y : Int,
y_only : Bool,
) -> Double {
let idx = (y * img1.width + x) * 4
color_delta(
img1.data[idx],
img1.data[idx + 1],
img1.data[idx + 2],
img1.data[idx + 3],
img2.data[idx],
img2.data[idx + 1],
img2.data[idx + 2],
img2.data[idx + 3],
y_only,
)
}
///|
/// Check if a pixel is anti-aliased
/// Note: Uses color_delta_at for luminance comparison between neighbors
fn is_antialiased(img1 : Image, img2 : Image, x : Int, y : Int) -> Bool {
let width = img1.width
let height = img1.height
// Find min/max luminance delta among neighbors
let mut min_delta = 0.0
let mut max_delta = 0.0
let mut min_x = x
let mut min_y = y
let mut max_x = x
let mut max_y = y
// Check 8 neighbors
for dy in -1..<=1 {
for dx in -1..<=1 {
if dx == 0 && dy == 0 {
continue
}
let nx = x + dx
let ny = y + dy
if nx < 0 || nx >= width || ny < 0 || ny >= height {
continue
}
let delta = color_delta_at(img1, img1, x, y, true) -
color_delta_at(img1, img1, nx, ny, true)
if delta == 0.0 {
continue // Identical neighbor
}
if delta < min_delta {
min_delta = delta
min_x = nx
min_y = ny
}
if delta > max_delta {
max_delta = delta
max_x = nx
max_y = ny
}
}
}
// No contrast difference
if min_delta == 0.0 && max_delta == 0.0 {
return false
}
// Check if darkest/brightest neighbor has 3+ equal siblings in both images
(
has_many_siblings(img1, min_x, min_y) &&
has_many_siblings(img2, min_x, min_y)
) ||
(
has_many_siblings(img1, max_x, max_y) &&
has_many_siblings(img2, max_x, max_y)
)
}
///|
/// Check if pixel has 3+ siblings with similar color
/// Optimized: early exit, cache data reference
fn has_many_siblings(img : Image, x : Int, y : Int) -> Bool {
let width = img.width
let height = img.height
let data = img.data
let mut count = 0
let idx = (y * width + x) * 4
let r = data[idx]
let g = data[idx + 1]
let b = data[idx + 2]
let a = data[idx + 3]
// Unrolled neighbor check for better performance
// Check 8 neighbors: (-1,-1), (0,-1), (1,-1), (-1,0), (1,0), (-1,1), (0,1), (1,1)
let neighbors = [
(-1, -1),
(0, -1),
(1, -1),
(-1, 0),
(1, 0),
(-1, 1),
(0, 1),
(1, 1),
]
for pair in neighbors {
let (dx, dy) = pair
let nx = x + dx
let ny = y + dy
if nx >= 0 && nx < width && ny >= 0 && ny < height {
let nidx = (ny * width + nx) * 4
// Check if colors are nearly identical
if (data[nidx] - r).abs() < 3 &&
(data[nidx + 1] - g).abs() < 3 &&
(data[nidx + 2] - b).abs() < 3 &&
(data[nidx + 3] - a).abs() < 3 {
count += 1
if count >= 3 {
return true
}
}
}
}
false
}
///|
/// Create a grayscale pixel blended with alpha
fn blend_gray(img : Image, x : Int, y : Int, alpha : Double) -> Color {
let idx = (y * img.width + x) * 4
let r = img.data[idx].to_double()
let g = img.data[idx + 1].to_double()
let b = img.data[idx + 2].to_double()
// Luminance
let lum = (0.299 * r + 0.587 * g + 0.114 * b).to_int()
let blended = (lum.to_double() * alpha).to_int()
Color::rgba(blended, blended, blended, 255)
}
///|
/// Inline color delta calculation for maximum performance
/// Returns absolute delta value (always positive)
fn color_delta_inline(
data1 : FixedArray[Int],
data2 : FixedArray[Int],
base : Int,
) -> Double {
let r1 = data1[base]
let g1 = data1[base + 1]
let b1 = data1[base + 2]
let a1 = data1[base + 3]
let r2 = data2[base]
let g2 = data2[base + 1]
let b2 = data2[base + 2]
let a2 = data2[base + 3]
// Fast path: both fully opaque (most common case)
let (rf1, gf1, bf1, rf2, gf2, bf2) : (
Double,
Double,
Double,
Double,
Double,
Double,
) = if a1 == a2 && a1 == 255 {
(
r1.to_double(),
g1.to_double(),
b1.to_double(),
r2.to_double(),
g2.to_double(),
b2.to_double(),
)
} else {
// Need to blend both with white
let alpha1 = a1.to_double() / 255.0
let white1 = 255.0 * (1.0 - alpha1)
let alpha2 = a2.to_double() / 255.0
let white2 = 255.0 * (1.0 - alpha2)
(
r1.to_double() * alpha1 + white1,
g1.to_double() * alpha1 + white1,
b1.to_double() * alpha1 + white1,
r2.to_double() * alpha2 + white2,
g2.to_double() * alpha2 + white2,
b2.to_double() * alpha2 + white2,
)
}
let dr = rf1 - rf2
let dg = gf1 - gf2
let db = bf1 - bf2
let y = 0.29889531 * dr + 0.58662247 * dg + 0.11448223 * db
let i = 0.59597799 * dr - 0.27417610 * dg - 0.32180189 * db
let q = 0.21147017 * dr - 0.52261711 * dg + 0.31114694 * db
let delta = 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q
delta
}
///|
/// Simple comparison without anti-aliasing detection
/// Returns the number of different pixels
/// Optimized: inline delta calculation, minimize function calls
pub fn pixelmatch_simple(img1 : Image, img2 : Image, threshold : Double) -> Int {
if img1.width != img2.width || img1.height != img2.height {
abort("Image dimensions must match")
}
let max_delta = 35215.0 * threshold * threshold * threshold * threshold
let mut diff_count = 0
let len = img1.data.length() / 4
let data1 = img1.data
let data2 = img2.data
for i in 0.. max_delta {
diff_count += 1
}
}
diff_count
}
///|
/// Simple comparison with row-level prefilter optimization.
/// Skips entire rows where all pixels are identical (common in VRT screenshots).
/// Falls back to per-pixel delta for rows with differences.
pub fn pixelmatch_simple_prefilter(
img1 : Image,
img2 : Image,
threshold : Double,
) -> Int {
if img1.width != img2.width || img1.height != img2.height {
abort("Image dimensions must match")
}
let max_delta = 35215.0 * threshold * threshold * threshold * threshold
let width = img1.width
let height = img1.height
let data1 = img1.data
let data2 = img2.data
let mut diff_count = 0
for y in 0.. max_delta {
diff_count += 1
}
}
}
diff_count
}
///|
/// Calculate match ratio (0.0 = completely different, 1.0 = identical)
pub fn match_ratio(img1 : Image, img2 : Image, options : Options) -> Double {
let total = img1.width * img1.height
if total == 0 {
return 1.0
}
let diff = pixelmatch(img1, img2, None, options)
1.0 - diff.to_double() / total.to_double()
}
// ============================================================================
// AI-friendly Diff Report
// ============================================================================
///|
/// Bounding box for a diff region
pub(all) struct DiffRegion {
x : Int
y : Int
width : Int
height : Int
diff_pixels : Int
}
///|
/// Comprehensive diff report for AI consumption
pub(all) struct DiffReport {
// Basic statistics
width : Int
height : Int
total_pixels : Int
diff_count : Int
aa_count : Int
match_ratio : Double
// Grid heatmap (cell values = diff count in that cell)
grid : Array[Array[Int]]
grid_cols : Int
grid_rows : Int
// Diff regions (bounding boxes of clustered differences)
regions : Array[DiffRegion]
}
///|
/// Generate a comprehensive diff report
/// Optimized: pre-allocate arrays, cache references
pub fn diff_report(
img1 : Image,
img2 : Image,
options : Options,
grid_size? : Int = 10,
) -> DiffReport {
if img1.width != img2.width || img1.height != img2.height {
abort("Image dimensions must match")
}
let width = img1.width
let height = img1.height
let total_pixels = width * height
let max_delta = 35215.0 *
options.threshold *
options.threshold *
options.threshold *
options.threshold
// Initialize grid - pre-allocate
let grid_cols = if width < grid_size { 1 } else { grid_size }
let grid_rows = if height < grid_size { 1 } else { grid_size }
let cell_w = width / grid_cols
let cell_h = height / grid_rows
let grid : Array[Array[Int]] = Array::make(grid_rows, [])
for i in 0.. max_delta {
if !options.include_aa &&
(is_antialiased(img1, img2, x, y) || is_antialiased(img2, img1, x, y)) {
aa_count += 1
} else {
diff_count += 1
diff_map[row_offset + x] = true
// Update grid
let gx = if cell_w > 0 { x / cell_w } else { 0 }
let gy = if cell_h > 0 { y / cell_h } else { 0 }
let gx = if gx >= grid_cols { grid_cols - 1 } else { gx }
let gy = if gy >= grid_rows { grid_rows - 1 } else { gy }
grid[gy][gx] += 1
}
}
}
}
// Find connected regions using simple bounding box detection
let regions = find_diff_regions_flat(diff_map, width, height)
let match_ratio = if total_pixels > 0 {
1.0 - diff_count.to_double() / total_pixels.to_double()
} else {
1.0
}
{
width,
height,
total_pixels,
diff_count,
aa_count,
match_ratio,
grid,
grid_cols,
grid_rows,
regions,
}
}
///|
/// Find bounding boxes of diff regions using connected component labeling
/// Optimized: use flat arrays, check bounds before push
fn find_diff_regions_flat(
diff_map : Array[Bool],
width : Int,
height : Int,
) -> Array[DiffRegion] {
let size = width * height
let visited : Array[Bool] = Array::make(size, false)
let regions : Array[DiffRegion] = []
for y in 0.. 0 {
let cidx = stack.pop().unwrap()
if visited[cidx] || !diff_map[cidx] {
continue
}
visited[cidx] = true
pixel_count += 1
let cx = cidx % width
let cy = cidx / width
if cx < min_x {
min_x = cx
}
if cx > max_x {
max_x = cx
}
if cy < min_y {
min_y = cy
}
if cy > max_y {
max_y = cy
}
// Add neighbors with bounds check before push
if cx > 0 {
stack.push(cidx - 1)
}
if cx < width - 1 {
stack.push(cidx + 1)
}
if cy > 0 {
stack.push(cidx - width)
}
if cy < height - 1 {
stack.push(cidx + width)
}
}
if pixel_count > 0 {
regions.push({
x: min_x,
y: min_y,
width: max_x - min_x + 1,
height: max_y - min_y + 1,
diff_pixels: pixel_count,
})
}
}
}
}
regions
}
///|
/// Convert DiffReport to AI-readable text format
pub fn DiffReport::to_text(self : DiffReport) -> String {
let mut s = "=== Diff Report ===\n"
// Summary
s += "Summary:\n"
s += " Image size: " +
self.width.to_string() +
"x" +
self.height.to_string() +
"\n"
s += " Total pixels: " + self.total_pixels.to_string() + "\n"
s += " Different pixels: " + self.diff_count.to_string() + "\n"
s += " Anti-aliased pixels: " + self.aa_count.to_string() + "\n"
let pct = (self.match_ratio * 100.0).to_int()
s += " Match ratio: " + pct.to_string() + "%\n"
// Verdict
s += "\nVerdict: "
if self.diff_count == 0 {
s += "IDENTICAL\n"
} else if self.match_ratio > 0.99 {
s += "NEARLY_IDENTICAL (minor differences)\n"
} else if self.match_ratio > 0.95 {
s += "SIMILAR (small differences)\n"
} else if self.match_ratio > 0.8 {
s += "DIFFERENT (moderate differences)\n"
} else {
s += "VERY_DIFFERENT (major differences)\n"
}
// Grid heatmap
s += "\nHeatmap (" +
self.grid_cols.to_string() +
"x" +
self.grid_rows.to_string() +
" grid):\n"
// Find max for normalization
let mut max_val = 1
for row in self.grid {
for val in row {
if val > max_val {
max_val = val
}
}
}
// Header
s += " "
for i in 0.. 0 {
s += "\nDiff Regions (" + self.regions.length().to_string() + "):\n"
for i, region in self.regions {
s += " [" + i.to_string() + "] "
s += "pos=(" + region.x.to_string() + "," + region.y.to_string() + ") "
s += "size=" +
region.width.to_string() +
"x" +
region.height.to_string() +
" "
s += "pixels=" + region.diff_pixels.to_string() + "\n"
}
}
s
}
///|
/// Convert DiffReport to compact AI format
/// Minimal tokens, maximum spatial information
pub fn DiffReport::to_compact(self : DiffReport) -> String {
let mut s = ""
// One-line summary
let pct = (self.match_ratio * 100.0).to_int()
s += "diff:" +
self.diff_count.to_string() +
"/" +
self.total_pixels.to_string() +
"(" +
pct.to_string() +
"%match)\n"
// Binary heatmap - single chars, no spaces
for row in self.grid {
for val in row {
s += if val == 0 { "." } else { "X" }
}
s += "\n"
}
// Compact regions: x,y,w,h format
if self.regions.length() > 0 {
s += "regions:"
for i, r in self.regions {
if i > 0 {
s += ";"
}
s += r.x.to_string() +
"," +
r.y.to_string() +
"," +
r.width.to_string() +
"x" +
r.height.to_string()
}
s += "\n"
}
s
}
///|
/// Analyze grid pattern to detect shape hints
fn detect_shape_hints(
grid : Array[Array[Int]],
regions : Array[DiffRegion],
) -> Array[String] {
let hints : Array[String] = []
let rows = grid.length()
let cols = if rows > 0 { grid[0].length() } else { 0 }
if rows == 0 || cols == 0 {
return hints
}
// Check for hole (ring/donut): diff on edges but gap in middle
// Check inner gap (center area has significantly less diff than edges)
// Use smaller center region (middle 20%) for better hole detection
let center_start_r = rows * 2 / 5
let center_end_r = rows * 3 / 5
let center_start_c = cols * 2 / 5
let center_end_c = cols * 3 / 5
let mut center_weight = 0
let mut edge_weight = 0
let mut total_weight = 0
for r in 0..= center_start_r &&
r < center_end_r &&
c >= center_start_c &&
c < center_end_c {
center_weight += v
} else {
edge_weight += v
}
}
}
// If edges have significantly more diff than center, likely has a hole
// Use ratio < 0.2 for smaller center region
let has_hole = edge_weight > 0 &&
center_weight.to_double() / total_weight.to_double() < 0.05
if has_hole && regions.length() == 1 {
hints.push("HAS_HOLE: shape may have empty center (ring/donut/frame)")
}
// Check for border/frame pattern
let mut is_border = true
let rows_inner = rows - 1
let cols_inner = cols - 1
for r in 1.. 0 {
// Check if it's only on edges
let on_edge = r < 2 || r >= rows - 2 || c < 2 || c >= cols - 2
if !on_edge {
is_border = false
}
}
}
}
if is_border && (grid[0][0] > 0 || grid[0][cols - 1] > 0) {
hints.push("IS_BORDER: changes only on edges (frame pattern)")
}
// Check for directional shape (asymmetric)
let mut top_weight = 0
let mut bottom_weight = 0
let mut left_weight = 0
let mut right_weight = 0
for r in 0.. 0 {
top_weight.to_double() / bottom_weight.to_double()
} else {
0.0
}
let horizontal_ratio = if right_weight > 0 {
left_weight.to_double() / right_weight.to_double()
} else {
0.0
}
// Only add directional hints if there's a significant imbalance
// Use 1.08 threshold (8% asymmetry), but only show the most prominent direction
// Prefer vertical direction when asymmetries are equal (arrows typically point up/down)
let v_asymmetry = if vertical_ratio > 1.0 {
vertical_ratio
} else {
1.0 / vertical_ratio
}
let h_asymmetry = if horizontal_ratio > 1.0 {
horizontal_ratio
} else {
1.0 / horizontal_ratio
}
// Only show directional hint for the dominant axis, and only if significant
if v_asymmetry >= 1.08 &&
v_asymmetry >= h_asymmetry &&
top_weight + bottom_weight > 0 {
let dir = if vertical_ratio >= 1.08 {
"top-heavy (pointing up?)"
} else {
"bottom-heavy (pointing down?)"
}
hints.push("DIRECTIONAL: " + dir)
} else if h_asymmetry >= 1.08 && left_weight + right_weight > 0 {
let dir = if horizontal_ratio >= 1.08 {
"left-heavy (pointing left?)"
} else {
"right-heavy (pointing right?)"
}
hints.push("DIRECTIONAL: " + dir)
}
// Check for multiple separate regions
if regions.length() > 3 {
hints.push(
"MULTI_REGION: " +
regions.length().to_string() +
" separate areas (scattered or pattern)",
)
}
// Check for repeating pattern (checkerboard-like)
if regions.length() > 5 {
let first = regions[0]
let mut same_size = true
for i in 1.. 2 ||
(r.height - first.height).abs() > 2 {
same_size = false
}
}
if same_size {
hints.push("REPEATING: similar-sized regions (grid/checkerboard pattern)")
}
}
hints
}
///|
/// Convert DiffReport to compact format with shape hints
/// Adds contextual hints to help AI interpretation
pub fn DiffReport::to_compact_with_hints(self : DiffReport) -> String {
let mut s = self.to_compact()
let hints = detect_shape_hints(self.grid, self.regions)
if hints.length() > 0 {
s += "hints:"
for i, hint in hints {
if i > 0 {
s += ";"
}
s += hint
}
s += "\n"
}
s
}
///|
/// Convert DiffReport to JSON string
pub fn DiffReport::to_json(self : DiffReport) -> String {
let mut s = "{\n"
s += " \"width\": " + self.width.to_string() + ",\n"
s += " \"height\": " + self.height.to_string() + ",\n"
s += " \"total_pixels\": " + self.total_pixels.to_string() + ",\n"
s += " \"diff_count\": " + self.diff_count.to_string() + ",\n"
s += " \"aa_count\": " + self.aa_count.to_string() + ",\n"
s += " \"match_ratio\": " + self.match_ratio.to_string() + ",\n"
// Grid
s += " \"grid\": [\n"
for row_idx, row in self.grid {
s += " ["
for col_idx, val in row {
s += val.to_string()
if col_idx < row.length() - 1 {
s += ", "
}
}
s += "]"
if row_idx < self.grid.length() - 1 {
s += ","
}
s += "\n"
}
s += " ],\n"
// Regions
s += " \"regions\": [\n"
for i, region in self.regions {
s += " {\"x\": " + region.x.to_string()
s += ", \"y\": " + region.y.to_string()
s += ", \"width\": " + region.width.to_string()
s += ", \"height\": " + region.height.to_string()
s += ", \"diff_pixels\": " + region.diff_pixels.to_string() + "}"
if i < self.regions.length() - 1 {
s += ","
}
s += "\n"
}
s += " ]\n"
s += "}"
s
}