Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions noise.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
///|
/// Deterministic noise generators for testing and denoise demos. All
/// randomness comes from a 64-bit LCG seeded by the caller, so a given
/// seed produces the identical image on every backend and every run —
/// keeping the library's reproducibility promise even for "random" ops.

///|
/// 64-bit linear congruential step (Knuth MMIX constants). UInt64
/// arithmetic wraps, which is exactly what an LCG needs.
fn lcg_next(state : UInt64) -> UInt64 {
state * 6364136223846793005UL + 1442695040888963407UL
}

///|
/// Extracts a uniform value in [0, 1) from the high bits of the state
/// (the low bits of an LCG are weak; the top 24 give a clean mantissa).
fn lcg_uniform(state : UInt64) -> Double {
((state >> 40) & 0xFFFFFFUL).to_uint().reinterpret_as_int().to_double() /
16777216.0
}

///|
/// Adds zero-mean Gaussian noise with standard deviation `sigma` (in luma
/// steps) to R, G and B; alpha is untouched. The normal deviate is the
/// classic CLT approximation (sum of 12 uniforms minus 6), which is
/// branch-free and deterministic. `sigma <= 0` returns a copy.
pub fn Image::add_gaussian_noise(
self : Image,
seed : UInt64,
sigma : Double,
) -> Image {
if sigma <= 0.0 {
return self.copy()
}
let out = self.copy()
let n = self.pixel_count()
let mut state = lcg_next(seed ^ 0x9E3779B97F4A7C15UL)
for p in 0..<n {
let base = p * 4
// One shared deviate per pixel keeps the noise gray (no color fringes).
let mut acc = 0.0
for _ in 0..<12 {
state = lcg_next(state)
acc = acc + lcg_uniform(state)
}
let gauss = acc - 6.0 // ~N(0, 1)
let scaled = gauss * sigma
let rounded = if scaled >= 0.0 { scaled + 0.5 } else { scaled - 0.5 }
let delta = rounded.to_int()
for c in 0..<3 {
out.data[base + c] = clamp_byte(self.data[base + c].to_int() + delta)
}
}
out
}

///|
/// Salt-and-pepper noise: each pixel independently becomes pure white
/// ("salt") or pure black ("pepper") with probability `density / 2` each.
/// `density` is clamped into [0, 1]; alpha is preserved. Deterministic
/// for a given seed.
pub fn Image::add_salt_pepper(
self : Image,
seed : UInt64,
density : Double,
) -> Image {
let d = if density < 0.0 {
0.0
} else if density > 1.0 {
1.0
} else {
density
}
let out = self.copy()
if d == 0.0 {
return out
}
let n = self.pixel_count()
let mut state = lcg_next(seed ^ 0xD1B54A32D192ED03UL)
for p in 0..<n {
state = lcg_next(state)
let u = lcg_uniform(state)
if u < d {
let base = p * 4
let v : Byte = if u < d / 2.0 { b'\x00' } else { b'\xFF' }
out.data[base] = v
out.data[base + 1] = v
out.data[base + 2] = v
}
}
out
}
120 changes: 120 additions & 0 deletions noise_test.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
///|
/// Noise generator tests: seed determinism, statistical sanity of the
/// generated noise, degenerate parameters, and alpha preservation.

///|
test "same seed reproduces the identical noisy image" {
let img = solid(16, 16, 128, 128, 128, 255)
let a = img.add_gaussian_noise(42UL, 12.0)
let b = img.add_gaussian_noise(42UL, 12.0)
for i in 0..<(16 * 16 * 4) {
assert_eq(a.data[i].to_int(), b.data[i].to_int())
}
let sp1 = img.add_salt_pepper(7UL, 0.2)
let sp2 = img.add_salt_pepper(7UL, 0.2)
for i in 0..<(16 * 16 * 4) {
assert_eq(sp1.data[i].to_int(), sp2.data[i].to_int())
}
}

///|
test "different seeds produce different noise" {
let img = solid(16, 16, 128, 128, 128, 255)
let a = img.add_gaussian_noise(1UL, 12.0)
let b = img.add_gaussian_noise(2UL, 12.0)
let mut differing = 0
for i in 0..<(16 * 16 * 4) {
if a.data[i].to_int() != b.data[i].to_int() {
differing = differing + 1
}
}
assert_true(differing > 0)
}

///|
test "gaussian noise stays roughly zero-mean and actually perturbs pixels" {
// On a mid-gray field the mean must stay near 128 and plenty of pixels
// must move; the exact values are pinned by the seeded LCG.
let img = solid(32, 32, 128, 128, 128, 255)
let out = img.add_gaussian_noise(99UL, 10.0)
let mut sum = 0
let mut changed = 0
for y in 0..<32 {
for x in 0..<32 {
let v = out.get_pixel(x, y).0.to_int()
sum = sum + v
if v != 128 {
changed = changed + 1
}
}
}
let mean = sum / (32 * 32)
assert_true(mean >= 123 && mean <= 133) // ~128 +/- 5
assert_true(changed > 512) // most pixels perturbed
// Alpha untouched.
assert_eq(out.get_pixel(0, 0).3.to_int(), 255)
}

///|
test "sigma 0 and density 0 are exact copies" {
let img = gray_image(4, 1, [10, 100, 180, 250])
let g = img.add_gaussian_noise(5UL, 0.0)
let s = img.add_salt_pepper(5UL, 0.0)
for i in 0..<(4 * 4) {
assert_eq(g.data[i].to_int(), img.data[i].to_int())
assert_eq(s.data[i].to_int(), img.data[i].to_int())
}
}

///|
test "salt and pepper hits roughly the requested density with both colors" {
// density 0.3 on 64x64 mid-gray: corrupted pixels are exactly 0 or 255,
// count within a generous band around 30%, and both colors appear.
let img = solid(64, 64, 128, 128, 128, 255)
let out = img.add_salt_pepper(2026UL, 0.3)
let mut salt = 0
let mut pepper = 0
let mut untouched = 0
for y in 0..<64 {
for x in 0..<64 {
let v = out.get_pixel(x, y).0.to_int()
if v == 255 {
salt = salt + 1
} else if v == 0 {
pepper = pepper + 1
} else {
assert_eq(v, 128) // anything else must be the original gray
untouched = untouched + 1
}
}
}
let corrupted = salt + pepper
let total = 64 * 64
assert_true(corrupted > total * 24 / 100) // >= ~24%
assert_true(corrupted < total * 36 / 100) // <= ~36%
assert_true(salt > 0 && pepper > 0)
assert_eq(corrupted + untouched, total)
}

///|
test "median filter visibly cleans salt-and-pepper noise" {
// The classic pairing: impulse noise -> 3x3 median. Count wrong pixels
// before and after; the median must remove most of them.
let img = solid(32, 32, 100, 100, 100, 255)
let noisy = img.add_salt_pepper(11UL, 0.08)
let cleaned = noisy.median()
let mut wrong_before = 0
let mut wrong_after = 0
for y in 0..<32 {
for x in 0..<32 {
if noisy.get_pixel(x, y).0.to_int() != 100 {
wrong_before = wrong_before + 1
}
if cleaned.get_pixel(x, y).0.to_int() != 100 {
wrong_after = wrong_after + 1
}
}
}
assert_true(wrong_before > 40) // noise actually landed
assert_true(wrong_after * 4 < wrong_before) // >= 75% removed
}
2 changes: 2 additions & 0 deletions pkg.generated.mbti
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ pub(all) struct Image {
height : Int
data : FixedArray[Byte]
}
pub fn Image::add_gaussian_noise(Self, UInt64, Double) -> Self
pub fn Image::add_salt_pepper(Self, UInt64, Double) -> Self
pub fn Image::affine(Self, Affine) -> Self
pub fn Image::apply_filter_id(Self, Int, Double) -> Self
pub fn Image::auto_contrast(Self) -> Self
Expand Down
Loading