diff --git a/noise.mbt b/noise.mbt new file mode 100644 index 0000000..9a63041 --- /dev/null +++ b/noise.mbt @@ -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..= 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.. 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 +} diff --git a/pkg.generated.mbti b/pkg.generated.mbti index aaf8106..86a0895 100644 --- a/pkg.generated.mbti +++ b/pkg.generated.mbti @@ -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