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 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 토큰을 아직 넣고 싶지 않다면 `목업 모드 사용`을 켜둔 채로 UI만 먼저 테스트할 수 있습니다. 목업 모드에서는 실제 NovelAI 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
  ![작가 태그 메뉴](docs/screenshots/02-artists.png)
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
+ ![취향 학습 메뉴](docs/screenshots/08-learning.png)
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
  ![프리셋 메뉴](docs/screenshots/03-presets.png)
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
  ![타율 테스트 메뉴](docs/screenshots/04-batting-test.png)
210
260
 
211
261
  ## 이미지 크기
@@ -255,7 +305,7 @@ Windows에서 Python을 설치할 때는 `Add Python to PATH` 옵션을 켜는
255
305
 
256
306
  ### 이미지가 실제로 생성되지 않고 테스트 이미지만 나옵니다
257
307
 
258
- `API 설정`에서 `목업 모드 사용`이 켜져 있는지 확인하세요. 실제 NovelAI 생성을 하려면 목업 모드를 꺼야 합니다.
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
- return AppState(
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
- result = []
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())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nai-aclab",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Local web UI for experimenting with NovelAI artist tag weight combinations.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",