///|
/// Per-channel 8-bit histogram collected in one pass over a canvas.
pub(all) struct PixelHistogram {
red : Array[Int]
green : Array[Int]
blue : Array[Int]
alpha : Array[Int]
luma : Array[Int]
total : Int
} derive(Debug)
///|
fn empty_bins() -> Array[Int] {
Array::make(256, 0)
}
///|
pub fn PixelHistogram::red_bins(self : PixelHistogram) -> ArrayView[Int] {
self.red[:]
}
///|
pub fn PixelHistogram::green_bins(self : PixelHistogram) -> ArrayView[Int] {
self.green[:]
}
///|
pub fn PixelHistogram::blue_bins(self : PixelHistogram) -> ArrayView[Int] {
self.blue[:]
}
///|
pub fn PixelHistogram::alpha_bins(self : PixelHistogram) -> ArrayView[Int] {
self.alpha[:]
}
///|
pub fn PixelHistogram::luma_bins(self : PixelHistogram) -> ArrayView[Int] {
self.luma[:]
}
///|
pub fn PixelHistogram::total(self : PixelHistogram) -> Int {
self.total
}
///|
/// Count all pixel channels, including transparent colors, for diagnostics.
pub fn Canvas::pixel_histogram(self : Canvas) -> PixelHistogram {
let red = empty_bins()
let green = empty_bins()
let blue = empty_bins()
let alpha = empty_bins()
let luma = empty_bins()
for color in self.pixels {
red[color.red().to_int()] += 1
green[color.green().to_int()] += 1
blue[color.blue().to_int()] += 1
alpha[color.alpha().to_int()] += 1
luma[color.luma().to_int()] += 1
}
PixelHistogram::{ red, green, blue, alpha, luma, total: self.pixels.length() }
}
///|
fn histogram_peak(bins : ArrayView[Int]) -> Byte {
let mut best_index = 0
let mut best_count = -1
for i, count in bins {
if count > best_count {
best_index = i
best_count = count
}
}
best_index.to_byte()
}
///|
pub fn PixelHistogram::red_peak(self : PixelHistogram) -> Byte {
histogram_peak(self.red[:])
}
///|
pub fn PixelHistogram::green_peak(self : PixelHistogram) -> Byte {
histogram_peak(self.green[:])
}
///|
pub fn PixelHistogram::blue_peak(self : PixelHistogram) -> Byte {
histogram_peak(self.blue[:])
}
///|
pub fn PixelHistogram::luma_peak(self : PixelHistogram) -> Byte {
histogram_peak(self.luma[:])
}
///|
fn histogram_mean(bins : ArrayView[Int], total : Int) -> Int {
if total <= 0 {
0
} else {
let mut sum = 0
for i, count in bins {
sum += i * count
}
sum / total
}
}
///|
pub fn PixelHistogram::mean_luma(self : PixelHistogram) -> Int {
histogram_mean(self.luma[:], self.total)
}
///|
pub fn PixelHistogram::percentile_luma(
self : PixelHistogram,
percentile : Int,
) -> Byte {
if self.total == 0 {
0
} else {
let target = self.total *
(if percentile < 0 {
0
} else if percentile > 100 {
100
} else {
percentile
}) /
100
let mut seen = 0
let mut result = 255
for i, count in self.luma {
seen += count
if seen > target {
result = i
break
}
}
result.to_byte()
}
}
///|
/// Region-level statistics used for crop selection and scene analysis.
pub(all) struct RegionSummary {
rect : GifRect
pixels : Int
opaque_pixels : Int
transparent_pixels : Int
average : Rgba
minimum_luma : Byte
maximum_luma : Byte
} derive(Debug)
///|
pub fn RegionSummary::rect(self : RegionSummary) -> GifRect {
self.rect
}
///|
pub fn RegionSummary::pixels(self : RegionSummary) -> Int {
self.pixels
}
///|
pub fn RegionSummary::opaque_pixels(self : RegionSummary) -> Int {
self.opaque_pixels
}
///|
pub fn RegionSummary::transparent_pixels(self : RegionSummary) -> Int {
self.transparent_pixels
}
///|
pub fn RegionSummary::average(self : RegionSummary) -> Rgba {
self.average
}
///|
pub fn RegionSummary::minimum_luma(self : RegionSummary) -> Byte {
self.minimum_luma
}
///|
pub fn RegionSummary::maximum_luma(self : RegionSummary) -> Byte {
self.maximum_luma
}
///|
/// Summarize a clipped logical rectangle. Empty rectangles return transparent
/// black and zero counts, which keeps analysis pipelines total.
pub fn Canvas::summarize(self : Canvas, rect : GifRect) -> RegionSummary {
let left = if rect.left() < 0 {
0
} else if rect.left() > self.width {
self.width
} else {
rect.left()
}
let top = if rect.top() < 0 {
0
} else if rect.top() > self.height {
self.height
} else {
rect.top()
}
let right = if rect.right() > self.width {
self.width
} else if rect.right() < left {
left
} else {
rect.right()
}
let bottom = if rect.bottom() > self.height {
self.height
} else if rect.bottom() < top {
top
} else {
rect.bottom()
}
let mut pixels = 0
let mut opaque_count = 0
let mut transparent = 0
let mut red = 0
let mut green = 0
let mut blue = 0
let mut alpha = 0
let mut minimum = 255
let mut maximum = 0
for y in top.. maximum {
maximum = luma
}
}
}
let average = if pixels == 0 {
rgba(0, 0, 0, 0)
} else {
rgba(
(red / pixels).to_byte(),
(green / pixels).to_byte(),
(blue / pixels).to_byte(),
(alpha / pixels).to_byte(),
)
}
RegionSummary::{
rect: GifRect::new(left~, top~, width=right - left, height=bottom - top),
pixels,
opaque_pixels: opaque_count,
transparent_pixels: transparent,
average,
minimum_luma: if pixels == 0 {
0
} else {
minimum.to_byte()
},
maximum_luma: if pixels == 0 {
0
} else {
maximum.to_byte()
},
}
}
///|
/// Mean squared and perceptual error metrics for comparing encoded frames.
pub(all) struct QualityMetrics {
pixels : Int
differing_pixels : Int
mean_squared_error : Int
maximum_error : Int
alpha_error : Int
psnr_milli : Int
} derive(Debug)
///|
pub fn QualityMetrics::pixels(self : QualityMetrics) -> Int {
self.pixels
}
///|
pub fn QualityMetrics::differing_pixels(self : QualityMetrics) -> Int {
self.differing_pixels
}
///|
pub fn QualityMetrics::mean_squared_error(self : QualityMetrics) -> Int {
self.mean_squared_error
}
///|
pub fn QualityMetrics::maximum_error(self : QualityMetrics) -> Int {
self.maximum_error
}
///|
pub fn QualityMetrics::alpha_error(self : QualityMetrics) -> Int {
self.alpha_error
}
///|
pub fn QualityMetrics::psnr_milli(self : QualityMetrics) -> Int {
self.psnr_milli
}
///|
/// Compare two canvases using all four channels. `psnr_milli` is a fixed-point
/// approximation that avoids floating-point differences in test snapshots.
pub fn Canvas::quality_against(
self : Canvas,
reference : Canvas,
) -> QualityMetrics {
if self.width != reference.width || self.height != reference.height {
QualityMetrics::{
pixels: self.pixels.length() + reference.pixels.length(),
differing_pixels: self.pixels.length() + reference.pixels.length(),
mean_squared_error: 65025,
maximum_error: 255,
alpha_error: 255,
psnr_milli: 0,
}
} else {
let mut sum = 0
let mut different = 0
let mut maximum = 0
let mut alpha_sum = 0
for i in 0.. maximum {
maximum = error
}
if squared != 0 || da != 0 {
different += 1
}
}
let pixels = self.pixels.length()
let mse = if pixels == 0 { 0 } else { sum / pixels }
let psnr = if mse == 0 { 99000 } else { 48000 - int_log2(mse) * 3010 }
QualityMetrics::{
pixels,
differing_pixels: different,
mean_squared_error: mse,
maximum_error: maximum,
alpha_error: if pixels == 0 {
0
} else {
alpha_sum / pixels
},
psnr_milli: if psnr < 0 {
0
} else {
psnr
},
}
}
}
///|
fn int_log2(value : Int) -> Int {
let mut current = if value < 1 { 1 } else { value }
let mut result = 0
while current > 1 {
current /= 2
result += 1
}
result
}
///|
/// A hard threshold with optional soft transition around the cutoff.
pub fn Canvas::threshold(
self : Canvas,
cutoff : Int,
softness? : Int = 0,
) -> Canvas {
let safe_softness = if softness < 0 { 0 } else { softness }
let result = self.copy()
for i in 0..= cutoff {
255
} else {
0
}
} else {
let low = cutoff - safe_softness
let high = cutoff + safe_softness
if value <= low {
0
} else if value >= high {
255
} else {
(value - low) * 255 / (high - low)
}
}
result.pixels[i] = rgba(
output.to_byte(),
output.to_byte(),
output.to_byte(),
source.alpha(),
)
}
result
}
///|
/// Stretch the luma range between the requested black and white points.
pub fn Canvas::normalize_luma(
self : Canvas,
black? : Int = 0,
white? : Int = 255,
) -> Canvas {
let low = if black < 0 { 0 } else if black > 255 { 255 } else { black }
let high = if white <= low {
low + 1
} else if white > 255 {
255
} else {
white
}
let result = self.copy()
for i in 0.. Canvas {
if levels <= 1 {
self.threshold(128)
} else {
let safe_levels = if levels > 256 { 256 } else { levels }
let result = self.copy()
for i in 0.. Byte {
let index = value.to_int() * (levels - 1) / 255
(index * 255 / (levels - 1)).to_byte()
}
///|
/// Find connected opaque components using four-neighbor flood fill.
pub fn Canvas::opaque_components(
self : Canvas,
threshold? : Byte = 1,
) -> Array[GifRect] {
let visited = Array::make(self.width * self.height, false)
let components : Array[GifRect] = []
for y in 0..= threshold {
let queue : Array[(Int, Int)] = [(x, y)]
visited[start] = true
let mut head = 0
let mut min_x = x
let mut max_x = x
let mut min_y = y
let mut max_y = y
while head < queue.length() {
let (current_x, current_y) = queue[head]
head += 1
if current_x < min_x {
min_x = current_x
}
if current_x > max_x {
max_x = current_x
}
if current_y < min_y {
min_y = current_y
}
if current_y > max_y {
max_y = current_y
}
let neighbors = [
(current_x - 1, current_y),
(current_x + 1, current_y),
(current_x, current_y - 1),
(current_x, current_y + 1),
]
for neighbor in neighbors {
let (next_x, next_y) = neighbor
if next_x >= 0 &&
next_y >= 0 &&
next_x < self.width &&
next_y < self.height {
let next = next_x + next_y * self.width
if !visited[next] && self.pixels[next].alpha() >= threshold {
visited[next] = true
queue.push((next_x, next_y))
}
}
}
}
components.push(
GifRect::new(
left=min_x,
top=min_y,
width=max_x - min_x + 1,
height=max_y - min_y + 1,
),
)
}
}
}
components
}