dsh-cosplay-studio 2.0.3 → 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.
@@ -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 = 1_950_000
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 = 92
66
- while quality >= 58:
67
- image.save(path, "JPEG", quality=quality, subsampling=1, optimize=True)
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 -= 4
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), (255, 255, 255)))
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), (255, 255, 255))
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", "1").lower() not in ("0", "false", "no")
170
- deoil_on = option("--deoil", "1").lower() not in ("0", "false", "no")
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
- # Skill order: P7 de-oil first, then P7b body makeup on the clean image.
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
- for name in os.listdir(final_dir):
230
- shutil.copy2(os.path.join(final_dir, name), os.path.join(deliver_dir, name))
231
- write_copy(project, finals, deliver_dir)
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.3",
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 recommendation drives a live-action cosplay photo set following the cosplay-photo-set skill v2.1 (nine-paragraph character card with SKIN, base-image gate, shot-script gate, 1440x2560 finals, QC, de-oil, body makeup, JPEG export).",
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
  ],