대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·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:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
410
docs/avatar-art/seoyeon/extract-character-sheet.py
Normal file
410
docs/avatar-art/seoyeon/extract-character-sheet.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue