diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..26bf0b4d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +** +!backend/ +!backend/** +!native/ +!native/pixel-perfect/ +!native/pixel-perfect/** + +backend/.venv/ +backend/.pytest_cache/ +backend/.ruff_cache/ +backend/**/__pycache__/ +native/pixel-perfect/**/target/ diff --git a/.env.example b/.env.example index a5e20f75..70ceb157 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,10 @@ WINDUP_CORS_ORIGINS= # 跨域正则匹配(默认关闭;如需 Vercel 预览域名,请限制到自己的项目名前缀) WINDUP_CORS_ORIGIN_REGEX= +# ── 独立完美像素工具 ── +# 单个进程同时处理的图片数;Rust 运算会释放 GIL,但仍受本机 CPU / 内存约束。 +PIXEL_PERFECT_CONCURRENCY=1 + # ── MQ(Redis Stream 轻量消息队列) ── # 本地/Compose 须同时起 web + worker,否则生成任务会一直 PENDING、邮件不会发出。 # docker compose up -d backend worker diff --git a/backend/Dockerfile b/backend/Dockerfile index 4761de95..4d81f49e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,7 +1,36 @@ # ── 后端 Dockerfile ────────────────────────────────────────────────── # 多阶段构建:builder 装依赖 → runtime 只拷贝产物,镜像更小 -# ── 阶段 1: 构建 ── +# ── 阶段 0: 构建 PyO3 原生扩展 wheel ── +FROM rust:1.89-slim AS rust-toolchain + +FROM python:3.12-slim AS pixel-native-builder + +COPY --from=rust-toolchain /usr/local/cargo /usr/local/cargo +COPY --from=rust-toolchain /usr/local/rustup /usr/local/rustup +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +ENV PATH="/usr/local/cargo/bin:$PATH" \ + CARGO_HOME=/usr/local/cargo \ + RUSTUP_HOME=/usr/local/rustup \ + UV_DEFAULT_INDEX=https://mirrors.aliyun.com/pypi/simple/ \ + UV_HTTP_TIMEOUT=180 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc libc6-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build/native/pixel-perfect +COPY native/pixel-perfect/crates/detector/ crates/detector/ +COPY native/pixel-perfect/crates/reconstructor/ crates/reconstructor/ +COPY native/pixel-perfect/bindings/python/ bindings/python/ +RUN uv tool run --from maturin==1.14.1 maturin build \ + --release \ + --locked \ + --manifest-path bindings/python/Cargo.toml \ + --out /wheels + +# ── 阶段 1: 构建 Python 环境 ── FROM python:3.12-slim AS builder # 安装 uv(比 pip 快 10x) @@ -17,19 +46,24 @@ ENV UV_DEFAULT_INDEX=https://mirrors.aliyun.com/pypi/simple/ \ WORKDIR /app # 先拷贝依赖定义,利用 Docker layer cache -COPY pyproject.toml uv.lock ./ -COPY packages/common/pyproject.toml packages/common/ -COPY packages/framework/pyproject.toml packages/framework/ -COPY packages/ai_engine/pyproject.toml packages/ai_engine/ -COPY packages/app/pyproject.toml packages/app/ +COPY backend/pyproject.toml backend/uv.lock ./ +COPY backend/packages/common/pyproject.toml packages/common/ +COPY backend/packages/framework/pyproject.toml packages/framework/ +COPY backend/packages/ai_engine/pyproject.toml packages/ai_engine/ +COPY backend/packages/app/pyproject.toml packages/app/ # 安装依赖(不含 dev 依赖) RUN uv sync --frozen --no-dev --no-install-workspace # 拷贝源码并安装 -COPY packages/ packages/ +COPY backend/packages/ packages/ RUN uv sync --frozen --no-dev +# 原生扩展是显式工具的可选源码依赖,由镜像在构建期安装;缺失时应用仍可启动。 +COPY --from=pixel-native-builder /wheels /wheels +RUN uv pip install --python /app/.venv/bin/python /wheels/*.whl \ + && /app/.venv/bin/python -c "import windup_pixel_perfect_native" + # ── 阶段 2: 运行时 ── FROM python:3.12-slim AS runtime @@ -38,6 +72,8 @@ WORKDIR /app # 从 builder 拷贝虚拟环境和包 COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/packages /app/packages +COPY native/pixel-perfect/crates/detector/LICENSE /licenses/pixel-grid-detector/LICENSE +COPY native/pixel-perfect/crates/reconstructor/LICENSE /licenses/pixel-grid-reconstructor/LICENSE # 把 venv/bin 加入 PATH ENV PATH="/app/.venv/bin:$PATH" diff --git a/backend/packages/app/pyproject.toml b/backend/packages/app/pyproject.toml index 52c4de6a..9cfc937b 100644 --- a/backend/packages/app/pyproject.toml +++ b/backend/packages/app/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "pydantic[email]>=2.7", "sqlalchemy>=2.0", "python-multipart>=0.0.9", + "pillow>=10.4", ] [project.scripts] diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 7a706aa3..ecdb60ba 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -12,6 +12,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from windup_framework.config.pixel_perfect import settings as pixel_perfect_settings from windup_framework.db import Base, engine # 模型导入:触发 Base.metadata 注册,确保 create_all 能发现所有表 @@ -29,15 +30,20 @@ from windup_app.web.api.auth import router as auth_router from windup_app.web.api.character import router as character_router from windup_app.server.orchestrator import task_repo +from windup_app.server.pixel_perfect import create_pixel_perfect_tool from windup_app.server.orchestrator.render3d_service import default_operations, precheck_master from windup_app.web.api.generation import router as generation_router from windup_app.web.api.media import router as media_router +from windup_app.web.api.pixel_perfect import router as pixel_perfect_router from windup_app.web.api.project import router as project_router from windup_app.web.api.quota import router as quota_router from windup_app.web.api.render3d import router as render3d_router from windup_app.web.api.workflow_run import router as workflow_run_router from windup_app.web.handler.exception_handlers import register_exception_handlers from windup_app.web.middleware.auth import AuthMiddleware +from windup_app.web.middleware.pixel_perfect_limits import ( + PixelPerfectRequestLimitsMiddleware, +) from windup_framework.mq.publisher import MqPublisher from windup_framework.mq.relay import relay_pending_messages from windup_framework.providers import create_chat_model @@ -102,6 +108,9 @@ def create_app() -> FastAPI: app = FastAPI(title="windup", version="0.1.0", lifespan=_lifespan) app.state.mq_publisher = MqPublisher() app.state.chat_model_factory = create_chat_model + app.state.pixel_perfect_tool = create_pixel_perfect_tool( + max_concurrency=pixel_perfect_settings.concurrency + ) # 起名器在 composition root 注入,避免 web→character.service 碰到 ai_engine。 # LangChainCharacterNamer 构造期不创建 ChatOpenAI;缺 AI_API_KEY 时应用仍能启动。 # 测试若已注入假 namer,不要覆盖。 @@ -114,6 +123,10 @@ def health() -> dict[str, str]: # 中间件(add_middleware 后加的先执行:请求先进 CORS → 再进 Auth → 最后到路由) app.add_middleware(AuthMiddleware) + app.add_middleware( + PixelPerfectRequestLimitsMiddleware, + max_concurrency=pixel_perfect_settings.concurrency, + ) app.add_middleware( CORSMiddleware, allow_origins=_cors_origins(), @@ -121,12 +134,21 @@ def health() -> dict[str, str]: allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + expose_headers=[ + "X-Pixel-Cols", + "X-Pixel-Rows", + "X-Pixel-Step-X", + "X-Pixel-Step-Y", + "X-Pixel-Consensus", + "X-Pixel-Confidence", + ], ) app.include_router(auth_router) app.include_router(project_router) app.include_router(character_router) app.include_router(workflow_run_router) app.include_router(media_router) + app.include_router(pixel_perfect_router) app.include_router(generation_router) app.include_router(quota_router) app.include_router(render3d_router) 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 00000000..7f8ebc90 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/__init__.py @@ -0,0 +1,32 @@ +from windup_app.server.pixel_perfect.errors import ( + PixelPerfectBusyError, + PixelPerfectError, + PixelPerfectInputError, + PixelPerfectUnavailableError, +) +from windup_app.server.pixel_perfect.factory import create_pixel_perfect_tool +from windup_app.server.pixel_perfect.model import GridDetection, PixelPerfectResult +from windup_app.server.pixel_perfect.native import ( + NativeGridDetector, + NativeGridReconstructor, +) +from windup_app.server.pixel_perfect.service import ( + GridDetector, + GridReconstructor, + PixelPerfectTool, +) + +__all__ = [ + "GridDetection", + "GridDetector", + "GridReconstructor", + "NativeGridDetector", + "NativeGridReconstructor", + "PixelPerfectBusyError", + "PixelPerfectError", + "PixelPerfectInputError", + "PixelPerfectResult", + "PixelPerfectTool", + "PixelPerfectUnavailableError", + "create_pixel_perfect_tool", +] diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/errors.py b/backend/packages/app/src/windup_app/server/pixel_perfect/errors.py new file mode 100644 index 00000000..f9e8a3d5 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/errors.py @@ -0,0 +1,17 @@ +"""完美像素工具可以映射为稳定 API 结果的失败类型。""" + + +class PixelPerfectError(Exception): + """本地工具可预期失败的基类。""" + + +class PixelPerfectBusyError(PixelPerfectError): + pass + + +class PixelPerfectInputError(PixelPerfectError): + pass + + +class PixelPerfectUnavailableError(PixelPerfectError): + pass diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/factory.py b/backend/packages/app/src/windup_app/server/pixel_perfect/factory.py new file mode 100644 index 00000000..6828cad8 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/factory.py @@ -0,0 +1,15 @@ +"""完美像素工具在应用 composition root 使用的本地装配。""" + +from windup_app.server.pixel_perfect.native import ( + NativeGridDetector, + NativeGridReconstructor, +) +from windup_app.server.pixel_perfect.service import PixelPerfectTool + + +def create_pixel_perfect_tool(*, max_concurrency: int) -> PixelPerfectTool: + return PixelPerfectTool( + detector=NativeGridDetector(), + reconstructor=NativeGridReconstructor(), + max_concurrency=max_concurrency, + ) diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/model.py b/backend/packages/app/src/windup_app/server/pixel_perfect/model.py new file mode 100644 index 00000000..7f37859c --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/model.py @@ -0,0 +1,18 @@ +"""完美像素工具在检测、重建与 API 之间传递的最小契约。""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class GridDetection: + cols: int + rows: int + step_x: float + step_y: float + consensus: str + confidence: str + + +@dataclass(frozen=True) +class PixelPerfectResult(GridDetection): + png: bytes diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/native.py b/backend/packages/app/src/windup_app/server/pixel_perfect/native.py new file mode 100644 index 00000000..bcd28180 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/native.py @@ -0,0 +1,116 @@ +"""PyO3 原生扩展与 Python 工具编排之间的适配层。""" + +from collections.abc import Callable +from importlib import import_module +import math +from typing import Protocol + +from windup_app.server.pixel_perfect.errors import ( + PixelPerfectInputError, + PixelPerfectUnavailableError, +) +from windup_app.server.pixel_perfect.model import GridDetection + + +class PixelPerfectNativeModule(Protocol): + def detect(self, source: bytes, mode: str) -> object: ... + + def reconstruct( + self, + source: bytes, + cols: int, + rows: int, + colors: int, + ) -> object: ... + + +NativeModuleLoader = Callable[[], PixelPerfectNativeModule] + + +def _load_installed_module() -> PixelPerfectNativeModule: + return import_module("windup_pixel_perfect_native") + + +def _load_native(loader: NativeModuleLoader) -> PixelPerfectNativeModule: + try: + return loader() + except (ImportError, OSError) as error: + raise PixelPerfectUnavailableError( + "本地像素原生扩展未安装或无法加载" + ) from error + + +class NativeGridDetector: + def __init__( + self, module_loader: NativeModuleLoader = _load_installed_module + ) -> None: + self._module_loader = module_loader + + def detect(self, source: bytes) -> GridDetection: + native = _load_native(self._module_loader) + try: + payload = native.detect(source, "full") + except ValueError as error: + raise PixelPerfectInputError( + str(error) or "原生检测器拒绝了输入" + ) from error + except Exception as error: + raise PixelPerfectUnavailableError("原生检测器调用失败") from error + + expected = { + "cols", + "rows", + "step_x", + "step_y", + "consensus", + "confidence", + } + if not isinstance(payload, dict) or set(payload) != expected: + raise PixelPerfectUnavailableError("检测器返回字段不符合约定") + try: + result = GridDetection(**payload) + except TypeError as error: + raise PixelPerfectUnavailableError("检测器返回类型不符合约定") from error + if ( + not isinstance(result.cols, int) + or isinstance(result.cols, bool) + or not isinstance(result.rows, int) + or isinstance(result.rows, bool) + or result.cols < 1 + or result.rows < 1 + or not isinstance(result.step_x, (int, float)) + or isinstance(result.step_x, bool) + or not isinstance(result.step_y, (int, float)) + or isinstance(result.step_y, bool) + or not math.isfinite(result.step_x) + or not math.isfinite(result.step_y) + or result.step_x <= 0 + or result.step_y <= 0 + or not isinstance(result.consensus, str) + or result.confidence not in {"high", "medium", "low"} + ): + raise PixelPerfectUnavailableError("检测器返回值超出约定") + return result + + +class NativeGridReconstructor: + def __init__( + self, module_loader: NativeModuleLoader = _load_installed_module + ) -> None: + self._module_loader = module_loader + + def reconstruct(self, source: bytes, *, cols: int, rows: int, colors: int) -> bytes: + native = _load_native(self._module_loader) + try: + output = native.reconstruct(source, cols, rows, colors) + except ValueError as error: + raise PixelPerfectInputError( + str(error) or "原生重建器拒绝了输入" + ) from error + except Exception as error: + raise PixelPerfectUnavailableError("原生重建器调用失败") from error + if not isinstance(output, bytes): + raise PixelPerfectUnavailableError("重建器返回类型不符合约定") + if len(output) > 32 * 1024 * 1024: + raise PixelPerfectUnavailableError("重建器返回数据过大") + return output diff --git a/backend/packages/app/src/windup_app/server/pixel_perfect/service.py b/backend/packages/app/src/windup_app/server/pixel_perfect/service.py new file mode 100644 index 00000000..20cd3eb3 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/pixel_perfect/service.py @@ -0,0 +1,136 @@ +"""独立完美像素工具的编排边界。""" + +from io import BytesIO +import math +from threading import BoundedSemaphore +from typing import Protocol + +from PIL import Image, UnidentifiedImageError + +from windup_app.server.pixel_perfect.errors import ( + PixelPerfectBusyError, + PixelPerfectInputError, + PixelPerfectUnavailableError, +) +from windup_app.server.pixel_perfect.model import GridDetection, PixelPerfectResult + + +class GridDetector(Protocol): + def detect(self, source: bytes) -> GridDetection: ... + + +class GridReconstructor(Protocol): + def reconstruct( + self, source: bytes, *, cols: int, rows: int, colors: int + ) -> bytes: ... + + +class PixelPerfectTool: + def __init__( + self, + *, + detector: GridDetector, + reconstructor: GridReconstructor, + max_concurrency: int = 1, + ) -> None: + self._detector = detector + self._reconstructor = reconstructor + if max_concurrency < 1: + raise ValueError("max_concurrency must be at least 1") + self._slots = BoundedSemaphore(max_concurrency) + + def process( + self, + source: bytes, + *, + colors: int, + pixel_size: float | None, + ) -> PixelPerfectResult: + if not self._slots.acquire(blocking=False): + raise PixelPerfectBusyError("完美像素工具正在处理另一张图片") + try: + width, height = _image_dimensions(source) + if not 2 <= colors <= 64: + raise PixelPerfectInputError("colors 必须在 2 到 64 之间") + if pixel_size is None: + grid = self._detector.detect(source) + if grid.step_x < 3 or grid.step_y < 3: + raise PixelPerfectInputError( + "自动识别到的隐含像素小于 3px,请提供手动 pixel_size" + ) + else: + if not math.isfinite(pixel_size) or pixel_size < 1: + raise PixelPerfectInputError("pixel_size 必须是大于等于 1 的有限数") + cols = max(1, round(width / pixel_size)) + rows = max(1, round(height / pixel_size)) + grid = GridDetection( + cols=cols, + rows=rows, + step_x=width / cols, + step_y=height / rows, + consensus="forced", + confidence="forced", + ) + if not (1 <= grid.cols <= width and 1 <= grid.rows <= height): + raise PixelPerfectUnavailableError("检测器返回了超出图片范围的网格") + png = self._reconstructor.reconstruct( + source, + cols=grid.cols, + rows=grid.rows, + colors=colors, + ) + _validate_reconstruction(png, grid.cols, grid.rows) + return PixelPerfectResult( + png=png, + cols=grid.cols, + rows=grid.rows, + step_x=grid.step_x, + step_y=grid.step_y, + consensus=grid.consensus, + confidence=grid.confidence, + ) + finally: + self._slots.release() + + +def _image_dimensions(source: bytes) -> tuple[int, int]: + if len(source) > 10 * 1024 * 1024: + raise PixelPerfectInputError("图片不能超过 10 MB") + try: + with Image.open(BytesIO(source)) as image: + width, height = image.size + image_format = image.format + except ( + Image.DecompressionBombError, + Image.DecompressionBombWarning, + UnidentifiedImageError, + OSError, + ) as error: + raise PixelPerfectInputError("来源不是可解码的图片") from error + if image_format not in {"PNG", "JPEG"}: + raise PixelPerfectInputError("当前只支持 PNG/JPEG 图片") + if min(width, height) < 16: + raise PixelPerfectInputError("图片最短边不能小于 16px") + pixel_count = width * height + if pixel_count > 4_000_000: + raise PixelPerfectInputError("图片像素数不能超过 4000000") + return width, height + + +def _validate_reconstruction(png: bytes, cols: int, rows: int) -> None: + try: + with Image.open(BytesIO(png)) as image: + if image.format != "PNG" or image.size != (cols, rows): + raise PixelPerfectUnavailableError( + "重建器返回的 PNG 尺寸与显式网格不一致" + ) + image.load() + except PixelPerfectUnavailableError: + raise + except ( + Image.DecompressionBombError, + Image.DecompressionBombWarning, + UnidentifiedImageError, + OSError, + ) as error: + raise PixelPerfectUnavailableError("重建器返回了无效 PNG") from error diff --git a/backend/packages/app/src/windup_app/web/api/pixel_perfect.py b/backend/packages/app/src/windup_app/web/api/pixel_perfect.py new file mode 100644 index 00000000..5ab8ecf8 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/pixel_perfect.py @@ -0,0 +1,87 @@ +"""独立完美像素工具 API;只接收文件并直接返回本地 PNG。""" + +import asyncio + +from fastapi import APIRouter, File, Form, Request, UploadFile +from fastapi.responses import Response +from pydantic import BaseModel +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException + +from windup_app.server.pixel_perfect import ( + PixelPerfectBusyError, + PixelPerfectInputError, + PixelPerfectUnavailableError, +) + + +router = APIRouter(prefix="/tools", tags=["tools"]) +MAX_UPLOAD_BYTES = 10 * 1024 * 1024 +_SIGNATURES = { + "image/png": b"\x89PNG\r\n\x1a\n", + "image/jpeg": b"\xff\xd8\xff", +} + + +class ToolErrorResponse(BaseModel): + code: int + message: str + data: object | None = None + + +@router.post( + "/pixel-perfect", + response_class=Response, + responses={ + 200: { + "model": ToolErrorResponse, + "content": {"image/png": {}}, + } + }, +) +async def pixel_perfect_file( + request: Request, + file: UploadFile = File(...), + colors: int = Form(32, ge=2, le=64), + pixel_size: float | None = Form(None, ge=1, allow_inf_nan=False), +) -> Response: + """显式调用本地工具;不会被任何生成流程自动触发。""" + signature = _SIGNATURES.get(file.content_type or "") + if signature is None: + raise BizException("当前只支持 PNG/JPEG 图片", code=BizCode.BAD_REQUEST) + source = bytearray() + while chunk := await file.read(64 * 1024): + if len(source) + len(chunk) > MAX_UPLOAD_BYTES: + raise BizException("图片不能超过 10 MB", code=BizCode.BAD_REQUEST) + source.extend(chunk) + if not source.startswith(signature): + raise BizException("文件内容与声明的图片类型不匹配", code=BizCode.BAD_REQUEST) + tool = getattr(request.app.state, "pixel_perfect_tool", None) + if tool is None: + raise BizException("完美像素工具未装配", code=BizCode.MODEL_UNAVAILABLE) + try: + result = await asyncio.to_thread( + tool.process, + bytes(source), + colors=colors, + pixel_size=pixel_size, + ) + except PixelPerfectInputError as error: + raise BizException(str(error), code=BizCode.BAD_REQUEST) from error + except PixelPerfectBusyError as error: + raise BizException(str(error), code=BizCode.TOO_MANY_REQUESTS) from error + except PixelPerfectUnavailableError as error: + raise BizException(str(error), code=BizCode.MODEL_UNAVAILABLE) from error + return Response( + content=result.png, + media_type="image/png", + headers={ + "Content-Disposition": 'attachment; filename="pixel-perfect.png"', + "X-Pixel-Cols": str(result.cols), + "X-Pixel-Rows": str(result.rows), + "X-Pixel-Step-X": str(result.step_x), + "X-Pixel-Step-Y": str(result.step_y), + "X-Pixel-Consensus": result.consensus, + "X-Pixel-Confidence": result.confidence, + }, + ) diff --git a/backend/packages/app/src/windup_app/web/middleware/pixel_perfect_limits.py b/backend/packages/app/src/windup_app/web/middleware/pixel_perfect_limits.py new file mode 100644 index 00000000..21bde619 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/middleware/pixel_perfect_limits.py @@ -0,0 +1,91 @@ +"""在 multipart 落盘前限制完美像素工具的请求体与并发。""" + +import asyncio + +from starlette.responses import JSONResponse +from windup_common.enums.biz_code import BizCode +from windup_common.result import Response + + +class _BodyTooLarge(Exception): + pass + + +class PixelPerfectRequestLimitsMiddleware: + def __init__( + self, + app, + *, + max_body_bytes: int = 11 * 1024 * 1024, + max_concurrency: int = 1, + ) -> None: + if max_body_bytes < 1 or max_concurrency < 1: + raise ValueError("pixel-perfect request limits must be positive") + self.app = app + self.max_body_bytes = max_body_bytes + self.max_concurrency = max_concurrency + self._active = 0 + self._lock = asyncio.Lock() + + async def __call__(self, scope, receive, send) -> None: + if scope["type"] != "http" or not ( + scope["method"] == "POST" and scope["path"] == "/tools/pixel-perfect" + ): + await self.app(scope, receive, send) + return + + async with self._lock: + if self._active >= self.max_concurrency: + await self._reject( + scope, + receive, + send, + "完美像素工具正在处理另一张图片", + BizCode.TOO_MANY_REQUESTS, + ) + return + self._active += 1 + + received = 0 + body_too_large = False + + async def bounded_receive(): + nonlocal body_too_large, received + message = await receive() + if message["type"] == "http.request": + received += len(message.get("body", b"")) + if received > self.max_body_bytes: + body_too_large = True + raise _BodyTooLarge + return message + + async def bounded_send(message): + # Starlette 把 multipart 读取异常改写成自己的 400;先压住该响应, + # 再由本中间件返回项目约定的业务错误包络。 + if not body_too_large: + await send(message) + + try: + try: + await self.app(scope, bounded_receive, bounded_send) + except _BodyTooLarge: + body_too_large = True + + if body_too_large: + await self._reject( + scope, + receive, + send, + "请求体不能超过 11 MB", + BizCode.BAD_REQUEST, + ) + finally: + async with self._lock: + self._active -= 1 + + @staticmethod + async def _reject(scope, receive, send, message: str, code: BizCode) -> None: + response = JSONResponse( + Response.fail(message, code=code).model_dump(mode="json") + ) + await response(scope, receive, send) diff --git a/backend/packages/framework/src/windup_framework/config/pixel_perfect.py b/backend/packages/framework/src/windup_framework/config/pixel_perfect.py new file mode 100644 index 00000000..b311db7c --- /dev/null +++ b/backend/packages/framework/src/windup_framework/config/pixel_perfect.py @@ -0,0 +1,18 @@ +"""独立完美像素工具的本地资源配置。""" + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class PixelPerfectSettings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="PIXEL_PERFECT_", + env_file=("../.env", ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + concurrency: int = Field(default=1, ge=1) + + +settings = PixelPerfectSettings() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0bb5cbc1..29071248 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -42,6 +42,30 @@ type = "forbidden" source_modules = ["windup_app.web", "windup_app.worker"] forbidden_modules = ["windup_ai_engine"] +[[tool.importlinter.contracts]] +name = "独立完美像素工具不依赖生成和业务基础设施" +type = "forbidden" +source_modules = [ + "windup_app.server.pixel_perfect", + "windup_app.web.api.pixel_perfect", +] +forbidden_modules = [ + "windup_ai_engine", + "windup_framework", + "windup_app.server.media", + "windup_app.server.orchestrator", +] + +[[tool.importlinter.contracts]] +name = "现有生成管线不依赖独立完美像素工具" +type = "forbidden" +source_modules = [ + "windup_app.server.orchestrator", + "windup_app.web.api.generation", + "windup_app.worker", +] +forbidden_modules = ["windup_app.server.pixel_perfect"] + # ── pytest 配置 ───────────────────────────────────────────────────── [tool.pytest.ini_options] markers = [ diff --git a/backend/tests/test_pixel_perfect_api.py b/backend/tests/test_pixel_perfect_api.py new file mode 100644 index 00000000..2447b8da --- /dev/null +++ b/backend/tests/test_pixel_perfect_api.py @@ -0,0 +1,465 @@ +"""独立完美像素工具的 API 与编排契约。""" + +import asyncio +from io import BytesIO +import struct +import zlib +from concurrent.futures import ThreadPoolExecutor +from threading import Event +from types import SimpleNamespace + +from PIL import Image +from windup_common.enums.biz_code import BizCode + +from windup_app.server.pixel_perfect import ( + GridDetection, + NativeGridDetector, + NativeGridReconstructor, + PixelPerfectInputError, + PixelPerfectBusyError, + PixelPerfectTool, + PixelPerfectUnavailableError, +) +from windup_app.web.middleware.pixel_perfect_limits import ( + PixelPerfectRequestLimitsMiddleware, +) + + +def _png_bytes(width: int = 8, height: int = 8) -> bytes: + image = Image.new("RGBA", (width, height), (180, 90, 40, 255)) + output = BytesIO() + image.save(output, "PNG") + return output.getvalue() + + +def _oversized_png_header(width: int, height: int) -> bytes: + ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) + chunk = b"IHDR" + ihdr + return ( + b"\x89PNG\r\n\x1a\n" + + struct.pack(">I", len(ihdr)) + + chunk + + struct.pack(">I", zlib.crc32(chunk)) + ) + + +class _RecordingTool: + def __init__(self) -> None: + self.calls: list[tuple[bytes, int, float | None]] = [] + + def process(self, source: bytes, *, colors: int, pixel_size: float | None): + self.calls.append((source, colors, pixel_size)) + return SimpleNamespace( + png=_png_bytes(2, 2), + cols=2, + rows=2, + step_x=4.0, + step_y=4.0, + consensus="forced", + confidence="forced", + ) + + +def test_pixel_perfect_file_endpoint_returns_png_without_business_storage(auth_client): + tool = _RecordingTool() + auth_client.app.state.pixel_perfect_tool = tool + source = _png_bytes() + + response = auth_client.post( + "/tools/pixel-perfect", + files={"file": ("source.png", source, "image/png")}, + data={"colors": "16", "pixel_size": "4"}, + ) + + assert response.status_code == 200 + assert response.headers["content-type"] == "image/png" + assert response.headers["x-pixel-cols"] == "2" + assert response.headers["x-pixel-rows"] == "2" + assert response.headers["x-pixel-consensus"] == "forced" + assert response.content == _png_bytes(2, 2) + assert tool.calls == [(source, 16, 4.0)] + + +def test_pixel_perfect_endpoint_rejects_unsupported_or_mislabeled_files(auth_client): + tool = _RecordingTool() + auth_client.app.state.pixel_perfect_tool = tool + + unsupported = auth_client.post( + "/tools/pixel-perfect", + files={"file": ("source.webp", b"RIFFxxxxWEBP", "image/webp")}, + ) + mislabeled = auth_client.post( + "/tools/pixel-perfect", + files={"file": ("source.png", b"not-a-png", "image/png")}, + ) + + assert unsupported.json()["code"] == BizCode.BAD_REQUEST + assert mislabeled.json()["code"] == BizCode.BAD_REQUEST + assert tool.calls == [] + + +def test_pixel_perfect_endpoint_bounds_the_uploaded_bytes(auth_client): + tool = _RecordingTool() + auth_client.app.state.pixel_perfect_tool = tool + + response = auth_client.post( + "/tools/pixel-perfect", + files={ + "file": ( + "source.png", + b"\x89PNG\r\n\x1a\n" + bytes(10 * 1024 * 1024), + "image/png", + ) + }, + ) + + assert response.json()["code"] == BizCode.BAD_REQUEST + assert tool.calls == [] + + +def test_pixel_perfect_endpoint_wraps_oversized_multipart_as_business_error( + auth_client, +): + response = auth_client.post( + "/tools/pixel-perfect", + files={ + "file": ( + "source.png", + bytes(11 * 1024 * 1024 + 1), + "image/png", + ) + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "code": BizCode.BAD_REQUEST, + "message": "请求体不能超过 11 MB", + "data": None, + } + + +def test_pixel_perfect_metadata_headers_are_visible_to_the_browser(auth_client): + auth_client.app.state.pixel_perfect_tool = _RecordingTool() + + response = auth_client.post( + "/tools/pixel-perfect", + headers={"origin": "http://127.0.0.1:5173"}, + files={"file": ("source.png", _png_bytes(), "image/png")}, + ) + + exposed = { + name.strip().lower() + for name in response.headers["access-control-expose-headers"].split(",") + } + assert { + "x-pixel-cols", + "x-pixel-rows", + "x-pixel-step-x", + "x-pixel-step-y", + "x-pixel-consensus", + "x-pixel-confidence", + } <= exposed + + +class _GridDetector: + def __init__(self, result: GridDetection) -> None: + self.result = result + self.calls: list[bytes] = [] + + def detect(self, source: bytes) -> GridDetection: + self.calls.append(source) + return self.result + + +class _GridReconstructor: + def __init__(self) -> None: + self.calls: list[tuple[bytes, int, int, int]] = [] + + def reconstruct(self, source: bytes, *, cols: int, rows: int, colors: int) -> bytes: + self.calls.append((source, cols, rows, colors)) + return _png_bytes(cols, rows) + + +def _detected_grid(*, step_x: float = 4.0, step_y: float = 4.0) -> GridDetection: + return GridDetection( + cols=8, + rows=6, + step_x=step_x, + step_y=step_y, + consensus="arbitrated", + confidence="medium", + ) + + +def test_tool_auto_mode_passes_only_the_detected_grid_to_reconstruction(): + source = _png_bytes(32, 24) + detector = _GridDetector(_detected_grid()) + reconstructor = _GridReconstructor() + tool = PixelPerfectTool(detector=detector, reconstructor=reconstructor) + + result = tool.process(source, colors=32, pixel_size=None) + + assert detector.calls == [source] + assert reconstructor.calls == [(source, 8, 6, 32)] + assert (result.cols, result.rows) == (8, 6) + assert result.consensus == "arbitrated" + + +def test_tool_manual_pixel_size_bypasses_detection(): + source = _png_bytes(32, 24) + detector = _GridDetector(_detected_grid()) + reconstructor = _GridReconstructor() + tool = PixelPerfectTool(detector=detector, reconstructor=reconstructor) + + result = tool.process(source, colors=16, pixel_size=4) + + assert detector.calls == [] + assert reconstructor.calls == [(source, 8, 6, 16)] + assert (result.step_x, result.step_y) == (4.0, 4.0) + assert (result.consensus, result.confidence) == ("forced", "forced") + + +def test_tool_does_not_auto_process_a_sub_three_pixel_detection(): + detector = _GridDetector(_detected_grid(step_x=2.8)) + reconstructor = _GridReconstructor() + tool = PixelPerfectTool(detector=detector, reconstructor=reconstructor) + + try: + tool.process(_png_bytes(32, 24), colors=32, pixel_size=None) + except PixelPerfectInputError as error: + assert "小于 3px" in str(error) + else: + raise AssertionError("sub-three-pixel auto mode must be rejected") + + assert reconstructor.calls == [] + + +def test_tool_rejects_non_finite_manual_pixel_size(): + tool = PixelPerfectTool( + detector=_GridDetector(_detected_grid()), + reconstructor=_GridReconstructor(), + ) + + for value in (float("inf"), float("-inf"), float("nan")): + try: + tool.process(_png_bytes(32, 24), colors=32, pixel_size=value) + except PixelPerfectInputError: + pass + else: + raise AssertionError("manual pixel_size must be finite") + + +def test_tool_maps_decompression_bombs_to_input_errors(): + tool = PixelPerfectTool( + detector=_GridDetector(_detected_grid()), + reconstructor=_GridReconstructor(), + ) + + try: + tool.process( + _oversized_png_header(100_000, 100_000), + colors=32, + pixel_size=None, + ) + except PixelPerfectInputError: + pass + else: + raise AssertionError("decompression bomb must be rejected") + + +def test_tool_validates_the_complete_reconstruction_png_and_grid_size(): + class InvalidReconstructor: + def __init__(self, output: bytes) -> None: + self.output = output + + def reconstruct(self, source, *, cols, rows, colors): + return self.output + + for output in (b"\x89PNG\r\n\x1a\nnot-a-png", _png_bytes(2, 2)): + tool = PixelPerfectTool( + detector=_GridDetector(_detected_grid()), + reconstructor=InvalidReconstructor(output), + ) + try: + tool.process(_png_bytes(32, 24), colors=32, pixel_size=None) + except PixelPerfectUnavailableError: + pass + else: + raise AssertionError("invalid reconstruction must be rejected") + + +def test_native_detector_calls_the_extension_and_parses_the_six_field_contract(): + payload = { + "cols": 8, + "rows": 6, + "step_x": 4.0, + "step_y": 4.0, + "consensus": "arbitrated", + "confidence": "medium", + } + calls = [] + + class NativeModule: + @staticmethod + def detect(source: bytes, mode: str): + calls.append((source, mode)) + return payload + + detector = NativeGridDetector(module_loader=lambda: NativeModule) + source = _png_bytes(32, 24) + + result = detector.detect(source) + + assert result == GridDetection(**payload) + assert calls == [(source, "full")] + + +def test_native_reconstructor_passes_only_the_explicit_grid_contract(): + output = _png_bytes(8, 6) + calls = [] + + class NativeModule: + @staticmethod + def reconstruct(source: bytes, cols: int, rows: int, colors: int): + calls.append((source, cols, rows, colors)) + return source + + reconstructor = NativeGridReconstructor(module_loader=lambda: NativeModule) + + result = reconstructor.reconstruct(output, cols=8, rows=6, colors=16) + + assert result == output + assert calls == [(output, 8, 6, 16)] + + +def test_native_extension_value_errors_are_reported_as_bad_input(): + class NativeModule: + @staticmethod + def detect(_source: bytes, _mode: str): + raise ValueError("invalid image") + + detector = NativeGridDetector(module_loader=lambda: NativeModule) + + try: + detector.detect(_png_bytes(32, 24)) + except PixelPerfectInputError as error: + assert "invalid image" in str(error) + else: + raise AssertionError("native input errors must preserve their category") + + +def test_missing_native_extension_is_reported_as_tool_unavailable(): + def missing_module(): + raise ModuleNotFoundError("windup_pixel_perfect_native") + + detector = NativeGridDetector(module_loader=missing_module) + + try: + detector.detect(_png_bytes(32, 24)) + except PixelPerfectUnavailableError as error: + assert "未安装" in str(error) + else: + raise AssertionError("missing native extensions must not break app startup") + + +def test_endpoint_maps_local_tool_failures_to_stable_business_codes(auth_client): + class FailingTool: + def __init__(self, error: Exception) -> None: + self.error = error + + def process(self, _source, *, colors, pixel_size): + raise self.error + + cases = [ + (PixelPerfectInputError("bad image"), BizCode.BAD_REQUEST), + (PixelPerfectBusyError("busy"), BizCode.TOO_MANY_REQUESTS), + (PixelPerfectUnavailableError("missing"), BizCode.MODEL_UNAVAILABLE), + ] + for error, expected_code in cases: + auth_client.app.state.pixel_perfect_tool = FailingTool(error) + response = auth_client.post( + "/tools/pixel-perfect", + files={"file": ("source.png", _png_bytes(), "image/png")}, + ) + assert response.json()["code"] == expected_code + + +def test_application_wires_the_standalone_pixel_perfect_tool(auth_client): + assert isinstance(auth_client.app.state.pixel_perfect_tool, PixelPerfectTool) + + +def test_tool_rejects_parallel_work_when_its_local_slot_is_busy(): + entered = Event() + release = Event() + + class BlockingReconstructor(_GridReconstructor): + def reconstruct(self, source, *, cols, rows, colors): + entered.set() + release.wait(timeout=1) + return super().reconstruct(source, cols=cols, rows=rows, colors=colors) + + tool = PixelPerfectTool( + detector=_GridDetector(_detected_grid()), + reconstructor=BlockingReconstructor(), + max_concurrency=1, + ) + source = _png_bytes(32, 24) + + with ThreadPoolExecutor(max_workers=1) as executor: + first = executor.submit(tool.process, source, colors=32, pixel_size=None) + assert entered.wait(timeout=1) + try: + tool.process(source, colors=32, pixel_size=None) + except PixelPerfectBusyError: + pass + else: + raise AssertionError("parallel processing must be rejected") + release.set() + assert first.result(timeout=1).png.startswith(b"\x89PNG") + + +def test_request_limit_counts_streamed_bytes_without_trusting_content_length(): + called = False + + async def app(scope, receive, send): + nonlocal called + called = True + while (await receive()).get("more_body"): + pass + + middleware = PixelPerfectRequestLimitsMiddleware(app, max_body_bytes=5) + messages = iter( + [ + {"type": "http.request", "body": b"1234", "more_body": True}, + {"type": "http.request", "body": b"56", "more_body": False}, + ] + ) + sent = [] + + async def receive(): + return next(messages) + + async def send(message): + sent.append(message) + + scope = { + "type": "http", + "method": "POST", + "path": "/tools/pixel-perfect", + "headers": [(b"content-length", b"1")], + } + asyncio.run(middleware(scope, receive, send)) + + assert called + assert sent[0]["status"] == 200 + assert b'"code":400' in sent[1]["body"] + + +def test_openapi_declares_png_success_and_json_business_errors(auth_client): + response = auth_client.get("/openapi.json").json()["paths"]["/tools/pixel-perfect"][ + "post" + ]["responses"]["200"]["content"] + + assert {"image/png", "application/json"} <= set(response) + assert "schema" in response["application/json"] diff --git a/backend/uv.lock b/backend/uv.lock index 732fcad6..1fcc0bf6 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2148,6 +2148,7 @@ version = "0.1.0" source = { editable = "packages/app" } dependencies = [ { name = "fastapi" }, + { name = "pillow" }, { name = "pydantic", extra = ["email"] }, { name = "python-multipart" }, { name = "sqlalchemy" }, @@ -2160,6 +2161,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, + { name = "pillow", specifier = ">=10.4" }, { name = "pydantic", extras = ["email"], specifier = ">=2.7" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "sqlalchemy", specifier = ">=2.0" }, diff --git a/docker-compose.yml b/docker-compose.yml index 91371cf6..7656cf1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,8 +46,8 @@ services: # ── 后端 API 服务 ── backend: build: - context: ./backend - dockerfile: Dockerfile + context: . + dockerfile: backend/Dockerfile container_name: windup-backend restart: unless-stopped depends_on: @@ -70,8 +70,8 @@ services: # ── Worker(MQ 消费:邮件 + 生成调度) ── worker: build: - context: ./backend - dockerfile: Dockerfile + context: . + dockerfile: backend/Dockerfile container_name: windup-worker restart: unless-stopped depends_on: diff --git a/native/pixel-perfect/bindings/python/.gitignore b/native/pixel-perfect/bindings/python/.gitignore new file mode 100644 index 00000000..b83d2226 --- /dev/null +++ b/native/pixel-perfect/bindings/python/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/native/pixel-perfect/bindings/python/Cargo.lock b/native/pixel-perfect/bindings/python/Cargo.lock new file mode 100644 index 00000000..48d53743 --- /dev/null +++ b/native/pixel-perfect/bindings/python/Cargo.lock @@ -0,0 +1,441 @@ +# 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.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[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 = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[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 = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[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-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "pyo3" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba0117f4212101ee6544044dae45abe1083d30ce7b29c4b5cbdfa2354e07383" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc6ddaf24947d12a9aa31ac65431fb1b851b8f4365426e182901eabfb87df5f" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "025474d3928738efb38ac36d4744a74a400c901c7596199e20e45d98eb194105" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e64eb489f22fe1c95911b77c44cc41e7c19f3082fc81cce90f657cdc42ffded" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "100246c0ecf400b475341b8455a9213344569af29a3c841d29270e53102e0fcf" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[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 = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "windup-pixel-grid-detector" +version = "0.1.0" +dependencies = [ + "image", + "rayon", + "rustfft", +] + +[[package]] +name = "windup-pixel-grid-reconstructor" +version = "0.1.0" +dependencies = [ + "image", + "rayon", +] + +[[package]] +name = "windup-pixel-perfect-native" +version = "0.1.0" +dependencies = [ + "pyo3", + "windup-pixel-grid-detector", + "windup-pixel-grid-reconstructor", +] + +[[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/native/pixel-perfect/bindings/python/Cargo.toml b/native/pixel-perfect/bindings/python/Cargo.toml new file mode 100644 index 00000000..7309c1a8 --- /dev/null +++ b/native/pixel-perfect/bindings/python/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "windup-pixel-perfect-native" +version = "0.1.0" +edition = "2021" +license = "MIT" +publish = false + +[lib] +name = "windup_pixel_perfect_native" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.26", features = ["abi3-py312", "extension-module"] } +windup-pixel-grid-detector = { path = "../../crates/detector" } +windup-pixel-grid-reconstructor = { path = "../../crates/reconstructor" } + +[profile.release] +lto = true +codegen-units = 1 diff --git a/native/pixel-perfect/bindings/python/README.md b/native/pixel-perfect/bindings/python/README.md new file mode 100644 index 00000000..ee4ccc4b --- /dev/null +++ b/native/pixel-perfect/bindings/python/README.md @@ -0,0 +1,16 @@ +# Python binding + +PyO3 binding for the independent detector and reconstructor crates. It exposes only two byte-oriented functions: + +```python +detect(source, mode="full") +reconstruct(source, cols, rows, colors) +``` + +For local development, install the extension into the active backend environment: + +```bash +uv tool run --from maturin==1.14.1 maturin develop --release --locked +``` + +The Rust computation detaches from the Python interpreter. Input validation errors are raised as `ValueError`; loading and output validation remain the responsibility of the Python adapter. diff --git a/native/pixel-perfect/bindings/python/pyproject.toml b/native/pixel-perfect/bindings/python/pyproject.toml new file mode 100644 index 00000000..5ece9db4 --- /dev/null +++ b/native/pixel-perfect/bindings/python/pyproject.toml @@ -0,0 +1,12 @@ +[build-system] +requires = ["maturin==1.14.1"] +build-backend = "maturin" + +[project] +name = "windup-pixel-perfect-native" +version = "0.1.0" +requires-python = ">=3.12" + +[tool.maturin] +bindings = "pyo3" +module-name = "windup_pixel_perfect_native" diff --git a/native/pixel-perfect/bindings/python/src/lib.rs b/native/pixel-perfect/bindings/python/src/lib.rs new file mode 100644 index 00000000..554d56a9 --- /dev/null +++ b/native/pixel-perfect/bindings/python/src/lib.rs @@ -0,0 +1,55 @@ +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyBytes, PyDict, PyModule}; +use windup_pixel_grid_detector::{detect_bytes, DetectorMode}; +use windup_pixel_grid_reconstructor::reconstruct_bytes; + +#[pyfunction] +#[pyo3(signature = (source, mode = "full"))] +fn detect<'py>( + py: Python<'py>, + source: &Bound<'py, PyBytes>, + mode: &str, +) -> PyResult> { + let detector_mode = match mode { + "full" => DetectorMode::Full, + "fast" => DetectorMode::Fast, + _ => return Err(PyValueError::new_err("mode must be 'full' or 'fast'")), + }; + // The Python buffer cannot be borrowed while the interpreter is detached. + let input = source.as_bytes().to_vec(); + let result = py + .detach(move || detect_bytes(&input, detector_mode)) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + + let payload = PyDict::new(py); + payload.set_item("cols", result.cols)?; + payload.set_item("rows", result.rows)?; + payload.set_item("step_x", result.step_x)?; + payload.set_item("step_y", result.step_y)?; + payload.set_item("consensus", result.consensus)?; + payload.set_item("confidence", result.confidence)?; + Ok(payload) +} + +#[pyfunction] +fn reconstruct<'py>( + py: Python<'py>, + source: &Bound<'py, PyBytes>, + cols: usize, + rows: usize, + colors: usize, +) -> PyResult> { + let input = source.as_bytes().to_vec(); + let result = py + .detach(move || reconstruct_bytes(&input, cols, rows, colors)) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + Ok(PyBytes::new(py, &result.png)) +} + +#[pymodule] +fn windup_pixel_perfect_native(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(detect, module)?)?; + module.add_function(wrap_pyfunction!(reconstruct, module)?)?; + Ok(()) +} diff --git a/native/pixel-perfect/bindings/python/tests/test_binding.py b/native/pixel-perfect/bindings/python/tests/test_binding.py new file mode 100644 index 00000000..be13db1c --- /dev/null +++ b/native/pixel-perfect/bindings/python/tests/test_binding.py @@ -0,0 +1,52 @@ +from io import BytesIO + +from PIL import Image +import pytest + +import windup_pixel_perfect_native as native + + +def _png_bytes(width: int = 64, height: int = 64) -> bytes: + logical = Image.new("RGBA", (16, 16)) + palette = [ + (30, 24, 20, 255), + (210, 130, 70, 255), + (235, 225, 200, 255), + (70, 120, 135, 255), + ] + for y in range(16): + for x in range(16): + logical.putpixel((x, y), palette[(x * 7 + y * 11) % len(palette)]) + image = logical.resize((width, height), Image.Resampling.NEAREST) + output = BytesIO() + image.save(output, "PNG") + return output.getvalue() + + +def test_binding_detects_the_six_field_grid_contract() -> None: + result = native.detect(_png_bytes(), "full") + + assert set(result) == { + "cols", + "rows", + "step_x", + "step_y", + "consensus", + "confidence", + } + assert 15 <= result["cols"] <= 17 + assert 15 <= result["rows"] <= 17 + assert result["confidence"] in {"high", "medium", "low"} + + +def test_binding_reconstructs_an_explicit_grid_to_png_bytes() -> None: + output = native.reconstruct(_png_bytes(), 16, 16, 4) + image = Image.open(BytesIO(output)) + + assert image.format == "PNG" + assert image.size == (16, 16) + + +def test_binding_maps_invalid_input_to_value_error() -> None: + with pytest.raises(ValueError, match="PNG or JPEG"): + native.detect(b"not-an-image", "full") diff --git a/openapi.json b/openapi.json index 92ab9dfe..44137368 100644 --- a/openapi.json +++ b/openapi.json @@ -96,6 +96,39 @@ "title": "AssistantMessageResponse", "type": "object" }, + "Body_pixel_perfect_file_tools_pixel_perfect_post": { + "properties": { + "colors": { + "default": 32, + "maximum": 64.0, + "minimum": 2.0, + "title": "Colors", + "type": "integer" + }, + "file": { + "contentMediaType": "application/octet-stream", + "title": "File", + "type": "string" + }, + "pixel_size": { + "anyOf": [ + { + "minimum": 1.0, + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Pixel Size" + } + }, + "required": [ + "file" + ], + "title": "Body_pixel_perfect_file_tools_pixel_perfect_post", + "type": "object" + }, "Body_upload_media_media_upload_post": { "properties": { "file": { @@ -2735,6 +2768,33 @@ "title": "ToolDefinition", "type": "object" }, + "ToolErrorResponse": { + "properties": { + "code": { + "title": "Code", + "type": "integer" + }, + "data": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Data" + }, + "message": { + "title": "Message", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "title": "ToolErrorResponse", + "type": "object" + }, "UpdateNicknameRequest": { "description": "修改昵称请求。", "properties": { @@ -4748,6 +4808,49 @@ ] } }, + "/tools/pixel-perfect": { + "post": { + "description": "显式调用本地工具;不会被任何生成流程自动触发。", + "operationId": "pixel_perfect_file_tools_pixel_perfect_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_pixel_perfect_file_tools_pixel_perfect_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolErrorResponse" + } + }, + "image/png": {} + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Pixel Perfect File", + "tags": [ + "tools" + ] + } + }, "/workflow-runs": { "get": { "description": "分页查询项目下的执行记录。",