대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정

SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리

페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침

버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)

검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
Yun Chan 2026-06-27 02:30:46 +09:00
parent cb2aebd76c
commit 085460b5e0
327 changed files with 31226 additions and 1829 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# 생성된 표정 변주(var-<name>-v1.png)를 BiRefNet 누끼 → 알파 정제 → 표준 캔버스 게시.
set -uo pipefail
cd "$(dirname "$0")"
PY="$HOME/.venvs/object-separation/Scripts/python.exe"
SEP="$HOME/.agents/skills/object-separation/scripts/separate_object.py"
for name in sad tired anxious warm startled eyes-closed speaking; do
src="var-${name}-v1.png"
[ -f "$src" ] || { echo "[skip] $name ($src 없음)"; continue; }
cut="cut-${name}.png"
echo "== [cut] $name =="
if "$PY" "$SEP" "$src" "$cut" --model birefnet-general >/dev/null 2>&1; then
"$PY" publish.py "$cut" "$name"
else
echo "[FAIL] cutout $name"
fi
done
echo "== public-assets =="
ls -la /d/workspace/vignette/apps/web/public/avatar/seoyeon/

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View file

@ -0,0 +1,410 @@
#!/usr/bin/env python3
"""Build a stable raster-parts rig for Seoyeon.
The generated character sheet contains useful loose parts, but recomposing those
parts directly is fragile because each item is drawn at a different scale. This
script uses the approved assembled bust as the coordinate authority, creates a
faceless base from it, then adds small aligned raster parts for the eyes, brows,
nose, mouth, blink, and hair-sway overlays.
Output files are full-canvas 900x1125 PNG layers so the React renderer can stack
them without per-image layout math.
"""
from __future__ import annotations
from pathlib import Path
from typing import Iterable, Literal
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(__file__).resolve().parents[3]
SHEET = ROOT / "docs/avatar-art/seoyeon/character-sheet-v1.png"
PUB = ROOT / "apps/web/public/avatar/seoyeon"
NEUTRAL = PUB / "neutral.png"
OUT = PUB / "parts"
PREVIEW = ROOT / "docs/avatar-art/seoyeon/character-sheet-rig-preview-v2.png"
LEGACY_PREVIEW = ROOT / "docs/avatar-art/seoyeon/character-sheet-recompose-preview.png"
CANVAS = (900, 1125)
Box = tuple[int, int, int, int]
ShapeKind = Literal["ellipse", "round", "rect"]
def alpha_bbox(im: Image.Image, threshold: int = 10) -> Box | None:
alpha = np.array(im.getchannel("A"))
ys, xs = np.where(alpha > threshold)
if len(xs) == 0:
return None
return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1)
def median_skin(im: Image.Image, box: Box) -> tuple[int, int, int, int]:
arr = np.array(im.crop(box).convert("RGBA"))
rgb = arr[:, :, :3].astype(np.float32)
alpha = arr[:, :, 3] > 180
luma = rgb[:, :, 0] * 0.2126 + rgb[:, :, 1] * 0.7152 + rgb[:, :, 2] * 0.0722
chroma = rgb.max(axis=2) - rgb.min(axis=2)
# Keep face skin, reject hair/linework/shirt.
mask = alpha & (luma > 145) & (chroma > 8)
if int(mask.sum()) < 20:
mask = alpha
color = np.median(rgb[mask], axis=0)
return int(color[0]), int(color[1]), int(color[2]), 255
def draw_shape(mask: Image.Image, box: Box, kind: ShapeKind, radius: int = 14) -> None:
draw = ImageDraw.Draw(mask)
if kind == "ellipse":
draw.ellipse(box, fill=255)
elif kind == "round":
draw.rounded_rectangle(box, radius=radius, fill=255)
else:
draw.rectangle(box, fill=255)
def shape_mask(
size: tuple[int, int],
shapes: Iterable[tuple[Box, ShapeKind, int]],
feather: float = 0,
) -> Image.Image:
mask = Image.new("L", size, 0)
for box, kind, radius in shapes:
draw_shape(mask, box, kind, radius)
if feather > 0:
mask = mask.filter(ImageFilter.GaussianBlur(feather))
return mask
def erase_features(neutral: Image.Image) -> Image.Image:
base = neutral.convert("RGBA")
patches = [
((286, 284, 424, 388), (418, 360, 476, 392), "round", 11),
((476, 284, 614, 388), (418, 360, 476, 392), "round", 11),
((404, 374, 498, 434), (392, 360, 512, 410), "ellipse", 9),
((370, 404, 530, 476), (388, 382, 512, 428), "ellipse", 10),
]
for erase_box, sample_box, kind, feather in patches:
color = median_skin(base, sample_box)
fill = Image.new("RGBA", CANVAS, color)
mask = Image.new("L", CANVAS, 0)
draw_shape(mask, erase_box, kind, 30)
mask = mask.filter(ImageFilter.GaussianBlur(feather))
base = Image.composite(fill, base, mask)
return base
def save_image(name: str, im: Image.Image) -> Image.Image:
out = OUT / f"{name}.png"
im.save(out, optimize=True)
print(f"{name:18} bbox={alpha_bbox(im)}")
return im
def save_masked_neutral(
neutral: Image.Image,
name: str,
shapes: Iterable[tuple[Box, ShapeKind, int]],
*,
subtract_shapes: Iterable[tuple[Box, ShapeKind, int]] = (),
feather: float = 0,
mode: Literal["patch", "dark"] = "patch",
dark_hi: float = 175,
dark_lo: float = 80,
) -> Image.Image:
src = neutral.convert("RGBA")
mask_im = shape_mask(CANVAS, shapes, feather=0)
if subtract_shapes:
erase_im = shape_mask(CANVAS, subtract_shapes, feather=0)
mask_arr = np.array(mask_im)
mask_arr[np.array(erase_im) > 0] = 0
mask_im = Image.fromarray(mask_arr, "L")
if feather > 0:
mask_im = mask_im.filter(ImageFilter.GaussianBlur(feather))
mask = np.array(mask_im).astype(np.float32) / 255.0
arr = np.array(src).astype(np.float32)
alpha = arr[:, :, 3] * mask
if mode == "dark":
rgb = arr[:, :, :3]
luma = rgb[:, :, 0] * 0.2126 + rgb[:, :, 1] * 0.7152 + rgb[:, :, 2] * 0.0722
dark = np.clip((dark_hi - luma) / max(1.0, dark_hi - dark_lo), 0, 1)
alpha *= dark
arr[:, :, 3] = alpha
arr[alpha < 1, :3] = 0
return save_image(name, Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA"))
def remove_sheet_bg(im: Image.Image, *, lo: float = 18.0, hi: float = 62.0) -> Image.Image:
arr = np.array(im.convert("RGBA")).astype(np.float32)
rgb = arr[:, :, :3]
border = np.concatenate(
[
rgb[:5, :, :].reshape(-1, 3),
rgb[-5:, :, :].reshape(-1, 3),
rgb[:, :5, :].reshape(-1, 3),
rgb[:, -5:, :].reshape(-1, 3),
],
axis=0,
)
bg = np.median(border, axis=0)
dist_bg = np.sqrt(((rgb - bg.reshape(1, 1, 3)) ** 2).sum(axis=2))
dist_white = np.sqrt(((255.0 - rgb) ** 2).sum(axis=2))
alpha_bg = np.clip((dist_bg - lo) * 255.0 / max(1.0, hi - lo), 0, 255)
alpha_white = np.clip((dist_white - 15.0) * 255.0 / 50.0, 0, 255)
alpha = np.minimum(alpha_bg, alpha_white)
luma = rgb[:, :, 0] * 0.2126 + rgb[:, :, 1] * 0.7152 + rgb[:, :, 2] * 0.0722
chroma = rgb.max(axis=2) - rgb.min(axis=2)
keep = (luma < 205) | (chroma > 16)
alpha = np.where(keep & (alpha > 35), np.maximum(alpha, 230), alpha)
arr[:, :, 3] = np.minimum(alpha, arr[:, :, 3])
arr[arr[:, :, 3] < 1, :3] = 0
return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA")
def save_sheet_part(
sheet: Image.Image,
name: str,
src_box: Box,
dst_box: Box,
*,
matte_lo: float = 14.0,
matte_hi: float = 54.0,
) -> Image.Image:
part = remove_sheet_bg(sheet.crop(src_box), lo=matte_lo, hi=matte_hi)
dst_w = dst_box[2] - dst_box[0]
dst_h = dst_box[3] - dst_box[1]
part = part.resize((dst_w, dst_h), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", CANVAS, (0, 0, 0, 0))
canvas.alpha_composite(part, (dst_box[0], dst_box[1]))
return save_image(name, canvas)
def duplicate(src: str, dst: str) -> None:
im = Image.open(OUT / f"{src}.png").convert("RGBA")
save_image(dst, im)
def make_preview(parts: dict[str, Image.Image]) -> Image.Image:
bg = Image.new("RGBA", CANVAS, (30, 39, 36, 255))
order = [
"base-faceless",
"hair-left",
"hair-right",
"hair-bangs",
"brow-neutral",
"eyes-neutral",
"nose-neutral",
"mouth-neutral",
]
for name in order:
layer = parts.get(name)
if layer is None:
layer = Image.open(OUT / f"{name}.png").convert("RGBA")
bg.alpha_composite(layer)
# Add small comparison swatches for blink and mouth variants without touching
# the app-visible neutral render.
swatch = Image.new("RGBA", (360, 210), (30, 39, 36, 255))
for i, name in enumerate(["eyelid-closed", "mouth-sad", "mouth-warm", "mouth-open"]):
layer = Image.open(OUT / f"{name}.png").convert("RGBA")
crop_box = alpha_bbox(layer) or (0, 0, 1, 1)
crop = layer.crop(crop_box)
crop.thumbnail((155, 85), Image.Resampling.LANCZOS)
x = 18 + (i % 2) * 174
y = 18 + (i // 2) * 98
swatch.alpha_composite(crop, (x, y))
bg.alpha_composite(swatch, (20, 895))
return bg
def main() -> int:
OUT.mkdir(parents=True, exist_ok=True)
neutral = Image.open(NEUTRAL).convert("RGBA")
sheet = Image.open(SHEET).convert("RGBA")
if neutral.size != CANVAS:
raise ValueError(f"Expected {CANVAS}, got {neutral.size} for {NEUTRAL}")
parts: dict[str, Image.Image] = {}
parts["base-faceless"] = save_image("base-faceless", erase_features(neutral))
# Compatibility/static layers used by older manifests or future experiments.
parts["face-faceless"] = save_masked_neutral(
parts["base-faceless"],
"face-faceless",
[((250, 205, 650, 535), "ellipse", 40)],
feather=3,
)
parts["forehead"] = save_masked_neutral(
parts["base-faceless"],
"forehead",
[((340, 228, 560, 315), "round", 22)],
feather=4,
)
parts["neck"] = save_masked_neutral(
neutral,
"neck",
[((352, 470, 548, 618), "round", 28)],
feather=2,
)
parts["shoulders"] = save_masked_neutral(
neutral,
"shoulders",
[((160, 545, 740, 820), "round", 28)],
feather=2,
)
parts["hair-back"] = save_masked_neutral(
neutral,
"hair-back",
[((170, 80, 730, 520), "round", 80)],
subtract_shapes=[
((262, 210, 638, 540), "ellipse", 72),
((292, 280, 608, 388), "round", 34),
],
feather=2,
mode="dark",
dark_hi=172,
dark_lo=70,
)
parts["hair-bangs"] = save_masked_neutral(
neutral,
"hair-bangs",
[((326, 120, 574, 310), "round", 44)],
subtract_shapes=[((292, 282, 608, 386), "round", 34)],
feather=1.5,
mode="dark",
dark_hi=178,
dark_lo=75,
)
parts["hair-left"] = save_masked_neutral(
neutral,
"hair-left",
[
((168, 155, 318, 522), "round", 54),
((242, 430, 392, 522), "round", 42),
],
subtract_shapes=[
((270, 228, 430, 538), "ellipse", 48),
((292, 282, 425, 390), "round", 32),
],
feather=1.5,
mode="dark",
dark_hi=178,
dark_lo=75,
)
parts["hair-right"] = save_masked_neutral(
neutral,
"hair-right",
[
((582, 155, 732, 522), "round", 54),
((508, 430, 658, 522), "round", 42),
],
subtract_shapes=[
((470, 228, 630, 538), "ellipse", 48),
((475, 282, 608, 390), "round", 32),
],
feather=1.5,
mode="dark",
dark_hi=178,
dark_lo=75,
)
# Ear files are kept as small static compatibility layers; the faceless base
# already contains the ears in their approved position.
parts["ear-left"] = save_masked_neutral(
neutral,
"ear-left",
[((285, 318, 337, 392), "ellipse", 20)],
feather=2,
)
parts["ear-right"] = save_masked_neutral(
neutral,
"ear-right",
[((563, 318, 615, 392), "ellipse", 20)],
feather=2,
)
parts["brow-neutral"] = save_sheet_part(
sheet,
"brow-neutral",
(600, 424, 848, 482),
(312, 288, 588, 326),
matte_lo=10,
matte_hi=42,
)
parts["eyes-neutral"] = save_sheet_part(
sheet,
"eyes-neutral",
(594, 458, 856, 548),
(306, 314, 594, 376),
matte_lo=10,
matte_hi=44,
)
parts["nose-neutral"] = save_masked_neutral(
neutral,
"nose-neutral",
[((414, 385, 488, 426), "ellipse", 18)],
feather=2,
)
parts["mouth-neutral"] = save_masked_neutral(
neutral,
"mouth-neutral",
[((382, 414, 518, 468), "ellipse", 20)],
feather=2,
)
parts["eyelid-closed"] = save_sheet_part(
sheet,
"eyelid-closed",
(592, 552, 856, 620),
(306, 315, 594, 369),
matte_lo=11,
matte_hi=46,
)
parts["mouth-sad"] = save_sheet_part(
sheet,
"mouth-sad",
(888, 520, 1016, 586),
(383, 414, 517, 468),
matte_lo=10,
matte_hi=44,
)
parts["mouth-warm"] = save_sheet_part(
sheet,
"mouth-warm",
(888, 596, 1016, 662),
(383, 414, 517, 468),
matte_lo=10,
matte_hi=44,
)
parts["mouth-open"] = save_sheet_part(
sheet,
"mouth-open",
(888, 672, 1016, 752),
(380, 407, 520, 475),
matte_lo=10,
matte_hi=44,
)
for variant in ["sad", "tired", "anxious", "warm", "startled"]:
duplicate("brow-neutral", f"brow-{variant}")
duplicate("eyes-neutral", f"eyes-{variant}")
duplicate("mouth-sad", "mouth-tired")
duplicate("mouth-sad", "mouth-anxious")
duplicate("mouth-open", "mouth-startled")
preview = make_preview(parts)
preview.save(PREVIEW, optimize=True)
preview.save(LEGACY_PREVIEW, optimize=True)
print(f"preview {PREVIEW}")
print(f"legacy-preview {LEGACY_PREVIEW}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""character-sheet-v1.png의 완성 흉상을 안전한 public neutral.png로 게시한다.
깨진 실험 파츠가 사용자 화면에 바로 노출되지 않도록, 렌더 기본값은
완성 흉상 컷아웃을 사용한다. 세부 파츠 리깅은 별도 parts 실험 파일로 유지한다.
"""
from pathlib import Path
from PIL import Image, ImageFilter
import numpy as np
ROOT = Path("D:/workspace/vignette")
SHEET = ROOT / "docs/avatar-art/seoyeon/character-sheet-v1.png"
PUB = ROOT / "apps/web/public/avatar/seoyeon"
OUT = PUB / "neutral.png"
CANVAS = (900, 1125)
def remove_white_bg(im: Image.Image) -> Image.Image:
arr = np.array(im.convert("RGBA")).astype(np.float32)
rgb = arr[:, :, :3]
border = np.concatenate(
[
rgb[:6, :, :].reshape(-1, 3),
rgb[-6:, :, :].reshape(-1, 3),
rgb[:, :6, :].reshape(-1, 3),
rgb[:, -6:, :].reshape(-1, 3),
],
axis=0,
)
bg = np.median(border, axis=0)
dist_bg = np.sqrt(((rgb - bg.reshape(1, 1, 3)) ** 2).sum(axis=2))
dist_white = np.sqrt(((255.0 - rgb) ** 2).sum(axis=2))
alpha_bg = np.clip((dist_bg - 15.0) * 255.0 / (58.0 - 15.0), 0, 255)
alpha_white = np.clip((dist_white - 18.0) * 255.0 / (58.0 - 18.0), 0, 255)
alpha = np.minimum(alpha_bg, alpha_white)
# Keep linework/hair/clothes solid once away from the paper background.
chroma = rgb.max(axis=2) - rgb.min(axis=2)
dark = rgb.max(axis=2) < 205
alpha = np.where((dark | (chroma > 18)) & (alpha > 60), np.maximum(alpha, 235), alpha)
alpha = np.minimum(alpha, arr[:, :, 3])
arr[:, :, 3] = alpha
arr[alpha < 1, :3] = 0
return Image.fromarray(arr.astype(np.uint8), "RGBA").filter(ImageFilter.GaussianBlur(0.12))
def main() -> int:
sheet = Image.open(SHEET).convert("RGBA")
# Left assembled bust from the generated parts sheet.
crop = sheet.crop((0, 60, 570, 835))
cut = remove_white_bg(crop)
cut.thumbnail((820, 1040), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", CANVAS, (0, 0, 0, 0))
x = (CANVAS[0] - cut.width) // 2
y = 52
canvas.alpha_composite(cut, (x, y))
OUT.parent.mkdir(parents=True, exist_ok=True)
canvas.save(OUT, optimize=True)
for name in ["sad", "tired", "anxious", "warm", "startled", "eyes-closed", "speaking"]:
canvas.save(PUB / f"{name}.png", optimize=True)
print(f"published {OUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# 서연 표정 변주 일괄 생성. 베이스(base-v1.png)를 -i 참조로 넘겨 아이덴티티/프레이밍/배경을
# 고정하고 표정만 교체한다. 동일 평면 회색 배경(#C9CDD2)으로 컷아웃 일관성 확보.
set -uo pipefail
cd "$(dirname "$0")"
GEN="$HOME/.codex/imagegen-headless/codex_imagegen.sh"
BASE="base-v1.png"
# 변주: 이름 | 표정 설명
read -r -d '' INVARIANTS <<'EOF' || true
INVARIANTS (절대 바꾸지 말 것): EXACTLY the same character — identical face, identical long brown wavy hair that is center-parted with straight horizontal bangs and side locks framing the face past the shoulders, identical grayish-blue crewneck top (shoulders only), identical skin tone and shading style. EXACTLY the same head-and-shoulders bust framing: centered, perfectly front-facing, symmetrical, same scale and position, same soft even frontal lighting, same flat solid light gray background (#C9CDD2) with absolutely no gradient, no shadow on background, no floor, no scenery. The ONLY thing that changes is the facial expression described below. Semi-realistic soft anime style, Ghibli-like warmth. No text, no watermark, no signature, no hands, no jewelry. Keep bangs above the eyes unless the expression explicitly closes the eyes.
EOF
declare -a VARIANTS=(
"sad|eyes gently downcast and sorrowful, inner eyebrows raised and slightly pinched upward together, mouth a soft small downturned frown, an overall melancholic downcast depressed mood. Eyes remain open."
"tired|heavy drowsy half-closed eyelids, relaxed slightly drooping eyebrows, soft neutral slightly parted mouth, weary exhausted mood. Eyes mostly open but heavy."
"anxious|worried anxious uneasy expression, eyes slightly wider and tense, eyebrows raised and pulled slightly together, mouth closed and tense, faint furrow of concern. Eyes open."
"warm|a warm gentle genuine soft smile, eyes slightly curved into gentle warm crescents, very subtle relaxed uplift of the mouth corners, kind comforting reassured mood. Eyes open."
"startled|startled surprised expression, eyes wide open round, eyebrows raised high, mouth slightly open in a small oval, mild shock. Eyes wide open."
"eyes-closed|eyes gently and fully closed in a relaxed slow blink, smooth closed eyelids, gentle relaxed neutral mouth, calm. (Bangs still above the closed eyes.)"
"speaking|mouth opened slightly as if speaking mid-sentence (small natural mouth opening), relaxed open eyes, calm neutral brow, talking expression. Eyes open."
)
for entry in "${VARIANTS[@]}"; do
name="${entry%%|*}"
desc="${entry#*|}"
out="var-${name}-v1.png"
echo "=============================="
echo "[gen] $name -> $out"
prompt="Use the supplied reference image as the exact character and framing to preserve. Generate the SAME character with ONLY the facial expression changed.
EXPRESSION: ${desc}
${INVARIANTS}"
if bash "$GEN" --out "$out" --size 1024x1280 --quality high -i "$BASE" --prompt "$prompt" >/tmp/seoyeon_${name}.log 2>&1; then
echo "[ok] $out ($(stat -c %s "$out" 2>/dev/null || echo '?') bytes)"
else
echo "[FAIL] $name — log:"
tail -15 "/tmp/seoyeon_${name}.log"
fi
done
echo "=============================="
echo "ALL DONE"
ls -la var-*-v1.png 2>/dev/null

View file

@ -0,0 +1,330 @@
#!/usr/bin/env python3
"""발행된 변주 PNG(900x1125)를 Live2D식 파츠로 분리한다.
구조:
parts/base-faceless.png : ///눈썹을 지운 +머리+피부 베이스
parts/shoulders.png : 어깨/상의
parts/neck.png :
parts/face-faceless.png : 눈코입 없는 얼굴/피부
parts/forehead.png : 이마 피부
parts/ear-left/right.png :
parts/brow-<expr>.png : 눈썹
parts/eyes-<expr>.png :
parts/eyelid-closed.png : 닫힌 (깜빡임)
parts/nose-neutral.png : (정적)
parts/mouth-<expr>.png : + mouth-open(발화)
parts/hair-left/right/bangs.png : 찰랑임용 얇은 머리카락 오버레이
핵심은 base에 기존 눈코입을 남기지 않는 . 원본 얼굴 위에 표정 패치를 얹으면
/입이 겹쳐 보여 불쾌해지므로, 달걀귀신 같은 베이스 위에 파츠를 올린다.
"""
import os
from PIL import Image, ImageDraw, ImageFilter
import numpy as np
SRC = r"D:/workspace/vignette/apps/web/public/avatar/seoyeon"
OUT = os.path.join(SRC, "parts")
os.makedirs(OUT, exist_ok=True)
# 현재 서연 v1 게시 에셋(900x1125) 기준 ROI.
BROW_ROI = (220, 300, 684, 372)
EYES_ROI = (214, 340, 696, 458)
EYELID_ROI = (214, 336, 696, 464)
NOSE_ROI = (360, 400, 546, 548)
MOUTH_ROI = (314, 486, 606, 650)
HAIR_BANGS_ROI = (210, 72, 696, 356)
HAIR_LEFT_ROI = (34, 88, 360, 1034)
HAIR_RIGHT_ROI = (548, 88, 866, 1034)
SHOULDERS_ROI = (0, 745, 900, 1125)
NECK_ROI = (300, 610, 604, 858)
FACE_ROI = (198, 160, 712, 690)
FOREHEAD_ROI = (278, 198, 628, 344)
EAR_LEFT_ROI = (126, 340, 278, 514)
EAR_RIGHT_ROI = (628, 340, 780, 514)
# diff seed → MaxFilter 팽창 → blur 페더. 보조/호환 파츠용.
UPPERFACE_ROI = (218, 314, 684, 456)
UPPERFACE_DILATE = 29
UPPERFACE_FEATHER = 6
EYELID_DILATE = 31
EYELID_FEATHER = 6
MOUTH_DILATE = 27
MOUTH_FEATHER = 6
EXPR_VARIANTS = ["neutral", "sad", "tired", "anxious", "warm", "startled"]
def odd(value: int) -> int:
return value if value % 2 else value + 1
def empty_part(out_name: str, size: tuple[int, int]) -> None:
im = Image.new("RGBA", size, (0, 0, 0, 0))
im.save(f"{OUT}/{out_name}.png", optimize=True)
print(f" {out_name}.png blank")
def save_part(arr: np.ndarray, out_name: str) -> None:
arr = arr.astype(np.uint8)
Image.fromarray(arr, "RGBA").save(f"{OUT}/{out_name}.png", optimize=True)
al = arr[:, :, 3]
ys, xs = np.where(al > 0)
bbox = None if len(xs) == 0 else (int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1))
print(f" {out_name}.png bbox={bbox} "
f"opaque%={float((al == 255).mean()) * 100:.2f} "
f"vis%={float((al > 0).mean()) * 100:.2f}")
def full_mask(size: tuple[int, int]) -> Image.Image:
return Image.new("L", size, 0)
def soft_shapes(size: tuple[int, int], shapes: list[tuple[str, tuple[int, int, int, int]]], feather: int) -> Image.Image:
mask = full_mask(size)
draw = ImageDraw.Draw(mask)
for kind, box in shapes:
if kind == "ellipse":
draw.ellipse(box, fill=255)
elif kind == "round":
draw.rounded_rectangle(box, radius=max(4, min(box[2] - box[0], box[3] - box[1]) // 3), fill=255)
else:
draw.rectangle(box, fill=255)
if feather > 0:
mask = mask.filter(ImageFilter.GaussianBlur(feather))
return mask
def mask_roi(size: tuple[int, int], roi: tuple[int, int, int, int], feather: int) -> Image.Image:
return soft_shapes(size, [("round", roi)], feather)
def alpha_part(im: Image.Image, mask: Image.Image, out_name: str) -> None:
arr = np.array(im.convert("RGBA")).astype(np.float32)
m = np.array(mask).astype(np.float32)
arr[:, :, 3] = np.minimum(arr[:, :, 3], m)
arr[arr[:, :, 3] < 1, :3] = 0
save_part(arr, out_name)
def make_feature_masks(size: tuple[int, int]) -> dict[str, Image.Image]:
return {
"shoulders": soft_shapes(size, [("round", SHOULDERS_ROI)], 10),
"neck": soft_shapes(size, [("ellipse", NECK_ROI)], 12),
"face": soft_shapes(size, [("ellipse", FACE_ROI)], 12),
"forehead": soft_shapes(size, [("ellipse", FOREHEAD_ROI)], 8),
"ear-left": soft_shapes(size, [("ellipse", EAR_LEFT_ROI)], 7),
"ear-right": soft_shapes(size, [("ellipse", EAR_RIGHT_ROI)], 7),
"brow": soft_shapes(
size,
[
("ellipse", (238, 306, 424, 370)),
("ellipse", (484, 306, 670, 370)),
],
7,
),
"eyes": soft_shapes(
size,
[
("ellipse", (220, 328, 440, 452)),
("ellipse", (470, 328, 690, 452)),
],
8,
),
"eyelid": soft_shapes(
size,
[
("ellipse", (218, 326, 442, 458)),
("ellipse", (468, 326, 692, 458)),
],
8,
),
"nose": soft_shapes(size, [("ellipse", NOSE_ROI)], 10),
"mouth": soft_shapes(size, [("ellipse", MOUTH_ROI)], 9),
}
def make_faceless_base(base: Image.Image, masks: dict[str, Image.Image]) -> None:
arr = np.array(base.convert("RGBA")).astype(np.float32)
H, W = arr.shape[:2]
feature = np.zeros((H, W), dtype=np.float32)
for key in ["brow", "eyes", "nose", "mouth"]:
feature = np.maximum(feature, np.array(masks[key]).astype(np.float32))
# 주변 피부색으로 덮는다. 오버레이 파츠가 올라올 자리라 완벽한 인페인팅보다
# 기존 눈코입 흔적 제거가 더 중요하다.
skin = arr[:, :, :3]
r, g, b = skin[:, :, 0], skin[:, :, 1], skin[:, :, 2]
alpha = arr[:, :, 3]
skin_pixels = (
(alpha > 160)
& (r > 145)
& (g > 100)
& (b > 75)
& (r > b + 22)
& (g > b + 8)
& (feature < 16)
)
if skin_pixels.any():
fill = np.median(skin[skin_pixels], axis=0)
else:
fill = np.array([224, 180, 145], dtype=np.float32)
smooth = np.array(base.filter(ImageFilter.GaussianBlur(18)).convert("RGBA")).astype(np.float32)
cover_rgb = smooth[:, :, :3] * 0.35 + fill.reshape(1, 1, 3) * 0.65
weight = (feature / 255.0)[:, :, None]
arr[:, :, :3] = arr[:, :, :3] * (1 - weight) + cover_rgb * weight
arr[:, :, 3] = np.array(base.getchannel("A")).astype(np.float32)
save_part(arr, "base-faceless")
def make_faceless_skin_part(base: Image.Image, masks: dict[str, Image.Image], part_key: str, out_name: str) -> None:
arr = np.array(base.convert("RGBA")).astype(np.float32)
H, W = arr.shape[:2]
feature = np.zeros((H, W), dtype=np.float32)
for key in ["brow", "eyes", "nose", "mouth"]:
feature = np.maximum(feature, np.array(masks[key]).astype(np.float32))
r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2]
src_a = arr[:, :, 3]
skin_pixels = (
(src_a > 160)
& (r > 145)
& (g > 100)
& (b > 75)
& (r > b + 22)
& (g > b + 8)
& (feature < 16)
)
fill = np.median(arr[:, :, :3][skin_pixels], axis=0) if skin_pixels.any() else np.array([224, 180, 145])
smooth = np.array(base.filter(ImageFilter.GaussianBlur(18)).convert("RGBA")).astype(np.float32)
cover_rgb = smooth[:, :, :3] * 0.35 + fill.reshape(1, 1, 3) * 0.65
remove_weight = (feature / 255.0)[:, :, None]
arr[:, :, :3] = arr[:, :, :3] * (1 - remove_weight) + cover_rgb * remove_weight
mask = np.array(masks[part_key]).astype(np.float32)
arr[:, :, 3] = np.minimum(src_a, mask)
arr[arr[:, :, 3] < 1, :3] = 0
save_part(arr, out_name)
def hair_seed(im: Image.Image, roi: tuple[int, int, int, int]) -> Image.Image:
arr = np.array(im.convert("RGBA")).astype(np.int16)
r, g, b, a = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2], arr[:, :, 3]
# 갈색 머리 위주. 피부/셔츠/배경은 제외한다.
hair = (
(a > 80)
& (r > 22)
& (r < 155)
& (g > 18)
& (g < 130)
& (b > 14)
& (b < 120)
& (r >= g - 8)
& (g >= b - 12)
)
full = np.zeros(a.shape, dtype=np.uint8)
x1, y1, x2, y2 = roi
full[y1:y2, x1:x2] = hair[y1:y2, x1:x2].astype(np.uint8) * 255
mask = Image.fromarray(full, "L").filter(ImageFilter.MaxFilter(9)).filter(ImageFilter.GaussianBlur(3))
return mask
def make_hair_part(base: Image.Image, roi: tuple[int, int, int, int], out_name: str) -> None:
alpha_part(base, hair_seed(base, roi), out_name)
def diff_seed(base: Image.Image, variant: Image.Image, roi: tuple[int, int, int, int], threshold: int) -> Image.Image:
b = np.array(base.convert("RGBA")).astype(np.int16)
v = np.array(variant.convert("RGBA")).astype(np.int16)
rgb_diff = np.abs(v[:, :, :3] - b[:, :, :3]).max(axis=2)
alpha_gate = (b[:, :, 3] > 96) | (v[:, :, 3] > 96)
seed = ((rgb_diff >= threshold) & alpha_gate).astype(np.uint8) * 255
full = np.zeros(seed.shape, dtype=np.uint8)
x1, y1, x2, y2 = roi
full[y1:y2, x1:x2] = seed[y1:y2, x1:x2]
return Image.fromarray(full, "L")
def expand_mask(seed: Image.Image, dilate: int, feather: int) -> Image.Image:
mask = seed.filter(ImageFilter.MaxFilter(odd(dilate)))
if feather > 0:
mask = mask.filter(ImageFilter.GaussianBlur(feather))
return mask
def make_diff_part(
base: Image.Image,
variant: str,
roi: tuple[int, int, int, int],
out_name: str,
*,
threshold: int,
dilate: int,
feather: int,
) -> None:
im = Image.open(f"{SRC}/{variant}.png").convert("RGBA")
seed = diff_seed(base, im, roi, threshold)
mask = expand_mask(seed, dilate, feather)
arr = np.array(im).astype(np.float32)
m = np.array(mask).astype(np.float32)
src_a = arr[:, :, 3]
alpha = np.minimum(src_a, m)
arr[:, :, 3] = alpha
arr[alpha < 1, :3] = 0
Image.fromarray(arr.astype(np.uint8), "RGBA").save(f"{OUT}/{out_name}.png", optimize=True)
al = arr[:, :, 3]
ys, xs = np.where(al > 0)
bbox = None if len(xs) == 0 else (int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1))
print(f" {out_name}.png bbox={bbox} "
f"opaque%={float((al == 255).mean()) * 100:.2f} "
f"vis%={float((al > 0).mean()) * 100:.2f}")
def main() -> int:
base = Image.open(f"{SRC}/neutral.png").convert("RGBA")
masks = make_feature_masks(base.size)
print("[base]")
make_faceless_base(base, masks)
print("[body]")
alpha_part(base, masks["shoulders"], "shoulders")
make_faceless_skin_part(base, masks, "neck", "neck")
make_faceless_skin_part(base, masks, "face", "face-faceless")
make_faceless_skin_part(base, masks, "forehead", "forehead")
alpha_part(base, masks["ear-left"], "ear-left")
alpha_part(base, masks["ear-right"], "ear-right")
print("[hair]")
make_hair_part(base, HAIR_LEFT_ROI, "hair-left")
make_hair_part(base, HAIR_RIGHT_ROI, "hair-right")
make_hair_part(base, HAIR_BANGS_ROI, "hair-bangs")
print("[brow]")
for v in EXPR_VARIANTS:
alpha_part(Image.open(f"{SRC}/{v}.png").convert("RGBA"), masks["brow"], f"brow-{v}")
print("[eyes]")
for v in EXPR_VARIANTS:
alpha_part(Image.open(f"{SRC}/{v}.png").convert("RGBA"), masks["eyes"], f"eyes-{v}")
print("[nose]")
alpha_part(base, masks["nose"], "nose-neutral")
print("[upperface]")
for v in EXPR_VARIANTS:
# 구버전 렌더러 호환용. 새 렌더러는 brow/eyes를 사용한다.
make_diff_part(
base,
v,
UPPERFACE_ROI,
f"upperface-{v}",
threshold=18 if v != "neutral" else 1,
dilate=UPPERFACE_DILATE,
feather=UPPERFACE_FEATHER,
)
print("[eyelid]")
alpha_part(Image.open(f"{SRC}/eyes-closed.png").convert("RGBA"), masks["eyelid"], "eyelid-closed")
print("[mouth]")
for v in EXPR_VARIANTS:
alpha_part(Image.open(f"{SRC}/{v}.png").convert("RGBA"), masks["mouth"], f"mouth-{v}")
alpha_part(Image.open(f"{SRC}/speaking.png").convert("RGBA"), masks["mouth"], "mouth-open")
print("done ->", OUT)
return 0
if __name__ == "__main__":
raise SystemExit(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

View file

@ -0,0 +1,27 @@
{
"artSet": "seoyeon",
"canvas": { "width": 900, "height": 1125 },
"sourceSheet": "docs/avatar-art/seoyeon/character-sheet-v1.png",
"parts": [
{ "id": "base.facelessHead", "file": "parts/base-faceless.png", "role": "static-base" },
{ "id": "skin.forehead", "file": "parts/forehead.png", "role": "static-skin" },
{ "id": "ear.left", "file": "parts/ear-left.png", "role": "head-attached", "pivot": [270, 415] },
{ "id": "ear.right", "file": "parts/ear-right.png", "role": "head-attached", "pivot": [630, 415] },
{ "id": "eye.left.open", "file": "parts/eye-left-open.png", "role": "blink-gaze", "pivot": [340, 390] },
{ "id": "eye.right.open", "file": "parts/eye-right-open.png", "role": "blink-gaze", "pivot": [560, 390] },
{ "id": "eyelid.closed", "file": "parts/eyelid-closed.png", "role": "blink-overlay" },
{ "id": "brow.left", "file": "parts/brow-left-neutral.png", "role": "expression-brow", "pivot": [330, 330] },
{ "id": "brow.right", "file": "parts/brow-right-neutral.png", "role": "expression-brow", "pivot": [570, 330] },
{ "id": "nose.neutral", "file": "parts/nose-neutral.png", "role": "static-face" },
{ "id": "mouth.neutral", "file": "parts/mouth-neutral.png", "role": "expression-mouth", "pivot": [450, 570] },
{ "id": "mouth.sad", "file": "parts/mouth-sad.png", "role": "expression-mouth", "pivot": [450, 570] },
{ "id": "mouth.warm", "file": "parts/mouth-warm.png", "role": "expression-mouth", "pivot": [450, 570] },
{ "id": "mouth.open", "file": "parts/mouth-open.png", "role": "lip-sync", "pivot": [450, 570] },
{ "id": "hair.back", "file": "parts/hair-back.png", "role": "hair-static", "pivot": [450, 190] },
{ "id": "hair.bangs", "file": "parts/hair-bangs.png", "role": "hair-sway", "pivot": [450, 230] },
{ "id": "hair.left", "file": "parts/hair-left.png", "role": "hair-sway", "pivot": [300, 340] },
{ "id": "hair.right", "file": "parts/hair-right.png", "role": "hair-sway", "pivot": [600, 340] },
{ "id": "neck", "file": "parts/neck.png", "role": "body-attached" },
{ "id": "shoulders", "file": "parts/shoulders.png", "role": "body-breath" }
]
}

View file

@ -0,0 +1,9 @@
Use case: stylized-concept
Asset type: counseling-simulation Live2D-style raster avatar base
Primary request: Create a non-creepy educational 2D character bust for a virtual client named Seoyeon. A 17-year-old Korean high-school girl, front-facing head-and-shoulders bust, calm neutral expression with a slightly sad undertone.
Subject: short medium-brown bob haircut ending around the jaw/neck, soft straight bangs above the eyes, simple warm beige skin, modest gray-blue crewneck top, small natural mouth, simplified nose, soft brown almond eyes that are expressive but not oversized.
Style/medium: semi-figurative soft anime/paper illustration, about 35 percent realism, clean hand-painted 2D, simplified features, gentle matte texture. Avoid doll-like or photorealistic rendering. No glossy skin, no detailed pores, no hyperreal hair strands, no uncanny large eyes.
Composition/framing: perfectly centered, perfectly front-facing, symmetrical, head and shoulders only, same scale suitable for layer-separated animation, generous padding around head and shoulders.
Lighting/mood: soft even frontal light, calm and approachable, restrained clinical education tone.
Background: perfectly flat solid light gray #C9CDD2 only, no gradient, no shadow, no scenery.
Constraints: one character only; no hands; no jewelry; no text; no watermark; no signature; bangs must not cover the eyes; low-uncanny educational avatar, not a portrait photo.

View file

@ -0,0 +1,10 @@
Use case: illustration-story / stylized character bust
Asset type: counseling-simulation avatar bust (head and shoulders only), centered, perfectly front-facing, symmetrical
Primary request: a gentle, semi-realistic anime illustration bust portrait of a 17-year-old Korean high-school girl, calm with a slightly melancholic, wistful mood.
Subject: young woman, oval face with a soft rounded jaw and small chin, smooth light warm beige skin with NO pores and NO wrinkles, large soft brown almond eyes with a single gentle white highlight in each, thin softly-arched dark-brown eyebrows, a very small subtle nose (minimal), closed neutral mouth with slightly downturned natural soft-pink lips. Long brown wavy hair, center-parted, with straight softly-textured bangs across the forehead and two side locks framing the face down past the shoulders. Wearing a soft gray-blue rounded-neckline top, shoulders only, no collar detail.
Style/medium: semi-realistic anime illustration, clean soft cel shading, gentle and tasteful and approachable, soft Studio-Ghibli-like warmth. NOT photorealistic, NOT chibi, NOT over-stylized or sexualized.
Composition/framing: head-and-shoulders bust, centered, perfectly front-facing and symmetrical, eyes looking straight forward at viewer, generous even padding around the head and shoulders.
Lighting/mood: soft even gentle frontal light, calm and slightly wistful mood, subtle warmth.
Background: perfectly flat solid plain light gray (#C9CDD2) only, absolutely no gradient, no shadow on the background, no floor, no props, no scenery, no text, no watermark. The flat background is intentional for clean background removal.
Constraints: perfectly front-facing and symmetrical; mouth closed and neutral; both eyes fully open; bangs must NOT cover the eyes; plain flat solid background only; no text; no watermark; no signature.
Avoid: any text or watermark or signature, extra people, hands or fingers visible, earrings or jewelry or hair accessories, busy or textured background, cast shadow or contact shadow on the background, reflections.

View file

@ -0,0 +1,25 @@
Use case: stylized-concept
Asset type: Live2D-style character parts sheet for a counseling simulation avatar
Primary request: Create a production character parts sheet for Seoyeon, a virtual client avatar. Use the attached sample sheets only as structural references: one assembled bust plus separated parts arranged around it. Do not copy their characters, outfits, colors, accessories, or style.
Character: Seoyeon, a 17-year-old Korean high-school girl, calm and slightly sad, educational counseling simulation. Short medium-brown bob haircut ending near the jaw/neck, soft straight bangs above the eyes, warm beige skin, modest gray-blue crewneck top, soft brown eyes. She should feel safe, subdued, and non-creepy; avoid photorealistic or doll-like rendering.
Style: clean 2D anime/paper illustration, about 35 percent realism, simplified face, matte texture, gentle clinical-education tone. No glossy skin, no pores, no hyperreal hair, no extreme eye size.
Sheet layout: wide canvas. Left side: assembled front-facing head-and-shoulders bust, neutral calm expression. Right side and surrounding space: separated Live2D parts, each with generous padding and no overlap.
Required separated parts:
- faceless head/face base with no eyes, no brows, no nose, no mouth
- forehead skin patch
- left ear and right ear
- left eye open, right eye open, closed eyelid pair
- left eyebrow and right eyebrow, neutral
- small simplified nose
- mouth neutral, mouth sad/frown, mouth warm/smile, mouth open for speaking
- front bangs, left side hair, right side hair, back hair mass
- neck
- shoulders and gray-blue crewneck top
Part requirements: all parts must match the assembled character exactly in style, color, scale, and lighting. Parts should be cleanly separated, front-facing, symmetrical where appropriate, and suitable for alpha cutout and coordinate-based recomposition. Leave visible gaps between parts. Use a plain very light background with subtle grid or flat white; no labels, no text, no watermark, no signature.
Avoid: extra characters, hands, jewelry, hair accessories, logos, written labels, speech bubbles, decorative props, photorealism, creepy doll face, heavy shadows, perspective view, side view.

View file

@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""컷아웃 PNG → 알파 정제 → 표준 캔버스(900x1125) 정규화 → public/avatar/seoyeon/<name>.png 게시.
모든 변주가 동일 캔버스·중앙 배치로 레이어 픽셀 정렬.
사용: python publish.py <입력.png> <name> : python publish.py cut-sad.png sad
"""
import sys
from PIL import Image
import numpy as np
CANVAS = (900, 1125) # 4:5
PUB = r"D:/workspace/vignette/apps/web/public/avatar/seoyeon"
LO, HI = 35, 205 # 알파 정제 임계치(배경 헤이즈 제거, 주체는 완전불투명)
def main() -> int:
src, name = sys.argv[1], sys.argv[2]
im = Image.open(src).convert("RGBA")
arr = np.array(im).astype(np.float32)
a = arr[:, :, 3]
clean = np.clip((a - LO) * 255.0 / (HI - LO), 0, 255)
arr[:, :, 3] = clean
im = Image.fromarray(arr.astype(np.uint8), "RGBA")
im.thumbnail(CANVAS, Image.LANCZOS)
canvas = Image.new("RGBA", CANVAS, (0, 0, 0, 0))
canvas.alpha_composite(im, ((CANVAS[0] - im.width) // 2, (CANVAS[1] - im.height) // 2))
out = f"{PUB}/{name}.png"
canvas.save(out, optimize=True)
al = np.array(canvas.split()[3])
print(f"published {name}.png size={canvas.size} "
f"opaque%={float((al == 255).mean()) * 100:.1f} "
f"transparent%={float((al == 0).mean()) * 100:.1f} "
f"semi%={float(((al > 0) & (al < 255)).mean()) * 100:.1f}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB