psdparse 0.7.0__tar.gz → 0.8.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {psdparse-0.7.0 → psdparse-0.8.1}/PKG-INFO +19 -1
- {psdparse-0.7.0 → psdparse-0.8.1}/README.md +18 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/docs/PYTHON_API.md +37 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/docs/ROADMAP.md +17 -2
- {psdparse-0.7.0 → psdparse-0.8.1}/docs/SUPPORT.md +4 -2
- psdparse-0.8.1/examples/README.md +84 -0
- psdparse-0.8.1/examples/composite.py +111 -0
- psdparse-0.8.1/examples/extract_layers.py +105 -0
- psdparse-0.8.1/examples/variations.py +153 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdfile.cpp +34 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdfile.h +6 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdimage.cpp +22 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdlayer.cpp +35 -42
- {psdparse-0.7.0 → psdparse-0.8.1}/pyproject.toml +1 -1
- {psdparse-0.7.0 → psdparse-0.8.1}/python/psdparse_module.cpp +47 -4
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/conftest.py +14 -0
- psdparse-0.8.1/tests/test_comps.py +78 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit.py +45 -0
- psdparse-0.8.1/tests/test_merged.py +54 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/.gitignore +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/CMakeLists.txt +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/CMakePresets.json +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/LICENSE +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/Makefile +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/docs/ARCHITECTURE.md +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/CMakeLists.txt +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/bmp.cpp +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psd_cli.cpp +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdbase.h +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psddata.h +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psddesc.cpp +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psddesc.h +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdengine.cpp +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdengine.h +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdlayer.h +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdparse.cpp +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdparse.h +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdresource.cpp +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdresource.h +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdwrite.cpp +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/psdparse/psdwrite.h +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/python/CMakeLists.txt +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_colormodes.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_create.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_descriptors.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit_effects.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit_mask.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit_mask_pixels.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit_name.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit_pixels.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit_run_style.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_edit_text.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_header.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_images.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_layers.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_mask_extras.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_metadata.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_resources.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_save.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tests/test_text.py +0 -0
- {psdparse-0.7.0 → psdparse-0.8.1}/tools/psd_export.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: psdparse
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: Fast PSD (Photoshop) reader/writer — C++17 core with pybind11 bindings
|
|
5
5
|
Keywords: psd,photoshop,parser,image,graphics
|
|
6
6
|
Author-Email: wamsoft <wtnbgo@gmail.com>
|
|
@@ -166,6 +166,24 @@ Tests live under `tests/` and use [pytest](https://docs.pytest.org/). They need
|
|
|
166
166
|
python -m pytest -v
|
|
167
167
|
```
|
|
168
168
|
|
|
169
|
+
## Examples (composite / variations / sprite extraction)
|
|
170
|
+
|
|
171
|
+
psdparse gives you the pieces (per-layer pixels, position, opacity, masks) but
|
|
172
|
+
does **not** composite or re-render — the Python imaging ecosystem does that
|
|
173
|
+
better. [`examples/`](examples/) shows the recipes:
|
|
174
|
+
|
|
175
|
+
- **`composite.py`** — composite layers with Pillow (position + opacity + mask,
|
|
176
|
+
normal blend), and optionally write the result back as the PSD's stored
|
|
177
|
+
preview via `set_merged_image`.
|
|
178
|
+
- **`variations.py`** — enumerate tachie/expression combinations by treating
|
|
179
|
+
layer folders as mutually-exclusive option slots.
|
|
180
|
+
- **`extract_layers.py`** — export per-layer PNGs + a manifest, with alpha edge
|
|
181
|
+
extension (bleed) so sprites stay clean under rotation/scaling.
|
|
182
|
+
|
|
183
|
+
See [examples/README.md](examples/README.md). On the sample tachie PSDs
|
|
184
|
+
(all normal blend) `composite.py` matches Photoshop's stored composite to ~0.1
|
|
185
|
+
mean level difference.
|
|
186
|
+
|
|
169
187
|
## tools/psd_export.py
|
|
170
188
|
|
|
171
189
|
```
|
|
@@ -125,6 +125,24 @@ Tests live under `tests/` and use [pytest](https://docs.pytest.org/). They need
|
|
|
125
125
|
python -m pytest -v
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
+
## Examples (composite / variations / sprite extraction)
|
|
129
|
+
|
|
130
|
+
psdparse gives you the pieces (per-layer pixels, position, opacity, masks) but
|
|
131
|
+
does **not** composite or re-render — the Python imaging ecosystem does that
|
|
132
|
+
better. [`examples/`](examples/) shows the recipes:
|
|
133
|
+
|
|
134
|
+
- **`composite.py`** — composite layers with Pillow (position + opacity + mask,
|
|
135
|
+
normal blend), and optionally write the result back as the PSD's stored
|
|
136
|
+
preview via `set_merged_image`.
|
|
137
|
+
- **`variations.py`** — enumerate tachie/expression combinations by treating
|
|
138
|
+
layer folders as mutually-exclusive option slots.
|
|
139
|
+
- **`extract_layers.py`** — export per-layer PNGs + a manifest, with alpha edge
|
|
140
|
+
extension (bleed) so sprites stay clean under rotation/scaling.
|
|
141
|
+
|
|
142
|
+
See [examples/README.md](examples/README.md). On the sample tachie PSDs
|
|
143
|
+
(all normal blend) `composite.py` matches Photoshop's stored composite to ~0.1
|
|
144
|
+
mean level difference.
|
|
145
|
+
|
|
128
146
|
## tools/psd_export.py
|
|
129
147
|
|
|
130
148
|
```
|
|
@@ -112,6 +112,7 @@ Read-only view of one layer.
|
|
|
112
112
|
| `effects` | `dict` \| `None` | layer effects (`lfx2`) as a descriptor dict — see [Descriptor blocks](#descriptor-blocks) |
|
|
113
113
|
| `fill` | `dict` \| `None` | fill-layer content (solid/gradient/pattern) — see [Descriptor blocks](#descriptor-blocks) |
|
|
114
114
|
| `sheet_color` | `dict` \| `None` | layer-panel color label (`lclr`): `{"index", "name"}` — `None` when no `lclr` block |
|
|
115
|
+
| `comp_states` | `dict` | per layer-comp state `{comp_id: {"enabled", "offset_x", "offset_y"}}` (empty if the layer is in no comps). `enabled` says if the layer shows in that comp — see [Layer comps](#layer-comps) |
|
|
115
116
|
| `info_keys` | `list[str]` | 4cc keys of every additional-layer-info block on this layer |
|
|
116
117
|
| `visible` | `bool` | flag bit 1 inverted |
|
|
117
118
|
| `transparency_protected` | `bool` | flag bit 0 |
|
|
@@ -258,6 +259,7 @@ Quick map of the API (details in the subsections below):
|
|
|
258
259
|
| edit text content | `p.set_text(i, str)` |
|
|
259
260
|
| edit a text run's style | `p.set_run_style(i, run, size_px=…, color=…, …)` |
|
|
260
261
|
| build a new PSD | `p.create_blank(w, h)` then `add_layer(...)` |
|
|
262
|
+
| write a composited preview back | `p.set_merged_image(bgra)` |
|
|
261
263
|
|
|
262
264
|
**8-bit RGB only** for the pixel/mask/new-document operations. The stored
|
|
263
265
|
composite image is **not** re-rendered after edits (see [Saving](#saving)).
|
|
@@ -329,6 +331,9 @@ new_i = p.copy_layer_from(src, j, dest_index=-1) # copy layer j from another PS
|
|
|
329
331
|
```
|
|
330
332
|
|
|
331
333
|
Notes:
|
|
334
|
+
- `duplicate_layer` / `copy_layer_from` assign the copy a **fresh `layer_id`**
|
|
335
|
+
(max existing lyid + 1, like Photoshop) so layer IDs stay unique within the
|
|
336
|
+
document; the `lyid` additional-info block is rewritten on save.
|
|
332
337
|
- `delete_layer` / `duplicate_layer` on a single layer are exact. Deleting **one
|
|
333
338
|
half of a group's FOLDER/HIDDEN divider pair unbalances the group** — delete
|
|
334
339
|
whole groups (both dividers + contents) for clean nesting. (Unbalanced results
|
|
@@ -433,6 +438,14 @@ p.save("new.psd")
|
|
|
433
438
|
|
|
434
439
|
### Saving
|
|
435
440
|
|
|
441
|
+
The stored **composite (merged) image** is not re-rendered after edits. If you
|
|
442
|
+
composite the layers yourself (e.g. with Pillow — see [`examples/`](../examples/)),
|
|
443
|
+
write the result back as the PSD's preview with:
|
|
444
|
+
|
|
445
|
+
```python
|
|
446
|
+
p.set_merged_image(bgra_bytes) # canvas-sized BGRA (header.width*header.height*4)
|
|
447
|
+
```
|
|
448
|
+
|
|
436
449
|
`save(path)` returns `True`/`False`. **Do not save over a file that is currently
|
|
437
450
|
loaded** (by this or any live `PSDFile`): `load()` memory-maps the file
|
|
438
451
|
read-only, so the write is refused and `save()` returns `False` (the original is
|
|
@@ -460,6 +473,30 @@ p.color_table # dict|None : {"colors":[(r,g,b,a)], "valid_count", "transparenc
|
|
|
460
473
|
p.global_layer_mask # dict|None : {"overlay_color_space", "color":(c1,c2,c3,c4), "opacity", "kind"}
|
|
461
474
|
```
|
|
462
475
|
|
|
476
|
+
### Layer comps
|
|
477
|
+
|
|
478
|
+
`p.layer_comps` lists the document's layer comps (saved layer-state snapshots).
|
|
479
|
+
Which layers each comp shows is on the **layers**: `layer.comp_states` maps a
|
|
480
|
+
comp id to that layer's state in the comp:
|
|
481
|
+
|
|
482
|
+
```python
|
|
483
|
+
{ comp_id: {"enabled": True, "offset_x": 0, "offset_y": 0}, ... }
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
`enabled` is whether the layer is visible in that comp; a layer the comp doesn't
|
|
487
|
+
mention isn't in the dict (fall back to its current visibility). To render a comp
|
|
488
|
+
(visibility only — position/appearance overrides aren't applied):
|
|
489
|
+
|
|
490
|
+
```python
|
|
491
|
+
for comp in p.layer_comps:
|
|
492
|
+
show = {i for i, l in enumerate(p.layers)
|
|
493
|
+
if (l.comp_states[comp["id"]]["enabled"]
|
|
494
|
+
if comp["id"] in l.comp_states else l.visible)}
|
|
495
|
+
# composite `show` with Pillow — see examples/variations.py (composite_comp)
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
`examples/variations.py --comps` renders every comp this way.
|
|
499
|
+
|
|
463
500
|
### Image resources (raw)
|
|
464
501
|
|
|
465
502
|
Most image resources are exposed as their raw on-disk bytes; decoding (EXIF
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
For a feature-by-feature account of what is and isn't supported today, see
|
|
4
4
|
[SUPPORT.md](SUPPORT.md). This file tracks planned work.
|
|
5
5
|
|
|
6
|
-
## Current state (2026-08-
|
|
6
|
+
## Current state (2026-08-12, v0.8.1)
|
|
7
7
|
|
|
8
8
|
- ✅ Pure C++17 parser (no Boost)
|
|
9
9
|
- ✅ mmap + StreamReader / Source abstraction
|
|
10
10
|
- ✅ Python bindings (pybind11)
|
|
11
|
-
- ✅ pytest regression suite (
|
|
11
|
+
- ✅ pytest regression suite (140 tests)
|
|
12
|
+
- ✅ Python composite recipes (`examples/`) + `set_merged_image`; layer comps exposed (`layer.comp_states`)
|
|
12
13
|
- ✅ Round-trip PSD save (byte-identical)
|
|
13
14
|
- ✅ Edit & save: structure / pixels / mask / parameters / effects / text / new-from-scratch (E1–E6, byte-exact re-serialization)
|
|
14
15
|
- ✅ UTF-8 path I/F (Win32 conversion internal only)
|
|
@@ -90,6 +91,20 @@ Editing text content/style means writing the `TySh` block back: re-serializing t
|
|
|
90
91
|
|
|
91
92
|
## Other future work
|
|
92
93
|
|
|
94
|
+
- ✅ **`duplicate_layer` assigns a fresh `lyid`.** *Done 2026-08-12.* The
|
|
95
|
+
duplicated layer used to copy the source's `lyid` (layer ID) verbatim, so the
|
|
96
|
+
output PSD contained duplicate layer IDs — Photoshop itself assigns a *new*
|
|
97
|
+
ID when duplicating. Downstream tools that use `lyid` as a persistent layer
|
|
98
|
+
identity (e.g. elements_console's uitool reference resolution) then saw
|
|
99
|
+
ambiguous matches. Fix (`psdfile.cpp`): `assignFreshLayerId` sets
|
|
100
|
+
`layerId = max existing lyid + 1` and replaces (or appends) the `lyid`
|
|
101
|
+
additional-info entry on the copy, flipping `useRawBytes=false` so save
|
|
102
|
+
re-serializes it; applied in both `duplicateLayer` and `copyLayerFrom` (the
|
|
103
|
+
latter picks an ID unique within the *destination* document). `add_layer`
|
|
104
|
+
already picked max+1 — verified. Tests (`tests/test_edit.py`): duplicate →
|
|
105
|
+
all `layer.layer_id` distinct → save → reload → still distinct; double
|
|
106
|
+
duplicate cross-checked via psd-tools tagged blocks; `copy_layer_from`
|
|
107
|
+
uniqueness in the destination.
|
|
93
108
|
- ✅ **Text layer content extraction (`TySh` type-tool additional info).** *Done 2026-07-27.* `lay.text` returns `{"text", "orientation", "justification", "transform", "runs":[{"length","font","size_px","color","tracking","kerning","auto_kerning"}]}` (or `None` for non-text layers). Implementation: `psddesc` now reads `tdta` raw data (length-prefixed), `loadLayerTypeTool` (`psdlayer.cpp`) parses the `TySh` header + text descriptor, and `psdengine.cpp` parses the embedded Adobe *EngineData* mini-language (FontSet / StyleRun / ParagraphRun) into per-run styling. Validated against `tests/data/fontsample.psd` (multi-font/size/color, **vertical**, emoji, tracking) — see `tests/test_text.py`.
|
|
94
109
|
|
|
95
110
|
**Deferred (need targeted sample PSDs, next turn):**
|
|
@@ -54,7 +54,8 @@ Python API の使い方は [PYTHON_API.md](PYTHON_API.md) を参照。
|
|
|
54
54
|
| テキストのラン単位スタイル編集 | ✅ | `set_run_style(i, run, size_px=/color=/tracking=/bold=…)` (v0.7.0) |
|
|
55
55
|
| テキストのフォント変更 (FontSet 追加) / ラン再構成 | ❌ | 既存ランの値上書きのみ |
|
|
56
56
|
| マスク幾何の単独編集 (画素なし) | 🟡 | `set_layer_mask_pixels` で画素とセットのみ |
|
|
57
|
-
|
|
|
57
|
+
| 合成済み画像 (composite) の入れ替え | ✅ | `set_merged_image(bgra)` — Python で合成した結果を書き戻せる (v0.7.x) |
|
|
58
|
+
| 効果込みの合成 (composite) の自動再生成 | ❌ | psdparse 自身は再描画しない。合成は Python (Pillow 等, `examples/`) で |
|
|
58
59
|
|
|
59
60
|
## 圧縮 / ビット深度
|
|
60
61
|
|
|
@@ -163,7 +164,8 @@ Photoshop の汎用ディスクリプタで格納されるブロックを dict
|
|
|
163
164
|
| グリッド & ガイド (1032) | ✅ | `PSDFile.guides` (v0.3.0) |
|
|
164
165
|
| スライス (1050 v6) | ✅ | `PSDFile.slices` (v0.3.0) |
|
|
165
166
|
| スライス (1050 v7/v8, descriptor) | ❌ | 未格納 |
|
|
166
|
-
| レイヤーカンプ (1065) | ✅ | `PSDFile.layer_comps` (v0.3.0) |
|
|
167
|
+
| レイヤーカンプ (1065, 文書レベル) | ✅ | `PSDFile.layer_comps` (id/name/comment/record_*, v0.3.0) |
|
|
168
|
+
| レイヤーカンプの各レイヤ状態 (可視) | ✅ | `layer.comp_states` = `{comp_id: {enabled, offset_x, offset_y}}` (v0.7.x)。位置/効果の上書きは未適用 |
|
|
167
169
|
| インデックスカラーパレット (色/count/透明index) | ✅ | `PSDFile.color_table` (v0.3.0) |
|
|
168
170
|
| ICC プロファイル (1039) | ✅ | `PSDFile.icc_profile` (生バイト, v0.5.0) |
|
|
169
171
|
| EXIF (1058) | ✅ | `PSDFile.exif` (生バイト, v0.5.0) |
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# psdparse examples
|
|
2
|
+
|
|
3
|
+
Recipes that combine **psdparse** (structure + pixel extraction + editing) with
|
|
4
|
+
the Python imaging ecosystem (Pillow / numpy). psdparse deliberately does *not*
|
|
5
|
+
composite or re-render — it hands you the pieces, and these scripts show how to
|
|
6
|
+
put them together.
|
|
7
|
+
|
|
8
|
+
Install the deps: `pip install psdparse pillow numpy`.
|
|
9
|
+
|
|
10
|
+
## composite.py — composite layers with Pillow
|
|
11
|
+
|
|
12
|
+
Composites layers bottom-to-top (position + per-layer opacity + mask), **normal
|
|
13
|
+
blend only**. Layer effects and non-normal blend modes are not re-rendered
|
|
14
|
+
(read `layer.effects` / `layer.blend_mode` and implement them here if needed).
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
python composite.py character.psd out.png
|
|
18
|
+
python composite.py character.psd out.png --write-back preview.psd
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`--write-back` writes the composite into a copy of the PSD as its stored preview
|
|
22
|
+
via `PSDFile.set_merged_image(...)` — useful after editing layers, since psdparse
|
|
23
|
+
does not regenerate the composite itself.
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from composite import composite
|
|
27
|
+
img = composite(psd) # PIL.Image of the visible layers
|
|
28
|
+
img = composite(psd, show={0, 3}) # only these layer indices
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
On the sample tachie PSDs (all normal blend) this matches Photoshop's stored
|
|
32
|
+
composite to ~0.1 mean level difference.
|
|
33
|
+
|
|
34
|
+
## variations.py — tachie / expression combinations
|
|
35
|
+
|
|
36
|
+
Game and VTuber character PSDs group mutually-exclusive parts (eyes, mouth,
|
|
37
|
+
expression, outfit) into **folders**; each folder is a slot where one option
|
|
38
|
+
shows at a time. This enumerates option combinations and composites each.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python variations.py character.psd out_dir/ # sweep the first group
|
|
42
|
+
python variations.py character.psd out_dir/ --group 表情 # sweep a named group
|
|
43
|
+
python variations.py doc.psd out_dir/ --comps # render each Photoshop layer comp
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`--comps` renders each of the document's Photoshop **layer comps** using
|
|
47
|
+
`layer.comp_states` (visibility only; position/appearance overrides aren't
|
|
48
|
+
applied). Otherwise it sweeps folder-based option slots.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from variations import option_groups, composite_choices
|
|
52
|
+
groups = option_groups(psd) # {folder_index: [option_index, ...]}
|
|
53
|
+
img = composite_choices(psd, {folder_i: option_i})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
It treats each top-level folder as a simple "pick one direct child" slot. Real
|
|
57
|
+
data sometimes nests (a body folder with an always-on base plus arm options) —
|
|
58
|
+
adapt `option_groups` for that.
|
|
59
|
+
|
|
60
|
+
## extract_layers.py — sprite extraction with alpha bleed
|
|
61
|
+
|
|
62
|
+
Turns a PSD into per-layer PNGs plus a `manifest.json`, and **extends each
|
|
63
|
+
layer's base colour into its transparent area** (alpha bleed / edge dilation) so
|
|
64
|
+
the alpha edge doesn't leak a halo when the sprite is rotated or scaled with
|
|
65
|
+
bilinear filtering — a standard step when prepping tachie sprites for a game
|
|
66
|
+
engine.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
python extract_layers.py character.psd out_dir/ --bleed 8
|
|
70
|
+
python extract_layers.py character.psd out_dir/ --no-bleed
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The bleed only fills RGB under `alpha == 0`; the alpha channel is left untouched.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from extract_layers import alpha_bleed
|
|
77
|
+
safe = alpha_bleed(layer_rgba_image, passes=8)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## See also
|
|
81
|
+
|
|
82
|
+
`tools/psd_export.py` (repo root) dumps `layers.json` + `merged.png` + per-layer
|
|
83
|
+
PNGs in one shot. The examples here are smaller, focused building blocks you can
|
|
84
|
+
copy into your own pipeline.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Composite psdparse layers with Pillow.
|
|
2
|
+
|
|
3
|
+
psdparse gives you each layer's pixels (`layer_image`), position, opacity and
|
|
4
|
+
visibility; the actual compositing is done here in Python with Pillow. This keeps
|
|
5
|
+
the C++ core lean and lets you use the mature imaging ecosystem.
|
|
6
|
+
|
|
7
|
+
Scope: **normal blend** + per-layer opacity + mask (via ``layer_image(i,
|
|
8
|
+
"masked")``) + position, composited bottom-to-top. Non-normal blend modes and
|
|
9
|
+
layer effects are *not* applied (psdparse does not re-render effects); if you
|
|
10
|
+
need those, read `layer.blend_mode` / `layer.effects` and implement them here.
|
|
11
|
+
|
|
12
|
+
python composite.py input.psd output.png [--write-back edited.psd]
|
|
13
|
+
|
|
14
|
+
As a library:
|
|
15
|
+
|
|
16
|
+
from composite import composite
|
|
17
|
+
img = composite(psd) # PIL.Image, currently-visible layers
|
|
18
|
+
img = composite(psd, show={0, 3, 7}) # only these layer indices
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
|
|
24
|
+
import psdparse
|
|
25
|
+
from PIL import Image
|
|
26
|
+
|
|
27
|
+
# Layer types that carry pixels (skip folder/divider markers).
|
|
28
|
+
_PIXEL_TYPES = (
|
|
29
|
+
psdparse.LayerType.NORMAL,
|
|
30
|
+
psdparse.LayerType.TEXT,
|
|
31
|
+
psdparse.LayerType.ADJUST,
|
|
32
|
+
psdparse.LayerType.FILL,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def layer_rgba(psd: psdparse.PSDFile, index: int) -> Image.Image | None:
|
|
37
|
+
"""A layer's masked pixels as an RGBA PIL image (opacity folded into alpha),
|
|
38
|
+
or None for empty layers."""
|
|
39
|
+
layer = psd.layers[index]
|
|
40
|
+
if layer.width <= 0 or layer.height <= 0:
|
|
41
|
+
return None
|
|
42
|
+
bgra = psd.layer_image(index, "masked") # mask applied to alpha
|
|
43
|
+
img = Image.frombytes("RGBA", (layer.width, layer.height), bgra, "raw", "BGRA")
|
|
44
|
+
if layer.opacity < 255:
|
|
45
|
+
alpha = img.getchannel("A").point(lambda v: v * layer.opacity // 255)
|
|
46
|
+
img.putalpha(alpha)
|
|
47
|
+
return img
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _paste_clipped(canvas: Image.Image, img: Image.Image, x: int, y: int) -> None:
|
|
51
|
+
"""alpha-composite `img` onto `canvas` at (x, y), clipping to the canvas
|
|
52
|
+
(handles layers that extend past the edges / negative offsets)."""
|
|
53
|
+
cw, ch = canvas.size
|
|
54
|
+
w, h = img.size
|
|
55
|
+
sx0, sy0 = max(0, -x), max(0, -y)
|
|
56
|
+
sx1, sy1 = min(w, cw - x), min(h, ch - y)
|
|
57
|
+
if sx1 <= sx0 or sy1 <= sy0:
|
|
58
|
+
return
|
|
59
|
+
crop = img.crop((sx0, sy0, sx1, sy1))
|
|
60
|
+
tmp = Image.new("RGBA", (cw, ch), (0, 0, 0, 0))
|
|
61
|
+
tmp.paste(crop, (max(0, x), max(0, y)))
|
|
62
|
+
canvas.alpha_composite(tmp)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def composite(psd: psdparse.PSDFile, show: set[int] | None = None) -> Image.Image:
|
|
66
|
+
"""Composite layers bottom-to-top into a canvas-sized RGBA image.
|
|
67
|
+
|
|
68
|
+
`show`: set of layer indices to include. Default (None) = every currently
|
|
69
|
+
**visible** pixel layer. Folder/divider markers are always skipped.
|
|
70
|
+
"""
|
|
71
|
+
canvas = Image.new("RGBA", (psd.header.width, psd.header.height), (0, 0, 0, 0))
|
|
72
|
+
for i, layer in enumerate(psd.layers): # index 0 = bottom-most
|
|
73
|
+
if layer.layer_type not in _PIXEL_TYPES:
|
|
74
|
+
continue
|
|
75
|
+
if show is None:
|
|
76
|
+
if not layer.visible:
|
|
77
|
+
continue
|
|
78
|
+
elif i not in show:
|
|
79
|
+
continue
|
|
80
|
+
img = layer_rgba(psd, i)
|
|
81
|
+
if img is not None:
|
|
82
|
+
_paste_clipped(canvas, img, layer.left, layer.top)
|
|
83
|
+
return canvas
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> None:
|
|
87
|
+
ap = argparse.ArgumentParser(description="Composite visible layers with Pillow.")
|
|
88
|
+
ap.add_argument("input")
|
|
89
|
+
ap.add_argument("output", help="output PNG")
|
|
90
|
+
ap.add_argument("--write-back", metavar="PSD",
|
|
91
|
+
help="also write the composite into a copy of the PSD "
|
|
92
|
+
"(as the stored preview) via set_merged_image")
|
|
93
|
+
args = ap.parse_args()
|
|
94
|
+
|
|
95
|
+
psd = psdparse.PSDFile()
|
|
96
|
+
if not psd.load(args.input):
|
|
97
|
+
raise SystemExit(f"failed to load {args.input}")
|
|
98
|
+
img = composite(psd)
|
|
99
|
+
img.save(args.output)
|
|
100
|
+
print(f"wrote {args.output} ({img.width}x{img.height})")
|
|
101
|
+
|
|
102
|
+
if args.write_back:
|
|
103
|
+
# set_merged_image wants canvas-sized BGRA
|
|
104
|
+
bgra = img.convert("RGBA").tobytes("raw", "BGRA")
|
|
105
|
+
psd.set_merged_image(bgra)
|
|
106
|
+
psd.save(args.write_back)
|
|
107
|
+
print(f"wrote {args.write_back} with the composite as its stored preview")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
main()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Extract each layer to a PNG, with alpha edge-extension (bleed), + a manifest.
|
|
2
|
+
|
|
3
|
+
A common step when turning a tachie PSD into game-ready sprites: pull each part
|
|
4
|
+
out as its own image, and **extend the base colour into the transparent area** so
|
|
5
|
+
the alpha edge doesn't leak a dark/transparent halo when the sprite is rotated or
|
|
6
|
+
scaled with bilinear filtering. This writes:
|
|
7
|
+
|
|
8
|
+
out_dir/000_<name>.png ... each pixel layer (RGB bled under the alpha)
|
|
9
|
+
out_dir/manifest.json ... index/name/position/size/opacity/blend/parent
|
|
10
|
+
|
|
11
|
+
python extract_layers.py character.psd out_dir/ [--bleed 8] [--no-bleed]
|
|
12
|
+
|
|
13
|
+
Needs numpy (for the bleed). The bleed leaves the alpha channel untouched — it
|
|
14
|
+
only fills RGB in transparent pixels, which is exactly what edge-safe scaling
|
|
15
|
+
wants.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import psdparse
|
|
25
|
+
from PIL import Image
|
|
26
|
+
|
|
27
|
+
from composite import _PIXEL_TYPES, layer_rgba
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def alpha_bleed(img: Image.Image, passes: int = 8) -> Image.Image:
|
|
31
|
+
"""Extend RGB into transparent pixels by iterative dilation (`passes` px).
|
|
32
|
+
Alpha is preserved; only the colour under alpha==0 is filled from neighbours."""
|
|
33
|
+
arr = np.array(img.convert("RGBA"))
|
|
34
|
+
rgb = arr[..., :3].astype(np.float32)
|
|
35
|
+
filled = arr[..., 3] > 0
|
|
36
|
+
for _ in range(passes):
|
|
37
|
+
if filled.all():
|
|
38
|
+
break
|
|
39
|
+
color_sum = np.zeros_like(rgb)
|
|
40
|
+
count = np.zeros(filled.shape, np.float32)
|
|
41
|
+
for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)):
|
|
42
|
+
m = np.roll(filled, (dy, dx), (0, 1))
|
|
43
|
+
shifted = np.roll(rgb, (dy, dx), (0, 1))
|
|
44
|
+
if dy == 1: m[0, :] = False # kill np.roll wrap-around
|
|
45
|
+
elif dy == -1: m[-1, :] = False
|
|
46
|
+
if dx == 1: m[:, 0] = False
|
|
47
|
+
elif dx == -1: m[:, -1] = False
|
|
48
|
+
color_sum += shifted * m[..., None]
|
|
49
|
+
count += m
|
|
50
|
+
newly = (~filled) & (count > 0)
|
|
51
|
+
rgb[newly] = color_sum[newly] / count[newly][..., None]
|
|
52
|
+
filled |= newly
|
|
53
|
+
arr[..., :3] = np.clip(rgb, 0, 255).astype(np.uint8)
|
|
54
|
+
return Image.fromarray(arr, "RGBA")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _blend_key(layer: psdparse.LayerInfo) -> str:
|
|
58
|
+
k = layer.blend_mode_key
|
|
59
|
+
return "".join(chr((k >> s) & 0xFF) for s in (24, 16, 8, 0))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> None:
|
|
63
|
+
ap = argparse.ArgumentParser(description="Extract layers to PNGs with alpha bleed.")
|
|
64
|
+
ap.add_argument("input")
|
|
65
|
+
ap.add_argument("out_dir")
|
|
66
|
+
ap.add_argument("--bleed", type=int, default=8, help="edge-extension passes (px)")
|
|
67
|
+
ap.add_argument("--no-bleed", action="store_true")
|
|
68
|
+
args = ap.parse_args()
|
|
69
|
+
|
|
70
|
+
psd = psdparse.PSDFile()
|
|
71
|
+
if not psd.load(args.input):
|
|
72
|
+
raise SystemExit(f"failed to load {args.input}")
|
|
73
|
+
os.makedirs(args.out_dir, exist_ok=True)
|
|
74
|
+
|
|
75
|
+
manifest = {"width": psd.header.width, "height": psd.header.height, "layers": []}
|
|
76
|
+
for i, layer in enumerate(psd.layers):
|
|
77
|
+
if layer.layer_type not in _PIXEL_TYPES:
|
|
78
|
+
continue
|
|
79
|
+
img = layer_rgba(psd, i)
|
|
80
|
+
if img is None:
|
|
81
|
+
continue
|
|
82
|
+
if not args.no_bleed and args.bleed > 0:
|
|
83
|
+
img = alpha_bleed(img, args.bleed)
|
|
84
|
+
try:
|
|
85
|
+
name = layer.name_unicode
|
|
86
|
+
except UnicodeDecodeError:
|
|
87
|
+
name = f"layer{i}"
|
|
88
|
+
safe = "".join(c if c.isalnum() or c in "._-()() " else "_" for c in name).strip()
|
|
89
|
+
fname = f"{i:03d}_{safe or 'layer'}.png"
|
|
90
|
+
img.save(os.path.join(args.out_dir, fname))
|
|
91
|
+
manifest["layers"].append({
|
|
92
|
+
"index": i, "name": name, "file": fname,
|
|
93
|
+
"left": layer.left, "top": layer.top,
|
|
94
|
+
"width": layer.width, "height": layer.height,
|
|
95
|
+
"opacity": layer.opacity, "blend": _blend_key(layer),
|
|
96
|
+
"visible": layer.visible, "parent_index": layer.parent_index,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
with open(os.path.join(args.out_dir, "manifest.json"), "w", encoding="utf-8") as f:
|
|
100
|
+
json.dump(manifest, f, ensure_ascii=False, indent=2)
|
|
101
|
+
print(f"extracted {len(manifest['layers'])} layers to {args.out_dir}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
main()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Generate character (tachie) variations from grouped layers.
|
|
2
|
+
|
|
3
|
+
Game/VTuber character PSDs put mutually-exclusive parts (eyes, mouth, outfit,
|
|
4
|
+
expression) into layer **folders** — each folder is a "slot" where one option is
|
|
5
|
+
shown at a time. This example enumerates option combinations and composites each
|
|
6
|
+
with Pillow (see composite.py).
|
|
7
|
+
|
|
8
|
+
python variations.py character.psd out_dir/ # sweep the first slot
|
|
9
|
+
python variations.py character.psd out_dir/ --group 表情 --group 目
|
|
10
|
+
|
|
11
|
+
As a library:
|
|
12
|
+
|
|
13
|
+
from variations import option_groups, composite_choices
|
|
14
|
+
groups = option_groups(psd) # {folder_index: [option_index, ...]}
|
|
15
|
+
img = composite_choices(psd, {folder_i: option_i}) # PIL.Image
|
|
16
|
+
|
|
17
|
+
Note: this treats each top-level folder as a simple "pick one of its direct
|
|
18
|
+
pixel-layer children" slot. Real PSDs sometimes nest (a body folder with an
|
|
19
|
+
always-on base plus arm options); adapt `option_groups` for those.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import itertools
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
import psdparse
|
|
28
|
+
|
|
29
|
+
from composite import composite, _PIXEL_TYPES
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _label(psd: psdparse.PSDFile, index: int) -> str:
|
|
33
|
+
try:
|
|
34
|
+
return psd.layers[index].name_unicode
|
|
35
|
+
except UnicodeDecodeError:
|
|
36
|
+
return f"layer{index}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def option_groups(psd: psdparse.PSDFile) -> dict[int, list[int]]:
|
|
40
|
+
"""Top-level folders → their direct pixel-layer children (the options)."""
|
|
41
|
+
groups: dict[int, list[int]] = {}
|
|
42
|
+
for i, layer in enumerate(psd.layers):
|
|
43
|
+
if layer.layer_type == psdparse.LayerType.FOLDER and layer.parent_index == -1:
|
|
44
|
+
options = [j for j, c in enumerate(psd.layers)
|
|
45
|
+
if c.parent_index == i and c.layer_type in _PIXEL_TYPES]
|
|
46
|
+
if options:
|
|
47
|
+
groups[i] = options
|
|
48
|
+
return groups
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def composite_choices(psd: psdparse.PSDFile, choices: dict[int, int]):
|
|
52
|
+
"""Composite with one option chosen per group.
|
|
53
|
+
|
|
54
|
+
`choices`: {folder_index: option_index}. For each chosen group the picked
|
|
55
|
+
option is shown and its siblings hidden; groups not listed keep their
|
|
56
|
+
currently-visible option; layers outside any group follow their visibility.
|
|
57
|
+
"""
|
|
58
|
+
show = {i for i, l in enumerate(psd.layers)
|
|
59
|
+
if l.visible and l.layer_type in _PIXEL_TYPES}
|
|
60
|
+
groups = option_groups(psd)
|
|
61
|
+
for folder_index, chosen in choices.items():
|
|
62
|
+
for opt in groups.get(folder_index, []):
|
|
63
|
+
show.discard(opt)
|
|
64
|
+
show.add(chosen)
|
|
65
|
+
return composite(psd, show)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def find_group(psd: psdparse.PSDFile, name: str) -> int | None:
|
|
69
|
+
for folder_index in option_groups(psd):
|
|
70
|
+
if _label(psd, folder_index) == name:
|
|
71
|
+
return folder_index
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def composite_comp(psd: psdparse.PSDFile, comp_id: int):
|
|
76
|
+
"""Composite a document **layer comp** (`comp_id` from psd.layer_comps).
|
|
77
|
+
|
|
78
|
+
Each layer's `comp_states[comp_id].enabled` says whether it shows in that
|
|
79
|
+
comp; layers the comp doesn't mention keep their current visibility. (Comp
|
|
80
|
+
position/appearance overrides are not applied — visibility only.)
|
|
81
|
+
"""
|
|
82
|
+
show = set()
|
|
83
|
+
for i, layer in enumerate(psd.layers):
|
|
84
|
+
if layer.layer_type not in _PIXEL_TYPES:
|
|
85
|
+
continue
|
|
86
|
+
st = layer.comp_states
|
|
87
|
+
visible = st[comp_id]["enabled"] if comp_id in st else layer.visible
|
|
88
|
+
if visible:
|
|
89
|
+
show.add(i)
|
|
90
|
+
return composite(psd, show)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main() -> None:
|
|
94
|
+
ap = argparse.ArgumentParser(description="Composite tachie layer-group combinations.")
|
|
95
|
+
ap.add_argument("input")
|
|
96
|
+
ap.add_argument("out_dir")
|
|
97
|
+
ap.add_argument("--group", action="append", default=[],
|
|
98
|
+
help="folder name to sweep (repeatable). Default: the first group.")
|
|
99
|
+
ap.add_argument("--comps", action="store_true",
|
|
100
|
+
help="instead of sweeping groups, render each document layer comp")
|
|
101
|
+
ap.add_argument("--limit", type=int, default=24, help="max images to write")
|
|
102
|
+
args = ap.parse_args()
|
|
103
|
+
|
|
104
|
+
psd = psdparse.PSDFile()
|
|
105
|
+
if not psd.load(args.input):
|
|
106
|
+
raise SystemExit(f"failed to load {args.input}")
|
|
107
|
+
|
|
108
|
+
if args.comps:
|
|
109
|
+
comps = psd.layer_comps
|
|
110
|
+
if not comps:
|
|
111
|
+
raise SystemExit("this PSD has no document layer comps")
|
|
112
|
+
os.makedirs(args.out_dir, exist_ok=True)
|
|
113
|
+
for n, c in enumerate(comps):
|
|
114
|
+
img = composite_comp(psd, c["id"])
|
|
115
|
+
safe = "".join(ch if ch.isalnum() else "_" for ch in c["name"]).strip("_")
|
|
116
|
+
img.save(os.path.join(args.out_dir, f"comp{n:02d}_{safe or c['id']}.png"))
|
|
117
|
+
print(f"wrote {len(comps)} layer comps to {args.out_dir}")
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
groups = option_groups(psd)
|
|
121
|
+
if not groups:
|
|
122
|
+
raise SystemExit("no option groups (top-level folders) found")
|
|
123
|
+
|
|
124
|
+
print("option groups:")
|
|
125
|
+
for folder_index, opts in groups.items():
|
|
126
|
+
print(f" {_label(psd, folder_index)!r}: {[_label(psd, o) for o in opts]}")
|
|
127
|
+
|
|
128
|
+
# which groups to sweep
|
|
129
|
+
if args.group:
|
|
130
|
+
sweep = [find_group(psd, n) for n in args.group]
|
|
131
|
+
if None in sweep:
|
|
132
|
+
raise SystemExit(f"group not found among {[_label(psd, g) for g in groups]}")
|
|
133
|
+
else:
|
|
134
|
+
sweep = [next(iter(groups))]
|
|
135
|
+
|
|
136
|
+
os.makedirs(args.out_dir, exist_ok=True)
|
|
137
|
+
combos = itertools.product(*[groups[g] for g in sweep])
|
|
138
|
+
n = 0
|
|
139
|
+
for combo in combos:
|
|
140
|
+
if n >= args.limit:
|
|
141
|
+
print(f"(stopped at --limit {args.limit})")
|
|
142
|
+
break
|
|
143
|
+
choices = dict(zip(sweep, combo))
|
|
144
|
+
img = composite_choices(psd, choices)
|
|
145
|
+
tag = "_".join(_label(psd, o).split()[0] for o in combo) or f"v{n}"
|
|
146
|
+
path = os.path.join(args.out_dir, f"{n:03d}_{tag}.png")
|
|
147
|
+
img.save(path)
|
|
148
|
+
n += 1
|
|
149
|
+
print(f"wrote {n} images to {args.out_dir}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
main()
|