From d083a85e902b3c8d145846b213360fbc21fe652f Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 24 Aug 2026 19:39:20 +0800 Subject: [PATCH 1/5] build(pixel-grid): declare Python image dependencies The server reconstructor imports NumPy and Pillow directly. Declare both packages in the app manifest and refresh the workspace lock metadata. Deployments no longer rely on transitive image-processing dependencies. --- backend/packages/app/pyproject.toml | 2 ++ backend/uv.lock | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/backend/packages/app/pyproject.toml b/backend/packages/app/pyproject.toml index 52c4de6af..68c82cdaa 100644 --- a/backend/packages/app/pyproject.toml +++ b/backend/packages/app/pyproject.toml @@ -12,6 +12,8 @@ dependencies = [ "pydantic[email]>=2.7", "sqlalchemy>=2.0", "python-multipart>=0.0.9", + "numpy>=1.26", + "pillow>=10.4", ] [project.scripts] diff --git a/backend/uv.lock b/backend/uv.lock index 732fcad65..8fefe48bd 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2148,6 +2148,8 @@ version = "0.1.0" source = { editable = "packages/app" } dependencies = [ { name = "fastapi" }, + { name = "numpy" }, + { name = "pillow" }, { name = "pydantic", extra = ["email"] }, { name = "python-multipart" }, { name = "sqlalchemy" }, @@ -2160,6 +2162,8 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, + { name = "numpy", specifier = ">=1.26" }, + { name = "pillow", specifier = ">=10.4" }, { name = "pydantic", extras = ["email"], specifier = ">=2.7" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "sqlalchemy", specifier = ">=2.0" }, From 2540b1d7ba37474e62cd289c8a203d368c63ecd3 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 24 Aug 2026 19:39:36 +0800 Subject: [PATCH 2/5] docs(pixel-grid): record reconstructor provenance The migrated algorithm remains derived from a fixed MIT-licensed upstream revision. Record the reuse boundary and include the upstream copyright and license terms. Reviewers can verify provenance without consulting the retired Rust pull request. --- .../pixel_perfect/LICENSE.pixel-art-fixer | 21 +++++++++++++++++++ .../server/pixel_perfect/UPSTREAM.md | 9 ++++++++ 2 files changed, 30 insertions(+) create mode 100644 backend/packages/app/src/windup_app/server/pixel_perfect/LICENSE.pixel-art-fixer create mode 100644 backend/packages/app/src/windup_app/server/pixel_perfect/UPSTREAM.md diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/LICENSE.pixel-art-fixer b/backend/packages/app/src/windup_app/server/pixel_perfect/LICENSE.pixel-art-fixer new file mode 100644 index 000000000..9610c123c --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/LICENSE.pixel-art-fixer @@ -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/packages/app/src/windup_app/server/pixel_perfect/UPSTREAM.md b/backend/packages/app/src/windup_app/server/pixel_perfect/UPSTREAM.md new file mode 100644 index 000000000..a4fd319f5 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/UPSTREAM.md @@ -0,0 +1,9 @@ +# Upstream + +两阶段重建与 deterministic k-means 逻辑源自 +[Retro-Diffusion/pixel-art-fixer](https://github.com/Retro-Diffusion/pixel-art-fixer), +固定于提交 `ef376e57e1c272633ca2dbf5f29ec3fcf6596465`,使用 MIT License。 + +本目录将既有 Rust 重建器等价迁移为 NumPy/Pillow 服务端实现。迁移保留 +60,000 点确定性采样、固定种子 k-means++、Lloyd 迭代、两阶段结构投票、 +原色恢复与透明度多数票;同色点去重与规则网格缓存只减少重复计算。 From ead7cc3d73754369c76555a8976172366e2f7d81 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 24 Aug 2026 19:39:54 +0800 Subject: [PATCH 3/5] refactor(pixel-grid): migrate reconstructor to Python The explicit grid reconstructor does not require a separate Rust delivery path. Port deterministic clustering and two-stage packing to bounded NumPy and Pillow code. RGBA behavior stays compatible while removing the future PyO3 integration burden. --- .../server/pixel_perfect/__init__.py | 1 + .../server/pixel_perfect/reconstructor.py | 419 ++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 backend/packages/app/src/windup_app/server/pixel_perfect/__init__.py create mode 100644 backend/packages/app/src/windup_app/server/pixel_perfect/reconstructor.py diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/__init__.py b/backend/packages/app/src/windup_app/server/pixel_perfect/__init__.py new file mode 100644 index 000000000..21ff00a23 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/__init__.py @@ -0,0 +1 @@ +"""独立完美像素工具的服务端算法边界。""" diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/reconstructor.py b/backend/packages/app/src/windup_app/server/pixel_perfect/reconstructor.py new file mode 100644 index 000000000..78b169ab8 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/reconstructor.py @@ -0,0 +1,419 @@ +"""显式规则网格重建器。 + +两阶段结构投票与原色恢复源自 Retro-Diffusion/pixel-art-fixer 的 MIT 实现, +固定参考提交 ``ef376e57e1c272633ca2dbf5f29ec3fcf6596465``。这里保留显式网格 +重建,不包含网格检测、生成管线或业务存储。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +from io import BytesIO + +import numpy as np +from PIL import Image, UnidentifiedImageError + +MAX_INPUT_PIXELS = 4_000_000 +MAX_INPUT_BYTES = 32 * 1024 * 1024 +MAX_WORKING_BYTES = 128 * 1024 * 1024 +MIN_INPUT_SIDE = 16 +_SAMPLE_LIMIT = 60_000 +_ASSIGNMENT_CHUNK = 65_536 +_TRAINING_CHUNK = 8_192 +_COLOR_LOOKUP_BYTES = 1 << 24 +_WORKING_RESERVE_BYTES = 12 * 1024 * 1024 + + +class ReconstructorError(ValueError): + """输入或资源边界不满足显式网格重建契约。""" + + +@dataclass(frozen=True, slots=True) +class ReconstructedImage: + png: bytes + width: int + height: int + visible_color_count: int + + +class _XorShift64Star: + """与固定上游实现一致的确定性 k-means++ 随机序列。""" + + _MASK = (1 << 64) - 1 + + def __init__(self, seed: int) -> None: + self._state = max(seed, 1) & self._MASK + + def next_u64(self) -> int: + value = self._state + value ^= value >> 12 + value ^= (value << 25) & self._MASK + value ^= value >> 27 + self._state = value & self._MASK + return (self._state * 0x2545F4914F6CDD1D) & self._MASK + + def next_float(self) -> float: + return (self.next_u64() >> 11) / float(1 << 53) + + def below(self, limit: int) -> int: + return int(self.next_float() * limit) % max(limit, 1) + + +def reconstruct_bytes( + source: bytes, + cols: int, + rows: int, + structure_colors: int, +) -> ReconstructedImage: + """把 PNG/JPEG 按调用方给定网格重建为每格单色的 1x PNG。""" + + rgba = _decode_source(source, cols, rows, structure_colors) + reconstructed = _two_stage_pack(rgba, cols, rows, structure_colors) + output = Image.fromarray(reconstructed, "RGBA") + encoded = BytesIO() + output.save(encoded, format="PNG") + visible = reconstructed[reconstructed[:, :, 3] > 0, :3] + visible_color_count = len(np.unique(visible, axis=0)) if len(visible) else 0 + return ReconstructedImage( + png=encoded.getvalue(), + width=cols, + height=rows, + visible_color_count=visible_color_count, + ) + + +def _decode_source( + source: bytes, + cols: int, + rows: int, + structure_colors: int, +) -> np.ndarray: + if len(source) > MAX_INPUT_BYTES: + raise ReconstructorError(f"encoded input exceeds {MAX_INPUT_BYTES} bytes") + try: + image = Image.open(BytesIO(source)) + except (UnidentifiedImageError, OSError) as error: + raise ReconstructorError("input must be PNG or JPEG") from error + if image.format not in {"PNG", "JPEG"}: + raise ReconstructorError("input must be PNG or JPEG") + + width, height = image.size + if min(width, height) < MIN_INPUT_SIDE: + raise ReconstructorError( + f"image is too small (minimum side is {MIN_INPUT_SIDE}px)" + ) + pixel_count = width * height + if pixel_count > MAX_INPUT_PIXELS: + raise ReconstructorError( + f"image is too large (maximum is {MAX_INPUT_PIXELS} pixels)" + ) + if cols <= 0 or rows <= 0 or cols > width or rows > height: + raise ReconstructorError( + f"grid must be within source bounds (received {cols}x{rows} " + f"for {width}x{height})" + ) + if not 2 <= structure_colors <= 64: + raise ReconstructorError("structure colors must be between 2 and 64") + working_bytes = _estimated_working_bytes( + pixel_count, cols * rows, structure_colors, width, height + ) + if working_bytes > MAX_WORKING_BYTES: + raise ReconstructorError( + f"reconstruction working set exceeds {MAX_WORKING_BYTES} bytes" + ) + try: + return np.asarray(image.convert("RGBA"), dtype=np.uint8) + except (OSError, ValueError) as error: + raise ReconstructorError(f"cannot decode PNG/JPEG image: {error}") from error + finally: + image.close() + + +def _estimated_working_bytes( + source_pixels: int, + cell_count: int, + structure_colors: int, + width: int, + height: int, +) -> int: + # Python 版同时持有 RGBA、标签/颜色编码、规则网格和分块距离缓冲。 + source_buffers = source_pixels * 48 + bytes_per_cell = 96 + structure_colors * 8 + cell_buffers = cell_count * bytes_per_cell + axis_buffers = (width + height) * 16 + return ( + source_buffers + + cell_buffers + + axis_buffers + + _COLOR_LOOKUP_BYTES + + _WORKING_RESERVE_BYTES + ) + + +def _even_sample_indices(length: int, maximum: int) -> np.ndarray: + if length <= maximum: + return np.arange(length, dtype=np.int64) + positions = np.arange(maximum, dtype=np.float64) + return np.floor(positions * (length - 1) / (maximum - 1)).astype(np.int64) + + +def _pack_rgb(points: np.ndarray) -> np.ndarray: + values = points.astype(np.uint32, copy=False) + return (values[:, 0] << 16) | (values[:, 1] << 8) | values[:, 2] + + +def _unpack_rgb(values: np.ndarray) -> np.ndarray: + return np.column_stack( + ((values >> 16) & 255, (values >> 8) & 255, values & 255) + ).astype(np.float32) + + +def _squared_distances(points: np.ndarray, centers: np.ndarray) -> np.ndarray: + """按 Rust 顺序先做 float32 差值,再以 float64 累加平方。""" + + difference = points[:, 0, None] - centers[None, :, 0] + distances = difference.astype(np.float64) + np.square(distances, out=distances) + for channel in (1, 2): + difference = points[:, channel, None] - centers[None, :, channel] + distances += difference.astype(np.float64) ** 2 + return distances + + +def _distance_to_center(points: np.ndarray, center: np.ndarray) -> np.ndarray: + difference = points - center + return np.einsum("ij,ij->i", difference, difference, dtype=np.float64) + + +def _assign_labels(points: np.ndarray, centers: np.ndarray) -> np.ndarray: + labels = np.empty(len(points), dtype=np.uint8) + for start in range(0, len(points), _ASSIGNMENT_CHUNK): + block = points[start : start + _ASSIGNMENT_CHUNK] + difference = block[:, 0, None] - centers[None, :, 0] + distances = difference * difference + for channel in (1, 2): + difference = block[:, channel, None] - centers[None, :, channel] + distances += difference * difference + labels[start : start + len(block)] = np.argmin(distances, axis=1) + return labels + + +def _assign_training_labels(points: np.ndarray, centers: np.ndarray) -> np.ndarray: + labels = np.empty(len(points), dtype=np.int32) + for start in range(0, len(points), _TRAINING_CHUNK): + block = points[start : start + _TRAINING_CHUNK] + labels[start : start + len(block)] = np.argmin( + _squared_distances(block, centers), axis=1 + ) + return labels + + +def _kmeans(sample: np.ndarray, cluster_count: int) -> np.ndarray: + # 同色点共享距离结果;inverse/counts 保留原采样顺序和重复权重,因而不改变 + # k-means++ 的确定性选点或 Lloyd 更新结果。 + sample_codes = _pack_rgb(sample) + unique_codes, inverse, counts = np.unique( + sample_codes, + return_inverse=True, + return_counts=True, + ) + points = _unpack_rgb(unique_codes) + cluster_count = max(1, min(cluster_count, len(points))) + point_weights = counts.astype(np.float64) + + rng = _XorShift64Star(42) + centers = [points[inverse[rng.below(len(inverse))]]] + nearest = _distance_to_center(points, centers[0]) + while len(centers) < cluster_count: + cumulative = np.cumsum(nearest[inverse], dtype=np.float64) + total = float(cumulative[-1]) + if total > 0: + target = rng.next_float() * total + pick = min(int(np.searchsorted(cumulative, target)), len(inverse) - 1) + center = points[inverse[pick]] + else: + center = points[inverse[rng.below(len(inverse))]] + centers.append(center) + candidate = _distance_to_center(points, center) + nearest = np.minimum(nearest, candidate) + + current = np.asarray(centers, dtype=np.float32) + for _ in range(15): + labels = _assign_training_labels(points, current) + cluster_weights = np.bincount( + labels, + weights=point_weights, + minlength=cluster_count, + ) + updated = current.copy() + empty_clusters = np.flatnonzero(cluster_weights == 0) + sums_by_channel = [ + np.bincount( + labels, + weights=points[:, channel] * point_weights, + minlength=cluster_count, + ) + for channel in range(3) + ] + if not len(empty_clusters): + for channel, sums in enumerate(sums_by_channel): + updated[:, channel] = sums / cluster_weights + else: + for cluster in range(cluster_count): + if cluster_weights[cluster] > 0: + updated[cluster] = [ + sums[cluster] / cluster_weights[cluster] + for sums in sums_by_channel + ] + continue + assigned = updated[labels] + differences = points - assigned + distances = np.einsum( + "ij,ij->i", + differences, + differences, + dtype=np.float64, + ) + farthest = int(np.argmax(distances[inverse])) + updated[cluster] = points[inverse[farthest]] + shift = _squared_distances(updated, current).diagonal().max(initial=0.0) + current = updated + if not len(empty_clusters) and shift <= 0.25: + break + return current + + +def _kmeans_labels(rgba: np.ndarray, structure_colors: int) -> np.ndarray: + flat_rgba = rgba.reshape(-1, 4) + visible_indexes = np.flatnonzero(flat_rgba[:, 3] > 0) + source_indexes = ( + visible_indexes + if len(visible_indexes) + else np.arange(len(flat_rgba), dtype=np.int64) + ) + sample_positions = _even_sample_indices(len(source_indexes), _SAMPLE_LIMIT) + sample = flat_rgba[source_indexes[sample_positions], :3].astype(np.float32) + cluster_count = max(1, min(structure_colors, len(sample))) + centers = _kmeans(sample, cluster_count) + + all_codes = _pack_rgb(flat_rgba[:, :3]) + unique_codes = np.unique(all_codes) + unique_labels = _assign_labels(_unpack_rgb(unique_codes), centers) + # 24-bit RGB 查表固定占用 16 MiB,避免为百万像素重复计算相同颜色的距离。 + lookup = np.empty(_COLOR_LOOKUP_BYTES, dtype=np.uint8) + lookup[unique_codes] = unique_labels + return lookup[all_codes] + + +@lru_cache(maxsize=1) +def _grid_geometry( + width: int, height: int, cols: int, rows: int +) -> tuple[np.ndarray, np.ndarray]: + # 动画帧通常复用尺寸与网格;只保留最近一组只读几何,限制常驻内存。 + cell_x = ((np.arange(width, dtype=np.int64) * cols) // width).astype(np.int32) + cell_y = ((np.arange(height, dtype=np.int64) * rows) // height).astype(np.int32) + cell_dtype = np.uint16 if cols * rows <= np.iinfo(np.uint16).max else np.uint32 + cell = (cell_y[:, None] * cols + cell_x[None, :]).astype(cell_dtype).ravel() + cell_width = width / cols + cell_height = height / rows + position_x = ( + np.arange(width, dtype=np.float64) + 0.5 - cell_x * cell_width + ) / cell_width + position_y = ( + np.arange(height, dtype=np.float64) + 0.5 - cell_y * cell_height + ) / cell_height + weight_x = np.maximum(1.0 - 2.0 * np.abs(position_x - 0.5), 0.0) + weight_y = np.maximum(1.0 - 2.0 * np.abs(position_y - 0.5), 0.0) + weights = (weight_y[:, None] * weight_x[None, :]).ravel() + 1e-4 + cell.flags.writeable = False + weights.flags.writeable = False + return cell, weights + + +def _two_stage_pack( + rgba: np.ndarray, + cols: int, + rows: int, + structure_colors: int, +) -> np.ndarray: + height, width = rgba.shape[:2] + labels = _kmeans_labels(rgba, structure_colors) + label_count = max(int(labels.max(initial=0)) + 1, 1) + cell_count = cols * rows + cell, weights = _grid_geometry(width, height, cols, rows) + + flat = rgba.reshape(-1, 4) + vote_bin_count = cell_count * label_count + label_weights = np.zeros(vote_bin_count, dtype=np.float64) + for start in range(0, len(cell), _ASSIGNMENT_CHUNK * 2): + stop = min(start + _ASSIGNMENT_CHUNK * 2, len(cell)) + compound = ( + cell[start:stop].astype(np.uint32) * np.uint32(label_count) + + labels[start:stop] + ) + label_weights += np.bincount( + compound, + weights=weights[start:stop], + minlength=vote_bin_count, + ) + label_weights = label_weights.reshape(cell_count, label_count) + winning_label = np.argmax(label_weights, axis=1) + del compound, label_weights + + visible = flat[:, 3] > 127 + selected = labels == winning_label[cell] + output_rgb = np.zeros((cell_count, 3), dtype=np.float64) + if width % cols == 0 and height % rows == 0: + cell_width = width // cols + cell_height = height // rows + weighted_selected = (weights * selected).reshape( + rows, cell_height, cols, cell_width + ) + weighted_selected = weighted_selected.transpose(0, 2, 1, 3) + color_weight = weighted_selected.sum(axis=(2, 3), dtype=np.float64).ravel() + for channel in range(3): + channel_grid = flat[:, channel].reshape(rows, cell_height, cols, cell_width) + channel_grid = channel_grid.transpose(0, 2, 1, 3) + output_rgb[:, channel] = ((channel_grid / 255.0) * weighted_selected).sum( + axis=(2, 3), dtype=np.float64 + ).ravel() / np.maximum(color_weight, 1e-9) + visible_count = ( + visible.reshape(rows, cell_height, cols, cell_width) + .sum(axis=(1, 3)) + .ravel() + ) + else: + selected_weight = weights[selected] + selected_cells = cell[selected] + color_weight = np.bincount( + selected_cells, weights=selected_weight, minlength=cell_count + ) + for channel in range(3): + output_rgb[:, channel] = np.bincount( + selected_cells, + weights=(flat[selected, channel] / 255.0) * selected_weight, + minlength=cell_count, + ) / np.maximum(color_weight, 1e-9) + visible_count = np.bincount(cell[visible], minlength=cell_count) + + missing = color_weight <= 1e-9 + if np.any(missing): + fallback = np.zeros((cell_count, 3), dtype=np.float64) + missing_pixels = missing[cell] + missing_cells = cell[missing_pixels] + missing_count = np.bincount(missing_cells, minlength=cell_count) + for channel in range(3): + fallback[:, channel] = np.bincount( + missing_cells, + weights=flat[missing_pixels, channel] / 255.0, + minlength=cell_count, + ) / np.maximum(missing_count, 1) + output_rgb[missing] = fallback[missing] + + pixel_count = np.bincount(cell, minlength=cell_count) + alpha = (visible_count / np.maximum(pixel_count, 1) > 0.5).astype(np.uint8) * 255 + output = np.empty((cell_count, 4), dtype=np.uint8) + output[:, :3] = np.clip(np.rint(output_rgb * 255.0), 0, 255).astype(np.uint8) + output[:, 3] = alpha + return output.reshape(rows, cols, 4) From 79e1d1187d79b8228a13debb405da7a86fed4f58 Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 24 Aug 2026 19:40:09 +0800 Subject: [PATCH 4/5] test(pixel-grid): preserve Rust reconstruction behavior A language migration must not alter grid, color, alpha, or resource contracts. Cover the Rust golden output alongside explicit grids, limits, and transparent RGB behavior. Future numerical or algorithm substitutions now fail against observable results. --- .../tests/test_pixel_grid_reconstructor.py | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 backend/tests/test_pixel_grid_reconstructor.py diff --git a/backend/tests/test_pixel_grid_reconstructor.py b/backend/tests/test_pixel_grid_reconstructor.py new file mode 100644 index 000000000..afef010bd --- /dev/null +++ b/backend/tests/test_pixel_grid_reconstructor.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import hashlib +from io import BytesIO + +import numpy as np +import pytest +from PIL import Image + +from windup_app.server.pixel_perfect.reconstructor import ( + MAX_INPUT_BYTES, + ReconstructorError, + reconstruct_bytes, +) + + +def _encode(image: Image.Image) -> bytes: + output = BytesIO() + image.save(output, format="PNG") + return output.getvalue() + + +def test_explicit_grid_rebuilds_one_color_per_output_cell() -> None: + palette = np.array( + [ + [0, 0, 0, 255], + [220, 60, 50, 255], + [50, 120, 210, 255], + [240, 235, 220, 255], + ], + dtype=np.uint8, + ) + indexes = np.fromfunction(lambda y, x: (x + y) % 4, (4, 4), dtype=int) + logical = palette[indexes] + source = Image.fromarray(logical, "RGBA").resize((32, 32), Image.Resampling.NEAREST) + + result = reconstruct_bytes(_encode(source), cols=4, rows=4, structure_colors=4) + + assert (result.width, result.height) == (4, 4) + assert result.visible_color_count == 4 + with Image.open(BytesIO(result.png)) as decoded: + np.testing.assert_array_equal(np.asarray(decoded.convert("RGBA")), logical) + + +def test_structure_color_count_does_not_cap_the_final_palette() -> None: + palette = np.array( + [ + [10, 20, 30, 255], + [220, 60, 50, 255], + [50, 120, 210, 255], + [240, 235, 220, 255], + ], + dtype=np.uint8, + ) + indexes = np.fromfunction(lambda y, x: (x + y) % 4, (4, 4), dtype=int) + logical = palette[indexes] + source = Image.fromarray(logical, "RGBA").resize((32, 32), Image.Resampling.NEAREST) + + result = reconstruct_bytes(_encode(source), cols=4, rows=4, structure_colors=2) + + assert result.visible_color_count == 4 + with Image.open(BytesIO(result.png)) as decoded: + np.testing.assert_array_equal(np.asarray(decoded.convert("RGBA")), logical) + + +def test_dense_grid_color_reconstruction_stays_within_source_color_bounds() -> None: + y, x = np.indices((64, 64)) + source = np.empty((64, 64, 4), dtype=np.uint8) + source[:, :, 0] = 100 + x % 11 + source[:, :, 1] = 100 + y % 11 + source[:, :, 2] = 100 + (x + y) % 11 + source[:, :, 3] = 255 + + result = reconstruct_bytes( + _encode(Image.fromarray(source, "RGBA")), + cols=36, + rows=36, + structure_colors=2, + ) + + with Image.open(BytesIO(result.png)) as decoded: + channels = np.asarray(decoded.convert("RGB")) + assert np.all((channels >= 100) & (channels <= 110)) + + +def test_transparent_rgb_preserves_the_existing_reconstructor_result() -> None: + source = np.zeros((16, 16, 4), dtype=np.uint8) + source[:12, :, :] = (255, 0, 0, 255) + source[12:, :, :] = (0, 0, 255, 0) + + result = reconstruct_bytes( + _encode(Image.fromarray(source, "RGBA")), + cols=1, + rows=1, + structure_colors=2, + ) + + with Image.open(BytesIO(result.png)) as decoded: + assert decoded.convert("RGBA").getpixel((0, 0)) == (223, 0, 32, 255) + + +def test_reconstructor_matches_the_rust_benchmark_golden() -> None: + y, x = np.indices((128, 128)) + group = ((x // 8) + (y // 8) * 3) % 32 + logical = np.empty((128, 128, 4), dtype=np.uint8) + logical[:, :, 0] = (group * 47 + x * 3) % 256 + logical[:, :, 1] = (group * 29 + y * 5) % 256 + logical[:, :, 2] = (group * 71 + x + y) % 256 + logical[:, :, 3] = np.where((x + y) % 29 == 0, 0, 255) + source = Image.fromarray(logical, "RGBA").resize( + (1024, 1024), Image.Resampling.NEAREST + ) + + result = reconstruct_bytes(_encode(source), cols=128, rows=128, structure_colors=32) + + with Image.open(BytesIO(result.png)) as decoded: + rgba = decoded.convert("RGBA").tobytes() + assert hashlib.sha256(rgba).hexdigest() == ( + "c5308291f48eb22166c178ca518dc9e33f55ea7a1518fdf1667b456b440244b1" + ) + + +def test_reconstructor_accepts_jpeg_input() -> None: + source = Image.new("RGB", (16, 16), (30, 80, 120)) + encoded = BytesIO() + source.save(encoded, format="JPEG", quality=100, subsampling=0) + + result = reconstruct_bytes(encoded.getvalue(), cols=1, rows=1, structure_colors=2) + + assert (result.width, result.height) == (1, 1) + with Image.open(BytesIO(result.png)) as decoded: + assert decoded.format == "PNG" + assert decoded.convert("RGBA").getpixel((0, 0))[3] == 255 + + +@pytest.mark.parametrize("structure_colors", [1, 65]) +def test_reconstructor_rejects_structure_colors_outside_bounds( + structure_colors: int, +) -> None: + source = _encode(Image.new("RGBA", (16, 16), (0, 0, 0, 255))) + + with pytest.raises(ReconstructorError, match="between 2 and 64"): + reconstruct_bytes(source, cols=1, rows=1, structure_colors=structure_colors) + + +def test_reconstructor_rejects_a_grid_larger_than_the_source() -> None: + source = _encode(Image.new("RGBA", (32, 32))) + + with pytest.raises(ReconstructorError, match="grid must be within source bounds"): + reconstruct_bytes(source, cols=33, rows=32, structure_colors=16) + + +def test_reconstructor_rejects_a_dense_grid_before_large_allocations() -> None: + source = _encode(Image.new("RGBA", (512, 512))) + + with pytest.raises(ReconstructorError, match="working set exceeds"): + reconstruct_bytes(source, cols=512, rows=512, structure_colors=64) + + +def test_reconstructor_rejects_more_than_four_million_pixels_before_decoding() -> None: + source = _encode(Image.new("RGBA", (2001, 2000))) + + with pytest.raises(ReconstructorError, match="maximum is 4000000 pixels"): + reconstruct_bytes(source, cols=16, rows=16, structure_colors=16) + + +def test_reconstructor_rejects_oversized_encoded_input_before_inspection() -> None: + source = b"not-an-image" + bytes(MAX_INPUT_BYTES) + + with pytest.raises(ReconstructorError, match="encoded input exceeds"): + reconstruct_bytes(source, cols=1, rows=1, structure_colors=2) + + +def test_reconstructor_rejects_unsupported_image_format() -> None: + encoded = BytesIO() + Image.new("RGB", (16, 16)).save(encoded, format="GIF") + + with pytest.raises(ReconstructorError, match="input must be PNG or JPEG"): + reconstruct_bytes(encoded.getvalue(), cols=1, rows=1, structure_colors=2) + + +def test_reconstructor_rejects_images_with_a_side_below_sixteen_pixels() -> None: + source = _encode(Image.new("RGBA", (15, 16))) + + with pytest.raises(ReconstructorError, match="minimum side is 16px"): + reconstruct_bytes(source, cols=1, rows=1, structure_colors=2) From 78133b3029e49ec177f12481652d4d9fa329e24f Mon Sep 17 00:00:00 2001 From: huyan Date: Mon, 24 Aug 2026 19:40:23 +0800 Subject: [PATCH 5/5] perf(pixel-grid): add migration benchmark The Python port needs repeatable latency, memory, and output-parity evidence. Add the same 1024-square workload and Rust RGBA golden used during migration. Reviewers can measure warmed median latency and peak RSS on their target host. --- .../benchmark_pixel_grid_reconstructor.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 backend/scripts/benchmark_pixel_grid_reconstructor.py diff --git a/backend/scripts/benchmark_pixel_grid_reconstructor.py b/backend/scripts/benchmark_pixel_grid_reconstructor.py new file mode 100644 index 000000000..894d874ad --- /dev/null +++ b/backend/scripts/benchmark_pixel_grid_reconstructor.py @@ -0,0 +1,67 @@ +"""运行与 PR #495 Rust 基线相同的显式网格重建负载。""" + +from __future__ import annotations + +import gc +import hashlib +from io import BytesIO +import resource +import statistics +import sys +import time + +import numpy as np +from PIL import Image + +from windup_app.server.pixel_perfect.reconstructor import reconstruct_bytes + +_RUST_RGBA_SHA256 = "c5308291f48eb22166c178ca518dc9e33f55ea7a1518fdf1667b456b440244b1" + + +def _fixture() -> bytes: + y, x = np.indices((128, 128)) + group = ((x // 8) + (y // 8) * 3) % 32 + logical = np.empty((128, 128, 4), dtype=np.uint8) + logical[:, :, 0] = (group * 47 + x * 3) % 256 + logical[:, :, 1] = (group * 29 + y * 5) % 256 + logical[:, :, 2] = (group * 71 + x + y) % 256 + logical[:, :, 3] = np.where((x + y) % 29 == 0, 0, 255) + source = Image.fromarray(logical, "RGBA").resize( + (1024, 1024), Image.Resampling.NEAREST + ) + encoded = BytesIO() + source.save(encoded, format="PNG") + return encoded.getvalue() + + +def _peak_rss_bytes() -> int: + value = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + return int(value if sys.platform == "darwin" else value * 1024) + + +def main() -> None: + source = _fixture() + gc.collect() + baseline_rss = _peak_rss_bytes() + + warmup = reconstruct_bytes(source, cols=128, rows=128, structure_colors=32) + elapsed_ms: list[float] = [] + result = warmup + for _ in range(7): + started = time.perf_counter() + result = reconstruct_bytes(source, cols=128, rows=128, structure_colors=32) + elapsed_ms.append((time.perf_counter() - started) * 1000) + + with Image.open(BytesIO(result.png)) as decoded: + rgba_sha256 = hashlib.sha256(decoded.convert("RGBA").tobytes()).hexdigest() + peak_rss = _peak_rss_bytes() + print(f"median_ms={statistics.median(elapsed_ms):.3f}") + print(f"runs_ms={[round(value, 3) for value in sorted(elapsed_ms)]}") + print(f"processing_rss_delta_bytes={max(peak_rss - baseline_rss, 0)}") + print(f"peak_rss_bytes={peak_rss}") + print(f"rgba_sha256={rgba_sha256}") + print(f"matches_rust_golden={rgba_sha256 == _RUST_RGBA_SHA256}") + + +if __name__ == "__main__": + main()