dsh-cosplay-studio 2.0.4 → 2.1.0
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.
- package/README.md +33 -16
- package/lib/card.js +156 -52
- package/lib/client.js +376 -24
- package/lib/comfy.js +10 -2
- package/lib/index.js +314 -27
- package/lib/project.js +147 -32
- package/lib/research.js +719 -0
- package/lib/tools/finalize.py +100 -17
- package/package.json +3 -2
package/lib/tools/finalize.py
CHANGED
|
@@ -14,8 +14,9 @@ import json
|
|
|
14
14
|
import os
|
|
15
15
|
import shutil
|
|
16
16
|
import sys
|
|
17
|
+
from collections import deque
|
|
17
18
|
|
|
18
|
-
from PIL import Image
|
|
19
|
+
from PIL import Image, ImageFilter
|
|
19
20
|
|
|
20
21
|
try:
|
|
21
22
|
import numpy as np
|
|
@@ -26,13 +27,77 @@ from skin_makeup import makeup as skin_makeup
|
|
|
26
27
|
|
|
27
28
|
CELL_W, CELL_H = 720, 1280
|
|
28
29
|
GAP = 32
|
|
29
|
-
MAX_BYTES =
|
|
30
|
+
MAX_BYTES = 6_000_000
|
|
31
|
+
PREVIEW_BG = (28, 29, 34)
|
|
30
32
|
|
|
31
33
|
# P7b defaults from the skill: light touch above the waist, strong on the legs.
|
|
32
34
|
DEFAULT_STRENGTH = 0.35
|
|
33
35
|
DEFAULT_LEG_STRENGTH = 0.85
|
|
34
36
|
|
|
35
37
|
|
|
38
|
+
def flatten_white_border(image: Image.Image, feather: int = 8) -> Image.Image:
|
|
39
|
+
"""Replace edge-connected near-white poster paper with a full-bleed gradient.
|
|
40
|
+
|
|
41
|
+
Reference character sheets often have a white margin, and img2img keeps it,
|
|
42
|
+
so the shot looks like it floats on blank paper. Only pure white pixels that
|
|
43
|
+
are connected to the image edge are touched (shaded fabric, skin and the
|
|
44
|
+
character never are), and they are filled with a vertical gradient sampled
|
|
45
|
+
from the artwork's own colours, so the backdrop stays character-adapted.
|
|
46
|
+
"""
|
|
47
|
+
if np is None:
|
|
48
|
+
return image
|
|
49
|
+
rgb = image.convert("RGB")
|
|
50
|
+
arr = np.asarray(rgb, np.float32) / 255.0
|
|
51
|
+
height, width, _ = arr.shape
|
|
52
|
+
|
|
53
|
+
small = rgb.resize((720, max(1, int(round(height * 720.0 / width)))), Image.BILINEAR)
|
|
54
|
+
mask = np.asarray(small, np.uint8).min(axis=2) > 235
|
|
55
|
+
|
|
56
|
+
h, w = mask.shape
|
|
57
|
+
seen = np.zeros_like(mask, bool)
|
|
58
|
+
queue: deque[tuple[int, int]] = deque()
|
|
59
|
+
for x in range(w):
|
|
60
|
+
for y in (0, h - 1):
|
|
61
|
+
if mask[y, x] and not seen[y, x]:
|
|
62
|
+
seen[y, x] = True
|
|
63
|
+
queue.append((y, x))
|
|
64
|
+
for y in range(h):
|
|
65
|
+
for x in (0, w - 1):
|
|
66
|
+
if mask[y, x] and not seen[y, x]:
|
|
67
|
+
seen[y, x] = True
|
|
68
|
+
queue.append((y, x))
|
|
69
|
+
while queue:
|
|
70
|
+
y, x = queue.popleft()
|
|
71
|
+
for ny, nx in ((y - 1, x), (y + 1, x), (y, x - 1), (y, x + 1)):
|
|
72
|
+
if 0 <= ny < h and 0 <= nx < w and mask[ny, nx] and not seen[ny, nx]:
|
|
73
|
+
seen[ny, nx] = True
|
|
74
|
+
queue.append((ny, nx))
|
|
75
|
+
# No margin, or such a large white area that this is not a border: leave it.
|
|
76
|
+
if seen.mean() < 0.01 or seen.mean() > 0.45:
|
|
77
|
+
return rgb
|
|
78
|
+
|
|
79
|
+
alpha = Image.fromarray((seen * 255).astype(np.uint8)).resize((width, height), Image.BILINEAR)
|
|
80
|
+
alpha = alpha.filter(ImageFilter.GaussianBlur(feather))
|
|
81
|
+
|
|
82
|
+
body = ~seen
|
|
83
|
+
if body.sum() < 1000:
|
|
84
|
+
return rgb
|
|
85
|
+
ys, xs = np.nonzero(body)
|
|
86
|
+
small_arr = np.asarray(small, np.float32) / 255.0
|
|
87
|
+
top_rows = ys < h * 0.2
|
|
88
|
+
bottom_rows = ys > h * 0.8
|
|
89
|
+
pick = small_arr[ys[top_rows], xs[top_rows]] if top_rows.any() else small_arr[ys, xs]
|
|
90
|
+
pick2 = small_arr[ys[bottom_rows], xs[bottom_rows]] if bottom_rows.any() else pick
|
|
91
|
+
top = np.median(pick, axis=0)
|
|
92
|
+
bottom = np.median(pick2, axis=0)
|
|
93
|
+
rows = np.linspace(0.0, 1.0, height, dtype=np.float32)[:, None, None]
|
|
94
|
+
gradient = (top[None, None, :] * (1.0 - rows) + bottom[None, None, :] * rows) * np.ones((1, width, 1), np.float32)
|
|
95
|
+
|
|
96
|
+
blend = (np.asarray(alpha, np.float32) / 255.0)[..., None]
|
|
97
|
+
out = (arr * (1.0 - blend) + gradient * blend).clip(0.0, 1.0)
|
|
98
|
+
return Image.fromarray((out * 255.0 + 0.5).astype(np.uint8))
|
|
99
|
+
|
|
100
|
+
|
|
36
101
|
def deoil(image: Image.Image) -> Image.Image:
|
|
37
102
|
"""Flatten oily/crushed highlights on skin: V>0.80 and S<0.45 -> V=0.72."""
|
|
38
103
|
if np is None:
|
|
@@ -61,14 +126,15 @@ def apply_body_makeup(image: Image.Image, strength: float, leg_strength: float)
|
|
|
61
126
|
|
|
62
127
|
|
|
63
128
|
def save_jpeg(image: Image.Image, path: str, max_bytes: int = MAX_BYTES) -> int:
|
|
129
|
+
"""Near-original export: q95 4:4:4, stepping down only if the file is huge."""
|
|
64
130
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
65
|
-
quality =
|
|
66
|
-
while quality >=
|
|
67
|
-
image.save(path, "JPEG", quality=quality, subsampling=
|
|
131
|
+
quality = 95
|
|
132
|
+
while quality >= 82:
|
|
133
|
+
image.save(path, "JPEG", quality=quality, subsampling=0, optimize=True)
|
|
68
134
|
size = os.path.getsize(path)
|
|
69
135
|
if size <= max_bytes:
|
|
70
136
|
return size
|
|
71
|
-
quality -=
|
|
137
|
+
quality -= 3
|
|
72
138
|
return os.path.getsize(path)
|
|
73
139
|
|
|
74
140
|
|
|
@@ -98,10 +164,10 @@ def build_preview(paths: list[str], out_path: str) -> bool:
|
|
|
98
164
|
if len(cells) == 0:
|
|
99
165
|
return False
|
|
100
166
|
while len(cells) < 4:
|
|
101
|
-
cells.append(Image.new("RGB", (CELL_W, CELL_H),
|
|
167
|
+
cells.append(Image.new("RGB", (CELL_W, CELL_H), PREVIEW_BG))
|
|
102
168
|
width = CELL_W * 2 + GAP * 3
|
|
103
169
|
height = CELL_H * 2 + GAP * 3
|
|
104
|
-
canvas = Image.new("RGB", (width, height),
|
|
170
|
+
canvas = Image.new("RGB", (width, height), PREVIEW_BG)
|
|
105
171
|
for index, cell in enumerate(cells):
|
|
106
172
|
row, col = divmod(index, 2)
|
|
107
173
|
x = GAP + col * (CELL_W + GAP)
|
|
@@ -166,8 +232,11 @@ def main() -> int:
|
|
|
166
232
|
index = sys.argv.index(flag)
|
|
167
233
|
return sys.argv[index + 1] if index + 1 < len(sys.argv) else default
|
|
168
234
|
|
|
169
|
-
makeup_on = option("--makeup", "
|
|
170
|
-
deoil_on = option("--deoil", "
|
|
235
|
+
makeup_on = option("--makeup", "0").lower() not in ("0", "false", "no")
|
|
236
|
+
deoil_on = option("--deoil", "0").lower() not in ("0", "false", "no")
|
|
237
|
+
flatten_on = option("--flatten", "1").lower() not in ("0", "false", "no")
|
|
238
|
+
preview_on = option("--preview", "1").lower() not in ("0", "false", "no")
|
|
239
|
+
copy_on = option("--copy", "1").lower() not in ("0", "false", "no")
|
|
171
240
|
strength = float(option("--strength", str(DEFAULT_STRENGTH)))
|
|
172
241
|
leg_strength = float(option("--leg-strength", str(DEFAULT_LEG_STRENGTH)))
|
|
173
242
|
|
|
@@ -187,6 +256,8 @@ def main() -> int:
|
|
|
187
256
|
) if os.path.isdir(base_dir) else []
|
|
188
257
|
if base_files:
|
|
189
258
|
base_image = Image.open(base_files[-1]).convert("RGB")
|
|
259
|
+
if flatten_on:
|
|
260
|
+
base_image = flatten_white_border(base_image)
|
|
190
261
|
if deoil_on:
|
|
191
262
|
base_image = deoil(base_image)
|
|
192
263
|
base_name = f"00_基准角色_{safe_name(project.get('character'), '角色')}.jpg"
|
|
@@ -203,7 +274,9 @@ def main() -> int:
|
|
|
203
274
|
shot = shots[index] if index < len(shots) else {}
|
|
204
275
|
title = safe_name(shot.get("title"), f"镜头{index + 1}")
|
|
205
276
|
image = Image.open(os.path.join(raw_dir, name)).convert("RGB")
|
|
206
|
-
#
|
|
277
|
+
# Backdrop first (clean the paper margin), then the optional beauty steps.
|
|
278
|
+
if flatten_on:
|
|
279
|
+
image = flatten_white_border(image)
|
|
207
280
|
if deoil_on:
|
|
208
281
|
image = deoil(image)
|
|
209
282
|
if makeup_on:
|
|
@@ -217,18 +290,28 @@ def main() -> int:
|
|
|
217
290
|
if made_up:
|
|
218
291
|
notes.append(f"P7b 身体化妆:{len(made_up)} 张(leg-strength {leg_strength:.2f})")
|
|
219
292
|
|
|
220
|
-
# 3. 2x2 preview 00_全套预览.jpg
|
|
293
|
+
# 3. 2x2 preview 00_全套预览.jpg (optional)
|
|
221
294
|
preview_name = ""
|
|
222
|
-
if finals:
|
|
295
|
+
if preview_on and finals:
|
|
223
296
|
first_four = [os.path.join(final_dir, name) for name in finals[:4]]
|
|
224
297
|
preview_name = "00_全套预览.jpg"
|
|
225
298
|
if build_preview(first_four, os.path.join(final_dir, preview_name)):
|
|
226
299
|
notes.append("预览图:00_全套预览.jpg(2×2,单格 720×1280)")
|
|
227
300
|
|
|
228
|
-
# 4. delivery folder
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
301
|
+
# 4. delivery folder (optional)
|
|
302
|
+
if copy_on:
|
|
303
|
+
for name in os.listdir(final_dir):
|
|
304
|
+
shutil.copy2(os.path.join(final_dir, name), os.path.join(deliver_dir, name))
|
|
305
|
+
write_copy(project, finals, deliver_dir)
|
|
306
|
+
notes.append("文案与导出副本已写入 deliver/")
|
|
307
|
+
|
|
308
|
+
notes.append(f"导出画质:JPEG q95、4:4:4(上限 {MAX_BYTES // 1_000_000} MB),原图 PNG 保留在 raw/")
|
|
309
|
+
if flatten_on:
|
|
310
|
+
notes.append("已把白边/海报留白替换为角色配色的满幅渐变(不想要可关闭 flatten 重新导出)")
|
|
311
|
+
if deoil_on:
|
|
312
|
+
notes.append("已应用去油光(会压低高光、整体略暗;不需要可重新导出)")
|
|
313
|
+
if makeup_on:
|
|
314
|
+
notes.append("已应用 P7b 身体化妆(会平滑肤色;不需要可重新导出)")
|
|
232
315
|
|
|
233
316
|
report = {"finals": finals, "preview": preview_name, "notes": notes, "makeup": made_up}
|
|
234
317
|
out = json.dumps(report, ensure_ascii=False)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cosplay-studio",
|
|
3
|
-
"version": "2.0
|
|
4
|
-
"description": "Cosplay Studio for the DSH web GUI: one-click ComfyUI (Z-Image-Turbo) deployment with a self-test, then a character name, an uploaded reference photo, or a hot-character
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Cosplay Studio for the DSH web GUI: one-click ComfyUI (Z-Image-Turbo) deployment with a self-test, then a character name, an uploaded reference photo, or a hot-character pick drives a staged character research (web search, image classification, vision feature extraction), a base-image gate with manual/smart retouch, a character-specific shot script, and 1440x2560 live-action cosplay finals with optional de-oil/makeup/export.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"lib",
|
|
14
|
+
"!lib/tools/__pycache__",
|
|
14
15
|
"cordis.patch.yml",
|
|
15
16
|
"README.md"
|
|
16
17
|
],
|