diff --git a/.github/workflows/pixel-grid-reconstructor.yml b/.github/workflows/pixel-grid-reconstructor.yml new file mode 100644 index 00000000..1db506f2 --- /dev/null +++ b/.github/workflows/pixel-grid-reconstructor.yml @@ -0,0 +1,29 @@ +name: Pixel grid reconstructor CI + +on: + push: + paths: + - "backend/native/pixel_grid_reconstructor/**" + - ".github/workflows/pixel-grid-reconstructor.yml" + pull_request: + paths: + - "backend/native/pixel_grid_reconstructor/**" + - ".github/workflows/pixel-grid-reconstructor.yml" + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend/native/pixel_grid_reconstructor + steps: + - uses: actions/checkout@v7 + + - name: Rust format + run: cargo fmt --check + + - name: Rust tests + run: cargo test --release --locked diff --git a/backend/native/pixel_grid_reconstructor/.gitignore b/backend/native/pixel_grid_reconstructor/.gitignore new file mode 100644 index 00000000..b83d2226 --- /dev/null +++ b/backend/native/pixel_grid_reconstructor/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/backend/native/pixel_grid_reconstructor/Cargo.lock b/backend/native/pixel_grid_reconstructor/Cargo.lock new file mode 100644 index 00000000..7c12534c --- /dev/null +++ b/backend/native/pixel_grid_reconstructor/Cargo.lock @@ -0,0 +1,210 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "windup-pixel-grid-reconstructor" +version = "0.1.0" +dependencies = [ + "image", + "rayon", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/backend/native/pixel_grid_reconstructor/Cargo.toml b/backend/native/pixel_grid_reconstructor/Cargo.toml new file mode 100644 index 00000000..af030cfc --- /dev/null +++ b/backend/native/pixel_grid_reconstructor/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "windup-pixel-grid-reconstructor" +version = "0.1.0" +edition = "2021" +license = "MIT" +publish = false + +[dependencies] +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +rayon = "1.12.0" + +[[bin]] +name = "windup-pixel-grid-reconstructor" +path = "src/main.rs" + +[profile.release] +lto = true +codegen-units = 1 diff --git a/backend/native/pixel_grid_reconstructor/LICENSE b/backend/native/pixel_grid_reconstructor/LICENSE new file mode 100644 index 00000000..9610c123 --- /dev/null +++ b/backend/native/pixel_grid_reconstructor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Astropulse, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/native/pixel_grid_reconstructor/README.md b/backend/native/pixel_grid_reconstructor/README.md new file mode 100644 index 00000000..93d37c10 --- /dev/null +++ b/backend/native/pixel_grid_reconstructor/README.md @@ -0,0 +1,11 @@ +# Pixel grid reconstructor + +Windup 的独立显式网格重建器。它不检测像素密度,只读取 PNG/JPEG 字节与明确的 `cols`、`rows`、`colors` 参数,并输出对应尺寸的原生 1x PNG。 + +```bash +cargo run --release -- --cols 64 --rows 64 --colors 32 < input.png > output.png +``` + +模块使用两阶段重建:先对结构色标签投票确定每个格子的归属,再从原图中携带胜出标签的像素恢复颜色。输出的每个像素就是一个规则网格单元,透明度按格内多数票确定。 + +`colors` 控制结构聚类规模,不是最终图片的强制全局色板上限;这样不会把稀有高光或单像素强调色提前删除。若业务需要固定总色板,应在独立的调色板阶段明确处理。 diff --git a/backend/native/pixel_grid_reconstructor/UPSTREAM.md b/backend/native/pixel_grid_reconstructor/UPSTREAM.md new file mode 100644 index 00000000..5448498e --- /dev/null +++ b/backend/native/pixel_grid_reconstructor/UPSTREAM.md @@ -0,0 +1,5 @@ +# Upstream + +两阶段重建与 deterministic k-means 逻辑源自 [Retro-Diffusion/pixel-art-fixer](https://github.com/Retro-Diffusion/pixel-art-fixer),固定于提交 `ef376e57e1c272633ca2dbf5f29ec3fcf6596465`,使用 MIT License。 + +Windup 将显式规则网格重建提取为独立模块,删除检测、旧重建器和未使用的量化路径,并修复密集网格下负三角权重造成颜色外插的问题。 diff --git a/backend/native/pixel_grid_reconstructor/src/kmeans.rs b/backend/native/pixel_grid_reconstructor/src/kmeans.rs new file mode 100644 index 00000000..fb41523a --- /dev/null +++ b/backend/native/pixel_grid_reconstructor/src/kmeans.rs @@ -0,0 +1,214 @@ +//! Deterministic k-means++ used to separate structure labels before cells vote. + +/// xorshift64* — deterministic, decent quality, no deps. +pub struct Rng(u64); + +impl Rng { + pub fn new(seed: u64) -> Rng { + Rng(seed.max(1)) + } + pub fn next_u64(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545F4914F6CDD1D) + } + pub fn next_f64(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 + } + pub fn below(&mut self, n: usize) -> usize { + (self.next_f64() * n as f64) as usize % n.max(1) + } +} + +/// Evenly-spaced deterministic sample of up to `max_n` row indices. +pub fn even_sample(n: usize, max_n: usize) -> Vec { + if n <= max_n { + (0..n).collect() + } else { + (0..max_n) + .map(|i| ((i as f64) * (n as f64 - 1.0) / (max_n as f64 - 1.0)) as usize) + .collect() + } +} + +fn dist2(a: &[f32; 3], b: &[f32; 3]) -> f64 { + let mut s = 0f64; + for c in 0..3 { + let d = (a[c] - b[c]) as f64; + s += d * d; + } + s +} + +/// k-means++ init + Lloyd iterations; returns (centers, inertia). +fn kmeans_once( + points: &[[f32; 3]], + k: usize, + max_iter: usize, + eps: f64, + rng: &mut Rng, +) -> (Vec<[f32; 3]>, f64) { + let n = points.len(); + let mut centers: Vec<[f32; 3]> = Vec::with_capacity(k); + centers.push(points[rng.below(n)]); + let mut d2: Vec = points.iter().map(|p| dist2(p, ¢ers[0])).collect(); + while centers.len() < k { + let total: f64 = d2.iter().sum(); + let mut pick = 0usize; + if total > 0.0 { + let target = rng.next_f64() * total; + let mut acc = 0f64; + for (i, distance) in d2.iter().enumerate() { + acc += distance; + if acc >= target { + pick = i; + break; + } + } + } else { + pick = rng.below(n); + } + let c = points[pick]; + centers.push(c); + for (i, distance) in d2.iter_mut().enumerate().take(n) { + let d = dist2(&points[i], &c); + if d < *distance { + *distance = d; + } + } + } + + let mut labels = vec![0u32; n]; + for _ in 0..max_iter { + // assign + for i in 0..n { + let mut best = 0usize; + let mut bd = f64::INFINITY; + for (ci, c) in centers.iter().enumerate() { + let d = dist2(&points[i], c); + if d < bd { + bd = d; + best = ci; + } + } + labels[i] = best as u32; + } + // update + let mut sums = vec![[0f64; 3]; k]; + let mut cnts = vec![0usize; k]; + for i in 0..n { + let l = labels[i] as usize; + cnts[l] += 1; + for c in 0..3 { + sums[l][c] += points[i][c] as f64; + } + } + let mut max_shift = 0f64; + for ci in 0..k { + if cnts[ci] == 0 { + // OpenCV-style: reseed empty cluster at the farthest point + let mut far = 0usize; + let mut fd = -1f64; + for i in 0..n { + let d = dist2(&points[i], ¢ers[labels[i] as usize]); + if d > fd { + fd = d; + far = i; + } + } + centers[ci] = points[far]; + max_shift = f64::INFINITY; + continue; + } + let mut nc = [0f32; 3]; + for c in 0..3 { + nc[c] = (sums[ci][c] / cnts[ci] as f64) as f32; + } + let shift = dist2(&nc, ¢ers[ci]); + if shift > max_shift { + max_shift = shift; + } + centers[ci] = nc; + } + if max_shift <= eps * eps { + break; + } + } + let mut inertia = 0f64; + for i in 0..n { + inertia += dist2(&points[i], ¢ers[labels[i] as usize]); + } + (centers, inertia) +} + +/// Multi-attempt k-means (best inertia wins), fixed seed. +pub fn kmeans( + points: &[[f32; 3]], + k: usize, + max_iter: usize, + eps: f64, + attempts: usize, + seed: u64, +) -> Vec<[f32; 3]> { + let mut rng = Rng::new(seed); + let mut best: Option<(Vec<[f32; 3]>, f64)> = None; + for _ in 0..attempts { + let (c, inertia) = kmeans_once(points, k, max_iter, eps, &mut rng); + if best.as_ref().is_none_or(|b| inertia < b.1) { + best = Some((c, inertia)); + } + } + best.unwrap().0 +} + +/// k-means (sample for centroids, then assign every pixel) -> (labels, K). +/// Used by two-stage packing for the STRUCTURE quantisation. +pub fn kmeans_labels(rgba: &[u8], w: usize, h: usize, k: usize) -> (Vec, usize) { + use rayon::prelude::*; + let n = w * h; + let opaque: Vec = (0..n).filter(|&i| rgba[i * 4 + 3] > 0).collect(); + let src: Vec = if opaque.is_empty() { + (0..n).collect() + } else { + opaque + }; + let sample_idx = even_sample(src.len(), 60_000); + let sample: Vec<[f32; 3]> = sample_idx + .iter() + .map(|&si| { + let i = src[si]; + [ + rgba[i * 4] as f32, + rgba[i * 4 + 1] as f32, + rgba[i * 4 + 2] as f32, + ] + }) + .collect(); + let k_eff = k.min(sample.len()).max(1); + let centers = kmeans(&sample, k_eff, 15, 0.5, 1, 42); + let kc = centers.len(); + let labels: Vec = (0..n) + .into_par_iter() + .map(|i| { + let p = [ + rgba[i * 4] as f32, + rgba[i * 4 + 1] as f32, + rgba[i * 4 + 2] as f32, + ]; + let mut best = 0u32; + let mut bd = f32::INFINITY; + for (ci, c) in centers.iter().enumerate() { + let d = (p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2) + (p[2] - c[2]).powi(2); + if d < bd { + bd = d; + best = ci as u32; + } + } + best + }) + .collect(); + (labels, kc) +} diff --git a/backend/native/pixel_grid_reconstructor/src/lib.rs b/backend/native/pixel_grid_reconstructor/src/lib.rs new file mode 100644 index 00000000..302bdbe4 --- /dev/null +++ b/backend/native/pixel_grid_reconstructor/src/lib.rs @@ -0,0 +1,138 @@ +//! 独立显式网格重建器:图片与网格参数输入,原生 1x PNG 输出。 + +mod kmeans; +mod reconstruct; + +use std::collections::HashSet; +use std::io::Cursor; + +use image::{DynamicImage, ImageFormat, ImageReader, Limits, RgbaImage}; + +pub const MAX_INPUT_PIXELS: usize = 4_000_000; +pub const MAX_INPUT_BYTES: usize = 32 * 1024 * 1024; +pub const MAX_WORKING_BYTES: usize = 128 * 1024 * 1024; +pub const MIN_INPUT_SIDE: usize = 16; + +#[derive(Debug)] +pub struct ReconstructedImage { + pub png: Vec, + pub width: usize, + pub height: usize, + pub visible_color_count: usize, +} + +#[derive(Debug)] +pub struct ReconstructorError(String); + +impl std::fmt::Display for ReconstructorError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for ReconstructorError {} + +pub fn reconstruct_bytes( + source: &[u8], + cols: usize, + rows: usize, + structure_colors: usize, +) -> Result { + if source.len() > MAX_INPUT_BYTES { + return Err(ReconstructorError(format!( + "encoded input exceeds {MAX_INPUT_BYTES} bytes" + ))); + } + + let dimensions_reader = reader_for(source)?; + let (width, height) = dimensions_reader + .into_dimensions() + .map_err(|error| ReconstructorError(format!("cannot read image dimensions: {error}")))?; + let (width, height) = (width as usize, height as usize); + if width.min(height) < MIN_INPUT_SIDE { + return Err(ReconstructorError(format!( + "image is too small (minimum side is {MIN_INPUT_SIDE}px)" + ))); + } + let pixel_count = width + .checked_mul(height) + .ok_or_else(|| ReconstructorError("image dimensions overflow pixel count".into()))?; + if pixel_count > MAX_INPUT_PIXELS { + return Err(ReconstructorError(format!( + "image is too large (maximum is {MAX_INPUT_PIXELS} pixels)" + ))); + } + if cols == 0 || rows == 0 || cols > width || rows > height { + return Err(ReconstructorError(format!( + "grid must be within source bounds (received {cols}x{rows} for {width}x{height})" + ))); + } + if !(2..=64).contains(&structure_colors) { + return Err(ReconstructorError( + "structure colors must be between 2 and 64".into(), + )); + } + let cell_count = cols + .checked_mul(rows) + .ok_or_else(|| ReconstructorError("grid dimensions overflow cell count".into()))?; + let working_bytes = reconstruct::estimated_working_bytes( + pixel_count, + cell_count, + structure_colors, + width, + height, + ) + .ok_or_else(|| ReconstructorError("reconstruction working set overflow".into()))?; + if working_bytes > MAX_WORKING_BYTES { + return Err(ReconstructorError(format!( + "reconstruction working set exceeds {MAX_WORKING_BYTES} bytes" + ))); + } + + let mut decode_reader = reader_for(source)?; + let mut limits = Limits::default(); + limits.max_image_width = Some(width as u32); + limits.max_image_height = Some(height as u32); + limits.max_alloc = Some(64 * 1024 * 1024); + decode_reader.limits(limits); + let image = decode_reader + .decode() + .map_err(|error| ReconstructorError(format!("cannot decode PNG/JPEG image: {error}")))? + .to_rgba8(); + + let reconstructed = + reconstruct::two_stage_pack(image.as_raw(), width, height, cols, rows, structure_colors); + let visible_colors: HashSet<[u8; 3]> = reconstructed + .rgba + .chunks_exact(4) + .filter(|pixel| pixel[3] > 0) + .map(|pixel| [pixel[0], pixel[1], pixel[2]]) + .collect(); + let output = RgbaImage::from_raw( + reconstructed.cols as u32, + reconstructed.rows as u32, + reconstructed.rgba, + ) + .ok_or_else(|| ReconstructorError("invalid reconstruction buffer".into()))?; + let mut encoded = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(output) + .write_to(&mut encoded, ImageFormat::Png) + .map_err(|error| ReconstructorError(format!("cannot encode PNG: {error}")))?; + + Ok(ReconstructedImage { + png: encoded.into_inner(), + width: cols, + height: rows, + visible_color_count: visible_colors.len(), + }) +} + +fn reader_for(source: &[u8]) -> Result>, ReconstructorError> { + let reader = ImageReader::new(Cursor::new(source)) + .with_guessed_format() + .map_err(|error| ReconstructorError(format!("cannot inspect image: {error}")))?; + match reader.format() { + Some(ImageFormat::Png | ImageFormat::Jpeg) => Ok(reader), + _ => Err(ReconstructorError("input must be PNG or JPEG".into())), + } +} diff --git a/backend/native/pixel_grid_reconstructor/src/main.rs b/backend/native/pixel_grid_reconstructor/src/main.rs new file mode 100644 index 00000000..c3f849d6 --- /dev/null +++ b/backend/native/pixel_grid_reconstructor/src/main.rs @@ -0,0 +1,51 @@ +use std::io::{Read, Write}; + +use windup_pixel_grid_reconstructor::{reconstruct_bytes, MAX_INPUT_BYTES}; + +fn fail(message: impl std::fmt::Display) -> ! { + eprintln!("{message}"); + std::process::exit(1); +} + +fn parse_args() -> (usize, usize, usize) { + let args: Vec = std::env::args().skip(1).collect(); + let mut cols = None; + let mut rows = None; + let mut colors = 32usize; + let mut index = 0; + while index < args.len() { + let value = args + .get(index + 1) + .unwrap_or_else(|| fail("missing option value")); + match args[index].as_str() { + "--cols" => cols = Some(value.parse().unwrap_or_else(|_| fail("invalid cols"))), + "--rows" => rows = Some(value.parse().unwrap_or_else(|_| fail("invalid rows"))), + "--colors" => { + colors = value.parse().unwrap_or_else(|_| fail("invalid colors")); + } + option => fail(format!("unknown option: {option}")), + } + index += 2; + } + ( + cols.unwrap_or_else(|| fail("--cols is required")), + rows.unwrap_or_else(|| fail("--rows is required")), + colors, + ) +} + +fn main() { + let (cols, rows, colors) = parse_args(); + let mut source = Vec::with_capacity(MAX_INPUT_BYTES.min(1024 * 1024)); + std::io::stdin() + .take((MAX_INPUT_BYTES + 1) as u64) + .read_to_end(&mut source) + .unwrap_or_else(|error| fail(format!("cannot read image: {error}"))); + if source.len() > MAX_INPUT_BYTES { + fail(format!("encoded input exceeds {MAX_INPUT_BYTES} bytes")); + } + let result = reconstruct_bytes(&source, cols, rows, colors).unwrap_or_else(|error| fail(error)); + std::io::stdout() + .write_all(&result.png) + .unwrap_or_else(|error| fail(format!("cannot write PNG: {error}"))); +} diff --git a/backend/native/pixel_grid_reconstructor/src/reconstruct.rs b/backend/native/pixel_grid_reconstructor/src/reconstruct.rs new file mode 100644 index 00000000..9499ba1c --- /dev/null +++ b/backend/native/pixel_grid_reconstructor/src/reconstruct.rs @@ -0,0 +1,178 @@ +//! Explicit regular-grid reconstruction extracted from Pixel Art Fixer. + +pub struct Reconstruction { + pub rgba: Vec, + pub cols: usize, + pub rows: usize, +} + +pub fn estimated_working_bytes( + source_pixels: usize, + cell_count: usize, + structure_colors: usize, + width: usize, + height: usize, +) -> Option { + // Source buffers cover RGBA, per-pixel labels and opaque-index sampling. + // Per-cell buffers cover voting, winning labels, color/alpha accumulators + // and encoded-output overlap; 4 MiB reserves k-means samples and centers. + let source_buffers = source_pixels.checked_mul(16)?; + let bytes_per_cell = 96usize.checked_add(structure_colors.checked_mul(8)?)?; + let cell_buffers = cell_count.checked_mul(bytes_per_cell)?; + let axis_buffers = width.checked_add(height)?.checked_mul(16)?; + source_buffers + .checked_add(cell_buffers)? + .checked_add(axis_buffers)? + .checked_add(4 * 1024 * 1024) +} + +#[cfg(test)] +mod tests { + use super::estimated_working_bytes; + + #[test] + fn dense_high_color_grid_exceeds_the_bounded_working_set() { + let bytes = estimated_working_bytes(512 * 512, 512 * 512, 64, 512, 512) + .expect("estimate fits usize"); + + assert!(bytes > 128 * 1024 * 1024); + } + + #[test] + fn game_sized_grid_stays_inside_the_bounded_working_set() { + let bytes = estimated_working_bytes(1024 * 1024, 142 * 142, 64, 1024, 1024) + .expect("estimate fits usize"); + + assert!(bytes < 128 * 1024 * 1024); + } +} + +fn pyround(value: f64) -> f64 { + value.round_ties_even() +} + +pub fn two_stage_pack( + rgba: &[u8], + width: usize, + height: usize, + cols: usize, + rows: usize, + structure_colors: usize, +) -> Reconstruction { + let (labels, label_count) = crate::kmeans::kmeans_labels(rgba, width, height, structure_colors); + let label_count = label_count.max(1); + let cell_count = cols * rows; + let cell_width = width as f64 / cols as f64; + let cell_height = height as f64 / rows as f64; + + let mut cell_x = vec![0usize; width]; + let mut weight_x = vec![0f64; width]; + for x in 0..width { + let column = ((x * cols) / width).min(cols - 1); + cell_x[x] = column; + let position = (x as f64 + 0.5 - column as f64 * cell_width) / cell_width; + // Dense grids can put source-pixel centres just outside an even-grid + // cell. A negative triangular weight extrapolates colors instead of + // averaging them, so it must contribute zero weight. + weight_x[x] = (1.0 - 2.0 * (position - 0.5).abs()).max(0.0); + } + let mut cell_y = vec![0usize; height]; + let mut weight_y = vec![0f64; height]; + for y in 0..height { + let row = ((y * rows) / height).min(rows - 1); + cell_y[y] = row; + let position = (y as f64 + 0.5 - row as f64 * cell_height) / cell_height; + weight_y[y] = (1.0 - 2.0 * (position - 0.5).abs()).max(0.0); + } + + // Structure stage: each output cell votes for one clean k-means label. + let mut label_weights = vec![0f64; cell_count * label_count]; + for y in 0..height { + for x in 0..width { + let source_index = y * width + x; + let cell = cell_y[y] * cols + cell_x[x]; + let weight = weight_y[y] * weight_x[x] + 1e-4; + label_weights[cell * label_count + labels[source_index] as usize] += weight; + } + } + let mut winning_label = vec![0u32; cell_count]; + for (cell, winning) in winning_label.iter_mut().enumerate().take(cell_count) { + let base = cell * label_count; + let mut best_label = 0usize; + let mut best_weight = label_weights[base]; + for label in 1..label_count { + if label_weights[base + label] > best_weight { + best_weight = label_weights[base + label]; + best_label = label; + } + } + *winning = best_label as u32; + } + + // Color stage: average original colors that carry the winning label. + let mut color_sum = vec![[0f64; 3]; cell_count]; + let mut color_weight = vec![0f64; cell_count]; + let mut selected_count = vec![0f64; cell_count]; + let mut pixel_count = vec![0f64; cell_count]; + let mut fallback_sum = vec![[0f64; 3]; cell_count]; + let mut opaque_count = vec![0f64; cell_count]; + for y in 0..height { + for x in 0..width { + let source_index = y * width + x; + let source_offset = source_index * 4; + let cell = cell_y[y] * cols + cell_x[x]; + let weight = weight_y[y] * weight_x[x] + 1e-4; + let rgb = [ + rgba[source_offset] as f64 / 255.0, + rgba[source_offset + 1] as f64 / 255.0, + rgba[source_offset + 2] as f64 / 255.0, + ]; + pixel_count[cell] += 1.0; + for channel in 0..3 { + fallback_sum[cell][channel] += rgb[channel]; + } + if rgba[source_offset + 3] > 127 { + opaque_count[cell] += 1.0; + } + if labels[source_index] == winning_label[cell] { + selected_count[cell] += 1.0; + color_weight[cell] += weight; + for channel in 0..3 { + color_sum[cell][channel] += rgb[channel] * weight; + } + } + } + } + + let mut output = vec![0u8; cell_count * 4]; + for cell in 0..cell_count { + let color = if selected_count[cell] >= 0.5 && color_weight[cell] > 1e-9 { + [ + color_sum[cell][0] / color_weight[cell], + color_sum[cell][1] / color_weight[cell], + color_sum[cell][2] / color_weight[cell], + ] + } else { + let count = pixel_count[cell].max(1.0); + [ + fallback_sum[cell][0] / count, + fallback_sum[cell][1] / count, + fallback_sum[cell][2] / count, + ] + }; + for channel in 0..3 { + output[cell * 4 + channel] = pyround(color[channel] * 255.0).clamp(0.0, 255.0) as u8; + } + output[cell * 4 + 3] = if opaque_count[cell] / pixel_count[cell].max(1.0) > 0.5 { + 255 + } else { + 0 + }; + } + + Reconstruction { + rgba: output, + cols, + rows, + } +} diff --git a/backend/native/pixel_grid_reconstructor/tests/reconstructor.rs b/backend/native/pixel_grid_reconstructor/tests/reconstructor.rs new file mode 100644 index 00000000..66a45ef2 --- /dev/null +++ b/backend/native/pixel_grid_reconstructor/tests/reconstructor.rs @@ -0,0 +1,146 @@ +use std::io::{Cursor, Write}; +use std::process::{Command, Stdio}; + +use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; +use windup_pixel_grid_reconstructor::reconstruct_bytes; + +fn encode(image: RgbaImage) -> Vec { + let mut output = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(image) + .write_to(&mut output, ImageFormat::Png) + .expect("encode fixture"); + output.into_inner() +} + +#[test] +fn explicit_grid_rebuilds_one_color_per_output_cell() { + let mut logical = RgbaImage::new(4, 4); + let palette = [ + Rgba([0, 0, 0, 255]), + Rgba([220, 60, 50, 255]), + Rgba([50, 120, 210, 255]), + Rgba([240, 235, 220, 255]), + ]; + for y in 0..4 { + for x in 0..4 { + logical.put_pixel(x, y, palette[((x + y) % 4) as usize]); + } + } + let source = image::imageops::resize(&logical, 32, 32, image::imageops::Nearest); + + let result = reconstruct_bytes(&encode(source), 4, 4, 4).expect("reconstruct grid"); + + assert_eq!((result.width, result.height), (4, 4)); + assert_eq!(result.visible_color_count, 4); + let decoded = image::load_from_memory(&result.png).expect("decode output"); + assert_eq!((decoded.width(), decoded.height()), (4, 4)); + assert_eq!(decoded.to_rgba8(), logical); +} + +#[test] +fn structure_color_count_does_not_cap_the_final_palette() { + let mut logical = RgbaImage::new(4, 4); + let palette = [ + Rgba([10, 20, 30, 255]), + Rgba([220, 60, 50, 255]), + Rgba([50, 120, 210, 255]), + Rgba([240, 235, 220, 255]), + ]; + for y in 0..4 { + for x in 0..4 { + logical.put_pixel(x, y, palette[((x + y) % 4) as usize]); + } + } + let source = image::imageops::resize(&logical, 32, 32, image::imageops::Nearest); + + let result = reconstruct_bytes(&encode(source), 4, 4, 2).expect("reconstruct grid"); + + assert_eq!(result.visible_color_count, 4); + assert_eq!( + image::load_from_memory(&result.png) + .expect("decode output") + .to_rgba8(), + logical + ); +} + +#[test] +fn dense_grid_color_reconstruction_stays_within_source_color_bounds() { + let size = 64; + let mut source = RgbaImage::new(size, size); + for y in 0..size { + for x in 0..size { + source.put_pixel( + x, + y, + Rgba([ + 100 + (x % 11) as u8, + 100 + (y % 11) as u8, + 100 + ((x + y) % 11) as u8, + 255, + ]), + ); + } + } + + let result = reconstruct_bytes(&encode(source), 36, 36, 2).expect("reconstruct dense grid"); + let decoded = image::load_from_memory(&result.png) + .expect("decode output") + .to_rgb8(); + let channels: Vec = decoded.pixels().flat_map(|pixel| pixel.0).collect(); + + assert!(channels + .iter() + .all(|&channel| (100..=110).contains(&channel))); +} + +#[test] +fn reconstructor_rejects_a_grid_larger_than_the_source() { + let source = encode(RgbaImage::new(32, 32)); + + let error = reconstruct_bytes(&source, 33, 32, 16).unwrap_err(); + + assert!(error + .to_string() + .contains("grid must be within source bounds")); +} + +#[test] +fn reconstructor_rejects_a_dense_grid_before_large_algorithm_allocations() { + let source = encode(RgbaImage::new(512, 512)); + + let error = reconstruct_bytes(&source, 512, 512, 64).unwrap_err(); + + assert!(error.to_string().contains("working set exceeds")); +} + +#[test] +fn reconstructor_rejects_more_than_four_million_pixels_before_decoding() { + let source = encode(RgbaImage::new(2001, 2000)); + + let error = reconstruct_bytes(&source, 16, 16, 16).unwrap_err(); + + assert!(error.to_string().contains("maximum is 4000000 pixels")); +} + +#[test] +fn cli_rejects_an_oversized_encoded_input_before_decoding() { + let mut child = Command::new(env!("CARGO_BIN_EXE_windup-pixel-grid-reconstructor")) + .args(["--cols", "16", "--rows", "16"]) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn reconstructor CLI"); + child + .stdin + .take() + .expect("open stdin") + .write_all(&vec![0u8; 32 * 1024 * 1024 + 1]) + .expect("write oversized input"); + + let output = child + .wait_with_output() + .expect("wait for reconstructor CLI"); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("encoded input exceeds")); +}