genetics-mcp 2.0.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/LICENSE +43 -0
- package/genetics.mjs +2141 -0
- package/package.json +23 -0
- package/skills/genetics-report/SKILL.md +290 -0
- package/skills/genetics-report/assets/example/cases.json +24 -0
- package/skills/genetics-report/assets/example/report.json +55 -0
- package/skills/genetics-report/assets/generate-illustrations.py +159 -0
- package/skills/genetics-report/assets/qa.py +183 -0
- package/skills/genetics-report/assets/render.py +161 -0
- package/skills/genetics-report/assets/subset_font.py +93 -0
- package/skills/genetics-report/assets/visual-baseline.py +459 -0
- package/skills/genetics-report/references/content-pipeline.md +891 -0
- package/skills/genetics-report/references/design-system.md +733 -0
- package/skills/genetics-report/references/font-embedding.md +161 -0
- package/skills/genetics-report/references/illustrations.md +473 -0
- package/skills/genetics-report/references/lim-portal-format.md +272 -0
- package/skills/genetics-report/references/process.md +634 -0
- package/skills/genetics-report/references/qa.md +722 -0
- package/skills/genetics-report/references/textbook-mode.md +111 -0
- package/skills/genetics-report/references/wysiwyg.md +751 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""일러스트 시각 검수 — **정본 8장이 기준이다.**
|
|
3
|
+
|
|
4
|
+
왜 있는가
|
|
5
|
+
---------
|
|
6
|
+
2026-08-17 까지 일러스트 검수는 계약 §7 의 「2–8%」라는 숫자로 했다. 그런데 그 숫자를
|
|
7
|
+
**정본으로 검증한 적이 없었다.** 실제로 재 보니 정본은 0.75–1.91% 로 한 장도 그 구간에
|
|
8
|
+
들지 않는다 — 틀린 것은 그림이 아니라 기준이었다. 그 기준을 맞추려던 프롬프트 튜닝이
|
|
9
|
+
정본의 조형 문법을 통째로 지웠다.
|
|
10
|
+
|
|
11
|
+
그래서 기준을 **문장이 아니라 정본 실물에서 뜬다.** 계약 §7.1 이 이미 그 8장을
|
|
12
|
+
「회귀 기준물로만 보관」이라고 규정해 두었는데, 규정만 있고 **재는 절차가 없었다.**
|
|
13
|
+
|
|
14
|
+
쓰는 법
|
|
15
|
+
-------
|
|
16
|
+
python3 tools/visual-baseline.py freeze # 정본에서 기준선을 뜬다
|
|
17
|
+
python3 tools/visual-baseline.py check <png>... # 새 그림을 판정한다
|
|
18
|
+
python3 tools/visual-baseline.py selftest # 항등 + 뮤테이션 대조군
|
|
19
|
+
|
|
20
|
+
`selftest` 가 이 도구의 증명이다 — 정본을 넣으면 **8/8 통과**해야 하고(항등),
|
|
21
|
+
색을 흐트러뜨린 뮤테이션본은 **떨어져야** 한다. 한쪽만 보면 증명이 아니다.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from PIL import Image
|
|
31
|
+
except ImportError: # 검수는 선택 도구가 아니다
|
|
32
|
+
sys.exit('Pillow 가 필요하다: python3 -m pip install Pillow')
|
|
33
|
+
|
|
34
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
35
|
+
ROOT = os.getcwd() # 표시용 상대경로 기준. 프로젝트마다 다르다.
|
|
36
|
+
|
|
37
|
+
SAMPLE = 400 # 이 크기로 줄여 세면 충분하다(계약 §7 실측과 같은 조건)
|
|
38
|
+
|
|
39
|
+
# **팔레트는 문서마다 다르다.** `lim` 은 강조색이 라임 `#B9FF66` 이지만 `korean-navy` 는
|
|
40
|
+
# 금색 `#C9A961` 이다. 색을 상수로 박으면 그 서식에서 전량 오판정한다 —
|
|
41
|
+
# 그래서 네 색은 `private/builder/palette.py` 에서 받는다.
|
|
42
|
+
DEFAULT_PALETTE = None # **기본 팔레트를 두지 않는다** — 프로젝트마다 다르다.
|
|
43
|
+
DEFAULT_REF = None # 참조 세트도 마찬가지. 안 주면 물어보게 만든다.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def palette_colors(name: str, module: str | None = None) -> dict:
|
|
47
|
+
"""그 팔레트의 네 색(hex).
|
|
48
|
+
|
|
49
|
+
**어느 한 저장소의 팔레트에 묶이지 않는다.** 찾는 순서:
|
|
50
|
+
① `--palette-module <경로>` 로 준 파일 ② 이 스크립트 옆의 `palette.py`
|
|
51
|
+
둘 다에 그 이름이 없으면 `--accent/--ink/--surface/--white` 로 직접 주면 된다.
|
|
52
|
+
"""
|
|
53
|
+
import importlib.util
|
|
54
|
+
cands = [module] if module else []
|
|
55
|
+
cands.append(os.path.join(HERE, 'palette.py'))
|
|
56
|
+
seen = []
|
|
57
|
+
for c in cands:
|
|
58
|
+
if not c or not os.path.exists(c):
|
|
59
|
+
continue
|
|
60
|
+
spec = importlib.util.spec_from_file_location('_pal', c)
|
|
61
|
+
m = importlib.util.module_from_spec(spec)
|
|
62
|
+
spec.loader.exec_module(m)
|
|
63
|
+
pals = getattr(m, 'PALETTES', {})
|
|
64
|
+
seen += sorted(pals)
|
|
65
|
+
if name in pals:
|
|
66
|
+
q = pals[name]
|
|
67
|
+
return {'white': q.get('bg', '#FFFFFF'), 'surface': q.get('surface', '#F3F3F3'),
|
|
68
|
+
'ink': q.get('ink', q['primary']), 'accent': q['secondary']}
|
|
69
|
+
sys.exit(f'그런 팔레트가 없다: {name}\n'
|
|
70
|
+
f' 찾아본 곳: {", ".join(c for c in cands if c)}\n'
|
|
71
|
+
f' 있는 것: {", ".join(sorted(set(seen))) or "(없음)"}\n'
|
|
72
|
+
f' 또는 색을 직접: --accent #RRGGBB --ink #RRGGBB --surface #RRGGBB --white #RRGGBB')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _rgb(h: str) -> tuple[int, int, int]:
|
|
76
|
+
h = h.lstrip('#')
|
|
77
|
+
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── 픽셀을 네 색으로 가른다 ────────────────────────────────────────────────
|
|
81
|
+
# 4색 선화이므로 분류가 곧 계약 §1 의 면적 규칙이다. **거리로 가른다** —
|
|
82
|
+
# 색상(hue) 조건을 손으로 적으면 그 팔레트에서만 맞는다.
|
|
83
|
+
|
|
84
|
+
def make_classifier(cols: dict):
|
|
85
|
+
ref = {k: _rgb(v) for k, v in cols.items()}
|
|
86
|
+
|
|
87
|
+
def classify(px: tuple[int, int, int]) -> str:
|
|
88
|
+
best, bd = None, 1e9
|
|
89
|
+
for k, c in ref.items():
|
|
90
|
+
d = sum((a - b) ** 2 for a, b in zip(px, c)) ** .5
|
|
91
|
+
if d < bd:
|
|
92
|
+
best, bd = k, d
|
|
93
|
+
# 생성물은 원본 색을 그대로 안 낸다. 너무 멀면 「그 밖」으로 센다.
|
|
94
|
+
if best == 'white' and bd > 18:
|
|
95
|
+
return 'other' if bd > 40 else 'white'
|
|
96
|
+
return best if bd <= 70 else 'other'
|
|
97
|
+
|
|
98
|
+
return classify
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# 시작값 — freeze/check 가 실제 팔레트로 **덮어쓴다**(상수로 쓰라고 둔 것이 아니다).
|
|
102
|
+
_BOOT = {'white': '#FFFFFF', 'surface': '#F3F3F3', 'ink': '#191A23', 'accent': '#B9FF66'}
|
|
103
|
+
classify = make_classifier(_BOOT)
|
|
104
|
+
ACCENT = _rgb(_BOOT['accent'])
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def measure(path: str) -> dict:
|
|
108
|
+
"""한 장에서 판정에 쓸 지표를 뽑는다."""
|
|
109
|
+
im = Image.open(path).convert('RGB')
|
|
110
|
+
w, h = im.size
|
|
111
|
+
im.thumbnail((SAMPLE, SAMPLE))
|
|
112
|
+
px = list(im.getdata())
|
|
113
|
+
W, H = im.size
|
|
114
|
+
n = len(px)
|
|
115
|
+
|
|
116
|
+
count: dict[str, int] = {}
|
|
117
|
+
acc_px = []
|
|
118
|
+
for p in px:
|
|
119
|
+
k = classify(p)
|
|
120
|
+
count[k] = count.get(k, 0) + 1
|
|
121
|
+
if k == 'accent':
|
|
122
|
+
acc_px.append(p)
|
|
123
|
+
|
|
124
|
+
pct = {k: round(100 * count.get(k, 0) / n, 3)
|
|
125
|
+
for k in ('white', 'surface', 'accent', 'ink', 'other')}
|
|
126
|
+
|
|
127
|
+
# 대표 강조색 — 목표 hex 와 얼마나 떨어졌나
|
|
128
|
+
if acc_px:
|
|
129
|
+
rep = tuple(round(sum(c[i] for c in acc_px) / len(acc_px)) for i in range(3))
|
|
130
|
+
dist = round(sum((a - b) ** 2 for a, b in zip(rep, ACCENT)) ** .5, 1)
|
|
131
|
+
else:
|
|
132
|
+
rep, dist = (0, 0, 0), 999.0
|
|
133
|
+
|
|
134
|
+
# 글자리 — 글이 **그림 위에 얹히므로**(계약 4.2절 v1.3) 그 자리가 읽을 수 있어야 한다.
|
|
135
|
+
#
|
|
136
|
+
# 「비어 있는가」로 재면 안 된다. 정본 챕터 6장 중 4장이 좌40% 에 잉크를 갖는다
|
|
137
|
+
# (최대 14.94%) — 그래도 글은 읽힌다. **연하고 고르기** 때문이다.
|
|
138
|
+
# 그래서 재는 것은 공백이 아니라 **밝기와 균질함**이다:
|
|
139
|
+
# · 평균 밝기가 높아야 ink(#191A23, 밝기 25) 글자가 대비를 얻는다
|
|
140
|
+
# · 표준편차가 낮아야 글줄이 얼룩 위를 지나지 않는다
|
|
141
|
+
# 정본 실측(좌46%): 평균 237.7~251.8 · 편차 11.8~51.4.
|
|
142
|
+
#
|
|
143
|
+
# 글은 **좌·우 한쪽**에 앉고 장마다 바뀐다(렌더러의 `data-side`). 그래서 두 쪽을 다
|
|
144
|
+
# 재고, **읽히는 쪽이 하나라도 있으면** 그 그림은 쓸 수 있다. 어느 쪽이 비었는지는
|
|
145
|
+
# `text_side` 가 말해 주고, 브리프가 지정한 쪽과 같아야 한다.
|
|
146
|
+
g = im.convert('L')
|
|
147
|
+
w46 = int(W * .46)
|
|
148
|
+
|
|
149
|
+
def band(box):
|
|
150
|
+
d = list(g.crop(box).getdata())
|
|
151
|
+
mu = sum(d) / len(d)
|
|
152
|
+
return round(mu, 1), round((sum((x - mu) ** 2 for x in d) / len(d)) ** .5, 1)
|
|
153
|
+
|
|
154
|
+
bg_l, sd_l = band((0, 0, w46, H))
|
|
155
|
+
bg_r, sd_r = band((W - w46, 0, W, H))
|
|
156
|
+
left_ink = round(100 * sum(1 for p in list(im.crop((0, 0, int(W * .4), H)).getdata())
|
|
157
|
+
if max(p) < 235) / max(1, (int(W * .4) * H)), 2)
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
'size': [w, h],
|
|
161
|
+
'aspect': round(w / h, 3),
|
|
162
|
+
'pct': pct,
|
|
163
|
+
'accent_rgb': '#%02X%02X%02X' % rep,
|
|
164
|
+
'accent_dist': dist,
|
|
165
|
+
'left40_ink': left_ink,
|
|
166
|
+
'text_bg_l': bg_l, 'text_sd_l': sd_l, # 왼쪽 46% — 글이 읽히는가
|
|
167
|
+
'text_bg_r': bg_r, 'text_sd_r': sd_r, # 오른쪽 46%
|
|
168
|
+
'text_bg': max(bg_l, bg_r), # 밝은 쪽 = 글을 앉힐 수 있는 쪽
|
|
169
|
+
'text_side': 'l' if bg_l >= bg_r else 'r',
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ── 판정 밴드 ─────────────────────────────────────────────────────────────
|
|
174
|
+
# 정본 실측에서 유도한다. **손으로 고른 숫자를 쓰지 않는다** — 그것이 지난 사고의 원인이다.
|
|
175
|
+
# 여유(margin)는 정본 폭에 비례해 준다.
|
|
176
|
+
|
|
177
|
+
def derive_bands(rows: dict[str, dict]) -> dict:
|
|
178
|
+
art = {k: v for k, v in rows.items() if not k.startswith('eof')}
|
|
179
|
+
|
|
180
|
+
def span(getter, floor=0.0, ceil=100.0, pad=0.25):
|
|
181
|
+
vals = [getter(v) for v in art.values()]
|
|
182
|
+
lo, hi = min(vals), max(vals)
|
|
183
|
+
gap = max((hi - lo) * pad, 0.3)
|
|
184
|
+
return [round(max(floor, lo - gap), 2), round(min(ceil, hi + gap), 2)]
|
|
185
|
+
|
|
186
|
+
out = {
|
|
187
|
+
'accent': span(lambda v: v['pct']['accent']),
|
|
188
|
+
'white': span(lambda v: v['pct']['white'], ceil=100),
|
|
189
|
+
'surface': span(lambda v: v['pct']['surface']),
|
|
190
|
+
'ink': span(lambda v: v['pct']['ink']),
|
|
191
|
+
'accent_dist_max': round(max(v['accent_dist'] for v in art.values()) * 1.6, 1),
|
|
192
|
+
}
|
|
193
|
+
# hero 안전 영역은 **hero 가 있을 때만** 잰다. 참조 세트에 hero 가 없는 문서도 있고,
|
|
194
|
+
# 없다고 죽으면 그 프로젝트에서 도구를 아예 못 쓴다(2026-08-17 실측으로 걸렸다).
|
|
195
|
+
# 글자리 하한 — 두 축을 뜬다. 「비어 있는가」가 아니라 「읽히는가」다
|
|
196
|
+
# (정본 6장 중 4장은 좌40% 에 잉크가 있고도 글이 읽힌다).
|
|
197
|
+
#
|
|
198
|
+
# ① 밝은 쪽 — 어느 쪽이든 하나는 글을 받을 수 있어야 한다.
|
|
199
|
+
# ② 앉히는 쪽 — 브리프가 정한 쪽이 실제로 그만큼 밝아야 한다.
|
|
200
|
+
#
|
|
201
|
+
# ②의 하한을 ①과 같은 값으로 두면 **정본이 자기 기준에 떨어진다** — 정본
|
|
202
|
+
# chapter-05 는 글이 왼쪽(정본 HTML 의 `chapter-opener` 에 `is-reverse` 없음)인데
|
|
203
|
+
# 그 왼쪽이 238.9 다. 계약 §7 의 「Lime 2–8%」가 정본 0/7 이었던 것과 같은 함정이라
|
|
204
|
+
# ②는 **정본이 실제로 글을 앉힌 쪽**의 최솟값에서 뜬다.
|
|
205
|
+
#
|
|
206
|
+
# 정본의 좌우는 추정이 아니라 정본 HTML 실측이다:
|
|
207
|
+
# 1·3·5장 = 글 왼쪽(is-reverse 없음) · 2·4·6장 = 글 오른쪽(is-reverse) · hero = 왼쪽
|
|
208
|
+
CANON_SIDE = {'1': 'l', '2': 'r', '3': 'l', '4': 'r', '5': 'l', '6': 'r'}
|
|
209
|
+
|
|
210
|
+
bgs = [v['text_bg'] for v in art.values()]
|
|
211
|
+
if bgs:
|
|
212
|
+
out['text_bg_min'] = round(min(bgs) - 1.0, 1)
|
|
213
|
+
|
|
214
|
+
used = []
|
|
215
|
+
for k, v in art.items():
|
|
216
|
+
if k.startswith('hero'):
|
|
217
|
+
sd = 'l'
|
|
218
|
+
else:
|
|
219
|
+
digits = [c for c in k if c.isdigit()]
|
|
220
|
+
sd = CANON_SIDE.get(digits[-1] if digits else '', None)
|
|
221
|
+
if sd:
|
|
222
|
+
used.append(v['text_bg_l'] if sd == 'l' else v['text_bg_r'])
|
|
223
|
+
if used:
|
|
224
|
+
out['text_side_bg_min'] = round(min(used) - 1.0, 1)
|
|
225
|
+
|
|
226
|
+
heroes = [v['left40_ink'] for k, v in art.items() if k.startswith('hero')]
|
|
227
|
+
if heroes:
|
|
228
|
+
out['hero_left40_ink_max'] = round(max(heroes) + 0.5, 2)
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def load_baseline(base: str) -> dict:
|
|
233
|
+
if not os.path.exists(base):
|
|
234
|
+
sys.exit(f'기준선이 없다: {base}\n 먼저: python3 tools/visual-baseline.py freeze')
|
|
235
|
+
d = json.load(open(base, encoding='utf-8'))
|
|
236
|
+
# 기준선이 **자기 팔레트를 지고 다닌다** — 판정은 뜰 때와 같은 색으로 해야 한다.
|
|
237
|
+
global classify, ACCENT
|
|
238
|
+
if d.get('colors'):
|
|
239
|
+
classify = make_classifier(d['colors'])
|
|
240
|
+
ACCENT = _rgb(d['colors']['accent'])
|
|
241
|
+
return d
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def cmd_freeze(ref: str, pal: str, base: str, module: str | None = None) -> int:
|
|
245
|
+
return cmd_freeze_cols(ref, palette_colors(pal, module), pal, base)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def cmd_freeze_cols(ref: str, cols: dict, pal: str, base: str) -> int:
|
|
249
|
+
import glob
|
|
250
|
+
global classify
|
|
251
|
+
globals()['ACCENT'] = _rgb(cols['accent'])
|
|
252
|
+
classify = make_classifier(cols)
|
|
253
|
+
files = sorted(glob.glob(os.path.join(ref, '*.png')))
|
|
254
|
+
if not files:
|
|
255
|
+
sys.exit(f'참조 세트가 없다: {ref}')
|
|
256
|
+
rows = {}
|
|
257
|
+
for f in files:
|
|
258
|
+
name = os.path.basename(f)[:-4].replace('lim-report-', '').replace('lim-', '')
|
|
259
|
+
rows[name] = measure(f)
|
|
260
|
+
p = rows[name]['pct']
|
|
261
|
+
print(f" {name:14} W{p['white']:6.2f}% S{p['surface']:5.2f}% "
|
|
262
|
+
f"A{p['accent']:5.2f}% I{p['ink']:5.2f}% {rows[name]['accent_rgb']}")
|
|
263
|
+
|
|
264
|
+
out = {
|
|
265
|
+
'note': '참조 세트에서 뜬 시각 기준선. 손으로 고친 값을 넣지 말 것 — freeze 로만 갱신한다.',
|
|
266
|
+
'source': os.path.relpath(ref, ROOT) + '/*.png',
|
|
267
|
+
'palette': pal,
|
|
268
|
+
'colors': cols,
|
|
269
|
+
'sample_px': SAMPLE,
|
|
270
|
+
'assets': rows,
|
|
271
|
+
'bands': derive_bands(rows),
|
|
272
|
+
}
|
|
273
|
+
with open(base, 'w', encoding='utf-8') as fh:
|
|
274
|
+
json.dump(out, fh, ensure_ascii=False, indent=2)
|
|
275
|
+
fh.write('\n')
|
|
276
|
+
b = out['bands']
|
|
277
|
+
print(f"\n → {os.path.relpath(base, ROOT)}")
|
|
278
|
+
print(f" 강조 {b['accent'][0]}–{b['accent'][1]}% · White {b['white'][0]}–{b['white'][1]}% "
|
|
279
|
+
f"· Surface {b['surface'][0]}–{b['surface'][1]}% · Ink {b['ink'][0]}–{b['ink'][1]}%")
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def judge(path: str, bands: dict, is_hero: bool | None = None) -> tuple[bool, list[str]]:
|
|
284
|
+
m = measure(path)
|
|
285
|
+
name = os.path.basename(path)
|
|
286
|
+
if is_hero is None:
|
|
287
|
+
is_hero = 'hero' in name.lower()
|
|
288
|
+
bad = []
|
|
289
|
+
|
|
290
|
+
for key, label in (('accent', '강조'), ('white', 'White'), ('surface', 'Surface'), ('ink', 'Ink')):
|
|
291
|
+
lo, hi = bands[key]
|
|
292
|
+
v = m['pct'][key]
|
|
293
|
+
if not (lo <= v <= hi):
|
|
294
|
+
bad.append(f'{label} {v}% (밴드 {lo}–{hi}%)')
|
|
295
|
+
|
|
296
|
+
if m['accent_dist'] > bands['accent_dist_max']:
|
|
297
|
+
bad.append(f"강조색 {m['accent_rgb']} 거리 {m['accent_dist']} (한도 {bands['accent_dist_max']})")
|
|
298
|
+
|
|
299
|
+
# ⚠ 글자리(밝기·쪽) 두 축은 **뺐다**(2026-08-18).
|
|
300
|
+
#
|
|
301
|
+
# 그 축은 글이 그림 **위에** 얹힐 때만 뜻이 있었다. 배치가 좌우 분리로 정정되면서
|
|
302
|
+
# (계약 4.2절 v1.4) 그림 위에는 아무것도 앉지 않는다 — 잴 것이 없다.
|
|
303
|
+
# 안 재는 것을 재는 척하는 관문은 없는 관문보다 나쁘다: 초록불이 근거로 인용된다.
|
|
304
|
+
#
|
|
305
|
+
# 측정 자체(`text_bg_l`·`text_bg_r`·`text_side`)는 남겨 둔다. 판정에는 안 쓰지만
|
|
306
|
+
# 「이 그림은 어느 쪽이 밝은가」는 배치를 고를 때 사람에게 쓸모가 있다.
|
|
307
|
+
|
|
308
|
+
return (not bad), bad
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def cmd_check(paths: list[str], base: str) -> int:
|
|
312
|
+
bands = load_baseline(base)['bands']
|
|
313
|
+
if not paths:
|
|
314
|
+
sys.exit('검사할 파일을 달라')
|
|
315
|
+
# `그림.png:r` 은 **더 이상 판정에 안 쓴다**(위 judge 주석). 옛 명령이 죽지 않게
|
|
316
|
+
# 받아서 떼기만 한다.
|
|
317
|
+
sides = {}
|
|
318
|
+
clean = []
|
|
319
|
+
for raw in paths:
|
|
320
|
+
if raw.endswith((':l', ':r')) and not os.path.exists(raw):
|
|
321
|
+
f, sd = raw[:-2], raw[-1]
|
|
322
|
+
sides[f] = sd
|
|
323
|
+
clean.append(f)
|
|
324
|
+
else:
|
|
325
|
+
clean.append(raw)
|
|
326
|
+
paths = clean
|
|
327
|
+
fail = 0
|
|
328
|
+
for p in paths:
|
|
329
|
+
# 없는 파일에 예외를 던지면 **관문이 죽는다** — 죽은 관문은 통과로 오독된다.
|
|
330
|
+
# 생성이 덜 끝났을 때 실제로 그렇게 죽었다(2026-08-17). 없는 것도 실패로 센다.
|
|
331
|
+
if not os.path.exists(p):
|
|
332
|
+
print(f' [실패] {os.path.basename(p)}\n ✗ 파일이 없다 — 생성이 안 끝났다')
|
|
333
|
+
fail += 1
|
|
334
|
+
continue
|
|
335
|
+
ok, bad = judge(p, bands)
|
|
336
|
+
mark = '통과' if ok else '실패'
|
|
337
|
+
m = measure(p)
|
|
338
|
+
print(f" [{mark}] {os.path.basename(p)} (밝은 쪽 {m['text_side']})")
|
|
339
|
+
for b in bad:
|
|
340
|
+
print(f' ✗ {b}')
|
|
341
|
+
fail += 0 if ok else 1
|
|
342
|
+
print(f"\n {len(paths)-fail}/{len(paths)} 통과")
|
|
343
|
+
return 1 if fail else 0
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def cmd_selftest(ref: str, base: str) -> int:
|
|
347
|
+
"""대조군 없는 검사는 증명이 아니다 — 양성과 음성을 둘 다 본다."""
|
|
348
|
+
import glob
|
|
349
|
+
import io
|
|
350
|
+
bands = load_baseline(base)['bands']
|
|
351
|
+
files = sorted(glob.glob(os.path.join(ref, '*.png')))
|
|
352
|
+
art = [f for f in files if 'eof' not in os.path.basename(f)]
|
|
353
|
+
|
|
354
|
+
print(' ① 항등 — 정본을 넣으면 전부 통과해야 한다')
|
|
355
|
+
ok_n = 0
|
|
356
|
+
for f in art:
|
|
357
|
+
ok, bad = judge(f, bands)
|
|
358
|
+
ok_n += ok
|
|
359
|
+
if not ok:
|
|
360
|
+
print(f" ✗ {os.path.basename(f)}: {'; '.join(bad)}")
|
|
361
|
+
print(f' {ok_n}/{len(art)} 통과')
|
|
362
|
+
|
|
363
|
+
print(' ② 뮤테이션 — 흐트러뜨린 것은 떨어져야 한다')
|
|
364
|
+
muts = {
|
|
365
|
+
'강조색을 흐리게': lambda p: (p[0] - 20, p[1] - 40, p[2] - 10) if classify(p) == 'accent' else p,
|
|
366
|
+
'강조색을 넓은 면으로': None, # 아래에서 따로 만든다
|
|
367
|
+
'전체를 회색으로': lambda p: tuple(min(255, int(c * .55 + 90)) for c in p),
|
|
368
|
+
}
|
|
369
|
+
caught = 0
|
|
370
|
+
total = 0
|
|
371
|
+
src = art[0]
|
|
372
|
+
tmp = os.path.join(HERE, '.mutation.png')
|
|
373
|
+
for label, fn in muts.items():
|
|
374
|
+
im = Image.open(src).convert('RGB')
|
|
375
|
+
if fn is None: # 라임을 프레임의 큰 띠로 칠한다
|
|
376
|
+
w, h = im.size
|
|
377
|
+
for y in range(int(h * .40), int(h * .62)):
|
|
378
|
+
for x in range(0, w):
|
|
379
|
+
im.putpixel((x, y), ACCENT)
|
|
380
|
+
else:
|
|
381
|
+
im = Image.eval(im, lambda v: v) if False else Image.merge(
|
|
382
|
+
'RGB', im.split()) # 사본 확보
|
|
383
|
+
px = im.load()
|
|
384
|
+
w, h = im.size
|
|
385
|
+
for y in range(h):
|
|
386
|
+
for x in range(w):
|
|
387
|
+
px[x, y] = tuple(max(0, min(255, c)) for c in fn(px[x, y]))
|
|
388
|
+
im.save(tmp)
|
|
389
|
+
ok, bad = judge(tmp, bands, is_hero=False)
|
|
390
|
+
total += 1
|
|
391
|
+
caught += (not ok)
|
|
392
|
+
print(f" [{'잡음' if not ok else '놓침'}] {label}"
|
|
393
|
+
+ (f" ({bad[0]})" if bad else ''))
|
|
394
|
+
if os.path.exists(tmp):
|
|
395
|
+
os.remove(tmp)
|
|
396
|
+
print(f' {caught}/{total} 잡음')
|
|
397
|
+
|
|
398
|
+
good = ok_n == len(art) and caught == total
|
|
399
|
+
print(f"\n 판정: {'통과' if good else '실패'} "
|
|
400
|
+
f"— 항등 {ok_n}/{len(art)} · 뮤테이션 {caught}/{total}")
|
|
401
|
+
return 0 if good else 1
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def main() -> int:
|
|
405
|
+
"""**어느 프로젝트에서도 돌아간다.** 참조 세트와 색을 인자로 받는다.
|
|
406
|
+
|
|
407
|
+
# 팔레트 이름으로 (그 프로젝트의 palette.py 를 가리킨다)
|
|
408
|
+
python3 visual-baseline.py freeze --ref <원본폴더> \\
|
|
409
|
+
--palette-module <프로젝트>/palette.py --palette <이름> --base <기준선.json>
|
|
410
|
+
|
|
411
|
+
# 팔레트 모듈이 없으면 색을 직접
|
|
412
|
+
python3 visual-baseline.py freeze --ref <원본폴더> \\
|
|
413
|
+
--accent '#C9A961' --ink '#141413' --surface '#FAF9F5' --base <기준선.json>
|
|
414
|
+
|
|
415
|
+
python3 visual-baseline.py check --base <기준선.json> <png>...
|
|
416
|
+
python3 visual-baseline.py selftest --ref <원본폴더> --base <기준선.json>
|
|
417
|
+
|
|
418
|
+
`check` 는 기준선이 지고 있는 색으로 판정하므로 인자가 `--base` 하나면 된다.
|
|
419
|
+
"""
|
|
420
|
+
argv = sys.argv[1:]
|
|
421
|
+
cmd = argv[0] if argv else ''
|
|
422
|
+
opt = {'--ref': DEFAULT_REF, '--palette': DEFAULT_PALETTE, '--base': None,
|
|
423
|
+
'--palette-module': None, '--accent': None, '--ink': None,
|
|
424
|
+
'--surface': None, '--white': None}
|
|
425
|
+
files, i = [], 1
|
|
426
|
+
while i < len(argv):
|
|
427
|
+
a = argv[i]
|
|
428
|
+
if a in opt and i + 1 < len(argv):
|
|
429
|
+
opt[a] = argv[i + 1]; i += 2; continue
|
|
430
|
+
files.append(a); i += 1
|
|
431
|
+
|
|
432
|
+
if cmd in ('freeze', 'selftest') and not opt['--ref']:
|
|
433
|
+
sys.exit('참조 세트를 달라: --ref <원본 PNG 들이 있는 폴더>')
|
|
434
|
+
if not opt['--base']:
|
|
435
|
+
if cmd == 'freeze':
|
|
436
|
+
sys.exit('기준선 파일 경로를 달라: --base <어디에 쓸지>.json')
|
|
437
|
+
sys.exit('기준선 파일을 달라: --base <freeze 로 만든>.json')
|
|
438
|
+
|
|
439
|
+
if cmd == 'freeze':
|
|
440
|
+
if opt['--accent']: # 색을 직접 준 경우
|
|
441
|
+
cols = {'white': opt['--white'] or '#FFFFFF',
|
|
442
|
+
'surface': opt['--surface'] or '#F3F3F3',
|
|
443
|
+
'ink': opt['--ink'] or '#191A23', 'accent': opt['--accent']}
|
|
444
|
+
return cmd_freeze_cols(opt['--ref'], cols, opt['--palette'] or '(직접 지정)',
|
|
445
|
+
opt['--base'])
|
|
446
|
+
if not opt['--palette']:
|
|
447
|
+
sys.exit('팔레트 이름(--palette) 이나 색(--accent …) 중 하나는 줘야 한다')
|
|
448
|
+
return cmd_freeze(opt['--ref'], opt['--palette'], opt['--base'],
|
|
449
|
+
opt['--palette-module'])
|
|
450
|
+
if cmd == 'check':
|
|
451
|
+
return cmd_check(files, opt['--base'])
|
|
452
|
+
if cmd == 'selftest':
|
|
453
|
+
return cmd_selftest(opt['--ref'], opt['--base'])
|
|
454
|
+
print(__doc__)
|
|
455
|
+
return 2
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
if __name__ == '__main__':
|
|
459
|
+
raise SystemExit(main())
|