리노컷 공통 파이프라인 캐스트 확장 — 정렬·정규화·얼굴 해칭·원화 눈썹·눈 실측
- register_edit(편집본 ECC 정렬·챔퍼 지표), normalize_base(P1 머리 크기 정규화, 위쪽 종이색 채움) - faceDetail region=face(얼굴 해칭을 원화 픽셀로 보존)·matchFacelessColor - brow_sprite 단계: 원화 눈썹 잉크 스프라이트(hairFront 아래 제외), 윗눈꺼풀 두께·홍채 실측 - 리그 계약 RigBrow.halfWidth/sprite·RigEye.lineScale, 렌더러 눈썹 16띠·바깥쪽 눈꺼풀 굵기 - 실행기 UTF-8 하위 실행, brow_centerline 패딩본 자체 생성, QA 시트 촬영 도구(깜빡임 회피·고해상도 대조) - P1은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
This commit is contained in:
parent
9a22d1647b
commit
98577cf4ae
18 changed files with 2524 additions and 85 deletions
|
|
@ -59,6 +59,15 @@ LIP_SPECS = [
|
|||
]
|
||||
LIP_SIZE_BUDGET_BYTES = 80 * 1024 # 그늘 조각 분리로 3장
|
||||
|
||||
# --- 눈썹 스프라이트(결정문 §8.4.1, 작업 패킷 "원화 눈썹 스프라이트와 눈꺼풀 선 굵기") --
|
||||
|
||||
BROW_SPECS = [
|
||||
# (rig key, 파일 stem, public href stem)
|
||||
("left", "brow-left", "brow-left"),
|
||||
("right", "brow-right", "brow-right"),
|
||||
]
|
||||
BROW_SIZE_BUDGET_BYTES = 60 * 1024
|
||||
|
||||
# --- 턱 조각(결정문 §8.4 하관 띠 변형) ---------------------------------------
|
||||
|
||||
JAW_SPECS = [
|
||||
|
|
@ -307,6 +316,123 @@ def publish_lip_texture(cfg: PersonaConfig, manifest: dict, layers_v2_dir: Path)
|
|||
return {"upper": lip_field["upper"], "lower": lip_field["lower"], "shadow": lip_field["shadow"]}, publish_report
|
||||
|
||||
|
||||
def publish_brow_sprite(cfg: PersonaConfig, manifest: dict, layers_v2_dir: Path) -> tuple[dict | None, dict | None]:
|
||||
"""layers/v2/brow-{left,right}.png(brow_sprite.py 산출)를 알파 bbox로 잘라
|
||||
WebP로 게시한다(결정문 §8.4.1). 소스가 없으면 (None, None) —
|
||||
browSpriteFromArt=false인 리그(P1)는 RigBrow.sprite 없이 벡터 눈썹을 그대로
|
||||
쓴다(렌더러 대체 경로).
|
||||
|
||||
PNG는 이미 알파 bbox로 꽉 차게 잘려 있어(brow_sprite.py crop_to_alpha_bbox)
|
||||
crop_to_bbox는 사실상 no-op이고, 캔버스 위치는 manifest["browSprite"]
|
||||
["canvasOrigin"]에서 읽는다(publish_lip_texture와 같은 패턴). 인코딩은 입술
|
||||
스프라이트와 같은 무손실(exact) 우선 — 알파=0 영역도 원화 RGB를 그대로
|
||||
담고 있으므로(brow_sprite.py의 피부색 역산·잉크색 대체) exact=True로
|
||||
버려지지 않게 한다."""
|
||||
canvas_origin = manifest.get("browSprite", {}).get("canvasOrigin")
|
||||
if canvas_origin is None:
|
||||
return None, None
|
||||
|
||||
sources: dict[str, tuple[Path, str]] = {}
|
||||
for key, stem, href_stem in BROW_SPECS:
|
||||
p = layers_v2_dir / f"{stem}.png"
|
||||
if not p.exists():
|
||||
return None, None
|
||||
sources[key] = (p, href_stem)
|
||||
|
||||
cropped: dict[str, tuple[Image.Image, tuple[int, int, int, int], str]] = {}
|
||||
for key, (path, href_stem) in sources.items():
|
||||
im = Image.open(path)
|
||||
cropped_im, local_bbox = crop_to_bbox(im)
|
||||
lx0, ly0 = canvas_origin[key]
|
||||
abs_bbox = (lx0 + local_bbox[0], ly0 + local_bbox[1], local_bbox[2], local_bbox[3])
|
||||
cropped[key] = (cropped_im, abs_bbox, href_stem)
|
||||
|
||||
BROW_DIFF_TARGET = 2.0
|
||||
|
||||
def encode_all(quality: int) -> dict[str, bytes]:
|
||||
out: dict[str, bytes] = {}
|
||||
for key, (im, _bbox, href_stem) in cropped.items():
|
||||
out[href_stem] = encode_webp(im.convert("RGBA"), quality)
|
||||
return out
|
||||
|
||||
def encode_all_lossless() -> dict[str, bytes]:
|
||||
out: dict[str, bytes] = {}
|
||||
for key, (im, _bbox, href_stem) in cropped.items():
|
||||
buf = io.BytesIO()
|
||||
im.convert("RGBA").save(buf, "WEBP", lossless=True, quality=100, method=6, exact=True)
|
||||
out[href_stem] = buf.getvalue()
|
||||
return out
|
||||
|
||||
def webp_vs_png_diff(encoded: dict[str, bytes]) -> dict[str, float]:
|
||||
diffs: dict[str, float] = {}
|
||||
for key, (im, _bbox, href_stem) in cropped.items():
|
||||
src_arr = np.array(im.convert("RGBA")).astype(np.float64)
|
||||
dec_arr = np.array(Image.open(io.BytesIO(encoded[href_stem])).convert("RGBA")).astype(np.float64)
|
||||
diffs[key] = round(float(np.abs(src_arr - dec_arr).mean()), 3)
|
||||
return diffs
|
||||
|
||||
lossless_encoded = encode_all_lossless()
|
||||
lossless_total = sum(len(b) for b in lossless_encoded.values())
|
||||
if lossless_total <= BROW_SIZE_BUDGET_BYTES:
|
||||
chosen, chosen_quality = lossless_encoded, "lossless"
|
||||
else:
|
||||
candidates: list[tuple[int, dict[str, bytes], int, dict[str, float]]] = []
|
||||
chosen, chosen_quality = None, None
|
||||
for q in (100, 99, 98, 97, 96, 95):
|
||||
encoded = encode_all(q)
|
||||
total = sum(len(b) for b in encoded.values())
|
||||
diffs = webp_vs_png_diff(encoded)
|
||||
candidates.append((q, encoded, total, diffs))
|
||||
if total <= BROW_SIZE_BUDGET_BYTES and max(diffs.values()) <= BROW_DIFF_TARGET:
|
||||
chosen, chosen_quality = encoded, q
|
||||
break
|
||||
if chosen is None:
|
||||
within_budget = [c for c in candidates if c[2] <= BROW_SIZE_BUDGET_BYTES]
|
||||
pool = within_budget if within_budget else candidates
|
||||
best = min(pool, key=lambda c: max(c[3].values()))
|
||||
chosen_quality, chosen, _total, _diffs = best[0], best[1], best[2], best[3]
|
||||
print(
|
||||
f" [경고] quality 95~100 중 예산({BROW_SIZE_BUDGET_BYTES}B) AND 평균차<={BROW_DIFF_TARGET} "
|
||||
f"동시 만족 없음 — quality={chosen_quality}(평균차 최소) 채택"
|
||||
)
|
||||
assert chosen is not None and chosen_quality is not None
|
||||
|
||||
public_dir = cfg.public_dir
|
||||
public_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_sizes: dict[str, int] = {}
|
||||
for href_stem, data in chosen.items():
|
||||
out_path = public_dir / f"{href_stem}.webp"
|
||||
out_path.write_bytes(data)
|
||||
file_sizes[href_stem] = len(data)
|
||||
total_bytes = sum(file_sizes.values())
|
||||
print(f"눈썹 스프라이트 인코딩: quality={chosen_quality} (무손실 시도 시 합계 {lossless_total} bytes)")
|
||||
for href_stem, size in file_sizes.items():
|
||||
print(f" {href_stem}.webp = {size} bytes")
|
||||
print(
|
||||
f"눈썹 스프라이트 합계 = {total_bytes} bytes (예산 {BROW_SIZE_BUDGET_BYTES} bytes) "
|
||||
f"{'OK' if total_bytes <= BROW_SIZE_BUDGET_BYTES else '[초과]'}"
|
||||
)
|
||||
|
||||
webp_vs_png = webp_vs_png_diff(chosen)
|
||||
for href_stem in file_sizes:
|
||||
key = next(k for k, (_im, _bbox, hs) in cropped.items() if hs == href_stem)
|
||||
print(f" 검사 [{href_stem}] WebP 대 PNG 평균차={webp_vs_png[key]}")
|
||||
|
||||
brow_field = {}
|
||||
for key, (_im, bbox, href_stem) in cropped.items():
|
||||
x0, y0, w, h = bbox
|
||||
brow_field[key] = {
|
||||
"href": f"{cfg.public_href_prefix}/{href_stem}.webp",
|
||||
"x": round(x0, 1), "y": round(y0, 1), "w": round(w, 1), "h": round(h, 1),
|
||||
}
|
||||
publish_report = {
|
||||
"quality": chosen_quality, "fileSizes": file_sizes, "totalBytes": total_bytes,
|
||||
"budgetBytes": BROW_SIZE_BUDGET_BYTES,
|
||||
"webpVsPngMeanAbsDiff": webp_vs_png,
|
||||
}
|
||||
return brow_field, publish_report
|
||||
|
||||
|
||||
# --- 턱 조각 게시(결정문 §8.4 하관 띠 변형) ----------------------------------
|
||||
|
||||
|
||||
|
|
@ -764,29 +890,49 @@ def compute_mouth_center_from_manifest(manifest: dict) -> tuple[float, float]:
|
|||
# --- landmarks 매핑(manifest 값을 이름만 바꿔 그대로 사용) -----------------
|
||||
|
||||
|
||||
def build_landmarks(manifest: dict, mouth_center: tuple[float, float]) -> dict:
|
||||
def build_landmarks(
|
||||
cfg: PersonaConfig, manifest: dict, mouth_center: tuple[float, float], brow_sprite_field: dict | None
|
||||
) -> dict:
|
||||
lm = manifest["landmarks"]
|
||||
half_widths = manifest.get("browCenterline", {}) if cfg.brow_half_width_from_art else {}
|
||||
eye_line_scale = manifest.get("eyeLineScale", {}) if cfg.eye_line_scale_from_art else {}
|
||||
iris_measured = manifest.get("iris", {}) if cfg.iris_from_art else {}
|
||||
|
||||
def pt(p) -> list[float]:
|
||||
return [round(p[0], 1), round(p[1], 1)]
|
||||
|
||||
def eye(e) -> dict:
|
||||
return {
|
||||
def eye(e, line_scale_key: str) -> dict:
|
||||
iris_m = iris_measured.get(line_scale_key)
|
||||
if iris_m is not None:
|
||||
iris_field = {"center": pt(iris_m["measuredCenter"]), "radius": round(float(iris_m["measuredRadius"]), 1)}
|
||||
else:
|
||||
iris_field = {"center": pt(e["iris"]["center"]), "radius": round(e["iris"]["radius"], 1)}
|
||||
d = {
|
||||
"inner": pt(e["innerCorner"]),
|
||||
"outer": pt(e["outerCorner"]),
|
||||
"upperLidTop": pt(e["upperLidTop"]),
|
||||
"lowerLidBottom": pt(e["lowerLidBottom"]),
|
||||
"iris": {"center": pt(e["iris"]["center"]), "radius": round(e["iris"]["radius"], 1)},
|
||||
"iris": iris_field,
|
||||
}
|
||||
line_scale = eye_line_scale.get(line_scale_key, {}).get("lineScale")
|
||||
if line_scale is not None:
|
||||
d["lineScale"] = round(float(line_scale), 3)
|
||||
return d
|
||||
|
||||
def brow(b) -> dict:
|
||||
return {"inner": pt(b["inner"]), "peak": pt(b["peak"]), "outer": pt(b["outer"])}
|
||||
def brow(b, half_width_key: str, sprite_key: str) -> dict:
|
||||
d = {"inner": pt(b["inner"]), "peak": pt(b["peak"]), "outer": pt(b["outer"])}
|
||||
hw = half_widths.get(half_width_key)
|
||||
if hw is not None:
|
||||
d["halfWidth"] = [round(float(v), 2) for v in hw]
|
||||
if brow_sprite_field is not None and brow_sprite_field.get(sprite_key) is not None:
|
||||
d["sprite"] = brow_sprite_field[sprite_key]
|
||||
return d
|
||||
|
||||
return {
|
||||
"eyeLeft": eye(lm["eyeLeft"]),
|
||||
"eyeRight": eye(lm["eyeRight"]),
|
||||
"browLeft": brow(lm["eyebrowLeft"]),
|
||||
"browRight": brow(lm["eyebrowRight"]),
|
||||
"eyeLeft": eye(lm["eyeLeft"], "left"),
|
||||
"eyeRight": eye(lm["eyeRight"], "right"),
|
||||
"browLeft": brow(lm["eyebrowLeft"], "halfWidthLeft", "left"),
|
||||
"browRight": brow(lm["eyebrowRight"], "halfWidthRight", "right"),
|
||||
"noseTip": pt(lm["noseTip"]),
|
||||
"mouthCornerLeft": pt(lm["mouthCornerLeft"]),
|
||||
"mouthCornerRight": pt(lm["mouthCornerRight"]),
|
||||
|
|
@ -937,11 +1083,22 @@ def render_rig_ts(cfg: PersonaConfig, rig: dict) -> str:
|
|||
lines.append(f' upperLidTop: {ts_point(e["upperLidTop"])},')
|
||||
lines.append(f' lowerLidBottom: {ts_point(e["lowerLidBottom"])},')
|
||||
lines.append(f' iris: {{ center: {ts_point(e["iris"]["center"])}, radius: {ts_num(e["iris"]["radius"])} }},')
|
||||
if e.get("lineScale") is not None:
|
||||
lines.append(f' lineScale: {ts_num(e["lineScale"])},')
|
||||
lines.append(" },")
|
||||
|
||||
def emit_brow(name, b):
|
||||
hw = b.get("halfWidth")
|
||||
hw_str = f", halfWidth: [{ts_num(hw[0])}, {ts_num(hw[1])}, {ts_num(hw[2])}]" if hw else ""
|
||||
sprite = b.get("sprite")
|
||||
sprite_str = ""
|
||||
if sprite:
|
||||
sprite_str = (
|
||||
f', sprite: {{ href: "{sprite["href"]}", x: {ts_num(sprite["x"])}, y: {ts_num(sprite["y"])}, '
|
||||
f'w: {ts_num(sprite["w"])}, h: {ts_num(sprite["h"])} }}'
|
||||
)
|
||||
lines.append(
|
||||
f' {name}: {{ inner: {ts_point(b["inner"])}, peak: {ts_point(b["peak"])}, outer: {ts_point(b["outer"])} }},'
|
||||
f' {name}: {{ inner: {ts_point(b["inner"])}, peak: {ts_point(b["peak"])}, outer: {ts_point(b["outer"])}{hw_str}{sprite_str} }},'
|
||||
)
|
||||
|
||||
emit_eye("eyeLeft", lmk["eyeLeft"])
|
||||
|
|
@ -1001,7 +1158,12 @@ def main(persona_dir: Path) -> int:
|
|||
for k, v in palette.items():
|
||||
print(f" {k} = {v}")
|
||||
|
||||
landmarks = build_landmarks(manifest, mouth_center)
|
||||
print("=== 눈썹 스프라이트 게시 ===")
|
||||
brow_sprite_field, brow_sprite_publish_report = publish_brow_sprite(cfg, manifest, layers_v2_dir)
|
||||
if brow_sprite_field is None:
|
||||
print(" brow-left.png/brow-right.png 없음(생략) — 벡터 눈썹을 그대로 쓰는 리그")
|
||||
|
||||
landmarks = build_landmarks(cfg, manifest, mouth_center, brow_sprite_field)
|
||||
face_crop = compute_face_crop(face_oval)
|
||||
print(f"crops.face = {[round(v,1) for v in face_crop]}")
|
||||
|
||||
|
|
@ -1083,6 +1245,7 @@ def main(persona_dir: Path) -> int:
|
|||
"lipTexturePublish": lip_publish_report,
|
||||
"jawPublish": jaw_publish_report,
|
||||
"motifPublish": motif_publish_report,
|
||||
"browSpritePublish": brow_sprite_publish_report,
|
||||
"rig": rig,
|
||||
}
|
||||
report_path = cfg.preview_v2_dir / "export-rig-report.json"
|
||||
|
|
@ -1097,8 +1260,14 @@ def main(persona_dir: Path) -> int:
|
|||
if jaw_publish_report is not None and "vsPublishedLayerAbsDiff" in jaw_publish_report:
|
||||
manifest.setdefault("jaw", {})["check1VsPublishedLayerAbsDiff"] = jaw_publish_report["vsPublishedLayerAbsDiff"]
|
||||
manifest["motifPublish"] = motif_publish_report
|
||||
manifest["browSpritePublish"] = brow_sprite_publish_report
|
||||
if brow_sprite_publish_report is not None and "webpVsPngMeanAbsDiff" in brow_sprite_publish_report:
|
||||
manifest.setdefault("browSprite", {})["checkWebpVsPngMeanAbsDiff"] = brow_sprite_publish_report["webpVsPngMeanAbsDiff"]
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신(lipTexturePublish, jawPublish, motifPublish): {manifest_path.relative_to(cfg.repo_root)}")
|
||||
print(
|
||||
f"manifest.json 갱신(lipTexturePublish, jawPublish, motifPublish, browSpritePublish): "
|
||||
f"{manifest_path.relative_to(cfg.repo_root)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue