diff --git a/.agents/skills/splitting-image-grids-by-content/SKILL.md b/.agents/skills/splitting-image-grids-by-content/SKILL.md index 1907375..7b1fb62 100644 --- a/.agents/skills/splitting-image-grids-by-content/SKILL.md +++ b/.agents/skills/splitting-image-grids-by-content/SKILL.md @@ -39,7 +39,7 @@ scan -> inspect source + candidate previews -> inventory intended assets 7. Accept a source crop or composite only when the confirmed asset count and names match, each subject is complete and uncontaminated, identity and view are correct, no unintended text, caption, watermark, border, duplicate, or foreign foreground remains, and the manifest has no unresolved triage or repair item. The contact sheet is a review artifact; `delivery.images` are the primary production assets. `delivery.transparent_images` are optional production variants only after their independent quality gate passes; `alpha_images`, `transparent_candidates`, and masks are not approved deliverables. 8. Submit a visual verdict for every primary route, not only generated reconstruction. Use `pass` only after all primary success conditions pass. Review transparent candidates separately with `--transparent-verdict pass`, `retryable`, or `reject`; a missing or rejected optional transparent variant does not block a valid primary delivery. On `retryable`, name the failed condition, revise the candidate choice or plan, re-apply, and review again. Retry only when a concrete change addresses the failure; if the same failure repeats twice, stop with explicit failure evidence instead of looping indefinitely. 9. If status is `needs_user_decision`, finish semantic triage before proceeding. If status is `awaiting_user_approval`, run `ultrasplit repair prepare`; show deliverable, repair, and ignored counts, every clean repair preview plus original context, reason, target count, call count, and two-attempt limit, then wait for explicit approval. -10. After approval, record it with `ultrasplit repair approve`. Generate one regular grid per conflict group, ingest it, and return to the same delivery review gate. Never retry a repair group more than twice. Deliver the final status, counts, absolute output directory, review contact sheet, manifest, and title-free production asset paths. +10. After approval, record it with `ultrasplit repair approve`. Generate one regular grid per conflict group, ingest it, and return to the same delivery review gate. Never retry a repair group more than twice. After the primary visual verdict passes, include `delivery.layered_psd` in the final handoff; it contains one named layer per approved asset. Deliver the final status, counts, absolute output directory, review contact sheet, manifest, layered PSD, and title-free production asset paths. Generated completion is inferred content. Always identify it as reconstruction, never extraction or factual recovery. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ba53f39..e301c88 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -41,6 +41,7 @@ The manifest separates four artifact roles: - `delivery.images` contains only title-free production assets; - `delivery.transparent_candidates` contains unapproved alpha variants and the legacy `alpha_images` field aliases those paths; - `delivery.transparent_images` contains only alpha variants that passed automatic checks and an explicit independent visual verdict; +- `delivery.layered_psd` points to the first Photoshop-compatible layered document generated only after the primary visual verdict passes. Assets are packed at native pixel dimensions into one PSD page when they fit within 30,000×30,000 pixels, or into numbered PSD pages when they do not; oversized individual assets are split into named pixel tiles with source coordinates recorded in `delivery.layered_psd_info`. No resampling is performed, and independently approved transparent variants are used when available; - `review/contact-sheet.png` is the named, automatically arranged delivery preview, not an alternate production asset; - `review/triage-sheet.png` labels every retained candidate as `deliver`, `repair`, or `triage`, and shows structured exclusions as `ignore`; - `delivery.pending_repair_images` and `delivery.ignored_images` remain review evidence and never masquerade as finished assets. @@ -70,6 +71,8 @@ Preparation never invokes a model. The manifest moves to `awaiting_user_approval ## Evaluation and bounded loop +For the evaluator entry points, review-state branches, and layered-PSD export gate, see the [Evaluator Pipeline](docs/evaluator-pipeline.md) (English) or [中文流程图](docs/evaluator-pipeline.zh-CN.md). + Deterministic grid checks cover dimensions, expected occupied cells, uniform background, edge contact, minimum margin, and obvious duplicate cells. A multimodal host then compares every reconstructed subject with the visible source anchors: identity, pose, silhouette, color, material, ornament, and absence of redesign. Visual verdicts are: diff --git a/README.md b/README.md index 3008fd1..0caef20 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Instead of asking a vision model to guess crop coordinates, UltraSplitter lets t - **Source-first output** — intact assets remain original-pixel crops. Separable overlaps use foreground masks and clean re-layout rather than regeneration. - **Approval-gated AIGC repair** — recognizable clipped or occluded subjects are grouped into one efficient repair grid. The user sees the scope before any generation call. - **Bounded quality loop** — generated grids are checked for count, duplicates, background, resolution, edge contact, safe margin, and visible identity; each group stops after at most two attempts. -- **Production-ready delivery** — named primary images, independently reviewed optional transparent variants, compact review sheets, provenance, status, evaluation evidence, and absolute access paths are written to `output/` and `manifest.json`. +- **Production-ready delivery** — named primary images, independently reviewed optional transparent variants, a layered PSD after visual approval, compact review sheets, provenance, status, evaluation evidence, and absolute access paths are written to `output/` and `manifest.json`. - **Agent-native operation** — install the Skill for Codex, Claude Code, or another compatible coding agent, or use the Python CLI directly. ## Showcases @@ -102,6 +102,8 @@ ultrasplit evaluate output/character-views/manifest.json --visual-verdict pass ultrasplit evaluate output/character-views/manifest.json --transparent-verdict pass ``` +After the primary visual review passes, UltraSplitter exports editable PSD layers at native pixel dimensions. It uses one `layers.psd` when assets fit within a 30,000×30,000 px canvas, and numbered PSD pages when they do not. An individual asset larger than the page limit is split into named pixel tiles with source coordinates recorded in the manifest. No resampling is performed. Independently approved transparent variants are used when available; otherwise the PSD uses the primary assets. Individual PNGs remain available as separate files. + `ultrasplit run` is a deterministic shortcut for simple inputs. It does not replace the Skill's model-guided inventory and scope-alignment workflow, and its output remains `needs_review` until an explicit visual pass.
@@ -129,6 +131,8 @@ ultrasplit evaluate output/task/manifest.json --visual-verdict pass Every run writes a schema-v3 manifest with exact source coordinates, route evidence, provenance, approval state, bounded repair attempts, evaluation status, and absolute delivery paths. See [ARCHITECTURE.md](ARCHITECTURE.md). +For the evaluation and layered-PSD export flow, see the [Evaluator Pipeline](docs/evaluator-pipeline.md). + ## Current boundary - Supports framed panels and spatially separated objects on transparent or approximately uniform backgrounds. diff --git a/README.zh-CN.md b/README.zh-CN.md index 0237d2d..6d63d18 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -31,7 +31,7 @@ UltraSplitter 不让视觉模型猜最终裁剪坐标。多模态宿主负责判 - **优先保留原图像素**——完整素材直接裁切;检测框重叠但轮廓可分时,通过前景掩码清理和重排解决,不重新生成。 - **需要 AIGC 补全时先审批**——可识别但被截断、遮挡的主体会合并成一次高效宫格任务;调用生成模型前,先向用户展示范围与成本。 - **有界质量循环**——检查数量、重复、背景、分辨率、触边、留白和可见身份特征;每个冲突组最多尝试两次,不无限生成。 -- **直接交付生产资产**——在 `output/` 和 `manifest.json` 中提供命名主资产、独立审阅后的可选透明变体、紧凑审阅图、来源、状态、评估证据和绝对访问路径。 +- **直接交付生产资产**——在 `output/` 和 `manifest.json` 中提供命名主资产、独立审阅后的可选透明变体、审核通过后生成的分层 PSD、紧凑审阅图、来源、状态、评估证据和绝对访问路径。 - **AI 原生调用**——可为 Codex、Claude Code 等兼容 Agent 安装 Skill,也可直接使用 Python CLI。 ## Showcases @@ -102,6 +102,8 @@ ultrasplit evaluate output/character-views/manifest.json --visual-verdict pass ultrasplit evaluate output/character-views/manifest.json --transparent-verdict pass ``` +主资产视觉审核通过后会生成分层 PSD。素材能装入 30,000×30,000 像素画布时,交付一个 `layers.psd`;装不下时自动分卷为 `layers.psd`、`layers-02.psd` 等文件,每份都保留独立图层。单个素材边长超过 30,000 像素时会按原像素切块,并在 manifest 中记录来源坐标。导出不缩放、不重采样;分卷避免因画布尺寸超过阈值直接失败。透明变体通过独立审核后,PSD 会使用透明版本;否则保留主资产像素。拆出的 PNG 仍会单独交付,分卷路径与图层来源记录在 manifest 中。 + `ultrasplit run` 只是面向简单输入的确定性快捷命令,不能替代 Skill 中由模型完成的资产盘点和范围对齐;在显式视觉通过前,输出保持 `needs_review`。
@@ -129,6 +131,8 @@ ultrasplit evaluate output/task/manifest.json --visual-verdict pass 每次运行都会写入 schema v3 的 `manifest.json`,记录准确的源图坐标、路由依据、来源追踪、批准状态、有界修复次数、评估结果和绝对访问路径。完整设计见 [ARCHITECTURE.md](ARCHITECTURE.md)。 +评估及分层 PSD 导出流程见[Evaluator 评估流程](docs/evaluator-pipeline.zh-CN.md)。 + ## 当前能力边界 - 支持带边框的分栏图,以及透明或近似纯色背景中空间分离的主体。 diff --git a/docs/evaluator-pipeline.md b/docs/evaluator-pipeline.md new file mode 100644 index 0000000..ede6020 --- /dev/null +++ b/docs/evaluator-pipeline.md @@ -0,0 +1,45 @@ +# Evaluator Pipeline + +This diagram covers the two evaluator paths: deterministic checks for generated repair grids, and manifest review with the primary-review gate for layered PSD export. + +```mermaid +flowchart TD + subgraph A["Repair-grid checks: evaluate_grid"] + A1["repair ingest receives generated grid"] --> A2["Check resolution, background, occupied-cell count, margins, edge contact, and duplicates"] + A2 --> A3{"Checks pass?"} + A3 -- "No" --> A4["Record failed attempt and issues; wait for retry or manual handling"] + A3 -- "Yes" --> A5["Split repaired assets and update manifest and delivery"] + A5 --> B1 + end + + subgraph B["Manifest review and PSD: evaluate_manifest"] + B1["Load manifest and validate schema v3"] --> B2["Check semantic triage, conflict groups, repair attempts, and visual verdict"] + B2 --> B3{"Any untriaged item?"} + B3 -- "Yes" --> B4["needs_user_decision"] + B3 -- "No" --> B31{"Pending conflict group without approval?"} + B31 -- "Yes" --> B5["awaiting_user_approval"] + B31 -- "No" --> B32{"Repair attempts exhausted?"} + B32 -- "Yes" --> B6["retry_exhausted"] + B32 -- "No" --> B33{"Visual verdict is identity_uncertain?"} + B33 -- "Yes" --> B4 + B33 -- "No" --> B34{"Retryable, failed repair, or pending work?"} + B34 -- "Yes" --> B7["needs_review"] + B34 -- "No" --> B35{"Visual verdict is pass?"} + B35 -- "Yes" --> B8["success"] + B35 -- "No" --> B7 + B4 --> B9["Apply optional transparency verdict"] + B5 --> B9 + B6 --> B9 + B7 --> B9 + B8 --> B9 + B9 --> B10{"Final status is success?"} + B10 -- "No" --> B11["Do not export PSD; write review state"] + B10 -- "Yes" --> B12["Export one or more PSD pages: layers.psd, layers-02.psd, …"] + B12 --> B13["Record first path, all PSD paths, and export details in manifest"] + end + + C["CLI: evaluate manifest.json"] --> B1 + A4 --> A1 +``` + +`ultrasplit run` creates and routes the manifest; it does not itself pass the evaluation gate. The CLI `evaluate` command, or repair ingestion after a successful grid check, calls `evaluate_manifest` and can reach PSD export. diff --git a/docs/evaluator-pipeline.zh-CN.md b/docs/evaluator-pipeline.zh-CN.md new file mode 100644 index 0000000..906607c --- /dev/null +++ b/docs/evaluator-pipeline.zh-CN.md @@ -0,0 +1,45 @@ +# Evaluator 评估流程 + +本图包含两条路径:对生成式修复宫格进行确定性检查,以及审核 manifest 并在主资产审核通过后导出分层 PSD。 + +```mermaid +flowchart TD + subgraph A["修复宫格检查:evaluate_grid"] + A1["repair ingest 收到生成的宫格图"] --> A2["检查分辨率、背景、有效格子数、留白、触边和重复图形"] + A2 --> A3{"检查通过?"} + A3 -- "否" --> A4["记录失败尝试和问题;等待重试或人工处理"] + A3 -- "是" --> A5["拆出修复资产并更新 manifest 和交付项"] + A5 --> B1 + end + + subgraph B["Manifest 审核与 PSD:evaluate_manifest"] + B1["读取 manifest 并校验 schema v3"] --> B2["检查语义分诊、冲突组、修复次数和视觉审核结论"] + B2 --> B3{"是否存在未分诊项?"} + B3 -- "是" --> B4["needs_user_decision"] + B3 -- "否" --> B31{"是否有未批准的待处理冲突组?"} + B31 -- "是" --> B5["awaiting_user_approval"] + B31 -- "否" --> B32{"修复次数是否耗尽?"} + B32 -- "是" --> B6["retry_exhausted"] + B32 -- "否" --> B33{"视觉结论是否为 identity_uncertain?"} + B33 -- "是" --> B4 + B33 -- "否" --> B34{"是否有可重试失败或待处理修复?"} + B34 -- "是" --> B7["needs_review"] + B34 -- "否" --> B35{"视觉结论是否为 pass?"} + B35 -- "是" --> B8["success"] + B35 -- "否" --> B7 + B4 --> B9["应用可选的透明图审核结论"] + B5 --> B9 + B6 --> B9 + B7 --> B9 + B8 --> B9 + B9 --> B10{"最终状态为 success?"} + B10 -- "否" --> B11["不导出 PSD;写回审核状态"] + B10 -- "是" --> B12["导出一份或多份 PSD:layers.psd、layers-02.psd、…"] + B12 --> B13["在 manifest 中记录首个路径、全部 PSD 路径和导出详情"] + end + + C["CLI:evaluate manifest.json"] --> B1 + A4 --> A1 +``` + +`ultrasplit run` 负责生成并路由 manifest,本身不会通过审核门控。CLI 的 `evaluate` 命令,或修复宫格检查通过后的导入流程,会调用 `evaluate_manifest` 并可能进入 PSD 导出。 diff --git a/pyproject.toml b/pyproject.toml index 70ded43..e1c8ade 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] -dependencies = ["Pillow>=10.0,<13"] +dependencies = ["Pillow>=10.0,<13", "psd-tools>=1.11,<2"] [project.urls] Homepage = "https://github.com/PlevanTem/UltraSplitter" diff --git a/src/ultrasplitter/core.py b/src/ultrasplitter/core.py index 59cc038..d65e613 100644 --- a/src/ultrasplitter/core.py +++ b/src/ultrasplitter/core.py @@ -805,7 +805,11 @@ def apply_plan( manifest_path = output_dir / "manifest.json" ordinal_width = max(2, len(str(len(items_plan)))) - planned_paths = [manifest_path, review_dir / "contact-sheet.png"] + planned_paths = [ + manifest_path, + review_dir / "contact-sheet.png", + output_dir / "delivery" / "layers.psd", + ] output_names: list[str] = [] for index, label in enumerate(labels, 1): output_name = f"{index:0{ordinal_width}d}-{label}.png" diff --git a/src/ultrasplitter/evaluator.py b/src/ultrasplitter/evaluator.py index 567bb1e..354a503 100644 --- a/src/ultrasplitter/evaluator.py +++ b/src/ultrasplitter/evaluator.py @@ -9,6 +9,7 @@ from .core import estimate_background, foreground_mask from .contracts import MAX_REPAIR_ATTEMPTS, load_json, require_v3, write_json +from .layered_psd import export_layered_psd from .transparency import apply_transparent_verdict @@ -167,5 +168,23 @@ def evaluate_manifest( manifest["status"] = "success" manifest["review_required"] = manifest["status"] != "success" apply_transparent_verdict(manifest, transparent_verdict) + delivery = manifest.setdefault("delivery", {}) + delivery["layered_psd"] = None + delivery["layered_psd_files"] = [] + delivery["layered_psd_info"] = None + manifest.setdefault("evaluation", {})["layered_psd"] = "pending_primary_review" + if manifest["status"] == "success": + try: + psd_info = export_layered_psd(manifest, manifest_path.parent / "delivery" / "layers.psd") + except (OSError, ValueError) as exc: + manifest["status"] = "failed" + manifest["review_required"] = True + manifest.setdefault("warnings", []).append(f"layered_psd_export_failed:{exc}") + manifest.setdefault("evaluation", {})["layered_psd"] = "failed" + else: + delivery["layered_psd"] = psd_info["path"] + delivery["layered_psd_files"] = psd_info["paths"] + delivery["layered_psd_info"] = psd_info + manifest.setdefault("evaluation", {})["layered_psd"] = "pass" write_json(manifest_path, manifest) return manifest diff --git a/src/ultrasplitter/layered_psd.py b/src/ultrasplitter/layered_psd.py new file mode 100644 index 0000000..cc7305a --- /dev/null +++ b/src/ultrasplitter/layered_psd.py @@ -0,0 +1,186 @@ +"""Export reviewed production assets as full-resolution editable PSD layers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from PIL import Image + + +MAX_PSD_DIMENSION = 30_000 +LAYER_GAP = 32 +MAX_LAYER_DIMENSION = MAX_PSD_DIMENSION + + +@dataclass(frozen=True) +class LayerSource: + label: str + path: Path + size: tuple[int, int] + crop: tuple[int, int, int, int] | None = None + + +@dataclass(frozen=True) +class PlacedLayer: + source: LayerSource + left: int + top: int + + +def _layer_items(manifest: dict[str, Any]) -> list[tuple[str, Path]]: + transparent_is_approved = ( + manifest.get("evaluation", {}).get("transparent", {}).get("status") == "pass" + ) + items: list[tuple[str, Path]] = [] + for item in manifest.get("items", []): + if not item.get("deliverable"): + continue + variants = item.get("variants", {}) + path = variants.get("transparent") if transparent_is_approved else None + path = path or item.get("image_path") + if path: + items.append((str(item.get("label") or item.get("id") or "Asset"), Path(path))) + if not items: + raise ValueError("no reviewed production assets are available for PSD export") + return items + + +def _split_large_sources(items: list[tuple[str, Path]]) -> list[LayerSource]: + sources: list[LayerSource] = [] + for label, path in items: + if not path.is_file(): + raise ValueError(f"PSD layer source is missing for {label}: {path}") + with Image.open(path) as opened: + width, height = opened.size + columns = (width + MAX_LAYER_DIMENSION - 1) // MAX_LAYER_DIMENSION + rows = (height + MAX_LAYER_DIMENSION - 1) // MAX_LAYER_DIMENSION + for row in range(rows): + top = row * MAX_LAYER_DIMENSION + bottom = min(height, top + MAX_LAYER_DIMENSION) + for column in range(columns): + left = column * MAX_LAYER_DIMENSION + right = min(width, left + MAX_LAYER_DIMENSION) + crop = None if rows == columns == 1 else (left, top, right, bottom) + tile_label = label + if crop is not None: + tile_label = f"{label} [tile r{row + 1} c{column + 1}]" + sources.append( + LayerSource(tile_label, path, (right - left, bottom - top), crop) + ) + return sources + + +def _pack_layers(sources: list[LayerSource]) -> list[list[PlacedLayer]]: + """Pack native-size layers into PSD pages; split only assets wider than one page.""" + ordered = sorted( + sources, + key=lambda source: (-max(source.size), -source.size[1], -source.size[0], source.label), + ) + pages: list[list[PlacedLayer]] = [[]] + left = 0 + top = 0 + shelf_height = 0 + + for source in ordered: + width, height = source.size + if width > MAX_LAYER_DIMENSION or height > MAX_LAYER_DIMENSION: + raise ValueError(f"internal PSD tiling error for {source.label}: {width}x{height}px") + if left + width > MAX_PSD_DIMENSION: + left = 0 + top += shelf_height + LAYER_GAP + shelf_height = 0 + if top + height > MAX_PSD_DIMENSION: + pages.append([]) + left = 0 + top = 0 + shelf_height = 0 + pages[-1].append(PlacedLayer(source, left, top)) + left += width + LAYER_GAP + shelf_height = max(shelf_height, height) + return pages + + +def _page_size(layers: list[PlacedLayer]) -> tuple[int, int]: + width = max(layer.left + layer.source.size[0] for layer in layers) + height = max(layer.top + layer.source.size[1] for layer in layers) + if max(width, height) > MAX_PSD_DIMENSION: + raise ValueError(f"internal PSD packing error: page would be {width}x{height}px") + return width, height + + +def _page_path(output_path: Path, index: int) -> Path: + if index == 0: + return output_path + return output_path.with_name(f"{output_path.stem}-{index + 1:02d}{output_path.suffix}") + + +def export_layered_psd(manifest: dict[str, Any], output_path: Path) -> dict[str, Any]: + """Export one PSD when it fits; otherwise emit lossless, full-resolution PSD pages.""" + try: + from psd_tools import PSDImage + except ImportError as exc: + raise ValueError("PSD export requires psd-tools; reinstall UltraSplitter from pyproject.toml") from exc + + sources = _split_large_sources(_layer_items(manifest)) + pages = _pack_layers(sources) + output_path.parent.mkdir(parents=True, exist_ok=True) + + page_info: list[dict[str, Any]] = [] + for page_index, layers in enumerate(pages): + canvas = _page_size(layers) + psd = PSDImage.new(mode="RGB", size=canvas, depth=8) + psd.background_color = None + layer_info: list[dict[str, Any]] = [] + for layer_index, placed in enumerate(layers, 1): + source = placed.source + with Image.open(source.path) as opened: + if source.crop is not None: + layer_image = opened.crop(source.crop).convert("RGBA") + else: + layer_image = opened.convert("RGBA") + name = f"{layer_index:02d} {source.label}" + psd.create_pixel_layer( + layer_image, + name=name, + top=placed.top, + left=placed.left, + ) + layer_info.append( + { + "name": name, + "source": str(source.path.resolve()), + "source_crop": list(source.crop) if source.crop is not None else None, + } + ) + + page_path = _page_path(output_path, page_index) + psd.save(page_path) + page_info.append( + { + "path": str(page_path.resolve()), + "canvas": list(canvas), + "layer_count": len(layers), + "layers": layer_info, + } + ) + + source_variant = ( + "transparent" + if manifest.get("evaluation", {}).get("transparent", {}).get("status") == "pass" + else "primary" + ) + return { + "path": page_info[0]["path"], + "paths": [page["path"] for page in page_info], + "page_count": len(page_info), + "layer_count": len(sources), + "asset_count": len({source.path.resolve() for source in sources}), + "canvas": page_info[0]["canvas"], + "pages": page_info, + "layout": "compact_native_size_pages", + "source_variant": source_variant, + "resampled": False, + "tiled_layer_count": sum(source.crop is not None for source in sources), + }