nai-aclab 1.1.1 → 1.2.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/README.md +59 -9
- package/app.py +340 -17
- package/package.json +1 -1
- package/web/index.html +130 -72
- package/web/main.js +725 -78
- package/web/styles.css +448 -2
- package/web_app.py +98 -50
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ NovelAI에서 여러 작가 태그 조합을 시험해보고, 마음에 드는
|
|
|
4
4
|
|
|
5
5
|
예를 들어 같은 캐릭터 프롬프트를 두고 작가 태그의 가중치만 바꿔 여러 장을 만든 뒤, 마음에 드는 이미지의 가중치를 다시 불러와 고정해서 더 테스트할 수 있습니다.
|
|
6
6
|
|
|
7
|
+
이미지에 `화풍 만족도`와 `신체 안정성` 평점을 남기면 앱이 메인 화풍 작가와 안정화 작가의 적절한 가중치를 따로 학습합니다. 자주 함께 쓰는 베이스와 캐릭터 프리셋은 하나의 세트로 묶어 한 번에 선택할 수 있습니다.
|
|
8
|
+
|
|
7
9
|
## 이런 분에게 좋습니다
|
|
8
10
|
|
|
9
11
|
- NovelAI에서 그림체 조합을 여러 번 실험하는 분
|
|
@@ -61,10 +63,10 @@ nai-aclab
|
|
|
61
63
|
|
|
62
64
|
1. `API 설정` 메뉴로 이동합니다.
|
|
63
65
|
2. NovelAI API 토큰을 입력합니다.
|
|
64
|
-
3.
|
|
66
|
+
3. `API 없이 체험하기`를 끕니다.
|
|
65
67
|
4. `설정 저장`을 누릅니다.
|
|
66
68
|
|
|
67
|
-
API 토큰을 아직 넣고 싶지 않다면
|
|
69
|
+
API 토큰을 아직 넣고 싶지 않다면 `API 없이 체험하기`를 켜둔 채로 UI만 먼저 테스트할 수 있습니다. 체험 모드에서는 실제 NovelAI API를 쓰지 않고 테스트용 이미지를 만듭니다.
|
|
68
70
|
|
|
69
71
|
대부분의 사용자는 `Endpoint`, `Model`, `Sampler`, `Noise Schedule` 같은 값은 기본값 그대로 두면 됩니다. 처음 설치하면 이미지 모델은 `NAI Diffusion V5 Full`로 설정됩니다.
|
|
70
72
|
|
|
@@ -91,10 +93,41 @@ artist:sample_a
|
|
|
91
93
|
|
|
92
94
|
앱은 여기서 `artist:*` 부분만 자동으로 찾아냅니다.
|
|
93
95
|
|
|
94
|
-
|
|
96
|
+
`한 번에 사용할 태그 수`를 비워두면 그 카테고리에 적은 작가 태그를 모두 사용합니다. 일부만 랜덤으로 뽑고 싶을 때만 숫자를 입력하면 됩니다.
|
|
97
|
+
|
|
98
|
+
아래의 `인식된 artist 태그`에 원하는 태그가 제대로 표시되는지만 확인하면 됩니다. 저장은 입력과 동시에 자동으로 처리됩니다.
|
|
95
99
|
|
|
96
100
|

|
|
97
101
|
|
|
102
|
+
### 평점으로 취향 학습시키기
|
|
103
|
+
|
|
104
|
+
이미지를 여러 장 만든 뒤 생성 화면의 실시간 미리보기에서 두 가지 1~5점 평점을 남길 수 있습니다. 히스토리와 가중치 비교에서 이미지를 클릭해 평가해도 됩니다.
|
|
105
|
+
|
|
106
|
+
| 평점 항목 | 평가 기준 | 다음 생성에서의 변화 |
|
|
107
|
+
| --- | --- | --- |
|
|
108
|
+
| 화풍 만족도 | 완성된 그림체가 내 취향에 맞는지 | 메인 화풍 작가의 포함 확률과 가중치 방향을 조정합니다. |
|
|
109
|
+
| 신체 안정성 | 인체·손발이 자연스럽고 안정화 작가가 메인 화풍을 과하게 덮지 않는지 | 안정화 작가마다 좋은 결과를 낸 가중치를 더 자주 선택합니다. |
|
|
110
|
+
|
|
111
|
+
4~5점은 좋은 결과, 3점은 중립, 1~2점은 좋지 않은 결과로 해석합니다. 평가가 한두 장뿐일 때는 우연한 결과에 지나치게 끌려가지 않도록 점수를 3점에 가깝게 완화합니다.
|
|
112
|
+
|
|
113
|
+
알아둘 점:
|
|
114
|
+
|
|
115
|
+
- `메인 화풍` 역할의 카테고리에는 화풍 만족도만 반영됩니다.
|
|
116
|
+
- `신체 안정화` 역할의 카테고리에는 신체 안정성만 반영됩니다.
|
|
117
|
+
- 안정화 학습은 작가 이름만 보지 않고 `작가 + 실제 가중치` 조합별로 점수를 모읍니다.
|
|
118
|
+
- 아직 평가가 적은 가중치도 가끔 다시 사용하여 더 좋은 값을 놓치지 않도록 합니다.
|
|
119
|
+
- `한 번에 사용할 태그 수`를 비워두면 모든 작가를 사용하므로 포함 확률보다 가중치 선택에 학습 결과가 더 많이 반영됩니다.
|
|
120
|
+
- 타율 테스트는 이미 불러온 작가 조합을 고정해 여러 씬에서 검증하는 기능이므로 평점을 받지 않으며 학습에도 포함되지 않습니다.
|
|
121
|
+
- 학습 내용을 지우고 처음부터 다시 시작하려면 `취향 학습` 메뉴에서 `평점 초기화`를 누르세요.
|
|
122
|
+
|
|
123
|
+
`취향 학습` 메뉴에서는 메인 화풍 선호 순위와 안정화 작가별 추천 가중치를 따로 확인할 수 있습니다. 작가별 `포함 ×1.20`은 중립 작가보다 선택 가중치가 약 20% 높다는 뜻입니다. 안정화 작가에는 지금까지 시험한 가중치 수, 추천 가중치와 보정 점수가 표시됩니다.
|
|
124
|
+
|
|
125
|
+
기존 버전에서 남긴 단일 평점은 업데이트 후 `화풍 만족도`로 자동 이전됩니다. 신체 안정성은 업데이트 이후 생성한 결과부터 별도로 평가하면 됩니다.
|
|
126
|
+
|
|
127
|
+
NAI V5에서는 작가 태그 순서도 결과에 영향을 줄 수 있어, 랜덤 모드에서는 높은 가중치의 태그를 프롬프트 앞과 뒤에 번갈아 배치합니다. 가장 높은 태그가 앞에 올지 뒤에 올지는 생성할 때마다 무작위로 정합니다. 이미지에서 `가중치 불러오기`를 누르면 가중치뿐 아니라 그 이미지의 작가 태그 순서도 함께 고정됩니다.
|
|
128
|
+
|
|
129
|
+

|
|
130
|
+
|
|
98
131
|
### 2. 프리셋 만들기
|
|
99
132
|
|
|
100
133
|
`프리셋` 메뉴에서 자주 쓸 프롬프트를 저장합니다.
|
|
@@ -114,13 +147,15 @@ artist:sample_a
|
|
|
114
147
|
|
|
115
148
|
NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지 않고 `|`로 나눕니다.
|
|
116
149
|
|
|
150
|
+
자주 함께 쓰는 `베이스 + 퀄리티`와 `캐릭터` 프리셋은 아래의 `프리셋 세트`에서 하나로 묶을 수 있습니다. 두 프리셋의 이름이 같으면 별도 설정 없이 자동 세트가 만들어집니다. 이후 생성이나 타율 테스트에서 세트 하나만 고르면 두 프리셋이 함께 선택됩니다.
|
|
151
|
+
|
|
117
152
|

|
|
118
153
|
|
|
119
154
|
### 3. 이미지 생성
|
|
120
155
|
|
|
121
156
|
`생성` 메뉴에서 사용할 프리셋과 이미지 크기를 고른 뒤 `이미지 생성`을 누릅니다.
|
|
122
157
|
|
|
123
|
-
생성 중에는 `실시간 이미지 미리보기`에서 결과를 바로 확인할 수 있습니다.
|
|
158
|
+
생성 중에는 `실시간 이미지 미리보기`에서 결과를 바로 확인할 수 있습니다. 생성이 끝난 이미지 카드에는 화풍과 신체 별점이 각각 나타나므로 메뉴를 이동하지 않고 바로 평가할 수 있습니다.
|
|
124
159
|
|
|
125
160
|
마음에 드는 이미지가 나오면 히스토리나 가중치 비교 화면에서 `가중치 불러오기`를 누릅니다. 그러면 해당 이미지에 쓰인 작가 가중치가 생성 메뉴에 고정됩니다.
|
|
126
161
|
|
|
@@ -147,10 +182,22 @@ NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지
|
|
|
147
182
|
|
|
148
183
|
기본 카테고리는 두 개입니다.
|
|
149
184
|
|
|
150
|
-
- 메인 그림체
|
|
151
|
-
- 그림체 안정화
|
|
185
|
+
- 메인 그림체 작가: `메인 화풍` 학습 역할
|
|
186
|
+
- 그림체 안정화 작가: `신체 안정화` 학습 역할
|
|
187
|
+
|
|
188
|
+
필요하면 직접 카테고리를 추가할 수 있습니다. 새 카테고리를 만들 때 `학습 역할`에서 어떤 평점을 사용할지 선택하세요.
|
|
152
189
|
|
|
153
|
-
|
|
190
|
+
이 화면은 태그 입력, 인식 결과, 사용할 태그 수와 가중치 범위를 설정하는 데 사용합니다. 평점 결과와 학습 상태는 별도의 `취향 학습` 메뉴에서 확인합니다.
|
|
191
|
+
|
|
192
|
+
### 취향 학습
|
|
193
|
+
|
|
194
|
+
이미지 생성 결과에 남긴 평점이 다음 랜덤 생성에 어떻게 반영되는지 확인하는 화면입니다.
|
|
195
|
+
|
|
196
|
+
- 화풍 만족도와 신체 안정성의 평가 이미지 수
|
|
197
|
+
- 메인 화풍 작가 선호 순위와 포함 배율
|
|
198
|
+
- 안정화 작가별 추천 가중치와 시험한 가중치 수
|
|
199
|
+
- 카테고리별 학습 역할과 선택 확률 보정 여부
|
|
200
|
+
- 모든 평점과 학습 결과 초기화
|
|
154
201
|
|
|
155
202
|
### 프리셋
|
|
156
203
|
|
|
@@ -167,6 +214,7 @@ NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지
|
|
|
167
214
|
- 마우스 휠로 확대/축소
|
|
168
215
|
- 드래그로 확대된 이미지 이동
|
|
169
216
|
- 마음에 드는 이미지의 가중치 불러오기
|
|
217
|
+
- 이미지에 화풍 만족도와 신체 안정성 평점 남기기
|
|
170
218
|
- 히스토리 선택 삭제 또는 전체 삭제
|
|
171
219
|
- 이상형 월드컵 실행
|
|
172
220
|
|
|
@@ -193,7 +241,7 @@ NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지
|
|
|
193
241
|
여기서 `씬`은 아래 조합을 뜻합니다.
|
|
194
242
|
|
|
195
243
|
```text
|
|
196
|
-
씬 = 베이스+퀄리티 프리셋 + 캐릭터 프리셋 + 생성 개수
|
|
244
|
+
씬 = 베이스+퀄리티 프리셋 + 캐릭터 프리셋 + 이미지 해상도 + 생성 개수
|
|
197
245
|
```
|
|
198
246
|
|
|
199
247
|
사용 순서:
|
|
@@ -206,6 +254,8 @@ NovelAI V4+의 멀티 캐릭터 프롬프트는 캐릭터를 쉼표로 합치지
|
|
|
206
254
|
|
|
207
255
|
생성 중 멈추고 싶으면 `생성 중지`를 누르면 됩니다. 현재 처리 중인 이미지가 끝난 뒤 남은 생성을 멈춥니다.
|
|
208
256
|
|
|
257
|
+
타율 테스트 화면에서는 실시간 미리보기와 전체 진행 상태가 가장 먼저 표시됩니다. 작가 가중치와 순서는 모든 씬에서 그대로 유지되며, 타율 테스트 결과는 취향 학습 평점에서 제외됩니다.
|
|
258
|
+
|
|
209
259
|

|
|
210
260
|
|
|
211
261
|
## 이미지 크기
|
|
@@ -255,7 +305,7 @@ Windows에서 Python을 설치할 때는 `Add Python to PATH` 옵션을 켜는
|
|
|
255
305
|
|
|
256
306
|
### 이미지가 실제로 생성되지 않고 테스트 이미지만 나옵니다
|
|
257
307
|
|
|
258
|
-
`API 설정`에서
|
|
308
|
+
`API 설정`에서 `API 없이 체험하기`가 켜져 있는지 확인하세요. 실제 NovelAI 생성을 하려면 체험 모드를 꺼야 합니다.
|
|
259
309
|
|
|
260
310
|
### 기본 endpoint가 아닌 주소라는 경고가 나옵니다
|
|
261
311
|
|
package/app.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import base64
|
|
4
4
|
import io
|
|
5
5
|
import json
|
|
6
|
+
import math
|
|
6
7
|
import os
|
|
7
8
|
import random
|
|
8
9
|
import re
|
|
@@ -159,6 +160,9 @@ def sanitize_history_item(item: dict) -> dict:
|
|
|
159
160
|
for key, value in dict(item or {}).items()
|
|
160
161
|
if key not in {"prompt", "negative_prompt", "uc_prompt", "request_path", "request_url"}
|
|
161
162
|
}
|
|
163
|
+
if "style_rating" not in clean and "rating" in clean:
|
|
164
|
+
clean["style_rating"] = clean.get("rating")
|
|
165
|
+
clean.pop("rating", None)
|
|
162
166
|
if "path" in clean:
|
|
163
167
|
clean["path"] = output_ref(clean.get("path", ""))
|
|
164
168
|
return clean
|
|
@@ -264,6 +268,11 @@ class Category:
|
|
|
264
268
|
max_weight: float
|
|
265
269
|
granule: float
|
|
266
270
|
picks: int = 0
|
|
271
|
+
learning_role: str = ""
|
|
272
|
+
|
|
273
|
+
def __post_init__(self) -> None:
|
|
274
|
+
if self.learning_role not in {"style", "stability"}:
|
|
275
|
+
self.learning_role = "stability" if "안정" in self.name else "style"
|
|
267
276
|
|
|
268
277
|
|
|
269
278
|
@dataclass
|
|
@@ -282,6 +291,14 @@ class CharacterPreset:
|
|
|
282
291
|
quality_override_prompt: str = ""
|
|
283
292
|
|
|
284
293
|
|
|
294
|
+
@dataclass
|
|
295
|
+
class PresetSet:
|
|
296
|
+
name: str
|
|
297
|
+
base_preset: str = ""
|
|
298
|
+
character_preset: str = ""
|
|
299
|
+
auto: bool = False
|
|
300
|
+
|
|
301
|
+
|
|
285
302
|
@dataclass
|
|
286
303
|
class ApiSettings:
|
|
287
304
|
token: str = ""
|
|
@@ -303,6 +320,7 @@ class ApiSettings:
|
|
|
303
320
|
|
|
304
321
|
@dataclass
|
|
305
322
|
class GenerationSettings:
|
|
323
|
+
preset_set: str = ""
|
|
306
324
|
base_preset: str = ""
|
|
307
325
|
character_preset: str = ""
|
|
308
326
|
count: int = 4
|
|
@@ -315,6 +333,7 @@ class GenerationSettings:
|
|
|
315
333
|
@dataclass
|
|
316
334
|
class BattingScene:
|
|
317
335
|
name: str = ""
|
|
336
|
+
preset_set: str = ""
|
|
318
337
|
base_preset: str = ""
|
|
319
338
|
character_preset: str = ""
|
|
320
339
|
image_size: str = "portrait"
|
|
@@ -326,6 +345,7 @@ class AppState:
|
|
|
326
345
|
categories: list[Category] = field(default_factory=list)
|
|
327
346
|
base_presets: list[PromptPreset] = field(default_factory=list)
|
|
328
347
|
character_presets: list[CharacterPreset] = field(default_factory=list)
|
|
348
|
+
preset_sets: list[PresetSet] = field(default_factory=list)
|
|
329
349
|
quality_override_prompt: str = ""
|
|
330
350
|
negative_prompt: str = "lowres, bad anatomy, bad hands, text, error, missing fingers"
|
|
331
351
|
api: ApiSettings = field(default_factory=ApiSettings)
|
|
@@ -334,11 +354,321 @@ class AppState:
|
|
|
334
354
|
history: list[dict] = field(default_factory=list)
|
|
335
355
|
|
|
336
356
|
|
|
357
|
+
def sync_auto_preset_sets(state: AppState) -> AppState:
|
|
358
|
+
base_names = {item.name for item in state.base_presets if item.name.strip()}
|
|
359
|
+
character_names = {item.name for item in state.character_presets if item.name.strip()}
|
|
360
|
+
manual_sets = [
|
|
361
|
+
item
|
|
362
|
+
for item in state.preset_sets
|
|
363
|
+
if not item.auto and item.base_preset in base_names and item.character_preset in character_names
|
|
364
|
+
]
|
|
365
|
+
used_names = {item.name for item in manual_sets}
|
|
366
|
+
auto_sets: list[PresetSet] = []
|
|
367
|
+
for common_name in sorted(base_names & character_names):
|
|
368
|
+
matching_manual = next(
|
|
369
|
+
(
|
|
370
|
+
item
|
|
371
|
+
for item in manual_sets
|
|
372
|
+
if item.base_preset == common_name and item.character_preset == common_name
|
|
373
|
+
),
|
|
374
|
+
None,
|
|
375
|
+
)
|
|
376
|
+
if matching_manual:
|
|
377
|
+
continue
|
|
378
|
+
set_name = common_name
|
|
379
|
+
if set_name in used_names:
|
|
380
|
+
base_name = f"{common_name} (자동)"
|
|
381
|
+
set_name = base_name
|
|
382
|
+
suffix = 2
|
|
383
|
+
while set_name in used_names:
|
|
384
|
+
set_name = f"{base_name} {suffix}"
|
|
385
|
+
suffix += 1
|
|
386
|
+
used_names.add(set_name)
|
|
387
|
+
auto_sets.append(PresetSet(set_name, common_name, common_name, True))
|
|
388
|
+
state.preset_sets = manual_sets + auto_sets
|
|
389
|
+
|
|
390
|
+
valid_set_names = {item.name for item in state.preset_sets}
|
|
391
|
+
if state.generation.preset_set not in valid_set_names:
|
|
392
|
+
matching_set = next(
|
|
393
|
+
(
|
|
394
|
+
item
|
|
395
|
+
for item in state.preset_sets
|
|
396
|
+
if item.base_preset == state.generation.base_preset
|
|
397
|
+
and item.character_preset == state.generation.character_preset
|
|
398
|
+
),
|
|
399
|
+
None,
|
|
400
|
+
)
|
|
401
|
+
state.generation.preset_set = matching_set.name if matching_set else ""
|
|
402
|
+
if state.generation.preset_set:
|
|
403
|
+
selected_set = next(item for item in state.preset_sets if item.name == state.generation.preset_set)
|
|
404
|
+
state.generation.base_preset = selected_set.base_preset
|
|
405
|
+
state.generation.character_preset = selected_set.character_preset
|
|
406
|
+
for scene in state.batting_scenes:
|
|
407
|
+
if scene.preset_set not in valid_set_names:
|
|
408
|
+
matching_set = next(
|
|
409
|
+
(
|
|
410
|
+
item
|
|
411
|
+
for item in state.preset_sets
|
|
412
|
+
if item.base_preset == scene.base_preset
|
|
413
|
+
and item.character_preset == scene.character_preset
|
|
414
|
+
),
|
|
415
|
+
None,
|
|
416
|
+
)
|
|
417
|
+
scene.preset_set = matching_set.name if matching_set else ""
|
|
418
|
+
if scene.preset_set:
|
|
419
|
+
selected_set = next(item for item in state.preset_sets if item.name == scene.preset_set)
|
|
420
|
+
scene.base_preset = selected_set.base_preset
|
|
421
|
+
scene.character_preset = selected_set.character_preset
|
|
422
|
+
return state
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
ARTIST_RATING_PRIOR_COUNT = 4
|
|
426
|
+
ARTIST_RATING_PRIOR_VALUE = 3.0
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _artist_learning_role(artist: dict) -> str:
|
|
430
|
+
role = str(artist.get("learning_role", "")).strip().lower()
|
|
431
|
+
if role in {"style", "stability"}:
|
|
432
|
+
return role
|
|
433
|
+
return "stability" if "안정" in str(artist.get("category", "")) else "style"
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _rating_value(item: dict, field: str) -> int:
|
|
437
|
+
value = item.get(field)
|
|
438
|
+
if value in (None, "") and field == "style_rating":
|
|
439
|
+
value = item.get("rating")
|
|
440
|
+
try:
|
|
441
|
+
rating = int(value or 0)
|
|
442
|
+
except (TypeError, ValueError):
|
|
443
|
+
return 0
|
|
444
|
+
return rating if 1 <= rating <= 5 else 0
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _finish_rating_entry(entry: dict) -> dict:
|
|
448
|
+
count = entry["count"]
|
|
449
|
+
smoothed = (
|
|
450
|
+
entry["rating_sum"] + ARTIST_RATING_PRIOR_VALUE * ARTIST_RATING_PRIOR_COUNT
|
|
451
|
+
) / (count + ARTIST_RATING_PRIOR_COUNT)
|
|
452
|
+
signal = max(-1.0, min(1.0, (smoothed - ARTIST_RATING_PRIOR_VALUE) / 2.0))
|
|
453
|
+
entry["average_rating"] = round(entry["rating_sum"] / count, 3)
|
|
454
|
+
entry["smoothed_rating"] = round(smoothed, 3)
|
|
455
|
+
entry["preference_signal"] = signal
|
|
456
|
+
entry["selection_multiplier"] = round(math.exp(signal), 3)
|
|
457
|
+
return entry
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def artist_rating_summary(history: list[dict]) -> dict[str, dict]:
|
|
461
|
+
summary: dict[str, dict] = {}
|
|
462
|
+
for history_entry in history or []:
|
|
463
|
+
if history_entry.get("type") == "batting_test":
|
|
464
|
+
continue
|
|
465
|
+
for item in history_entry.get("items", []) or []:
|
|
466
|
+
rating = _rating_value(item, "style_rating")
|
|
467
|
+
if not rating:
|
|
468
|
+
continue
|
|
469
|
+
seen = set()
|
|
470
|
+
for artist in item.get("artists", []) or []:
|
|
471
|
+
if _artist_learning_role(artist) != "style":
|
|
472
|
+
continue
|
|
473
|
+
tag = str(artist.get("tag", "")).strip()
|
|
474
|
+
key = tag.casefold()
|
|
475
|
+
if not key or key in seen:
|
|
476
|
+
continue
|
|
477
|
+
seen.add(key)
|
|
478
|
+
entry = summary.setdefault(key, {"tag": tag, "count": 0, "rating_sum": 0.0})
|
|
479
|
+
entry["count"] += 1
|
|
480
|
+
entry["rating_sum"] += rating
|
|
481
|
+
|
|
482
|
+
for entry in summary.values():
|
|
483
|
+
_finish_rating_entry(entry)
|
|
484
|
+
return summary
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def stability_rating_summary(history: list[dict]) -> dict[str, dict]:
|
|
488
|
+
summary: dict[str, dict] = {}
|
|
489
|
+
for history_entry in history or []:
|
|
490
|
+
if history_entry.get("type") == "batting_test":
|
|
491
|
+
continue
|
|
492
|
+
for item in history_entry.get("items", []) or []:
|
|
493
|
+
rating = _rating_value(item, "stability_rating")
|
|
494
|
+
if not rating:
|
|
495
|
+
continue
|
|
496
|
+
seen = set()
|
|
497
|
+
for artist in item.get("artists", []) or []:
|
|
498
|
+
if _artist_learning_role(artist) != "stability":
|
|
499
|
+
continue
|
|
500
|
+
tag = str(artist.get("tag", "")).strip()
|
|
501
|
+
try:
|
|
502
|
+
weight = round(float(artist.get("weight", 1.0)), 6)
|
|
503
|
+
except (TypeError, ValueError):
|
|
504
|
+
continue
|
|
505
|
+
key = tag.casefold()
|
|
506
|
+
pair_key = (key, weight)
|
|
507
|
+
if not key or pair_key in seen:
|
|
508
|
+
continue
|
|
509
|
+
seen.add(pair_key)
|
|
510
|
+
entry = summary.setdefault(
|
|
511
|
+
key,
|
|
512
|
+
{"tag": tag, "count": 0, "rating_sum": 0.0, "weights": {}},
|
|
513
|
+
)
|
|
514
|
+
entry["count"] += 1
|
|
515
|
+
entry["rating_sum"] += rating
|
|
516
|
+
weight_entry = entry["weights"].setdefault(
|
|
517
|
+
weight,
|
|
518
|
+
{"weight": weight, "count": 0, "rating_sum": 0.0},
|
|
519
|
+
)
|
|
520
|
+
weight_entry["count"] += 1
|
|
521
|
+
weight_entry["rating_sum"] += rating
|
|
522
|
+
|
|
523
|
+
for entry in summary.values():
|
|
524
|
+
_finish_rating_entry(entry)
|
|
525
|
+
weight_rows = []
|
|
526
|
+
for weight_entry in entry["weights"].values():
|
|
527
|
+
_finish_rating_entry(weight_entry)
|
|
528
|
+
weight_rows.append(weight_entry)
|
|
529
|
+
weight_rows.sort(key=lambda row: row["weight"])
|
|
530
|
+
best = max(
|
|
531
|
+
weight_rows,
|
|
532
|
+
key=lambda row: (row["smoothed_rating"], row["count"], -abs(row["weight"])),
|
|
533
|
+
)
|
|
534
|
+
entry["weights"] = weight_rows
|
|
535
|
+
entry["best_weight"] = best["weight"]
|
|
536
|
+
entry["best_smoothed_rating"] = best["smoothed_rating"]
|
|
537
|
+
entry["tested_weight_count"] = len(weight_rows)
|
|
538
|
+
best_signal = max(-1.0, min(1.0, (best["smoothed_rating"] - ARTIST_RATING_PRIOR_VALUE) / 2.0))
|
|
539
|
+
entry["selection_multiplier"] = round(math.exp(best_signal), 3)
|
|
540
|
+
return summary
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _weighted_sample_without_replacement(items: list[str], count: int, weights: list[float]) -> list[str]:
|
|
544
|
+
pool = list(items)
|
|
545
|
+
pool_weights = [max(0.001, float(value)) for value in weights]
|
|
546
|
+
result: list[str] = []
|
|
547
|
+
for _ in range(min(count, len(pool))):
|
|
548
|
+
selected_index = random.choices(range(len(pool)), weights=pool_weights, k=1)[0]
|
|
549
|
+
result.append(pool.pop(selected_index))
|
|
550
|
+
pool_weights.pop(selected_index)
|
|
551
|
+
return result
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _adaptive_weight(values: list[float], preference_signal: float) -> float:
|
|
555
|
+
if not values:
|
|
556
|
+
return 1.0
|
|
557
|
+
if len(values) == 1 or abs(preference_signal) < 0.001:
|
|
558
|
+
return random.choice(values)
|
|
559
|
+
likelihoods = []
|
|
560
|
+
for index in range(len(values)):
|
|
561
|
+
position = index / (len(values) - 1)
|
|
562
|
+
likelihoods.append(math.exp(1.35 * preference_signal * (position * 2.0 - 1.0)))
|
|
563
|
+
return random.choices(values, weights=likelihoods, k=1)[0]
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _stability_weight(values: list[float], rating: dict) -> float:
|
|
567
|
+
if not values:
|
|
568
|
+
return 1.0
|
|
569
|
+
if not rating:
|
|
570
|
+
return random.choice(values)
|
|
571
|
+
observed = {round(float(row["weight"]), 6): row for row in rating.get("weights", [])}
|
|
572
|
+
likelihoods = []
|
|
573
|
+
for value in values:
|
|
574
|
+
row = observed.get(round(float(value), 6), {})
|
|
575
|
+
count = int(row.get("count", 0))
|
|
576
|
+
estimate = float(row.get("smoothed_rating", ARTIST_RATING_PRIOR_VALUE))
|
|
577
|
+
exploration_bonus = 0.55 / math.sqrt(count + 1)
|
|
578
|
+
likelihoods.append(math.exp(1.45 * (estimate + exploration_bonus - ARTIST_RATING_PRIOR_VALUE)))
|
|
579
|
+
return random.choices(values, weights=likelihoods, k=1)[0]
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def random_artist_tags_for_state(state: AppState) -> list[dict]:
|
|
583
|
+
result = []
|
|
584
|
+
style_ratings = artist_rating_summary(state.history)
|
|
585
|
+
stability_ratings = stability_rating_summary(state.history)
|
|
586
|
+
for category in state.categories:
|
|
587
|
+
tags = parse_artist_tags(category.tags)
|
|
588
|
+
if not tags:
|
|
589
|
+
continue
|
|
590
|
+
role = category.learning_role if category.learning_role in {"style", "stability"} else "style"
|
|
591
|
+
ratings = style_ratings if role == "style" else stability_ratings
|
|
592
|
+
values = float_range(category.min_weight, category.max_weight, category.granule)
|
|
593
|
+
pick_count = len(tags) if category.picks <= 0 else min(category.picks, len(tags))
|
|
594
|
+
selection_weights = [ratings.get(tag.casefold(), {}).get("selection_multiplier", 1.0) for tag in tags]
|
|
595
|
+
selected_tags = _weighted_sample_without_replacement(tags, pick_count, selection_weights)
|
|
596
|
+
for tag in selected_tags:
|
|
597
|
+
rating = ratings.get(tag.casefold(), {})
|
|
598
|
+
weight = (
|
|
599
|
+
_adaptive_weight(values, float(rating.get("preference_signal", 0.0)))
|
|
600
|
+
if role == "style"
|
|
601
|
+
else _stability_weight(values, rating)
|
|
602
|
+
)
|
|
603
|
+
result.append(
|
|
604
|
+
{
|
|
605
|
+
"category": category.name,
|
|
606
|
+
"learning_role": role,
|
|
607
|
+
"tag": tag,
|
|
608
|
+
"weight": weight,
|
|
609
|
+
"prompt": weight_tag(tag, weight),
|
|
610
|
+
"rating_count": int(rating.get("count", 0)),
|
|
611
|
+
"learned_rating": rating.get("smoothed_rating"),
|
|
612
|
+
}
|
|
613
|
+
)
|
|
614
|
+
return result
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def fixed_artist_tags_for_state(state: AppState) -> list[dict]:
|
|
618
|
+
result = []
|
|
619
|
+
for item in state.generation.fixed_artists or []:
|
|
620
|
+
tag = str(item.get("tag", "")).strip()
|
|
621
|
+
if not tag:
|
|
622
|
+
continue
|
|
623
|
+
try:
|
|
624
|
+
weight = float(item.get("weight", 1.0))
|
|
625
|
+
except (TypeError, ValueError):
|
|
626
|
+
weight = 1.0
|
|
627
|
+
result.append(
|
|
628
|
+
{
|
|
629
|
+
"category": item.get("category", "fixed"),
|
|
630
|
+
"learning_role": item.get("learning_role") or _artist_learning_role(item),
|
|
631
|
+
"tag": tag,
|
|
632
|
+
"weight": weight,
|
|
633
|
+
"prompt": item.get("prompt") or weight_tag(tag, weight),
|
|
634
|
+
}
|
|
635
|
+
)
|
|
636
|
+
return result
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def order_artist_tags(artists: list[dict]) -> list[dict]:
|
|
640
|
+
if len(artists) < 2:
|
|
641
|
+
return list(artists)
|
|
642
|
+
shuffled = list(artists)
|
|
643
|
+
random.shuffle(shuffled)
|
|
644
|
+
ranked = sorted(shuffled, key=lambda item: float(item.get("weight", 0.0)), reverse=True)
|
|
645
|
+
ordered: list[dict | None] = [None] * len(ranked)
|
|
646
|
+
left = 0
|
|
647
|
+
right = len(ranked) - 1
|
|
648
|
+
highest_at_front = bool(random.getrandbits(1))
|
|
649
|
+
for index, artist in enumerate(ranked):
|
|
650
|
+
place_front = highest_at_front if index % 2 == 0 else not highest_at_front
|
|
651
|
+
if place_front:
|
|
652
|
+
ordered[left] = artist
|
|
653
|
+
left += 1
|
|
654
|
+
else:
|
|
655
|
+
ordered[right] = artist
|
|
656
|
+
right -= 1
|
|
657
|
+
return [item for item in ordered if item is not None]
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def artist_tags_for_prompt(state: AppState) -> list[dict]:
|
|
661
|
+
fixed = fixed_artist_tags_for_state(state)
|
|
662
|
+
if fixed:
|
|
663
|
+
return fixed
|
|
664
|
+
return order_artist_tags(random_artist_tags_for_state(state))
|
|
665
|
+
|
|
666
|
+
|
|
337
667
|
def default_state() -> AppState:
|
|
338
|
-
return AppState(
|
|
668
|
+
return sync_auto_preset_sets(AppState(
|
|
339
669
|
categories=[
|
|
340
|
-
Category("메인 그림체 작가", [], 1.0, 1.4, 0.05, 0),
|
|
341
|
-
Category("그림체 안정화 작가", [], 0.4, 0.9, 0.1, 0),
|
|
670
|
+
Category("메인 그림체 작가", [], 1.0, 1.4, 0.05, 0, "style"),
|
|
671
|
+
Category("그림체 안정화 작가", [], 0.4, 0.9, 0.1, 0, "stability"),
|
|
342
672
|
],
|
|
343
673
|
base_presets=[
|
|
344
674
|
PromptPreset("기본", "", "masterpiece, best quality, very aesthetic, detailed illustration"),
|
|
@@ -346,7 +676,7 @@ def default_state() -> AppState:
|
|
|
346
676
|
character_presets=[
|
|
347
677
|
CharacterPreset("1인 기본", ["1girl, looking at viewer, detailed eyes", "", ""], ["", "", ""]),
|
|
348
678
|
],
|
|
349
|
-
)
|
|
679
|
+
))
|
|
350
680
|
|
|
351
681
|
|
|
352
682
|
def load_state() -> AppState:
|
|
@@ -386,10 +716,11 @@ def load_state() -> AppState:
|
|
|
386
716
|
encoding="utf-8",
|
|
387
717
|
)
|
|
388
718
|
api_data["token"] = load_api_token()
|
|
389
|
-
|
|
719
|
+
state = AppState(
|
|
390
720
|
categories=[Category(**item) for item in data.get("categories", [])],
|
|
391
721
|
base_presets=[PromptPreset(**item) for item in base_data],
|
|
392
722
|
character_presets=[CharacterPreset(**item) for item in data.get("character_presets", [])],
|
|
723
|
+
preset_sets=[PresetSet(**item) for item in data.get("preset_sets", [])],
|
|
393
724
|
quality_override_prompt=quality_override,
|
|
394
725
|
negative_prompt=data.get("negative_prompt", ""),
|
|
395
726
|
api=ApiSettings(**api_data),
|
|
@@ -397,10 +728,12 @@ def load_state() -> AppState:
|
|
|
397
728
|
batting_scenes=[BattingScene(**item) for item in data.get("batting_scenes", [])],
|
|
398
729
|
history=[sanitize_history_entry(item) for item in data.get("history", [])],
|
|
399
730
|
)
|
|
731
|
+
return sync_auto_preset_sets(state)
|
|
400
732
|
|
|
401
733
|
|
|
402
734
|
def save_state(state: AppState) -> None:
|
|
403
735
|
ensure_dirs()
|
|
736
|
+
sync_auto_preset_sets(state)
|
|
404
737
|
STATE_PATH.write_text(
|
|
405
738
|
json.dumps(state_dict_without_secrets(state), ensure_ascii=False, indent=2),
|
|
406
739
|
encoding="utf-8",
|
|
@@ -1180,22 +1513,12 @@ class App(tk.Tk if tk else object):
|
|
|
1180
1513
|
save_state(self.state_data)
|
|
1181
1514
|
|
|
1182
1515
|
def random_artist_tags(self) -> list[dict]:
|
|
1183
|
-
|
|
1184
|
-
for cat in self.state_data.categories:
|
|
1185
|
-
tags = parse_artist_tags(cat.tags)
|
|
1186
|
-
if not tags:
|
|
1187
|
-
continue
|
|
1188
|
-
weights = float_range(cat.min_weight, cat.max_weight, cat.granule)
|
|
1189
|
-
pick_count = len(tags) if cat.picks <= 0 else min(cat.picks, len(tags))
|
|
1190
|
-
for tag in random.sample(tags, pick_count):
|
|
1191
|
-
weight = random.choice(weights) if weights else cat.min_weight
|
|
1192
|
-
result.append({"category": cat.name, "tag": tag, "weight": weight, "prompt": weight_tag(tag, weight)})
|
|
1193
|
-
return result
|
|
1516
|
+
return random_artist_tags_for_state(self.state_data)
|
|
1194
1517
|
|
|
1195
1518
|
def build_prompt(self) -> tuple[str, str, list[dict]]:
|
|
1196
1519
|
base = self.find_base()
|
|
1197
1520
|
char = self.find_char()
|
|
1198
|
-
artists = self.random_artist_tags()
|
|
1521
|
+
artists = order_artist_tags(self.random_artist_tags())
|
|
1199
1522
|
base_chunks = []
|
|
1200
1523
|
if base and base.prompt.strip():
|
|
1201
1524
|
base_chunks.append(base.prompt.strip())
|