///|
/// Neighborhood geometry used by mask cleanup and sprite extraction.
pub(all) enum MorphologyMode {
Square
Diamond
Cross
} derive(Eq, Debug)
///|
pub(all) struct ComponentInfo {
label : Int
pixels : Int
left : Int
top : Int
right : Int
bottom : Int
} derive(Debug)
///|
pub fn ComponentInfo::label(self : ComponentInfo) -> Int {
self.label
}
///|
pub fn ComponentInfo::pixels(self : ComponentInfo) -> Int {
self.pixels
}
///|
pub fn ComponentInfo::bounds(self : ComponentInfo) -> GifRect {
GifRect::new(
left=self.left,
top=self.top,
width=self.right - self.left + 1,
height=self.bottom - self.top + 1,
)
}
///|
fn morphology_offsets(mode : MorphologyMode, radius : Int) -> Array[(Int, Int)] {
let safe_radius = if radius < 0 { 0 } else { radius }
let offsets : Array[(Int, Int)] = []
for y in -safe_radius..<(safe_radius + 1) {
for x in -safe_radius..<(safe_radius + 1) {
let distance = if mode == Diamond { x.abs() + y.abs() } else { 0 }
let selected = match mode {
Square => true
Diamond => distance <= safe_radius
Cross => x == 0 || y == 0
}
if selected {
offsets.push((x, y))
}
}
}
offsets
}
///|
fn alpha_at(canvas : Canvas, x : Int, y : Int) -> Int {
match canvas.get(x~, y~) {
None => 0
Some(color) => color.alpha().to_int()
}
}
///|
fn any_neighbor(
canvas : Canvas,
x : Int,
y : Int,
offsets : ArrayView[(Int, Int)],
threshold : Int,
) -> Bool {
for pair in offsets {
let (dx, dy) = pair
if alpha_at(canvas, x + dx, y + dy) >= threshold {
return true
}
}
false
}
///|
fn all_neighbor(
canvas : Canvas,
x : Int,
y : Int,
offsets : ArrayView[(Int, Int)],
threshold : Int,
) -> Bool {
for pair in offsets {
let (dx, dy) = pair
if alpha_at(canvas, x + dx, y + dy) < threshold {
return false
}
}
true
}
///|
/// Expand opaque regions using a square, diamond, or cross neighborhood.
pub fn Canvas::dilate(
self : Canvas,
radius : Int,
mode? : MorphologyMode = Square,
threshold? : Byte = 1,
) -> Canvas {
let offsets = morphology_offsets(mode, radius)
let result = Canvas::new(
width=self.width,
height=self.height,
fill=rgba(0, 0, 0, 0),
)
for y in 0..= threshold.to_int() => {
chosen = color
break
}
_ => ()
}
}
ignore(result.set(x~, y~, color=chosen))
}
}
}
result
}
///|
/// Contract opaque regions while preserving the source color where possible.
pub fn Canvas::erode(
self : Canvas,
radius : Int,
mode? : MorphologyMode = Square,
threshold? : Byte = 1,
) -> Canvas {
let offsets = morphology_offsets(mode, radius)
let result = Canvas::new(
width=self.width,
height=self.height,
fill=rgba(0, 0, 0, 0),
)
for y in 0..= threshold.to_int() &&
all_neighbor(self, x, y, offsets[:], threshold.to_int()) =>
ignore(result.set(x~, y~, color~))
_ => ()
}
}
}
result
}
///|
/// Open an alpha mask: erode then dilate.
pub fn Canvas::open_mask(
self : Canvas,
radius : Int,
mode? : MorphologyMode = Square,
threshold? : Byte = 1,
) -> Canvas {
self.erode(radius, mode~, threshold~).dilate(radius, mode~, threshold~)
}
///|
/// Close small holes in an alpha mask: dilate then erode.
pub fn Canvas::close_mask(
self : Canvas,
radius : Int,
mode? : MorphologyMode = Square,
threshold? : Byte = 1,
) -> Canvas {
self.dilate(radius, mode~, threshold~).erode(radius, mode~, threshold~)
}
///|
/// Return the newly exposed outline of an alpha shape.
pub fn Canvas::outline(
self : Canvas,
radius? : Int = 1,
mode? : MorphologyMode = Square,
threshold? : Byte = 1,
) -> Canvas {
let expanded = self.dilate(radius, mode~, threshold~)
let result = Canvas::new(
width=self.width,
height=self.height,
fill=rgba(0, 0, 0, 0),
)
for i in 0..= threshold.to_int() &&
source_alpha < threshold.to_int() {
result.pixels[i] = rgba(
color.red(),
color.green(),
color.blue(),
color.alpha(),
)
}
}
result
}
///|
/// Keep only pixels whose color is within a Manhattan distance of the key.
pub fn Canvas::chroma_key(self : Canvas, key : Rgba, tolerance : Int) -> Canvas {
let safe_tolerance = if tolerance < 0 { 0 } else { tolerance }
let result = self.copy()
for i in 0.. Int {
let safe_tolerance = if tolerance < 0 { 0 } else { tolerance }
let mut count = 0
for i in 0.. ComponentInfo {
let queue : Array[(Int, Int)] = [(start_x, start_y)]
let width = canvas.width
visited[start_x + start_y * width] = true
let mut cursor = 0
let mut count = 0
let mut left = start_x
let mut right = start_x
let mut top = start_y
let mut bottom = start_y
while cursor < queue.length() {
let (x, y) = queue[cursor]
cursor += 1
count += 1
if x < left {
left = x
}
if x > right {
right = x
}
if y < top {
top = y
}
if y > bottom {
bottom = y
}
let neighbors = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)]
for pair in neighbors {
let (nx, ny) = pair
if nx >= 0 && ny >= 0 && nx < width && ny < canvas.height {
let index = nx + ny * width
if !visited[index] && alpha_at(canvas, nx, ny) >= threshold {
visited[index] = true
queue.push((nx, ny))
}
}
}
}
ComponentInfo::{
label: start_x + start_y * width + 1,
pixels: count,
left,
top,
right,
bottom,
}
}
///|
/// Find four-connected opaque components for sprite and mask extraction.
pub fn Canvas::components(
self : Canvas,
threshold? : Byte = 1,
minimum_pixels? : Int = 1,
) -> Array[ComponentInfo] {
let minimum = if minimum_pixels < 1 { 1 } else { minimum_pixels }
let visited = Array::make(self.pixels.length(), false)
let result : Array[ComponentInfo] = []
for y in 0..= threshold.to_int() {
let component = component_neighbors(
self,
x,
y,
visited,
threshold.to_int(),
)
if component.pixels >= minimum {
result.push(component)
}
}
}
}
result
}
///|
/// Paint a connected region starting at a coordinate, returning changed pixels.
pub fn Canvas::flood_fill(
self : Canvas,
x : Int,
y : Int,
replacement : Rgba,
tolerance? : Int = 0,
) -> Int {
match self.get(x~, y~) {
None => 0
Some(seed) => {
let safe_tolerance = if tolerance < 0 { 0 } else { tolerance }
if seed.distance(replacement) <= safe_tolerance && seed == replacement {
0
} else {
let queue : Array[(Int, Int)] = [(x, y)]
let visited = Array::make(self.pixels.length(), false)
let mut cursor = 0
let mut changed = 0
while cursor < queue.length() {
let (px, py) = queue[cursor]
cursor += 1
if px >= 0 && py >= 0 && px < self.width && py < self.height {
let index = px + py * self.width
if !visited[index] {
visited[index] = true
let current = self.pixels[index]
if current.distance(seed) <= safe_tolerance {
self.pixels[index] = replacement
changed += 1
queue.push((px - 1, py))
queue.push((px + 1, py))
queue.push((px, py - 1))
queue.push((px, py + 1))
}
}
}
}
changed
}
}
}
}
///|
/// Build a binary alpha mask with an explicit threshold.
pub fn Canvas::binary_mask(self : Canvas, threshold : Byte) -> Canvas {
let result = Canvas::new(
width=self.width,
height=self.height,
fill=rgba(0, 0, 0, 0),
)
for i in 0..= threshold {
rgba(255, 255, 255, 255)
} else {
rgba(0, 0, 0, 0)
}
}
result
}
///|
/// Remove connected components smaller than a pixel threshold.
pub fn Canvas::remove_small_components(
self : Canvas,
minimum_pixels : Int,
threshold? : Byte = 1,
) -> Canvas {
let result = self.copy()
let components = self.components(threshold~, minimum_pixels~)
let allowed : Map[Int, Bool] = Map([])
for component in components {
allowed[component.label()] = true
}
let visited = Array::make(self.pixels.length(), false)
for y in 0..= threshold.to_int() {
let component = component_neighbors(
self,
x,
y,
visited,
threshold.to_int(),
)
if allowed.get(component.label()) is None {
for py in component.top..<(component.bottom + 1) {
for px in component.left..<(component.right + 1) {
if px >= 0 && py >= 0 && px < self.width && py < self.height {
let offset = px + py * self.width
if self.pixels[offset].alpha().to_int() >= threshold.to_int() {
result.pixels[offset] = result.pixels[offset].with_alpha(0)
}
}
}
}
}
}
}
}
result
}