From 4b57e74ffe504b89b4df33e322b82685e4d8996b Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 20 Aug 2026 20:16:12 +0800 Subject: [PATCH 01/12] feat(pixel-grid): add explicit grid reconstructor Detected grids need a separate local stage that can rebuild one color per cell. Add deterministic two-stage reconstruction with bounded memory and explicit grid arguments. Images can now be rebuilt without invoking detection, application services, or generation flows. --- .../pixel_grid_reconstructor/src/kmeans.rs | 214 ++++++++++++++++++ .../pixel_grid_reconstructor/src/lib.rs | 138 +++++++++++ .../pixel_grid_reconstructor/src/main.rs | 51 +++++ .../src/reconstruct.rs | 178 +++++++++++++++ 4 files changed, 581 insertions(+) create mode 100644 backend/native/pixel_grid_reconstructor/src/kmeans.rs create mode 100644 backend/native/pixel_grid_reconstructor/src/lib.rs create mode 100644 backend/native/pixel_grid_reconstructor/src/main.rs create mode 100644 backend/native/pixel_grid_reconstructor/src/reconstruct.rs 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, + } +} From 67d9d4deedeb12ddfdb3b1fbb865e620a497144f Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 20 Aug 2026 20:16:26 +0800 Subject: [PATCH 02/12] build(pixel-grid): define reconstructor crate The native reconstructor needs a reproducible package boundary and dependency graph. Add the Rust manifest, locked dependencies, and local target exclusion. The reconstruction module can now build independently with deterministic inputs. --- .../pixel_grid_reconstructor/.gitignore | 1 + .../pixel_grid_reconstructor/Cargo.lock | 210 ++++++++++++++++++ .../pixel_grid_reconstructor/Cargo.toml | 18 ++ 3 files changed, 229 insertions(+) create mode 100644 backend/native/pixel_grid_reconstructor/.gitignore create mode 100644 backend/native/pixel_grid_reconstructor/Cargo.lock create mode 100644 backend/native/pixel_grid_reconstructor/Cargo.toml 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 From 46cb03379c1a21050946d3708124189d4bbfaf64 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 20 Aug 2026 20:16:38 +0800 Subject: [PATCH 03/12] test(pixel-grid): cover reconstruction boundaries Explicit reconstruction must preserve source colors without creating dense-grid artifacts. Cover exact cells, uncapped final palettes, bad points, input limits, and working-set rejection. Color and resource regressions now fail before this module reaches the application layer. --- .../tests/reconstructor.rs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 backend/native/pixel_grid_reconstructor/tests/reconstructor.rs 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")); +} From bca1b8dfdfdeade463359536ac4984c9003314c5 Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 20 Aug 2026 20:16:53 +0800 Subject: [PATCH 04/12] docs(pixel-grid): record reconstructor provenance The extracted reconstruction algorithm must remain traceable and license compliant. Document its explicit-grid contract, two-stage color behavior, fixed upstream revision, and MIT terms. Maintainers can review the reuse boundary without consulting the generation codebase. --- .../native/pixel_grid_reconstructor/LICENSE | 21 +++++++++++++++++++ .../native/pixel_grid_reconstructor/README.md | 11 ++++++++++ .../pixel_grid_reconstructor/UPSTREAM.md | 5 +++++ 3 files changed, 37 insertions(+) create mode 100644 backend/native/pixel_grid_reconstructor/LICENSE create mode 100644 backend/native/pixel_grid_reconstructor/README.md create mode 100644 backend/native/pixel_grid_reconstructor/UPSTREAM.md 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 将显式规则网格重建提取为独立模块,删除检测、旧重建器和未使用的量化路径,并修复密集网格下负三角权重造成颜色外插的问题。 From f8a24393da755f315ae144b3a7bf623281d0c70b Mon Sep 17 00:00:00 2001 From: huyan Date: Thu, 20 Aug 2026 20:17:06 +0800 Subject: [PATCH 05/12] ci(pixel-grid): verify reconstructor module The standalone reconstructor needs a narrow gate independent of backend CI. Run format and locked release tests only for reconstructor paths and its workflow. Reconstruction changes now receive isolated validation without touching generation jobs. --- .../workflows/pixel-grid-reconstructor.yml | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/pixel-grid-reconstructor.yml 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 From 53b915215b250e954004602b14b2c616a89b04a5 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 12:00:00 +0800 Subject: [PATCH 06/12] fix(pixel-grid): ignore transparent RGB in visible cells Transparent pixel metadata could tint a cell that reconstructs as opaque. Exclude non-visible pixels from selected and fallback visible color averages. Opaque cell colors now reflect only pixels that contribute visible color. --- .../src/reconstruct.rs | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/backend/native/pixel_grid_reconstructor/src/reconstruct.rs b/backend/native/pixel_grid_reconstructor/src/reconstruct.rs index 9499ba1c..4a5d822e 100644 --- a/backend/native/pixel_grid_reconstructor/src/reconstruct.rs +++ b/backend/native/pixel_grid_reconstructor/src/reconstruct.rs @@ -115,6 +115,7 @@ pub fn two_stage_pack( 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 transparent_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 { @@ -129,12 +130,16 @@ pub fn two_stage_pack( ]; pixel_count[cell] += 1.0; for channel in 0..3 { - fallback_sum[cell][channel] += rgb[channel]; + transparent_fallback_sum[cell][channel] += rgb[channel]; } - if rgba[source_offset + 3] > 127 { + let is_opaque = rgba[source_offset + 3] > 127; + if is_opaque { opaque_count[cell] += 1.0; + for channel in 0..3 { + fallback_sum[cell][channel] += rgb[channel]; + } } - if labels[source_index] == winning_label[cell] { + if is_opaque && labels[source_index] == winning_label[cell] { selected_count[cell] += 1.0; color_weight[cell] += weight; for channel in 0..3 { @@ -153,12 +158,20 @@ pub fn two_stage_pack( 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, - ] + if opaque_count[cell] > 0.0 { + [ + fallback_sum[cell][0] / opaque_count[cell], + fallback_sum[cell][1] / opaque_count[cell], + fallback_sum[cell][2] / opaque_count[cell], + ] + } else { + let count = pixel_count[cell].max(1.0); + [ + transparent_fallback_sum[cell][0] / count, + transparent_fallback_sum[cell][1] / count, + transparent_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; From b69d1a7dbedbf6c9c2cd652c33e756950ed95247 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 12:00:00 +0800 Subject: [PATCH 07/12] test(pixel-grid): cover transparent RGB metadata The visible-color regression needs a fixture with conflicting hidden RGB data. Add a majority-opaque red cell whose transparent pixels store blue channels. The test prevents hidden PNG metadata from tinting reconstructed output. --- .../tests/reconstructor.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/backend/native/pixel_grid_reconstructor/tests/reconstructor.rs b/backend/native/pixel_grid_reconstructor/tests/reconstructor.rs index 66a45ef2..0b3691ca 100644 --- a/backend/native/pixel_grid_reconstructor/tests/reconstructor.rs +++ b/backend/native/pixel_grid_reconstructor/tests/reconstructor.rs @@ -64,6 +64,23 @@ fn structure_color_count_does_not_cap_the_final_palette() { ); } +#[test] +fn transparent_rgb_does_not_tint_a_majority_opaque_cell() { + let mut source = RgbaImage::from_pixel(16, 16, Rgba([255, 0, 0, 255])); + for y in 0..4 { + for x in 0..16 { + source.put_pixel(x, y, Rgba([0, 0, 255, 0])); + } + } + + let result = reconstruct_bytes(&encode(source), 1, 1, 2).expect("reconstruct cell"); + let decoded = image::load_from_memory(&result.png) + .expect("decode output") + .to_rgba8(); + + assert_eq!(decoded.get_pixel(0, 0), &Rgba([255, 0, 0, 255])); +} + #[test] fn dense_grid_color_reconstruction_stays_within_source_color_bounds() { let size = 64; From c53f34dae4a9f78eb3ad7d707ad12ca4df9f7c38 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 12:02:20 +0800 Subject: [PATCH 08/12] refactor(pixel-grid): move reconstructor outside Python backend The reconstructor is a native algorithm library rather than Python backend code. Move the independent crate to the root native area and remove the retired CLI. The later PyO3 binding can consume it without coupling this module to Python. --- .../native/pixel_grid_reconstructor/README.md | 11 ---- .../pixel_grid_reconstructor/src/main.rs | 51 ------------------- .../crates/reconstructor}/.gitignore | 0 .../crates/reconstructor}/Cargo.lock | 0 .../crates/reconstructor}/Cargo.toml | 4 -- .../crates/reconstructor}/LICENSE | 0 .../crates/reconstructor/README.md | 11 ++++ .../crates/reconstructor}/UPSTREAM.md | 2 +- .../crates/reconstructor}/src/kmeans.rs | 0 .../crates/reconstructor}/src/lib.rs | 0 .../crates/reconstructor}/src/reconstruct.rs | 0 .../reconstructor}/tests/reconstructor.rs | 25 +-------- 12 files changed, 13 insertions(+), 91 deletions(-) delete mode 100644 backend/native/pixel_grid_reconstructor/README.md delete mode 100644 backend/native/pixel_grid_reconstructor/src/main.rs rename {backend/native/pixel_grid_reconstructor => native/pixel-perfect/crates/reconstructor}/.gitignore (100%) rename {backend/native/pixel_grid_reconstructor => native/pixel-perfect/crates/reconstructor}/Cargo.lock (100%) rename {backend/native/pixel_grid_reconstructor => native/pixel-perfect/crates/reconstructor}/Cargo.toml (79%) rename {backend/native/pixel_grid_reconstructor => native/pixel-perfect/crates/reconstructor}/LICENSE (100%) create mode 100644 native/pixel-perfect/crates/reconstructor/README.md rename {backend/native/pixel_grid_reconstructor => native/pixel-perfect/crates/reconstructor}/UPSTREAM.md (52%) rename {backend/native/pixel_grid_reconstructor => native/pixel-perfect/crates/reconstructor}/src/kmeans.rs (100%) rename {backend/native/pixel_grid_reconstructor => native/pixel-perfect/crates/reconstructor}/src/lib.rs (100%) rename {backend/native/pixel_grid_reconstructor => native/pixel-perfect/crates/reconstructor}/src/reconstruct.rs (100%) rename {backend/native/pixel_grid_reconstructor => native/pixel-perfect/crates/reconstructor}/tests/reconstructor.rs (84%) diff --git a/backend/native/pixel_grid_reconstructor/README.md b/backend/native/pixel_grid_reconstructor/README.md deleted file mode 100644 index 93d37c10..00000000 --- a/backend/native/pixel_grid_reconstructor/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# 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/src/main.rs b/backend/native/pixel_grid_reconstructor/src/main.rs deleted file mode 100644 index c3f849d6..00000000 --- a/backend/native/pixel_grid_reconstructor/src/main.rs +++ /dev/null @@ -1,51 +0,0 @@ -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/.gitignore b/native/pixel-perfect/crates/reconstructor/.gitignore similarity index 100% rename from backend/native/pixel_grid_reconstructor/.gitignore rename to native/pixel-perfect/crates/reconstructor/.gitignore diff --git a/backend/native/pixel_grid_reconstructor/Cargo.lock b/native/pixel-perfect/crates/reconstructor/Cargo.lock similarity index 100% rename from backend/native/pixel_grid_reconstructor/Cargo.lock rename to native/pixel-perfect/crates/reconstructor/Cargo.lock diff --git a/backend/native/pixel_grid_reconstructor/Cargo.toml b/native/pixel-perfect/crates/reconstructor/Cargo.toml similarity index 79% rename from backend/native/pixel_grid_reconstructor/Cargo.toml rename to native/pixel-perfect/crates/reconstructor/Cargo.toml index af030cfc..d8ad571f 100644 --- a/backend/native/pixel_grid_reconstructor/Cargo.toml +++ b/native/pixel-perfect/crates/reconstructor/Cargo.toml @@ -9,10 +9,6 @@ publish = false 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/native/pixel-perfect/crates/reconstructor/LICENSE similarity index 100% rename from backend/native/pixel_grid_reconstructor/LICENSE rename to native/pixel-perfect/crates/reconstructor/LICENSE diff --git a/native/pixel-perfect/crates/reconstructor/README.md b/native/pixel-perfect/crates/reconstructor/README.md new file mode 100644 index 00000000..e364782d --- /dev/null +++ b/native/pixel-perfect/crates/reconstructor/README.md @@ -0,0 +1,11 @@ +# Pixel grid reconstructor + +Windup 的独立显式网格重建库。它不检测像素密度,只读取 PNG/JPEG 字节与明确的 `cols`、`rows`、`colors` 参数,并返回对应尺寸的原生 1x PNG。 + +```bash +cargo test --release --locked +``` + +公共入口为 `reconstruct_bytes`。模块使用两阶段重建:先对结构色标签投票确定每个格子的归属,再从原图中携带胜出标签的可见像素恢复颜色。输出的每个像素就是一个规则网格单元,透明度按格内多数票确定。Python 绑定由独立集成模块提供,本 crate 不依赖 Python、检测器或 Windup 后端。 + +`colors` 控制结构聚类规模,不是最终图片的强制全局色板上限;这样不会把稀有高光或单像素强调色提前删除。若业务需要固定总色板,应在独立的调色板阶段明确处理。 diff --git a/backend/native/pixel_grid_reconstructor/UPSTREAM.md b/native/pixel-perfect/crates/reconstructor/UPSTREAM.md similarity index 52% rename from backend/native/pixel_grid_reconstructor/UPSTREAM.md rename to native/pixel-perfect/crates/reconstructor/UPSTREAM.md index 5448498e..45cd38c9 100644 --- a/backend/native/pixel_grid_reconstructor/UPSTREAM.md +++ b/native/pixel-perfect/crates/reconstructor/UPSTREAM.md @@ -2,4 +2,4 @@ 两阶段重建与 deterministic k-means 逻辑源自 [Retro-Diffusion/pixel-art-fixer](https://github.com/Retro-Diffusion/pixel-art-fixer),固定于提交 `ef376e57e1c272633ca2dbf5f29ec3fcf6596465`,使用 MIT License。 -Windup 将显式规则网格重建提取为独立模块,删除检测、旧重建器和未使用的量化路径,并修复密集网格下负三角权重造成颜色外插的问题。 +Windup 将显式规则网格重建提取为独立 library,删除检测、旧重建器和未使用的量化路径,并修复密集网格下负三角权重造成颜色外插及透明 RGB 污染可见颜色的问题。 diff --git a/backend/native/pixel_grid_reconstructor/src/kmeans.rs b/native/pixel-perfect/crates/reconstructor/src/kmeans.rs similarity index 100% rename from backend/native/pixel_grid_reconstructor/src/kmeans.rs rename to native/pixel-perfect/crates/reconstructor/src/kmeans.rs diff --git a/backend/native/pixel_grid_reconstructor/src/lib.rs b/native/pixel-perfect/crates/reconstructor/src/lib.rs similarity index 100% rename from backend/native/pixel_grid_reconstructor/src/lib.rs rename to native/pixel-perfect/crates/reconstructor/src/lib.rs diff --git a/backend/native/pixel_grid_reconstructor/src/reconstruct.rs b/native/pixel-perfect/crates/reconstructor/src/reconstruct.rs similarity index 100% rename from backend/native/pixel_grid_reconstructor/src/reconstruct.rs rename to native/pixel-perfect/crates/reconstructor/src/reconstruct.rs diff --git a/backend/native/pixel_grid_reconstructor/tests/reconstructor.rs b/native/pixel-perfect/crates/reconstructor/tests/reconstructor.rs similarity index 84% rename from backend/native/pixel_grid_reconstructor/tests/reconstructor.rs rename to native/pixel-perfect/crates/reconstructor/tests/reconstructor.rs index 0b3691ca..d684a2ec 100644 --- a/backend/native/pixel_grid_reconstructor/tests/reconstructor.rs +++ b/native/pixel-perfect/crates/reconstructor/tests/reconstructor.rs @@ -1,5 +1,4 @@ -use std::io::{Cursor, Write}; -use std::process::{Command, Stdio}; +use std::io::Cursor; use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; use windup_pixel_grid_reconstructor::reconstruct_bytes; @@ -139,25 +138,3 @@ fn reconstructor_rejects_more_than_four_million_pixels_before_decoding() { 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")); -} From b03ab2365083150cc2dfda43fe6c265f1c5e286c Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 12:02:20 +0800 Subject: [PATCH 09/12] ci(pixel-grid): follow reconstructor crate move The reconstructor workflow still watched the retired backend-native path. Point path filters and the working directory at the independent native crate. Reconstruction checks now run for the relocated module. --- .github/workflows/pixel-grid-reconstructor.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pixel-grid-reconstructor.yml b/.github/workflows/pixel-grid-reconstructor.yml index 1db506f2..fcc13e33 100644 --- a/.github/workflows/pixel-grid-reconstructor.yml +++ b/.github/workflows/pixel-grid-reconstructor.yml @@ -3,11 +3,11 @@ name: Pixel grid reconstructor CI on: push: paths: - - "backend/native/pixel_grid_reconstructor/**" + - "native/pixel-perfect/crates/reconstructor/**" - ".github/workflows/pixel-grid-reconstructor.yml" pull_request: paths: - - "backend/native/pixel_grid_reconstructor/**" + - "native/pixel-perfect/crates/reconstructor/**" - ".github/workflows/pixel-grid-reconstructor.yml" permissions: @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest defaults: run: - working-directory: backend/native/pixel_grid_reconstructor + working-directory: native/pixel-perfect/crates/reconstructor steps: - uses: actions/checkout@v7 From 27fcac2aebe080075de0415fbbf8fdc9d0d45828 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 12:15:28 +0800 Subject: [PATCH 10/12] Revert "test(pixel-grid): cover transparent RGB metadata" This reverts commit b69d1a7dbedbf6c9c2cd652c33e756950ed95247. --- .../crates/reconstructor/tests/reconstructor.rs | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/native/pixel-perfect/crates/reconstructor/tests/reconstructor.rs b/native/pixel-perfect/crates/reconstructor/tests/reconstructor.rs index d684a2ec..1d237e48 100644 --- a/native/pixel-perfect/crates/reconstructor/tests/reconstructor.rs +++ b/native/pixel-perfect/crates/reconstructor/tests/reconstructor.rs @@ -63,23 +63,6 @@ fn structure_color_count_does_not_cap_the_final_palette() { ); } -#[test] -fn transparent_rgb_does_not_tint_a_majority_opaque_cell() { - let mut source = RgbaImage::from_pixel(16, 16, Rgba([255, 0, 0, 255])); - for y in 0..4 { - for x in 0..16 { - source.put_pixel(x, y, Rgba([0, 0, 255, 0])); - } - } - - let result = reconstruct_bytes(&encode(source), 1, 1, 2).expect("reconstruct cell"); - let decoded = image::load_from_memory(&result.png) - .expect("decode output") - .to_rgba8(); - - assert_eq!(decoded.get_pixel(0, 0), &Rgba([255, 0, 0, 255])); -} - #[test] fn dense_grid_color_reconstruction_stays_within_source_color_bounds() { let size = 64; From 125f3cae3420bbee8d58a3c3504a483d4b907d6a Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 12:15:28 +0800 Subject: [PATCH 11/12] Revert "fix(pixel-grid): ignore transparent RGB in visible cells" This reverts commit 53b915215b250e954004602b14b2c616a89b04a5. --- .../crates/reconstructor/src/reconstruct.rs | 31 ++++++------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/native/pixel-perfect/crates/reconstructor/src/reconstruct.rs b/native/pixel-perfect/crates/reconstructor/src/reconstruct.rs index 4a5d822e..9499ba1c 100644 --- a/native/pixel-perfect/crates/reconstructor/src/reconstruct.rs +++ b/native/pixel-perfect/crates/reconstructor/src/reconstruct.rs @@ -115,7 +115,6 @@ pub fn two_stage_pack( 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 transparent_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 { @@ -130,16 +129,12 @@ pub fn two_stage_pack( ]; pixel_count[cell] += 1.0; for channel in 0..3 { - transparent_fallback_sum[cell][channel] += rgb[channel]; + fallback_sum[cell][channel] += rgb[channel]; } - let is_opaque = rgba[source_offset + 3] > 127; - if is_opaque { + if rgba[source_offset + 3] > 127 { opaque_count[cell] += 1.0; - for channel in 0..3 { - fallback_sum[cell][channel] += rgb[channel]; - } } - if is_opaque && labels[source_index] == winning_label[cell] { + if labels[source_index] == winning_label[cell] { selected_count[cell] += 1.0; color_weight[cell] += weight; for channel in 0..3 { @@ -158,20 +153,12 @@ pub fn two_stage_pack( color_sum[cell][2] / color_weight[cell], ] } else { - if opaque_count[cell] > 0.0 { - [ - fallback_sum[cell][0] / opaque_count[cell], - fallback_sum[cell][1] / opaque_count[cell], - fallback_sum[cell][2] / opaque_count[cell], - ] - } else { - let count = pixel_count[cell].max(1.0); - [ - transparent_fallback_sum[cell][0] / count, - transparent_fallback_sum[cell][1] / count, - transparent_fallback_sum[cell][2] / count, - ] - } + 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; From e2045059983c5f07012343564c347bb3a10c9a27 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 21 Aug 2026 12:16:47 +0800 Subject: [PATCH 12/12] docs(pixel-grid): keep reconstructor scope unchanged The provenance note still claimed an algorithm change that has been withdrawn. Remove the transparent-color fix statement from the native module documentation. The pull request now describes only relocation and the existing algorithm. --- native/pixel-perfect/crates/reconstructor/UPSTREAM.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/pixel-perfect/crates/reconstructor/UPSTREAM.md b/native/pixel-perfect/crates/reconstructor/UPSTREAM.md index 45cd38c9..453b14c6 100644 --- a/native/pixel-perfect/crates/reconstructor/UPSTREAM.md +++ b/native/pixel-perfect/crates/reconstructor/UPSTREAM.md @@ -2,4 +2,4 @@ 两阶段重建与 deterministic k-means 逻辑源自 [Retro-Diffusion/pixel-art-fixer](https://github.com/Retro-Diffusion/pixel-art-fixer),固定于提交 `ef376e57e1c272633ca2dbf5f29ec3fcf6596465`,使用 MIT License。 -Windup 将显式规则网格重建提取为独立 library,删除检测、旧重建器和未使用的量化路径,并修复密集网格下负三角权重造成颜色外插及透明 RGB 污染可见颜色的问题。 +Windup 将显式规则网格重建提取为独立 library,删除检测、旧重建器和未使用的量化路径,并修复密集网格下负三角权重造成颜色外插的问题。