
Bggg Creator Image2psd
- 81 installs
- 553 repo stars
- Updated August 5, 2026
- binggandata/bggg-skills
bggg-creator-image2psd is a Codex skill that organizes images into editable layered PSD files.
About
This skill organizes one or more images into an editable, layered PSD raster file. Its core Python script writes the PSD with layer names, transparency, and a composite preview without depending on Photoshop or ImageMagick. It can split a flat image into semantic or color-cluster layers and remove white backgrounds. A developer uses it to produce a PSD editable in Photoshop or Photopea.
- Organizes one or more images into an editable layered PSD file
- Writes PSD with layer names, alpha, and a composite preview without Photoshop or ImageMagick
- Supports semantic layer splitting, color-cluster splitting, and white-background removal
Bggg Creator Image2psd by the numbers
- 81 all-time installs (skills.sh)
- Ranked #819 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
bggg-creator-image2psd capabilities & compatibility
- Capabilities
- image generation · ui design
- Use cases
- image generation · ui design
What bggg-creator-image2psd says it does
把一张或多张图片整理成 PSD 图层文件的创作与转换 skill。
它不依赖 Photoshop 或 ImageMagick,能独立写出带图层名、透明通道和合成预览的 PSD。
把单张平面图按颜色拆成 PSD 图层
npx skills add https://github.com/binggandata/bggg-skills --skill bggg-creator-image2psdAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 81 |
|---|---|
| repo stars | ★ 553 |
| Last updated | August 5, 2026 |
| Repository | binggandata/bggg-skills ↗ |
What it does
Turn one or more images into a layered PSD file editable in Photoshop or Photopea.
Who is it for?
Producing a layered PSD from images for editing in Photoshop or Photopea.
When should I use this skill?
A user wants image2psd, images composed into a PSD, or a design split into layers.
What you get
A PSD with named layers, transparency, and a preview PNG.
- an output.psd file
- a preview PNG
- single-layer PNGs
By the numbers
- 5 remove_background modes (none, white, white-preserve, corner, color)
Files
BGGG Creator Image2PSD
用这个 skill 把图片资产组织成可编辑的 PSD 栅格图层。核心脚本是 scripts/image2psd.py,它不依赖 Photoshop 或 ImageMagick,能独立写出带图层名、透明通道和合成预览的 PSD。
Codex 适配默认策略
在 Codex 里处理图片转 PSD 时,默认配合 imagegen skill 的能力:
- 先用 Codex 的视觉理解能力判断图层结构、主体/背景/文字/装饰的边界、以及哪些区域需要重建背景。
- 如果用户要求从描述生成、补齐缺失元素、移除对象后补背景、或拆出更干净的独立商品/主体图,优先使用
imagegenskill 的内置生图/编辑能力生成项目内资产,再交给本 skill 组装 PSD。 - 如果用户明确要求“不改变相对位置”“直接在 Photoshop 拼接”,优先保留原图像素:每个图层输出为同尺寸全画布透明 PNG,只改变 alpha,不重绘内容。只有背景擦除/补洞可使用 inpaint 或 imagegen 辅助。
- 不要把 imagegen 的默认输出留在
$CODEX_HOME。项目要用的图片必须复制到本 skill 的项目目录。
项目目录约定
每次处理图片都创建独立项目目录,放在本 skill 文件夹下:
bggg-creator-image2psd/
└── projects/
└── YYYYMMDD_slug/
├── original_reference.png
├── manifest.json
├── layer_sources/
├── psd_full_canvas_layers/
├── output.psd
├── output.preview.png
├── psd_full_canvas_layers.zip
└── process_notes.md命名规则:
YYYYMMDD_slug使用当前日期和简短英文/拼音任务名,例如20260503_lifestyle_product.original_reference.png是本次处理的源图副本。layer_sources/保存用于组装的透明图层源。psd_full_canvas_layers/保存 Photoshop 可直接按原位叠放的全画布 PNG 图层。process_notes.md记录图层划分、是否使用 imagegen、验证结果和已知限制。
工作流
1. 判断任务类型:
- 多张元素图已经存在:复制到项目目录,用
assemble,按图层顺序写 manifest。 - 只有一张平面图且要保留相对位置:做语义/区域拆层,每层保持原图同尺寸;背景层用 inpaint 或 imagegen 辅助清理被拆出的区域。
- 只有一张平面图且只要粗拆:用
split-colors按颜色聚类拆层,并说明它不是语义对象拆层。 - 用户要从描述生成 PSD:先用 Codex imagegen 分别生成背景、主体、文字/装饰等项目资产,再用
assemble合成 PSD。
2. 创建 projects/YYYYMMDD_slug/,把源图和所有输出放进去。默认用脚本初始化:
python3 bggg-creator-image2psd/scripts/init_project.py lifestyle_product \
--source 用户附件.png \
--date 20260503如果使用了 imagegen,先把 $CODEX_HOME 下的生成结果复制到该项目的 imagegen_assets/,再进入拆层或合成流程。 3. 确定画布尺寸。优先沿用源图尺寸;海报类任务需要明确宽高。 4. 处理透明:
- 背景层用
remove_background: none。 - Logo、文字、装饰图常用
remove_background: white。 - 白帆、白色产品、浅色主体这类容易被白底吞掉的元素用
remove_background: white-preserve。 - 非白色纯底用
corner或color。
5. 运行脚本输出 PSD、预览 PNG、可选的单层 PNG 目录/zip。 6. 验证输出:检查脚本 JSON 摘要里的 layer_count、预览 PNG、PSD 文件大小;用 Pillow 或 Photoshop/Photopea 检查 PSD 可读性。 7. 写 process_notes.md,记录图层、路径、验证和限制。
常用命令
从 manifest 合成:
python3 bggg-creator-image2psd/scripts/image2psd.py assemble \
--manifest work/manifest.json \
--output work/output.psd \
--preview work/output.preview.png \
--save-layers work/layers \
--zip-layers work/layers.zip直接把多张图合成,第一张为背景:
python3 bggg-creator-image2psd/scripts/image2psd.py assemble bg.png title.png logo.png \
--first-is-background \
--names "Background,Title,Logo" \
--output output.psd \
--save-layers layers把单张平面图按颜色拆成 PSD 图层:
python3 bggg-creator-image2psd/scripts/image2psd.py split-colors poster.png \
--output poster-color-layers.psd \
--num-colors 10 \
--ignore-color white \
--save-layers poster-color-layersManifest 格式
用 manifest 管理复杂 PSD。图层数组按从底到顶排列。
{
"canvas": {
"width": 1122,
"height": 1402,
"composite_background": "#ffffff"
},
"output": "poster.psd",
"preview": "poster.preview.png",
"save_layers_dir": "layers",
"layers": [
{
"name": "Background",
"file": "assets/background.png",
"fit": "cover",
"remove_background": "none"
},
{
"name": "Ship and Waves",
"file": "assets/ship.png",
"remove_background": "white-preserve"
},
{
"name": "Title",
"type": "text",
"text": "AI Commerce Summit",
"x": 80,
"y": 120,
"font_size": 72,
"color": "#41270d",
"max_width": 900
}
]
}图层字段要点:
file/path/src: 图片路径,manifest 相对路径从 manifest 所在目录解析。type: "text": 用 PIL 渲染成独立栅格文字层,不是 Photoshop 可编辑文字对象。x,y: 图片或文字层左上角偏移。fit:none、contain、cover、stretch。remove_background:none、white、white-preserve、corner、color。opacity: 0 到 1。
生图到 PSD
在 Codex 中,默认把 imagegen 当成补强工具,而不是唯一处理方式。把设计拆成独立元素来生成或编辑,而不是只生成一张完整海报:
- 背景:完整画布,通常不去底。
- 主体/产品/人物/船/道具:白底或透明背景,合成时用
white或white-preserve去底。 - Logo、标题、日程、装饰:尽量单独生成或用 manifest 的 text 层重建。
- 每个元素的提示词要包含画布尺寸、视角、边缘干净、不要阴影污染背景等约束。
如果生图只得到一张完整图,先用 split-colors 做可编辑性最低限度拆层,再按用户需求补生关键元素。
单图语义拆层经验
复盘成功案例后,单张图转 PSD 优先采用这个顺序:
1. 复制源图到项目目录,保持原始尺寸。 2. 先列出图层清单,按“背景/主体/装饰/文字/阴影或光效”分组。 3. 每个可移动对象输出全画布透明 PNG,位置不裁切,便于 Photoshop 直接叠放。 4. 背景层用被拆出图层的 union mask 做 inpaint,必要时二次扩大遮罩清理残影。 5. 预览图与原图做像素差异或肉眼对比;发现文字/主体边缘被裁时,优先扩大 mask 框而不是移动图层。 6. 如果源图没有本地文件,先要求用户提供源文件路径;不要用 imagegen 重绘图冒充原始拆层。
何时读参考
- 修改核心脚本前,读
references/implementation-notes.md。 - 需要追溯外部项目启发时,读
references/source-projects.md。 - 不要把
reference/下克隆的外部仓库作为运行依赖;它们只是开发参考。
输出要求
交付时至少说明:
- PSD 路径。
- 预览 PNG 路径。
- 图层数量和主要图层名。
- 是否生成了单层 PNG/zip。
- 项目目录路径。
- 如果没有验证 Photoshop 打开效果,要明确说验证限于脚本和预览。
.DS_Store
__pycache__/
*.py[cod]
# Runtime outputs. Keep the folder itself for Codex project storage.
projects/*
!projects/.gitkeep
# Common generated artifacts.
*.psd
*.preview.png
*.layers.zip
psd_full_canvas_layers/
layer_sources/
imagegen_assets/
diagnostics/
{
"canvas": {
"width": 1200,
"height": 1600,
"composite_background": "#ffffff"
},
"output": "output.psd",
"preview": "output.preview.png",
"save_layers_dir": "layers",
"layers": [
{
"name": "Background",
"file": "background.png",
"fit": "cover",
"remove_background": "none"
},
{
"name": "Main Subject",
"file": "subject.png",
"remove_background": "white-preserve"
},
{
"name": "Title",
"type": "text",
"text": "Event Title",
"x": 80,
"y": 120,
"font_size": 76,
"color": "#1c1712",
"max_width": 960
}
]
}
{
"skill_name": "bggg-creator-image2psd",
"evals": [
{
"id": 1,
"prompt": "我有 background.png、logo.png、title.png、ship.png,帮我去掉白底并合成一个有 4 个图层的 PSD,同时给我预览图和每层 PNG。",
"expected_output": "使用 assemble 和 manifest 输出 PSD、preview PNG、layer PNG 目录;背景不去底,logo/title 使用 white,ship 使用 white-preserve。文件路径和图层数清楚可查。",
"files": []
},
{
"id": 2,
"prompt": "只有一张扁平海报 poster.png,先帮我尽量拆成可编辑的颜色图层 PSD。",
"expected_output": "使用 split-colors,设置合理 num-colors,必要时忽略白色背景,输出 PSD、预览和拆出的透明 PNG 层,并说明这是颜色拆层不是语义对象拆层。",
"files": []
},
{
"id": 3,
"prompt": "根据一个活动海报描述,先用 Codex 生背景、标题、主体元素图,再拼成 PSD。",
"expected_output": "先把视觉拆成独立元素生成,再为每个元素写 manifest 图层,运行 assemble 生成 PSD。说明每个元素的去底策略和最终图层结构。",
"files": []
},
{
"id": 4,
"prompt": "把这张商品生活方式图转成 PSD,要求不要改变元素相对位置,鞋、包、桌子花瓶、文字、背景尽量分层。",
"expected_output": "在 bggg-creator-image2psd/projects/YYYYMMDD_slug/ 下创建项目;复制 original_reference.png;输出全画布透明 PNG 图层、manifest、PSD、preview、zip 和 process_notes;如果源图未落盘,应先要求文件路径而不是重绘冒充。",
"files": []
}
]
}
MIT License
Copyright (c) 2026 BGGG
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.
bggg-creator-image2psd
中文 | English
bggg-creator-image2psd is a Codex skill for turning one or more raster images into a layered PSD. It is designed for image-to-PSD workflows such as poster decomposition, product-scene cutouts, AI-generated element assembly, white-background removal, color-cluster splitting, and Photoshop-ready layer export.
The bundled PSD writer is pure Python and does not require Photoshop, ImageMagick, Wand, or psd-tools.
What It Does
- Assemble multiple image or raster text layers into one PSD.
- Preserve layer names and alpha channels.
- Export a flattened PNG preview.
- Export full-canvas transparent PNG layers for manual Photoshop stacking at
(0, 0). - Split a flat image into color-cluster raster layers.
- Create a per-task
projects/YYYYMMDD_slug/folder for source files, intermediate images, PSD output, previews, and diagnostics. - In Codex, pair naturally with the
imagegenskill for generating, editing, or rebuilding image elements before PSD assembly.
Install
Copy this folder into your Codex skills directory:
mkdir -p ~/.codex/skills
cp -R bggg-creator-image2psd ~/.codex/skills/Or clone the whole bggg-skills repository and copy/symlink the skill:
git clone https://github.com/binggandata/bggg-skills.git
mkdir -p ~/.codex/skills
ln -s "$PWD/bggg-skills/bggg-creator-image2psd" ~/.codex/skills/bggg-creator-image2psdInstall runtime dependencies:
python3 -m pip install -r ~/.codex/skills/bggg-creator-image2psd/scripts/requirements.txtRequired dependencies are Pillow and numpy. opencv-python improves subject masks and light foreground preservation. scikit-learn improves split-colors --method kmeans.
Quick Start In Codex
Ask Codex something like:
Use bggg-creator-image2psd to turn this image into a PSD.
Keep element positions unchanged, split the main objects and text into separate layers,
and put all process images under the skill project's projects folder.For Codex image generation workflows:
Use imagegen to generate separate background, subject, title, and decoration images,
then use bggg-creator-image2psd to assemble them into a PSD.Command-Line Usage
Initialize a project folder:
python3 bggg-creator-image2psd/scripts/init_project.py lifestyle_product \
--source input.pngAssemble layers from a manifest:
python3 bggg-creator-image2psd/scripts/image2psd.py assemble \
--manifest bggg-creator-image2psd/projects/20260503_lifestyle_product/manifest.jsonAssemble positional images directly:
python3 bggg-creator-image2psd/scripts/image2psd.py assemble bg.png logo.png title.png \
--first-is-background \
--names "Background,Logo,Title" \
--output output.psd \
--save-layers layersSplit one flat image into color layers:
python3 bggg-creator-image2psd/scripts/image2psd.py split-colors poster.png \
--output poster-color-layers.psd \
--num-colors 10 \
--ignore-color white \
--save-layers poster-color-layersManifest Example
Layer order is bottom-to-top.
{
"canvas": {
"width": 1200,
"height": 1600,
"composite_background": "#ffffff"
},
"output": "output.psd",
"preview": "output.preview.png",
"save_layers_dir": "psd_full_canvas_layers",
"zip_layers": "psd_full_canvas_layers.zip",
"layers": [
{
"name": "Background",
"file": "layer_sources/background.png",
"fit": "cover",
"remove_background": "none"
},
{
"name": "Subject",
"file": "layer_sources/subject.png",
"remove_background": "white-preserve"
},
{
"name": "Title",
"type": "text",
"text": "Event Title",
"x": 80,
"y": 120,
"font_size": 76,
"color": "#1c1712",
"max_width": 960
}
]
}Background Removal Modes
none: keep the image as-is.white: convert white background to alpha.white-preserve: white-to-alpha plus a soft structure mask for pale foregrounds.corner: sample the four corners as the background color.color: use an explicitcolorfield in the layer spec.
Project Output Layout
Each real task should live under:
bggg-creator-image2psd/projects/YYYYMMDD_slug/
├── original_reference.png
├── manifest.json
├── layer_sources/
├── psd_full_canvas_layers/
├── imagegen_assets/
├── diagnostics/
├── output.psd
├── output.preview.png
├── psd_full_canvas_layers.zip
└── process_notes.mdGenerated project outputs are ignored by Git by default. Keep only .gitkeep in projects/.
Notes
- Text layers created by the manifest are raster layers, not editable Photoshop text objects.
- Semantic decomposition from a single flat image is inherently approximate. For exact editability, generate or provide separate source elements whenever possible.
- If a user asks to preserve exact relative position, use full-canvas transparent PNG layers so Photoshop can stack every layer at
(0, 0).
License
MIT
bggg-creator-image2psd
中文 | English
bggg-creator-image2psd 是一个 Codex skill,用来把一张或多张栅格图片整理成可编辑的分层 PSD。它适合海报拆层、产品场景拆图、AI 生图元素组装、白底转透明、颜色聚类拆层,以及“把图片拆成若干个 Photoshop 可移动图层”的工作流。
这个 skill 内置纯 Python PSD writer,不依赖 Photoshop、ImageMagick、Wand 或 psd-tools。
能做什么
- 把多张图片或栅格文字层组装成 PSD。
- 保留图层名和透明通道。
- 导出合成预览 PNG。
- 导出全画布透明 PNG 图层,方便 Photoshop 直接按
(0, 0)叠放。 - 把单张平面图按颜色聚类拆成多个图层。
- 每次任务自动使用
projects/YYYYMMDD_slug/保存源图、过程图、PSD、预览和诊断文件。 - 在 Codex 中默认配合
imagegenskill:需要生成、编辑、补齐、重建元素时,先产出项目内资产,再组装 PSD。
安装
把本目录复制到 Codex skills 目录:
mkdir -p ~/.codex/skills
cp -R bggg-creator-image2psd ~/.codex/skills/也可以克隆整个 bggg-skills 仓库后复制或软链接:
git clone https://github.com/binggandata/bggg-skills.git
mkdir -p ~/.codex/skills
ln -s "$PWD/bggg-skills/bggg-creator-image2psd" ~/.codex/skills/bggg-creator-image2psd安装运行依赖:
python3 -m pip install -r ~/.codex/skills/bggg-creator-image2psd/scripts/requirements.txt必需依赖是 Pillow 和 numpy。opencv-python 用于更好的主体遮罩和浅色主体保护;scikit-learn 可增强 split-colors --method kmeans。
在 Codex 中使用
可以直接这样对 Codex 说:
使用 bggg-creator-image2psd 把这张图转成 PSD。
保持元素相对位置不变,把主体、文字、背景拆成独立图层,
所有过程图片都放到 skill 的 projects 文件夹下。如果需要先生成元素再组装:
先用 imagegen 生成背景、主体、标题和装饰元素,
再用 bggg-creator-image2psd 把它们组装成 PSD。命令行用法
初始化一个项目目录:
python3 bggg-creator-image2psd/scripts/init_project.py lifestyle_product \
--source input.png从 manifest 组装 PSD:
python3 bggg-creator-image2psd/scripts/image2psd.py assemble \
--manifest bggg-creator-image2psd/projects/20260503_lifestyle_product/manifest.json直接组装多张图片,第一张作为背景:
python3 bggg-creator-image2psd/scripts/image2psd.py assemble bg.png logo.png title.png \
--first-is-background \
--names "Background,Logo,Title" \
--output output.psd \
--save-layers layers把单张平面图按颜色拆层:
python3 bggg-creator-image2psd/scripts/image2psd.py split-colors poster.png \
--output poster-color-layers.psd \
--num-colors 10 \
--ignore-color white \
--save-layers poster-color-layersManifest 示例
图层顺序是从底到顶。
{
"canvas": {
"width": 1200,
"height": 1600,
"composite_background": "#ffffff"
},
"output": "output.psd",
"preview": "output.preview.png",
"save_layers_dir": "psd_full_canvas_layers",
"zip_layers": "psd_full_canvas_layers.zip",
"layers": [
{
"name": "Background",
"file": "layer_sources/background.png",
"fit": "cover",
"remove_background": "none"
},
{
"name": "Subject",
"file": "layer_sources/subject.png",
"remove_background": "white-preserve"
},
{
"name": "Title",
"type": "text",
"text": "Event Title",
"x": 80,
"y": 120,
"font_size": 76,
"color": "#1c1712",
"max_width": 960
}
]
}去底模式
none:保持原图。white:把白色背景转透明。white-preserve:白底转透明,同时保留白色/浅色主体结构。corner:采样四角作为背景色。color:使用图层配置里的指定背景色。
项目输出结构
每次真实任务都会放在:
bggg-creator-image2psd/projects/YYYYMMDD_slug/
├── original_reference.png
├── manifest.json
├── layer_sources/
├── psd_full_canvas_layers/
├── imagegen_assets/
├── diagnostics/
├── output.psd
├── output.preview.png
├── psd_full_canvas_layers.zip
└── process_notes.md运行产物默认被 Git 忽略。开源仓库只保留 projects/.gitkeep。
注意事项
- Manifest 创建的文字层是栅格图层,不是 Photoshop 可编辑文字对象。
- 从单张平面图做语义拆层时,结果一定是近似的;如果要高度可编辑,最好提供或生成独立元素图。
- 如果用户要求相对位置不变,优先输出全画布透明 PNG 图层,Photoshop 中每层放在
(0, 0)即可对齐。
License
MIT
Implementation Notes
Core choices
scripts/image2psd.pywrites PSD directly. This keeps the skill independent from Photoshop, ImageMagick, Wand, and psd-tools.- PSD output is 8-bit RGB with raster layers, alpha channels, Unicode layer names, and a flattened composite image.
- Layers are supplied bottom-to-top by the workflow, then written top-to-bottom in the PSD layer records.
- Layer image data is cropped to non-empty alpha bounds to keep files smaller, while saved PNG layers remain full-canvas for manual reuse.
From the provided 参考.md
The useful pattern was:
1. Generate or collect one image per intended design element. 2. Convert white backgrounds to transparency for foreground assets. 3. Preserve pale subjects with an extra soft foreground mask when plain white-to-alpha would erase important whites. 4. Write a PSD with a normal background layer, separate element layers, and a preview PNG for validation. 5. Also save transparent PNG layers as a fallback for PSD readers with limited layer support.
That process is now bundled as assemble.
Successful Codex case review
The poster decomposition succeeded because it treated "PSD conversion" as a repeatable Codex workflow instead of a single file conversion:
1. Use visual understanding to name the intended layers before coding masks. 2. Keep all extracted layers as full-canvas transparent PNGs so Photoshop can stack them at (0, 0) without manual alignment. 3. Preserve original pixels for text and logos when the user asks not to change relative positions. 4. Make a clean background by inpainting the union of extracted masks. 5. Validate with a flattened preview and inspect suspicious regions at high zoom. A clipped header was fixed by expanding its mask, not by moving it. 6. Keep a zip of full-canvas PNG layers as a compatibility fallback.
This pattern should be the default for single-image-to-PSD tasks in Codex.
Project directory convention
Every real task should live under:
bggg-creator-image2psd/projects/YYYYMMDD_slug/Recommended contents:
original_reference.png: copied source image.layer_sources/: transparent layer images used by the manifest.psd_full_canvas_layers/: same-size PNGs for direct Photoshop stacking.manifest.json: bottom-to-top layer order.output.psd: final PSD.output.preview.png: flattened validation preview.psd_full_canvas_layers.zip: fallback bundle.process_notes.md: layer list, tool path, validation, known limits.
Background removal modes
none: keep the image as-is.white: best for logos, text, dark/colored graphics on white.white-preserve: best for light foreground subjects on white, such as sails or white packaging.corner: sample the four corners as the background color.color: use an explicitcolorfield in the manifest layer.
white-preserve uses OpenCV if available. If OpenCV is missing, it falls back to plain white-to-alpha.
Color split mode
split-colors reproduces the practical part of color-cluster-to-layer workflows:
quantizeis fast and dependency-free beyond Pillow and NumPy.kmeansuses scikit-learn when available, with a NumPy fallback.- Each cluster becomes a transparent raster layer using the original pixels.
This is useful for flat posters, scanned graphics, and rough first-pass editability. It is not a semantic object separator.
Text layers
The manifest supports type: "text" to render text as an independent raster layer. The script deliberately does not create Photoshop editable text descriptors because those are fragile and require a much larger PSD implementation. If editable text is mandatory, use Photoshop scripting as an optional downstream step.
Imagegen integration
In Codex, imagegen should be used by default as a visual/generative helper when:
- a clean background must be reconstructed beyond what local inpaint can do;
- missing elements need to be regenerated;
- the user wants a fresh PSD from a description;
- a product or subject cutout needs a cleaner generated companion layer.
For exact source-image decomposition, avoid regenerating layers that must remain pixel-identical. Use imagegen only for assisted cleanup or explicitly requested new/edited assets.
Source Projects
Development references used while creating this skill:
- https://github.com/yatharth-doshi/image-to-psd
- https://github.com/rockstarcoder333/Image2PSD
Capabilities absorbed
From image-to-psd:
- Split a flat image into color-derived layers.
- Save individual layer PNGs as a fallback.
- Treat ImageMagick/Wand PSD generation as optional inspiration, not a runtime dependency.
From Image2PSD:
- The idea that detected text/regions should become separate layers.
- The distinction between original-position layout and absolute-position reconstruction.
Deliberate differences
- The bundled script does not require Photoshop, PaddleOCR, Wand, or ImageMagick.
- OCR is not bundled because PaddleOCR is a heavy dependency and the output would still need human font/style cleanup.
- The skill creates raster text layers through the manifest. Editable Photoshop text layers can be added later as an optional Photoshop-specific extension.
- External repositories are references only. Do not import them from the skill at runtime.
#!/usr/bin/env python3
"""Build layered PSD files from image elements or color-split a flat image.
This script intentionally avoids Photoshop and ImageMagick. It writes a basic
8-bit RGB PSD with raster layers, layer names, alpha channels, and a flattened
composite preview. The resulting PSD opens in Photoshop, Photopea, Affinity,
and most PSD readers that support normal raster layers.
"""
from __future__ import annotations
import argparse
import json
import math
import re
import struct
import sys
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Sequence
import numpy as np
from PIL import Image, ImageDraw, ImageFont
BLEND_KEYS = {
"normal": b"norm",
"norm": b"norm",
"multiply": b"mul ",
"mul": b"mul ",
"screen": b"scrn",
"scrn": b"scrn",
"overlay": b"over",
"over": b"over",
}
@dataclass
class Layer:
name: str
image: Image.Image
blend_mode: str = "normal"
opacity: float = 1.0
def fail(message: str) -> None:
raise SystemExit(f"error: {message}")
def parse_color(value: str | Sequence[int] | None, default: tuple[int, int, int] = (255, 255, 255)) -> tuple[int, int, int]:
if value is None:
return default
if isinstance(value, (list, tuple)):
if len(value) < 3:
fail(f"color sequence needs at least 3 values: {value}")
return tuple(max(0, min(255, int(v))) for v in value[:3]) # type: ignore[return-value]
text = str(value).strip()
named = {
"white": "#ffffff",
"black": "#000000",
"transparent": "#ffffff",
}
text = named.get(text.lower(), text)
if text.startswith("#"):
text = text[1:]
if len(text) == 3:
text = "".join(ch * 2 for ch in text)
if not re.fullmatch(r"[0-9a-fA-F]{6}", text):
fail(f"invalid color: {value!r}")
return int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16)
def color_hex(rgb: Sequence[int]) -> str:
return "#{:02x}{:02x}{:02x}".format(int(rgb[0]), int(rgb[1]), int(rgb[2]))
def ensure_rgba(image: Image.Image) -> Image.Image:
if image.mode == "RGBA":
return image
return image.convert("RGBA")
def image_from_path(path: Path) -> Image.Image:
if not path.exists():
fail(f"image not found: {path}")
try:
return Image.open(path).convert("RGBA")
except Exception as exc: # pragma: no cover - keeps CLI errors readable
fail(f"cannot read image {path}: {exc}")
def fit_to_canvas(image: Image.Image, canvas: tuple[int, int], mode: str) -> Image.Image:
mode = (mode or "none").lower()
width, height = canvas
if mode == "none":
return image
if mode == "stretch":
return image.resize((width, height), Image.Resampling.LANCZOS)
scale_x = width / image.width
scale_y = height / image.height
if mode == "contain":
scale = min(scale_x, scale_y)
elif mode == "cover":
scale = max(scale_x, scale_y)
else:
fail(f"unknown fit mode {mode!r}; use none, contain, cover, or stretch")
resized = image.resize((max(1, round(image.width * scale)), max(1, round(image.height * scale))), Image.Resampling.LANCZOS)
if mode == "cover":
left = max(0, (resized.width - width) // 2)
top = max(0, (resized.height - height) // 2)
return resized.crop((left, top, left + width, top + height))
return resized
def place_on_canvas(image: Image.Image, canvas: tuple[int, int], x: int = 0, y: int = 0) -> Image.Image:
out = Image.new("RGBA", canvas, (0, 0, 0, 0))
out.alpha_composite(ensure_rgba(image), (int(x), int(y)))
return out
def apply_opacity(image: Image.Image, opacity: float) -> Image.Image:
opacity = max(0.0, min(1.0, float(opacity)))
if opacity >= 0.999:
return image
rgba = np.asarray(image.convert("RGBA")).copy()
rgba[:, :, 3] = np.clip(rgba[:, :, 3].astype(np.float32) * opacity, 0, 255).astype(np.uint8)
return Image.fromarray(rgba, "RGBA")
def background_to_alpha(
image: Image.Image,
bg_color: tuple[int, int, int] = (255, 255, 255),
tolerance: float = 8.0,
feather: float = 45.0,
strength: float = 1.0,
min_alpha: int = 2,
) -> Image.Image:
"""Turn a flat background color into transparency.
For a white background, use the standard white-to-alpha recovery so colored
text and antialiased edges stay crisp. For other colors, use distance from
the sampled background color.
"""
rgba = np.asarray(image.convert("RGBA")).astype(np.float32)
rgb = rgba[:, :, :3] / 255.0
existing_alpha = rgba[:, :, 3] / 255.0
bg = np.array(bg_color, dtype=np.float32) / 255.0
if max(bg_color) >= 245 and min(bg_color) >= 245:
alpha = (1.0 - np.min(rgb, axis=2)) * float(strength)
if tolerance > 0:
dist = np.linalg.norm((1.0 - rgb) * 255.0, axis=2)
gate = np.clip((dist - float(tolerance)) / max(1.0, float(feather) * 0.25), 0.0, 1.0)
alpha *= gate
else:
dist = np.linalg.norm((rgb - bg) * 255.0, axis=2)
denom = max(1.0, float(feather))
alpha = np.clip((dist - float(tolerance)) / denom, 0.0, 1.0) * float(strength)
alpha = np.clip(alpha, 0.0, 1.0) * existing_alpha
alpha[alpha < (float(min_alpha) / 255.0)] = 0.0
out_rgb = rgb.copy()
mask = alpha > 1e-6
# Recover foreground colors from alpha-composited background.
out_rgb[mask] = (rgb[mask] - bg * (1.0 - alpha[mask, None])) / alpha[mask, None]
out_rgb = np.clip(out_rgb, 0.0, 1.0)
out = np.dstack([(out_rgb * 255.0).astype(np.uint8), (alpha * 255.0).astype(np.uint8)])
return Image.fromarray(out, "RGBA")
def corner_color(image: Image.Image, sample: int = 12) -> tuple[int, int, int]:
rgb = np.asarray(image.convert("RGB"))
h, w = rgb.shape[:2]
sample = max(1, min(sample, h, w))
patches = [
rgb[:sample, :sample],
rgb[:sample, w - sample :],
rgb[h - sample :, :sample],
rgb[h - sample :, w - sample :],
]
merged = np.concatenate([p.reshape(-1, 3) for p in patches], axis=0)
return tuple(np.median(merged, axis=0).astype(int)) # type: ignore[return-value]
def preserve_light_foreground_to_alpha(
image: Image.Image,
tolerance: float = 10.0,
preserve_opacity: float = 0.72,
min_area_ratio: float = 0.00025,
) -> Image.Image:
"""White-to-alpha plus a soft structure mask for light foreground objects.
This helps with objects such as pale sails, paper, white product packaging,
or low-contrast illustrations whose interior is close to the background.
It is deliberately conservative: if OpenCV is unavailable, it falls back to
plain white-to-alpha.
"""
base = background_to_alpha(image, (255, 255, 255), tolerance=tolerance)
try:
import cv2 # type: ignore
except Exception:
return base
rgb_u8 = np.asarray(image.convert("RGB"))
h, w = rgb_u8.shape[:2]
dist = np.sqrt(np.sum((255.0 - rgb_u8.astype(np.float32)) ** 2, axis=2))
rough = (dist > float(tolerance)).astype(np.uint8) * 255
kernel_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
kernel_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (17, 17))
rough = cv2.morphologyEx(rough, cv2.MORPH_OPEN, kernel_open, iterations=1)
rough = cv2.morphologyEx(rough, cv2.MORPH_CLOSE, kernel_close, iterations=2)
count, labels, stats, _ = cv2.connectedComponentsWithStats(rough, 8)
keep = np.zeros_like(rough)
min_area = max(24, int(w * h * float(min_area_ratio)))
for idx in range(1, count):
if stats[idx, cv2.CC_STAT_AREA] >= min_area:
keep[labels == idx] = 255
flood = keep.copy()
ff_mask = np.zeros((h + 2, w + 2), dtype=np.uint8)
cv2.floodFill(flood, ff_mask, (0, 0), 255)
filled = cv2.bitwise_or(keep, cv2.bitwise_not(flood))
soft = cv2.GaussianBlur(filled, (0, 0), 5).astype(np.float32) / 255.0
near_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (29, 29))
near = cv2.dilate((dist > (float(tolerance) * 0.65)).astype(np.uint8) * 255, near_kernel, iterations=1)
near = cv2.GaussianBlur(near, (0, 0), 8).astype(np.float32) / 255.0
lift = np.minimum(soft, near) * float(preserve_opacity)
arr = np.asarray(base.convert("RGBA")).copy()
alpha = arr[:, :, 3].astype(np.float32) / 255.0
alpha = np.maximum(alpha, lift)
alpha[alpha < (2.0 / 255.0)] = 0.0
# Preserve original light pixels in lifted regions to avoid harsh unpremul artifacts.
original = np.asarray(image.convert("RGB"))
very_light = (np.mean(original, axis=2) > 224) & (lift > 0.12)
arr[:, :, :3][very_light] = original[very_light]
arr[:, :, 3] = np.clip(alpha * 255.0, 0, 255).astype(np.uint8)
return Image.fromarray(arr, "RGBA")
def remove_background(image: Image.Image, mode: str, spec: dict[str, Any] | None = None) -> Image.Image:
mode = (mode or "none").lower().replace("_", "-")
spec = spec or {}
if mode in {"none", "false", "0"}:
return image.convert("RGBA")
if mode in {"white", "white-to-alpha", "auto"}:
return background_to_alpha(
image,
(255, 255, 255),
tolerance=float(spec.get("tolerance", 8)),
feather=float(spec.get("feather", 45)),
strength=float(spec.get("strength", 1.0)),
min_alpha=int(spec.get("min_alpha", 2)),
)
if mode in {"white-preserve", "preserve-light", "subject"}:
return preserve_light_foreground_to_alpha(
image,
tolerance=float(spec.get("tolerance", 10)),
preserve_opacity=float(spec.get("preserve_opacity", 0.72)),
min_area_ratio=float(spec.get("min_area_ratio", 0.00025)),
)
if mode == "corner":
bg = corner_color(image, int(spec.get("sample", 12)))
return background_to_alpha(
image,
bg,
tolerance=float(spec.get("tolerance", 8)),
feather=float(spec.get("feather", 45)),
strength=float(spec.get("strength", 1.0)),
)
if mode == "color":
bg = parse_color(spec.get("color", "#ffffff"))
return background_to_alpha(
image,
bg,
tolerance=float(spec.get("tolerance", 8)),
feather=float(spec.get("feather", 45)),
strength=float(spec.get("strength", 1.0)),
)
fail(f"unknown remove_background mode: {mode}")
return image
def resolve_path(base: Path, value: str | None) -> Path | None:
if not value:
return None
path = Path(value).expanduser()
if not path.is_absolute():
path = base / path
return path
def parse_canvas(text: str | None) -> tuple[int, int] | None:
if not text:
return None
match = re.fullmatch(r"\s*(\d+)\s*[xX,]\s*(\d+)\s*", text)
if not match:
fail(f"canvas must look like WIDTHxHEIGHT, got {text!r}")
return int(match.group(1)), int(match.group(2))
def common_font_path() -> str | None:
candidates = [
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Supplemental/Helvetica.ttf",
"/System/Library/Fonts/PingFang.ttc",
"/Library/Fonts/Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
for item in candidates:
if Path(item).exists():
return item
return None
def load_font(font_path: Path | None, size: int) -> ImageFont.ImageFont:
if font_path and font_path.exists():
return ImageFont.truetype(str(font_path), size)
fallback = common_font_path()
if fallback:
return ImageFont.truetype(fallback, size)
return ImageFont.load_default()
def text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> int:
box = draw.textbbox((0, 0), text, font=font)
return box[2] - box[0]
def wrap_text(text: str, font: ImageFont.ImageFont, max_width: int | None) -> list[str]:
if not max_width:
return text.splitlines() or [text]
probe = Image.new("RGBA", (8, 8))
draw = ImageDraw.Draw(probe)
lines: list[str] = []
for raw_line in text.splitlines() or [text]:
words = raw_line.split(" ")
current = ""
for word in words:
trial = word if not current else f"{current} {word}"
if text_width(draw, trial, font) <= max_width:
current = trial
continue
if current:
lines.append(current)
if text_width(draw, word, font) <= max_width:
current = word
else:
chunk = ""
for char in word:
trial_chunk = chunk + char
if text_width(draw, trial_chunk, font) <= max_width or not chunk:
chunk = trial_chunk
else:
lines.append(chunk)
chunk = char
current = chunk
lines.append(current)
return lines
def render_text_layer(spec: dict[str, Any], canvas: tuple[int, int], base_dir: Path) -> Image.Image:
text = str(spec.get("text", ""))
font_size = int(spec.get("font_size", spec.get("size", 48)))
font = load_font(resolve_path(base_dir, spec.get("font_path") or spec.get("font")), font_size)
fill = parse_color(spec.get("color", "#000000"), (0, 0, 0))
alpha = int(max(0.0, min(1.0, float(spec.get("opacity", 1.0)))) * 255)
x = int(spec.get("x", 0))
y = int(spec.get("y", 0))
max_width = spec.get("max_width")
max_width_int = int(max_width) if max_width else None
line_spacing = float(spec.get("line_spacing", 1.18))
align = str(spec.get("align", "left")).lower()
out = Image.new("RGBA", canvas, (0, 0, 0, 0))
draw = ImageDraw.Draw(out)
lines = wrap_text(text, font, max_width_int)
line_height = max(1, round(font_size * line_spacing))
for idx, line in enumerate(lines):
tx = x
if max_width_int and align in {"center", "right"}:
width = text_width(draw, line, font)
if align == "center":
tx = x + (max_width_int - width) // 2
elif align == "right":
tx = x + max_width_int - width
draw.text((tx, y + idx * line_height), line, font=font, fill=(*fill, alpha))
return out
def load_layer_from_spec(spec: dict[str, Any], canvas: tuple[int, int], base_dir: Path) -> Layer | None:
if spec.get("visible", True) is False:
return None
layer_type = str(spec.get("type", "image")).lower()
name = str(spec.get("name") or spec.get("file") or spec.get("path") or layer_type)
opacity = float(spec.get("opacity", 1.0))
blend_mode = str(spec.get("blend_mode", spec.get("blend", "normal"))).lower()
if layer_type == "text":
image = render_text_layer(spec, canvas, base_dir)
return Layer(name=name, image=apply_opacity(image, opacity), blend_mode=blend_mode, opacity=opacity)
source = spec.get("file") or spec.get("path") or spec.get("src")
path = resolve_path(base_dir, source)
if not path:
fail(f"image layer {name!r} is missing file/path/src")
image = image_from_path(path)
fit = str(spec.get("fit", "none"))
image = fit_to_canvas(image, canvas, fit)
remove_spec = spec.get("background") if isinstance(spec.get("background"), dict) else spec
image = remove_background(image, str(spec.get("remove_background", spec.get("remove_bg", "none"))), remove_spec)
x = int(spec.get("x", 0))
y = int(spec.get("y", 0))
image = place_on_canvas(image, canvas, x, y)
image = apply_opacity(image, opacity)
return Layer(name=name, image=image, blend_mode=blend_mode, opacity=opacity)
def alpha_bbox(image: Image.Image) -> tuple[int, int, int, int] | None:
alpha = image.convert("RGBA").getchannel("A")
return alpha.getbbox()
def composite_layers(layers_bottom_to_top: Sequence[Layer], background: tuple[int, int, int] = (255, 255, 255)) -> Image.Image:
if not layers_bottom_to_top:
fail("no layers to composite")
canvas = layers_bottom_to_top[0].image.size
comp = Image.new("RGBA", canvas, (*background, 255))
for layer in layers_bottom_to_top:
comp.alpha_composite(layer.image.convert("RGBA"))
return comp.convert("RGB")
def pad_even(data: bytes) -> bytes:
if len(data) % 2:
return data + b"\x00"
return data
def pad4(data: bytes) -> bytes:
return data + (b"\x00" * ((4 - (len(data) % 4)) % 4))
def pascal_name(name: str) -> bytes:
raw = name.encode("macroman", errors="replace")[:255]
data = bytes([len(raw)]) + raw
return pad4(data)
def layer_resource_block(key: bytes, payload: bytes) -> bytes:
if len(key) != 4:
fail("PSD layer resource key must be 4 bytes")
return b"8BIM" + key + struct.pack(">I", len(payload)) + pad_even(payload)
def unicode_name_block(name: str) -> bytes:
payload = struct.pack(">I", len(name)) + name.encode("utf-16be")
return layer_resource_block(b"luni", payload)
def layer_extra_data(name: str) -> bytes:
data = b""
data += struct.pack(">I", 0) # layer mask data length
data += struct.pack(">I", 0) # layer blending ranges length
data += pascal_name(name)
data += unicode_name_block(name)
return data
def channel_bytes(arr: np.ndarray, channel_index: int) -> bytes:
return arr[:, :, channel_index].astype(np.uint8).tobytes(order="C")
def write_psd(
output_path: Path,
layers_bottom_to_top: Sequence[Layer],
composite_background: tuple[int, int, int] = (255, 255, 255),
) -> dict[str, Any]:
if not layers_bottom_to_top:
fail("cannot write PSD without layers")
width, height = layers_bottom_to_top[0].image.size
for layer in layers_bottom_to_top:
if layer.image.size != (width, height):
fail(f"layer {layer.name!r} has size {layer.image.size}, expected {(width, height)}")
records: list[bytes] = []
channel_data_blocks: list[bytes] = []
layer_summaries: list[dict[str, Any]] = []
layers_top_to_bottom = list(reversed(layers_bottom_to_top))
for layer in layers_top_to_bottom:
image = layer.image.convert("RGBA")
bbox = alpha_bbox(image)
if not bbox:
print(f"warning: skipping empty layer {layer.name!r}", file=sys.stderr)
continue
left, top, right, bottom = bbox
cropped = image.crop((left, top, right, bottom))
arr = np.asarray(cropped.convert("RGBA"), dtype=np.uint8)
layer_w = right - left
layer_h = bottom - top
channels = [
(0, channel_bytes(arr, 0)),
(1, channel_bytes(arr, 1)),
(2, channel_bytes(arr, 2)),
(-1, channel_bytes(arr, 3)),
]
channel_info = b""
data_block = b""
for channel_id, data in channels:
channel_info += struct.pack(">hI", channel_id, 2 + len(data))
data_block += struct.pack(">H", 0) + data # raw channel data
blend_key = BLEND_KEYS.get(layer.blend_mode, b"norm")
extra = layer_extra_data(layer.name)
record = b""
record += struct.pack(">iiii", top, left, bottom, right)
record += struct.pack(">H", len(channels))
record += channel_info
record += b"8BIM" + blend_key
record += bytes([255, 0, 0, 0]) # opacity, clipping, flags, filler
record += struct.pack(">I", len(extra)) + extra
records.append(record)
channel_data_blocks.append(data_block)
layer_summaries.append(
{
"name": layer.name,
"top": top,
"left": left,
"width": layer_w,
"height": layer_h,
"blend_mode": layer.blend_mode,
}
)
if not records:
fail("all layers were empty")
layer_info = struct.pack(">h", len(records)) + b"".join(records) + b"".join(channel_data_blocks)
layer_info = pad_even(layer_info)
layer_info_block = struct.pack(">I", len(layer_info)) + layer_info
global_layer_mask_block = struct.pack(">I", 0)
layer_mask_payload = layer_info_block + global_layer_mask_block
layer_and_mask = struct.pack(">I", len(layer_mask_payload)) + layer_mask_payload
composite = composite_layers(layers_bottom_to_top, composite_background)
comp_arr = np.asarray(composite, dtype=np.uint8)
composite_data = (
struct.pack(">H", 0)
+ comp_arr[:, :, 0].tobytes(order="C")
+ comp_arr[:, :, 1].tobytes(order="C")
+ comp_arr[:, :, 2].tobytes(order="C")
)
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("wb") as handle:
handle.write(b"8BPS")
handle.write(struct.pack(">H", 1))
handle.write(b"\x00" * 6)
handle.write(struct.pack(">HIIHH", 3, height, width, 8, 3))
handle.write(struct.pack(">I", 0)) # color mode data length
handle.write(struct.pack(">I", 0)) # image resources length
handle.write(layer_and_mask)
handle.write(composite_data)
layer_summaries.reverse() # return bottom-to-top order for humans
return {
"output": str(output_path),
"width": width,
"height": height,
"layers": layer_summaries,
"layer_count": len(layer_summaries),
"bytes": output_path.stat().st_size,
}
def save_layer_pngs(layers: Sequence[Layer], directory: Path) -> list[str]:
directory.mkdir(parents=True, exist_ok=True)
written: list[str] = []
for idx, layer in enumerate(layers, start=1):
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", layer.name).strip("_") or f"layer_{idx}"
path = directory / f"{idx:02d}_{safe}.png"
layer.image.save(path)
written.append(str(path))
return written
def zip_files(paths: Iterable[str], zip_path: Path) -> None:
zip_path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive:
for item in paths:
path = Path(item)
archive.write(path, arcname=path.name)
def resolve_output(path: str | None, fallback: Path, base_dir: Path) -> Path:
if not path:
return fallback
candidate = Path(path).expanduser()
if not candidate.is_absolute():
candidate = base_dir / candidate
return candidate
def layers_from_manifest(manifest_path: Path) -> tuple[list[Layer], dict[str, Any], Path]:
with manifest_path.open("r", encoding="utf-8") as handle:
manifest = json.load(handle)
base_dir = manifest_path.parent
layer_specs = manifest.get("layers")
if not isinstance(layer_specs, list) or not layer_specs:
fail("manifest must contain a non-empty layers array")
canvas_spec = manifest.get("canvas", {})
canvas: tuple[int, int] | None = None
if isinstance(canvas_spec, dict) and canvas_spec.get("width") and canvas_spec.get("height"):
canvas = (int(canvas_spec["width"]), int(canvas_spec["height"]))
elif isinstance(canvas_spec, str):
canvas = parse_canvas(canvas_spec)
if canvas is None:
first = layer_specs[0]
if not isinstance(first, dict):
fail("layer specs must be objects")
first_path = resolve_path(base_dir, first.get("file") or first.get("path") or first.get("src"))
if not first_path:
fail("canvas is missing and first layer has no image size to infer from")
first_image = image_from_path(first_path)
canvas = first_image.size
layers: list[Layer] = []
for raw_spec in layer_specs:
if not isinstance(raw_spec, dict):
fail("each manifest layer must be an object")
layer = load_layer_from_spec(raw_spec, canvas, base_dir)
if layer is not None:
layers.append(layer)
if not layers:
fail("manifest produced no visible layers")
return layers, manifest, base_dir
def command_assemble(args: argparse.Namespace) -> None:
if args.manifest:
manifest_path = Path(args.manifest).expanduser().resolve()
layers, manifest, base_dir = layers_from_manifest(manifest_path)
canvas_spec = manifest.get("canvas", {})
composite_bg = parse_color(
canvas_spec.get("composite_background") if isinstance(canvas_spec, dict) else None,
parse_color(args.composite_background),
)
output = resolve_output(args.output or manifest.get("output"), manifest_path.with_suffix(".psd"), base_dir)
preview = resolve_output(args.preview or manifest.get("preview"), output.with_suffix(".preview.png"), base_dir)
save_layers_dir_value = args.save_layers or manifest.get("save_layers_dir")
save_layers_dir = resolve_output(save_layers_dir_value, output.with_suffix("").parent / "layers", base_dir) if save_layers_dir_value else None
zip_layers_path = resolve_output(args.zip_layers or manifest.get("zip_layers"), output.with_suffix(".layers.zip"), base_dir) if (args.zip_layers or manifest.get("zip_layers")) else None
else:
if not args.images:
fail("assemble needs either --manifest or image paths")
paths = [Path(item).expanduser().resolve() for item in args.images]
names = split_names(args.names)
canvas = parse_canvas(args.canvas)
if canvas is None:
canvas = image_from_path(paths[0]).size
layers = []
for idx, path in enumerate(paths):
spec = {
"file": str(path),
"name": names[idx] if idx < len(names) else path.stem,
"fit": args.fit if idx == 0 and args.first_is_background else "none",
"remove_background": "none" if idx == 0 and args.first_is_background else args.remove_background,
"x": 0,
"y": 0,
"tolerance": args.tolerance,
"feather": args.feather,
}
layer = load_layer_from_spec(spec, canvas, Path.cwd())
if layer:
layers.append(layer)
composite_bg = parse_color(args.composite_background)
output = Path(args.output).expanduser().resolve() if args.output else Path("image2psd-output.psd").resolve()
preview = Path(args.preview).expanduser().resolve() if args.preview else output.with_suffix(".preview.png")
save_layers_dir = Path(args.save_layers).expanduser().resolve() if args.save_layers else None
zip_layers_path = Path(args.zip_layers).expanduser().resolve() if args.zip_layers else None
summary = write_psd(output, layers, composite_bg)
preview.parent.mkdir(parents=True, exist_ok=True)
composite_layers(layers, composite_bg).save(preview)
summary["preview"] = str(preview)
if save_layers_dir:
written = save_layer_pngs(layers, save_layers_dir)
summary["layer_pngs"] = written
if zip_layers_path:
zip_files(written, zip_layers_path)
summary["zip_layers"] = str(zip_layers_path)
print(json.dumps(summary, ensure_ascii=False, indent=2))
def split_names(value: str | None) -> list[str]:
if not value:
return []
if "|" in value:
return [item.strip() for item in value.split("|")]
return [item.strip() for item in value.split(",")]
def quantized_labels(rgb: Image.Image, colors: int) -> tuple[np.ndarray, list[tuple[int, int, int]]]:
quantized = rgb.quantize(colors=max(2, int(colors)), method=Image.Quantize.MEDIANCUT)
labels = np.asarray(quantized, dtype=np.uint8)
palette_raw = quantized.getpalette() or []
used = sorted(int(x) for x in np.unique(labels))
palette: list[tuple[int, int, int]] = []
for idx in used:
offset = idx * 3
palette.append(tuple(palette_raw[offset : offset + 3])) # type: ignore[arg-type]
remap = {old: new for new, old in enumerate(used)}
remapped = np.vectorize(remap.get)(labels).astype(np.uint8)
return remapped, palette
def kmeans_labels(rgb_arr: np.ndarray, colors: int, max_pixels: int = 180_000) -> tuple[np.ndarray, list[tuple[int, int, int]]]:
h, w = rgb_arr.shape[:2]
pixels = rgb_arr.reshape(-1, 3).astype(np.float32)
sample = pixels
if len(sample) > max_pixels:
rng = np.random.default_rng(42)
sample = pixels[rng.choice(len(pixels), size=max_pixels, replace=False)]
try:
from sklearn.cluster import MiniBatchKMeans # type: ignore
model = MiniBatchKMeans(n_clusters=int(colors), random_state=42, n_init=3, batch_size=4096)
model.fit(sample)
centers = np.clip(model.cluster_centers_, 0, 255).astype(np.uint8)
except Exception:
centers = numpy_kmeans(sample, int(colors), iterations=12)
distances = np.sum((pixels[:, None, :] - centers[None, :, :].astype(np.float32)) ** 2, axis=2)
labels = np.argmin(distances, axis=1).reshape(h, w).astype(np.uint8)
palette = [tuple(int(v) for v in center) for center in centers]
return labels, palette
def numpy_kmeans(sample: np.ndarray, colors: int, iterations: int = 10) -> np.ndarray:
rng = np.random.default_rng(42)
if len(sample) < colors:
colors = len(sample)
centers = sample[rng.choice(len(sample), size=colors, replace=False)].astype(np.float32)
for _ in range(iterations):
distances = np.sum((sample[:, None, :] - centers[None, :, :]) ** 2, axis=2)
labels = np.argmin(distances, axis=1)
for idx in range(colors):
members = sample[labels == idx]
if len(members):
centers[idx] = members.mean(axis=0)
return np.clip(centers, 0, 255).astype(np.uint8)
def should_ignore_color(color: tuple[int, int, int], ignore: tuple[int, int, int] | None, tolerance: float) -> bool:
if ignore is None:
return False
return math.sqrt(sum((int(color[i]) - int(ignore[i])) ** 2 for i in range(3))) <= tolerance
def command_split_colors(args: argparse.Namespace) -> None:
input_path = Path(args.input).expanduser().resolve()
source = image_from_path(input_path)
rgba = np.asarray(source.convert("RGBA"), dtype=np.uint8)
rgb = source.convert("RGB")
rgb_arr = np.asarray(rgb, dtype=np.uint8)
if args.method == "kmeans":
labels, palette = kmeans_labels(rgb_arr, args.num_colors, args.max_pixels)
elif args.method == "quantize":
labels, palette = quantized_labels(rgb, args.num_colors)
else:
# Quantize is fast and dependency-free; kmeans can improve gradients.
labels, palette = quantized_labels(rgb, args.num_colors)
ignore = parse_color(args.ignore_color) if args.ignore_color else None
layers: list[Layer] = []
alpha_source = rgba[:, :, 3]
for idx, color in enumerate(palette):
if should_ignore_color(color, ignore, float(args.ignore_tolerance)):
continue
mask = labels == idx
if not np.any(mask):
continue
layer_arr = np.zeros_like(rgba)
layer_arr[:, :, :3][mask] = rgba[:, :, :3][mask]
layer_arr[:, :, 3][mask] = alpha_source[mask]
layers.append(Layer(name=f"Color {idx + 1} {color_hex(color)}", image=Image.fromarray(layer_arr, "RGBA")))
if not layers:
fail("color split produced no layers")
if args.order == "small-on-top":
layers.sort(key=lambda item: np.count_nonzero(np.asarray(item.image.getchannel("A"))), reverse=True)
elif args.order == "large-on-top":
layers.sort(key=lambda item: np.count_nonzero(np.asarray(item.image.getchannel("A"))))
output = Path(args.output).expanduser().resolve()
composite_bg = parse_color(args.composite_background)
summary = write_psd(output, layers, composite_bg)
if args.preview:
preview = Path(args.preview).expanduser().resolve()
else:
preview = output.with_suffix(".preview.png")
preview.parent.mkdir(parents=True, exist_ok=True)
composite_layers(layers, composite_bg).save(preview)
summary["preview"] = str(preview)
if args.save_layers:
written = save_layer_pngs(layers, Path(args.save_layers).expanduser().resolve())
summary["layer_pngs"] = written
print(json.dumps(summary, ensure_ascii=False, indent=2))
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Create layered PSD files from images, manifests, text specs, or color clusters.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
sub = parser.add_subparsers(dest="command", required=True)
assemble = sub.add_parser("assemble", help="assemble image/text elements into a layered PSD")
assemble.add_argument("images", nargs="*", help="image files, used when --manifest is omitted")
assemble.add_argument("-m", "--manifest", help="JSON manifest describing canvas and layers")
assemble.add_argument("-o", "--output", help="output PSD path")
assemble.add_argument("--preview", help="flattened preview PNG path")
assemble.add_argument("--save-layers", help="directory to write full-canvas transparent PNG layers")
assemble.add_argument("--zip-layers", help="zip file for saved PNG layers")
assemble.add_argument("--canvas", help="canvas size for positional image input, e.g. 1200x1600")
assemble.add_argument("--names", help="comma-separated or pipe-separated layer names for positional input")
assemble.add_argument("--first-is-background", action="store_true", help="treat the first positional image as opaque background")
assemble.add_argument("--fit", default="cover", choices=["none", "contain", "cover", "stretch"], help="fit mode for first background image")
assemble.add_argument("--remove-background", default="white", choices=["none", "white", "corner", "color", "white-preserve"], help="background removal mode for positional foreground images")
assemble.add_argument("--tolerance", type=float, default=8.0, help="background removal tolerance")
assemble.add_argument("--feather", type=float, default=45.0, help="background alpha feather")
assemble.add_argument("--composite-background", default="#ffffff", help="background color used only for flattened preview/composite")
assemble.set_defaults(func=command_assemble)
split = sub.add_parser("split-colors", help="split one flat image into color-cluster raster layers")
split.add_argument("input", help="input image")
split.add_argument("-o", "--output", required=True, help="output PSD path")
split.add_argument("--num-colors", type=int, default=8, help="number of color layers")
split.add_argument("--method", choices=["auto", "quantize", "kmeans"], default="auto", help="color clustering method")
split.add_argument("--max-pixels", type=int, default=180_000, help="sample size for kmeans")
split.add_argument("--ignore-color", help="skip a background-like cluster, e.g. white or #ffffff")
split.add_argument("--ignore-tolerance", type=float, default=22.0, help="RGB distance tolerance for --ignore-color")
split.add_argument("--order", choices=["as-found", "small-on-top", "large-on-top"], default="small-on-top", help="layer stacking heuristic")
split.add_argument("--preview", help="flattened preview PNG path")
split.add_argument("--save-layers", help="directory to write full-canvas transparent PNG layers")
split.add_argument("--composite-background", default="#ffffff", help="background color used only for flattened preview/composite")
split.set_defaults(func=command_split_colors)
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
args.func(args)
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Create a bggg-creator-image2psd project folder for one conversion run."""
from __future__ import annotations
import argparse
import json
import re
import shutil
from datetime import datetime
from pathlib import Path
from typing import Sequence
SKILL_ROOT = Path(__file__).resolve().parent.parent
PROJECTS_ROOT = SKILL_ROOT / "projects"
def slugify(value: str) -> str:
text = value.strip().lower()
text = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "_", text)
text = re.sub(r"_+", "_", text).strip("_")
return text or "image2psd"
def unique_project_dir(date_prefix: str, slug: str) -> Path:
base = PROJECTS_ROOT / f"{date_prefix}_{slug}"
if not base.exists():
return base
index = 2
while True:
candidate = PROJECTS_ROOT / f"{date_prefix}_{slug}_{index}"
if not candidate.exists():
return candidate
index += 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Initialize a project folder under bggg-creator-image2psd/projects.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("slug", help="short project name, e.g. lifestyle_product")
parser.add_argument("--source", help="optional source image to copy as original_reference.png")
parser.add_argument("--date", help="YYYYMMDD override; defaults to local current date")
parser.add_argument("--force-dir", help="explicit project directory name under projects/")
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
date_prefix = args.date or datetime.now().strftime("%Y%m%d")
project_name = args.force_dir or f"{date_prefix}_{slugify(args.slug)}"
project_dir = PROJECTS_ROOT / project_name if args.force_dir else unique_project_dir(date_prefix, slugify(args.slug))
subdirs = [
"layer_sources",
"psd_full_canvas_layers",
"imagegen_assets",
"diagnostics",
]
project_dir.mkdir(parents=True, exist_ok=True)
for item in subdirs:
(project_dir / item).mkdir(parents=True, exist_ok=True)
source_out = None
if args.source:
source = Path(args.source).expanduser().resolve()
if not source.exists():
raise SystemExit(f"source not found: {source}")
source_out = project_dir / "original_reference.png"
shutil.copy2(source, source_out)
result = {
"project_dir": str(project_dir),
"source": str(source_out) if source_out else None,
"layer_sources": str(project_dir / "layer_sources"),
"psd_full_canvas_layers": str(project_dir / "psd_full_canvas_layers"),
"imagegen_assets": str(project_dir / "imagegen_assets"),
"diagnostics": str(project_dir / "diagnostics"),
}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Pillow>=10
numpy>=1.24
# Optional, used for stronger light-subject background preservation.
opencv-python>=4.6
# Optional, used by split-colors --method kmeans when available.
scikit-learn>=1.2
Related skills
FAQ
Does it require Photoshop?
No, the image2psd.py script writes PSD files without depending on Photoshop or ImageMagick.
How does it split a single flat image?
It can do semantic layer splitting or color-cluster splitting via the split-colors command.