Compare commits

...
Author SHA1 Message Date
Vito Sansevero 90c5848b13 test(tagging): classify the Skipped tags preview route as reading no media (#431) 2026-10-31 17:25:20 -07:00
Vito Sansevero 5981e1d9e3 fix(tagging): skip a damaged WD14 label list in the preview (#431) 2026-10-31 17:16:23 -07:00
Vito Sansevero 66f8071198 test(tagging): count the Skipped tags block as one concept difference (#431) 2026-10-31 17:07:16 -07:00
Vito Sansevero a20e59f674 feat(tagging): skipped tags hide matching suggestions (#431)
A Skipped tags list in System > Auto-tagging (D202) hides every suggested
tag whose words contain an entry's words, on every suggestion path
(Tag image, Auto-tag selected, the System batch, the legacy single and
batch routes), before the limit. Jobs snapshot the list at their start.
A read-only preview route counts what a list hides among the library's
tags and the downloaded WD14 label lists. The draft store compares list
and object drafts by value (sameDraft).
2026-10-31 16:43:15 -07:00
33 changed files with 1860 additions and 59 deletions
+48
View File
@@ -4578,6 +4578,26 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
Cancel-only review matches the concept review shell` (system-tagging-concept.spec) and
the batch tests of workspace-system-tagging.spec; the mutation check renames each grid
label in turn.
- **D202** (Auto-tagging, #431, beyond the concept): a "Skipped tags" block
(`.setting-block`, `data-setting="tag-skipped"`, `data-key="autotag_skipped_tags"`)
follows "Suggestions per image": the label and description in the concept's
setting-row tokens ("Suggested tags that contain one of these words are hidden.
Words are separated by spaces, _ or -; other characters such as ( ) belong to the
word."), an "Add a word or words" input (`#tag-skipped-input`) with Add (Enter adds
too), the entries as removable chips in the Tag image dialog's D83 chip markup (each
with its count, title "Hides N tags", or muted "none" with "Hides no known tag"), and
a `role="status"` preview line: "**Hides N tags from suggestions**: <up to 12>[ and K
more]. Tags already on images are not changed." ("Checked against library tags only;
no WD14 label list is downloaded." before the last sentence without a label list;
"No tags are skipped." for an empty list; "Checking…" while waiting; "The preview
could not be read." on failure). The client refuses an entry with "Enter a word.",
"No commas or line breaks.", "At most 50 characters.", "Already in the list." or "The
list is full (200)." under the input. The Tag image dialog's chip hint adds "N skipped
tags were not suggested." when the server hid any (the static mock's copy). Pinned by
the comparison's mapping (system-tagging-concept.spec: the block's tokens are the
concept row's), `skipped tags add, preview, remove, save and reload (D202)`, `skipped
tags preview checks library tags only …` (workspace-system-tagging.spec) and
`suggestions omit skipped tags and say how many were hidden` (workspace-tagging.spec).
- **D158** (Sources, #247 D-Q4): the concept has no switch for prompts from workflows
without a PromptManager node; the maintainer chose a visible switch over a config key.
A `.setting-row` in the concept's toggle markup follows "Watch for new outputs":
@@ -5186,6 +5206,34 @@ saved values (`wd14_general_threshold`, `wd14_character_threshold`, `suggestion_
`default_prompt`, `model_type`, `existing`), so the Tag image and Auto-tag selected
dialogs start from them; a preset (the bulk review, Preview tagging review) wins.
**Skipped tags** (#431, D202): `autotag.defaults.skipped_tags`, host draft
`autotag_skipped_tags`, a list of at most 200 entries, each trimmed, 1–50 code points,
no comma or control character, at least one word, no two with the same words (refused
with "Skipped tags: at most 200 entries.", "Skipped tags: entry N is empty.", "… must be
at most 50 characters.", "… must not contain a comma or a line break.", "… repeats entry
M."); a save carrying it without `expected_revision` is refused ("Reload the page and try
again."). A damaged saved list falls back to none with one warning. Words are the
NFC, case-folded text split on runs of space, `_` and `-` (`tag_words`); an entry hides
a suggested tag whose words contain the entry's words consecutively (`SkipMatcher`:
`blurry` hides `very_blurry` and `blurry_background`, not `unblurry`; `(fate)` hides
`saber_(fate)`, `fate` does not). `generate_tag_suggestions(skip=)` drops a match after
the name check and before the dedupe and the limit, so skipped tags use up no slot
(WD14 fills from lower scores; a language model may return fewer). Every path filters:
`/image-annotations/suggest` (compiled per request; the answer adds `skipped`, the
number of distinct names it hid), `/autotag/single`, and the `image-tags` (Auto-tag
selected) and `autotag` (System batch, legacy `/autotag/start`) jobs, which snapshot the
list at their start and log "Skipped tags: N entries" or "Skipped tags: none"; a save
during a run does not change it. Hand-typed and stored tags are never filtered; the
language models' hard-coded exclude list (`_parse_tags`) runs first. `POST
/prompt_manager/autotag/skipped-tags/preview {entries}` (validated as a save) answers
`count`, `sample` (12, library tags first, each part sorted case-folded),
`sample_limit`, `per_entry [{entry, count}]` and `sources {library, wd14}` over the
library's tags plus the category 0/4 names of each downloaded WD14 `selected_tags.csv`
(read as UTF-8, cached by mtime and size; a missing list is skipped). It loads no model,
opens no image, writes nothing and is `409 busy` while an exclusive job runs. Tests:
`tests/test_skipped_tags.py`, `tests/test_skipped_tags_routes.py`, the settings and
config tests, `tests/js/workspace-system-skipped-tags.test.js`.
`GET /prompt_manager/autotag/models` reads the disk in the executor, never loads or
downloads a model and carries no absolute path: per engine `name`, `label`,
`size_label`, `description`, `size`, `downloaded`, `loaded` (GGUF also `model_exists`,
+6 -2
View File
@@ -102,11 +102,14 @@ def model_refusal(status: dict, model_type: str, model_name: str):
return None
def run_suggestions(service, capture_path: str, model_type: str, options: dict):
def run_suggestions(
service, capture_path: str, model_type: str, options: dict, skip=None
):
"""Blocking: load (or swap to) *model_type*, then suggest for the capture.
The caller holds the model gate for the whole call; nothing is ever
downloaded (``load_model`` refuses a model that is not on disk).
downloaded (``load_model`` refuses a model that is not on disk). ``skip``
is the request's or job's compiled Skipped tags (#431).
"""
if not service.is_model_loaded() or service.get_loaded_model_type() != model_type:
service.load_model(model_type, options["use_gpu"])
@@ -116,6 +119,7 @@ def run_suggestions(service, capture_path: str, model_type: str, options: dict):
prompt=options.get("instructions"),
general_threshold=options.get("general_threshold"),
character_threshold=options.get("character_threshold"),
skip=skip,
)
+8 -2
View File
@@ -35,6 +35,7 @@ from .media_target import media_target as _target
from .images import _unavailable_link, _verified_title
from .prompts import bad_request
from .. import model_gate
from ..tagging_defaults import SkipMatcher
try:
from ...database.annotations import (
@@ -640,6 +641,8 @@ class AnnotationRoutesMixin:
owner = model_gate.new_owner("image-annotations")
if not model_gate.try_acquire(owner):
return model_busy_response()
# #431: the Skipped tags as they are when the request is accepted.
skip = SkipMatcher(AutotagConfig.SKIPPED_TAGS).counting()
try:
# From here the pin is the gate's to end: the worker ends it,
# or ``on_skipped`` does when the worker never starts.
@@ -653,6 +656,7 @@ class AnnotationRoutesMixin:
capture,
model_type,
options,
skip,
on_skipped=functools.partial(store.release_use, token),
)
except MissingBackendPackages as exc:
@@ -677,6 +681,8 @@ class AnnotationRoutesMixin:
"model_type": model_type,
"options": options,
"suggestions": suggestion_rows(pairs),
# How many distinct suggestions the Skipped tags hid (#431).
"skipped": len(skip.hidden),
},
headers={"Cache-Control": "no-store"},
)
@@ -732,10 +738,10 @@ def _byte_range(request: web.Request, size: int):
return start, stop
def _pinned_suggestions(store, token, service, capture, model_type, options):
def _pinned_suggestions(store, token, service, capture, model_type, options, skip=None):
"""Worker thread: infer on the capture, then end its pin (even on error)."""
try:
return run_suggestions(service, capture.path, model_type, options)
return run_suggestions(service, capture.path, model_type, options, skip)
finally:
store.release_use(token)
+95
View File
@@ -40,6 +40,12 @@ BATCH_PREVIEW_BODY_MAX = 1024
APPLY_BODY_MAX = 64 * 1024 # POST /autotag/apply: one image and its tags
_HEX64 = re.compile(r"[0-9a-f]{64}")
BATCH_JOB_BODY_MAX = 32 * 1024
# Skipped tags preview (#431): 200 entries of 50 characters, with room for JSON.
SKIPPED_PREVIEW_BODY_MAX = 64 * 1024
SKIPPED_PREVIEW_LEASE = "Skipped tags preview"
SKIPPED_PREVIEW_SAMPLE = 12
# WD14 label lists the preview found missing or unreadable (logged once each).
_MISSING_LABEL_LISTS = set()
REVIEW_REQUIRED = (
"Replacing tags needs a review of each image. Use Preview tagging review, "
"or choose Skip or Merge for a batch run."
@@ -247,6 +253,10 @@ class AutotagRoutesMixin:
async def preview_autotag_batch_route(request):
return await self.preview_autotag_batch(request)
@routes.post("/prompt_manager/autotag/skipped-tags/preview")
async def preview_skipped_tags_route(request):
return await self.preview_skipped_tags(request)
@routes.post("/prompt_manager/autotag/start")
async def start_autotag_route(request):
return await self.start_autotag(request)
@@ -699,6 +709,86 @@ class AutotagRoutesMixin:
return None, "existing must be one of " + ", ".join(EXISTING_POLICIES)
return MODELS[model_type], None
async def preview_skipped_tags(self, request):
"""Which known tags a Skipped tags list would hide (#431); no write.
``{entries}`` is checked as a save checks it (the same 400 sentence).
The known tags are the library's plus the general and character names
of each downloaded WD14 label list; no model is loaded and no image is
opened. Refused (409 ``busy``) while an exclusive job runs.
"""
from ..tagging_defaults import validate_skipped_tags
data, error = await self._batch_body(
request, ("entries",), SKIPPED_PREVIEW_BODY_MAX
)
if error is not None:
return error
try:
entries = validate_skipped_tags(data["entries"])
except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
try:
async with self._jobs.lease(SKIPPED_PREVIEW_LEASE):
result = await self._run_in_executor(self._skipped_preview, entries)
except JobBusy as busy:
return busy_response(busy)
except Exception:
self.logger.exception("The Skipped tags preview failed")
return web.json_response(
{"success": False, "error": _INTERNAL_ERROR}, status=500
)
return web.json_response({"success": True, **result})
def _skipped_preview(self, entries):
"""Worker thread: the preview's counts over the known tags."""
from ..autotag import MODELS, get_autotag_service
from ..tagging_defaults import (
WD14_MODEL_TYPES,
SkipMatcher,
wd14_label_names,
)
library = self.db.get_all_tags()
known = {} # case-folded -> (0 library / 1 label list, spelling)
for name in library:
known.setdefault(name.casefold(), (0, name))
lists = []
models_dir = getattr(get_autotag_service(), "models_dir", None)
for model_type in sorted(WD14_MODEL_TYPES, key=lambda t: MODELS[t]["name"]):
if models_dir is None:
break
path = Path(models_dir) / MODELS[model_type]["subdir"] / "selected_tags.csv"
try:
names = wd14_label_names(path)
except (OSError, ValueError) as exc:
if path not in _MISSING_LABEL_LISTS: # logged once per list
_MISSING_LABEL_LISTS.add(path)
self.logger.debug(f"No WD14 label list for {model_type}: {exc}")
continue
_MISSING_LABEL_LISTS.discard(path)
lists.append(MODELS[model_type]["name"])
for name in names:
known.setdefault(name.casefold(), (1, name))
matchers = [(entry, SkipMatcher([entry])) for entry in entries]
whole = SkipMatcher(entries)
hidden = [item for item in known.values() if whole.matches(item[1])]
hidden.sort(key=lambda item: (item[0], item[1].casefold()))
per_entry = [
{
"entry": entry,
"count": sum(1 for _, name in hidden if matcher.matches(name)),
}
for entry, matcher in matchers
]
return {
"count": len(hidden),
"sample": [name for _, name in hidden[:SKIPPED_PREVIEW_SAMPLE]],
"sample_limit": SKIPPED_PREVIEW_SAMPLE,
"per_entry": per_entry,
"sources": {"library": len(library), "wd14": lists},
}
async def preview_autotag_batch(self, request):
"""Review Tag output images: counts, model, backup and disk; no write.
@@ -1096,6 +1186,8 @@ class AutotagRoutesMixin:
async def _suggest_single(self, target, model_type, options, base):
from ..autotag import MODELS, MissingBackendPackages, get_autotag_service
from ..config import AutotagConfig
from ..tagging_defaults import SkipMatcher
from .annotation_suggest import model_refusal
from .annotations import _pinned_suggestions, _settled, _submit
@@ -1112,6 +1204,8 @@ class AutotagRoutesMixin:
owner = model_gate.new_owner("legacy-single")
if not model_gate.try_acquire(owner):
return model_busy_response()
# #431: the legacy review hides the Skipped tags too (19A).
skip = SkipMatcher(AutotagConfig.SKIPPED_TAGS)
store = self._capture_store()
try:
epoch = await self._run_in_executor(self.db.get_execution_epoch)
@@ -1151,6 +1245,7 @@ class AutotagRoutesMixin:
capture,
model_type,
options,
skip,
on_skipped=functools.partial(store.release_use, capture.token),
)
except MissingBackendPackages as exc:
+40 -4
View File
@@ -39,6 +39,7 @@ from typing import NamedTuple
from aiohttp import web
from .. import model_gate
from ..tagging_defaults import SkipMatcher
from .admin import _plural, _time_left
from .annotation_suggest import (
OptionError,
@@ -170,6 +171,8 @@ class _Run:
owner: object
service: object = None
roots: dict = field(default_factory=dict)
# The job's Skipped tags, compiled once at its start (#431).
skip: object = None
@dataclass(frozen=True)
@@ -183,6 +186,9 @@ class _Spec:
options: dict | None = None
# A listing filter the start resolves into ``targets`` (#392).
filter: dict | None = None
# The Skipped tags when the job was requested (#431); a later save does
# not change a running job.
skip: tuple = ()
@property
def title(self) -> str:
@@ -238,7 +244,11 @@ def _parse_auto(targets, data):
)
except OptionError as exc:
return None, bad_request(str(exc))
return _Spec("auto", targets, model_type=model_type, options=options), None
skip = tuple(AutotagConfig.SKIPPED_TAGS)
return (
_Spec("auto", targets, model_type=model_type, options=options, skip=skip),
None,
)
def parse_image_tag_request(data: dict):
@@ -329,6 +339,8 @@ class AutotagRun:
keep_in_memory: bool
backup_on: bool
owner: object
# The Skipped tags when the job was started (#431).
skip: tuple = ()
@dataclass(frozen=True)
@@ -400,6 +412,14 @@ def _load_model(service, model_type: str, use_gpu: bool) -> None:
raise RuntimeError(f"{model_type} could not be loaded")
def _skipped_tags_line(entries: tuple) -> str:
"""The job log's one line about the Skipped tags it runs with (#431)."""
if not entries:
return "Skipped tags: none"
count = len(entries)
return f"Skipped tags: {count} {'entry' if count == 1 else 'entries'}"
def _refusal(status: int, code: str, message: str) -> Refusal:
response = web.json_response(
{"success": False, "code": code, "error": message}, status=status
@@ -507,6 +527,7 @@ class ImageTagJobsMixin:
released when the task ends, also before its runner ever starts.
"""
from ..autotag import MODELS, get_autotag_service
from ..config import AutotagConfig
backup_on = self._safety_backup_policy() == "on"
status = await self._run_in_executor(get_autotag_service().get_models_status)
@@ -529,7 +550,13 @@ class ImageTagJobsMixin:
response = model_busy_response()
return None, Refusal(409, "model-busy", MODEL_BUSY_ERROR, response)
run = AutotagRun(
model_type, existing, options, keep_in_memory, backup_on, owner
model_type,
existing,
options,
keep_in_memory,
backup_on,
owner,
skip=tuple(AutotagConfig.SKIPPED_TAGS),
)
admitted = False
try:
@@ -598,7 +625,9 @@ class ImageTagJobsMixin:
return _batch_cancelled(job)
job.publish({"type": "phase", "phase": "model"})
job.update(status_line="Loading the tagging model")
spec = _Spec("auto", (), model_type=run.model_type, options=run.options)
spec = _Spec(
"auto", (), model_type=run.model_type, options=run.options, skip=run.skip
)
service = await self._prepare_tagging_model(job, spec, run.owner)
if service is None:
return "failed"
@@ -624,6 +653,7 @@ class ImageTagJobsMixin:
f"Tagging started · {job.scope.get('label') or run.model_type} · "
f"{_plural(total, 'image')} · {policy}"
)
job.log_line(_skipped_tags_line(spec.skip))
if job.cancelled:
return _batch_cancelled(job)
remaining = await self._autotag_prepass(job, targets, run.existing == "skip")
@@ -633,7 +663,9 @@ class ImageTagJobsMixin:
spec = dataclasses.replace(
spec, targets=tuple(item.target for item in remaining)
)
loop_run = _Run(job, spec, epoch, run.owner, service)
loop_run = _Run(
job, spec, epoch, run.owner, service, skip=SkipMatcher(spec.skip)
)
if not await self._tag_each_file(loop_run, done_before=done):
job.update(status_line=STORAGE_RESTORED)
job.log_line(f"Tagging stopped · {STORAGE_RESTORED}")
@@ -739,6 +771,9 @@ class ImageTagJobsMixin:
started += f" · {spec.model_type}"
job.log_line(started)
run = _Run(job, spec, epoch, owner)
if spec.mode == "auto":
job.log_line(_skipped_tags_line(spec.skip))
run.skip = SkipMatcher(spec.skip)
try:
if spec.mode == "auto":
run.service = await self._prepare_tagging_model(job, spec, owner)
@@ -894,6 +929,7 @@ class ImageTagJobsMixin:
capture,
spec.model_type,
spec.options,
run.skip,
on_skipped=functools.partial(store.release_use, capture.token),
)
except Exception:
+15
View File
@@ -56,7 +56,12 @@ _SETTINGS_FILE_KEYS = {
"AUTOTAG_CHARACTER_THRESHOLD": ("autotag", "defaults", "character_threshold"),
"AUTOTAG_TEMPLATE": ("autotag", "defaults", "template"),
"AUTOTAG_SUGGESTION_LIMIT": ("autotag", "defaults", "suggestion_limit"),
"AUTOTAG_SKIPPED_TAGS": ("autotag", "defaults", "skipped_tags"),
}
# Whole-list settings (#431): a save without ``expected_revision`` would merge
# a stale copy over a list changed elsewhere, so it is refused.
_REVISION_REQUIRED = frozenset({"autotag_skipped_tags"})
REVISION_REQUIRED_ERROR = "Reload the page and try again."
# The thumbnail settings a running Convert outdated job fixes (#317).
_THUMBNAIL_SETTINGS = ("THUMBNAIL_FORMAT", "THUMBNAIL_QUALITY", "THUMBNAIL_EDGE")
# Attributes that live on GalleryConfig; the others on PromptManagerConfig.
@@ -176,6 +181,7 @@ def _validate_autotag_settings(data):
MAX_SUGGESTION_LIMIT,
MODEL_TYPES,
validate_saved_threshold,
validate_skipped_tags,
validate_template,
)
@@ -219,6 +225,10 @@ def _validate_autotag_settings(data):
f"{MAX_SUGGESTION_LIMIT}."
)
updates["AUTOTAG_SUGGESTION_LIMIT"] = value
if "autotag_skipped_tags" in data:
updates["AUTOTAG_SKIPPED_TAGS"] = tuple(
validate_skipped_tags(data["autotag_skipped_tags"])
)
return updates
@@ -283,6 +293,7 @@ class SettingsRoutesMixin:
),
"autotag_template": AutotagConfig.TEMPLATE,
"autotag_suggestion_limit": AutotagConfig.SUGGESTION_LIMIT,
"autotag_skipped_tags": list(AutotagConfig.SKIPPED_TAGS),
"cpu_count": PromptManagerConfig.max_worker_threads(),
"gallery_root_paths": root_paths,
"gallery_root_path": root_paths[0] if root_paths else "",
@@ -383,6 +394,10 @@ class SettingsRoutesMixin:
{"success": False, "error": "expected_revision must be a string"},
status=400,
)
if expected_revision is None and _REVISION_REQUIRED & set(data):
return web.json_response(
{"success": False, "error": REVISION_REQUIRED_ERROR}, status=400
)
try:
updates = self._validate_settings(data)
except ValueError as ve:
+10 -10
View File
@@ -30,9 +30,9 @@ except ImportError:
# The tag template and thresholds live with the saved tagging defaults
# (#328), so config.py never imports PIL.
try:
from .tagging_defaults import DEFAULT_TAG_TEMPLATE
from .tagging_defaults import DEFAULT_TAG_TEMPLATE, read_wd14_label_rows
except ImportError:
from py.tagging_defaults import DEFAULT_TAG_TEMPLATE
from py.tagging_defaults import DEFAULT_TAG_TEMPLATE, read_wd14_label_rows
try:
from ..utils import exit_signal
@@ -559,7 +559,6 @@ class AutoTagService:
# A missing onnxruntime is refused by load_model before this runs
# (#424); a ModuleNotFoundError here becomes the same refusal.
import onnxruntime as ort
import csv
config = MODELS[model_type]
model_dir = self.models_dir / config["subdir"]
@@ -576,12 +575,8 @@ class AutoTagService:
session = ort.InferenceSession(str(model_path), providers=providers)
# Read tag list from CSV
tags_list = []
with open(tags_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
tags_list.append(row)
# Read tag list from CSV (the reader the Skipped tags preview shares)
tags_list = read_wd14_label_rows(tags_path)
self.logger.info(
f"WD14 model loaded ({len(tags_list)} tags, "
@@ -992,6 +987,7 @@ class AutoTagService:
prompt: Optional[str] = None,
general_threshold: Optional[float] = None,
character_threshold: Optional[float] = None,
skip: Any = None,
) -> List[Tuple[str, Optional[float]]]:
"""The top ``limit`` suggestions as ``(name, confidence)`` pairs.
@@ -1000,7 +996,9 @@ class AutoTagService:
``None``. Names are distinct case-insensitively (first one wins).
A name Save would reject (over 50 characters, a control character
such as a newline, a comma) is skipped before the dedupe and the
``limit`` count. ``prompt`` is used for this call only;
``limit`` count. A name ``skip`` matches (a ``SkipMatcher``, the
Skipped tags compiled for this request or job, #431) is dropped next,
so it uses up no slot. ``prompt`` is used for this call only;
``custom_prompt`` is never changed.
"""
if type(limit) is not int or not 1 <= limit <= MAX_SUGGESTION_LIMIT:
@@ -1017,6 +1015,8 @@ class AutoTagService:
name = validate_vocabulary_tag_name(raw)
except ValueError:
continue
if skip is not None and skip.matches(name):
continue
key = name.casefold()
if key in seen:
continue
+21 -5
View File
@@ -587,6 +587,7 @@ try:
MODEL_TYPES,
WD14_MODEL_TYPES,
validate_saved_threshold,
validate_skipped_tags,
validate_template,
validate_threshold,
)
@@ -600,6 +601,7 @@ except ImportError:
MODEL_TYPES,
WD14_MODEL_TYPES,
validate_saved_threshold,
validate_skipped_tags,
validate_template,
validate_threshold,
)
@@ -617,6 +619,20 @@ def _valid_existing(value: Any) -> str:
return value
def _valid_skipped_tags(value: Any) -> Tuple[str, ...]:
return tuple(validate_skipped_tags(value))
def _fallback_warning(key: str, error: ValueError, default: Any) -> str:
"""The one load warning of a saved tagging default that fails its check."""
if key == "template":
return f"autotag.defaults.template {error}; using the built-in one"
if key == "skipped_tags":
reason = str(error).removeprefix("Skipped tags").lstrip(": ").rstrip(".")
return f"autotag.defaults.skipped_tags {reason}; using no skipped tags"
return f"autotag.defaults.{key} {error}; using {default!r}"
def _valid_suggestion_limit(value: Any) -> int:
if type(value) is not int or not 1 <= value <= MAX_SUGGESTION_LIMIT:
raise ValueError(f"must be an integer from 1 to {MAX_SUGGESTION_LIMIT}")
@@ -639,6 +655,7 @@ class AutotagConfig:
CHARACTER_THRESHOLD = 0.85
TEMPLATE = DEFAULT_TAG_TEMPLATE
SUGGESTION_LIMIT = DEFAULT_SUGGESTION_LIMIT
SKIPPED_TAGS: Tuple[str, ...] = ()
# config.json key -> (attribute, validator, default)
_KEYS = {
@@ -660,6 +677,8 @@ class AutotagConfig:
_valid_suggestion_limit,
DEFAULT_SUGGESTION_LIMIT,
),
# #431: words whose suggested tags are never offered.
"skipped_tags": ("SKIPPED_TAGS", _valid_skipped_tags, ()),
}
@classmethod
@@ -681,6 +700,7 @@ class AutotagConfig:
}
if defaults["template"] == DEFAULT_TAG_TEMPLATE:
del defaults["template"]
defaults["skipped_tags"] = list(defaults["skipped_tags"])
return {"defaults": defaults}
@classmethod
@@ -698,11 +718,7 @@ class AutotagConfig:
try:
setattr(cls, attr, validate(defaults[key]))
except ValueError as error:
config_logger.warning(
f"autotag.defaults.{key} {error}; using {default!r}"
if key != "template"
else f"autotag.defaults.template {error}; using the built-in one"
)
config_logger.warning(_fallback_warning(key, error, default))
setattr(cls, attr, default)
+139 -1
View File
@@ -4,8 +4,13 @@ Server-free on purpose: ``py.api`` must import without ComfyUI's ``server``
module, which ``py/config.py`` needs; config re-exports all of this.
"""
import csv
import math
from typing import Any
import os
import re
import threading
import unicodedata
from typing import Any, Dict, Iterable, List, Set, Tuple
# The tagging engines (``py/autotag.py`` MODELS) and the options they take.
WD14_MODEL_TYPES = frozenset({"wd14-swinv2", "wd14-vit"})
@@ -88,3 +93,136 @@ def validate_template(value: Any, name: str = "instructions") -> str:
if len(encoded) > MAX_INSTRUCTION_BYTES:
raise ValueError(f"{name} {'are' if name == 'instructions' else 'is'} too long")
return value
# Skipped tags (#431): words whose suggested tags are never offered.
MAX_SKIPPED_TAGS = 200
MAX_SKIPPED_TAG_CHARS = 50
_WORD_SEPARATORS = re.compile(r"[ _-]+")
_FORBIDDEN_IN_ENTRY = re.compile(r"[,\x00-\x1f\x7f-\x9f\u2028\u2029]")
def tag_words(text: str) -> Tuple[str, ...]:
"""The words of a tag or a skipped entry: NFC, case-folded, split on runs
of space, ``_`` and ``-``. Every other character belongs to a word."""
folded = unicodedata.normalize("NFC", text).casefold()
return tuple(word for word in _WORD_SEPARATORS.split(folded) if word)
def validate_skipped_tags(value: Any) -> List[str]:
"""The Skipped tags list, each entry trimmed, or ValueError with the
sentence the System page shows (entries are numbered from 1)."""
if not isinstance(value, list):
raise ValueError("Skipped tags must be a list of words.")
if len(value) > MAX_SKIPPED_TAGS:
raise ValueError(f"Skipped tags: at most {MAX_SKIPPED_TAGS} entries.")
entries: List[str] = []
seen: Dict[Tuple[str, ...], int] = {}
for number, raw in enumerate(value, 1):
if not isinstance(raw, str):
raise ValueError(f"Skipped tags: entry {number} must be text.")
entry = raw.strip()
if not entry:
raise ValueError(f"Skipped tags: entry {number} is empty.")
if len(entry) > MAX_SKIPPED_TAG_CHARS:
raise ValueError(
f"Skipped tags: entry {number} must be at most "
f"{MAX_SKIPPED_TAG_CHARS} characters."
)
if _FORBIDDEN_IN_ENTRY.search(entry):
raise ValueError(
f"Skipped tags: entry {number} must not contain a comma "
"or a line break."
)
words = tag_words(entry)
if not words:
raise ValueError(f"Skipped tags: entry {number} is empty.")
if words in seen:
raise ValueError(
f"Skipped tags: entry {number} repeats entry {seen[words]}."
)
seen[words] = number
entries.append(entry)
return entries
class SkipMatcher:
"""Whether a suggested tag contains a skipped entry's words, consecutive
and whole (#431, 17B). Compiled once per request or job; the cost of a
check depends on the tag's words, not on the number of entries."""
def __init__(self, entries: Iterable[str] = ()):
self._entries = frozenset(filter(None, (tag_words(e) for e in entries)))
self._longest = max((len(words) for words in self._entries), default=0)
def __bool__(self) -> bool:
return bool(self._entries)
def matches(self, name: str) -> bool:
if not self._entries:
return False
words = tag_words(name)
for start in range(len(words)):
stop = min(len(words), start + self._longest)
for end in range(start + 1, stop + 1):
if words[start:end] in self._entries:
return True
return False
def counting(self) -> "CountingSkip":
"""A per-request view that also remembers which names it hid."""
return CountingSkip(self)
class CountingSkip:
"""A :class:`SkipMatcher` that records the distinct names it hid, so the
Tag image dialog can say how many skipped tags were not suggested."""
def __init__(self, matcher: SkipMatcher):
self._matcher = matcher
self.hidden: Set[str] = set()
def matches(self, name: str) -> bool:
if self._matcher.matches(name):
self.hidden.add(name.casefold())
return True
return False
# A WD14 label list's tags the engine can suggest: general (0) and character (4).
WD14_SUGGESTED_CATEGORIES = frozenset({"0", "4"})
_label_cache: Dict[str, Tuple[Tuple[int, int], Tuple[str, ...]]] = {}
_label_cache_lock = threading.Lock()
def read_wd14_label_rows(path) -> List[Dict[str, str]]:
"""Every row of a WD14 ``selected_tags.csv``, read as UTF-8 and closed
before returning (the handle never outlives the call, for Windows)."""
with open(path, "r", encoding="utf-8", newline="") as handle:
return list(csv.DictReader(handle))
def wd14_label_names(path) -> Tuple[str, ...]:
"""The names a WD14 label list can suggest (categories 0 and 4), cached
by ``(mtime_ns, size)``. OSError (missing) and ValueError (unreadable)
propagate; the caller skips that list."""
key = os.fspath(path)
stat = os.stat(key)
stamp = (stat.st_mtime_ns, stat.st_size)
with _label_cache_lock:
cached = _label_cache.get(key)
if cached is not None and cached[0] == stamp:
return cached[1]
try:
rows = read_wd14_label_rows(key)
except csv.Error as exc: # a damaged list (a NUL byte, an oversized field)
raise ValueError(f"unreadable label list: {exc}") from None
names = tuple(
name
for row in rows
if row.get("category", "0") in WD14_SUGGESTED_CATEGORIES
and (name := (row.get("name") or "").strip())
)
with _label_cache_lock:
_label_cache[key] = (stamp, names)
return names
+19 -10
View File
@@ -85,6 +85,9 @@ class TestModel:
suggestions: list | None = None
# The engine's model table (``py/autotag.py`` MODELS), set at startup.
models_config: dict = {}
# Where the WD14 label lists live (the Skipped tags preview, #431), set at
# startup; the tagging fixture writes or removes a small list there.
models_dir = None
def get_models_status(self) -> dict:
from py.autotag import package_fields
@@ -115,21 +118,26 @@ class TestModel:
return ["generated", Path(image_path).parent.parent.name]
def generate_tag_suggestions(
self, image_path: str, *, limit: int, **kwargs: object
self, image_path: str, *, limit: int, skip=None, **kwargs: object
) -> list[tuple[str, float | None]]:
"""Fixed scored suggestions (WD14) or unscored ones (language models)."""
"""Fixed scored suggestions (WD14) or unscored ones (language models),
without the ones ``skip`` hides before the limit, as the engine does
(#431)."""
scored = self.loaded is not None and self.loaded.startswith("wd14")
if self.suggestions is not None:
return [
pairs = [
(name, confidence if scored else None)
for name, confidence in self.suggestions[:limit]
for name, confidence in self.suggestions
]
names = ["sandstone", "desert", "arch", "sky", "rock", "dune", "canyon"]
names += [f"tag_{index:02d}" for index in range(100)]
return [
(name, round(0.98 - index * 0.008, 3) if scored else None)
for index, name in enumerate(names[:limit])
]
else:
names = ["sandstone", "desert", "arch", "sky", "rock", "dune", "canyon"]
names += [f"tag_{index:02d}" for index in range(100)]
pairs = [
(name, round(0.98 - index * 0.008, 3) if scored else None)
for index, name in enumerate(names)
]
kept = [pair for pair in pairs if skip is None or not skip.matches(pair[0])]
return kept[:limit]
def download_model(self, model_type: str, progress_callback=None) -> bool:
raise AssertionError("browser tests never download a model")
@@ -233,6 +241,7 @@ def create_app(root: Path) -> web.Application:
TestModel.models_config = autotag.MODELS
model = TestModel()
model.models_dir = root / "models"
autotag.get_autotag_service = lambda: model
api = PromptManagerAPI()
api.add_routes(routes)
+35 -5
View File
@@ -33,6 +33,8 @@ test.afterEach(async ({ page, request, browserName }) => {
const PREVIEW_URL = '/prompt_manager/autotag/batch/preview';
const MODEL_OPTIONS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB'];
const SUGGESTIONS = 'From 1 to 100: the most suggestions Generate shows for one image.';
const SKIPPED_DESCRIPTION = 'Suggested tags that contain one of these words are hidden. Words are separated by spaces, _ or -; '
+ 'other characters such as ( ) belong to the word.';
const BATCH = 'Tag each output image from its own pixels with the model above. Prompts are not changed. Review the scope first.';
/** Runs in the page: Auto-tagging's surface under `mainSelector`: rows, field, actions, operation, geometry and tokens. */
@@ -70,6 +72,16 @@ function measureTagging(mainSelector) {
...css(actions, 'display', 'flexWrap', 'columnGap', 'justifyContent'),
primary: css(actions.querySelector('.ca-primary'), 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft'),
plain: css(actions.querySelector('button:not(.ca-primary)'), 'backgroundColor', 'color', 'fontSize', 'borderTopColor') },
// D202 (#431): the Skipped tags block (the concept has none).
skipped: (block => block && { tree: [...block.children].map(name), key: block.dataset.key,
text: [...block.firstElementChild.children].map(node => [node.localName, text(node), node.localName === 'label' ? node.htmlFor : null]),
add: [...block.querySelector('.skip-add').children].map(node => [name(node), node.id, node.getAttribute('placeholder'), text(node)]),
preview: [text(block.querySelector('.skip-preview')), block.querySelector('.skip-preview').getAttribute('role')],
...css(block, 'paddingTop', 'paddingBottom', 'borderTopWidth', 'borderTopColor'),
label: css(block.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
description: css(block.querySelector('.setting-block-text p'), 'fontSize', 'color', 'marginTop'),
input: css(block.querySelector('input'), 'fontSize', 'backgroundColor', 'color', 'borderTopColor') })(
surface.querySelector(':scope > .setting-block')),
operations: [...surface.querySelectorAll(':scope > .operation')].map(row => ({ tree: [...row.children].map(name),
title: text(row.querySelector('h3')), description: text(row.querySelector('p')),
actions: [...row.querySelectorAll('.row-actions > button')].map(item => [text(item), item.className]) })),
@@ -108,8 +120,9 @@ function mapTaggingConcept(reference, { template, phone }) {
const [preview] = reference.actions.buttons;
return {
...reference,
// D133 adds a row after the thresholds; D134 adds the operation after the actions.
tree: [...reference.tree.slice(0, 6), 'div.setting-row', ...reference.tree.slice(6), 'div.operation'],
// D133 adds a row after the thresholds, D202 the Skipped tags block after it; D134 adds the operation after the
// actions.
tree: [...reference.tree.slice(0, 6), 'div.setting-row', 'div.setting-block', ...reference.tree.slice(6), 'div.operation'],
rows: [
// D132 (OQ5, OQ6): the four real engines with their download size, and the chosen one's live status.
{ ...model, text: [model.text[0], ['p', 'Downloaded. Not loaded.', null]],
@@ -121,6 +134,20 @@ function mapTaggingConcept(reference, { template, phone }) {
{ ...general, text: [['label', 'Suggestions per image', 'tag-suggestions'], ['p', SUGGESTIONS, null]],
control: { ...general.control, id: 'tag-suggestions', value: '10' } },
],
// D202 (#431): the Skipped tags block in the concept's setting-row tokens: its label and description as a row's,
// the row's padding and rule above, the concept's field input; an empty list says so.
skipped: { tree: ['div.setting-block-text', 'div.skip-add', 'p.skip-problem.small', 'div.skip-chips.tags', 'p.skip-preview.small'],
key: 'autotag_skipped_tags',
text: [['label', 'Skipped tags', 'tag-skipped-input'], ['p', SKIPPED_DESCRIPTION, null]],
add: [['input', 'tag-skipped-input', 'Add a word or words', ''], ['button', 'tag-skipped-add', null, 'Add']],
preview: ['No tags are skipped.', 'status'],
paddingTop: reference.row.paddingTop, paddingBottom: reference.row.paddingBottom,
borderTopWidth: reference.row.borderTopWidth, borderTopColor: reference.row.borderTopColor,
label: reference.label,
description: { fontSize: reference.description.fontSize, color: reference.description.color,
marginTop: reference.description.marginTop },
input: { fontSize: '12px', backgroundColor: reference.field.textarea.backgroundColor, color: reference.field.textarea.color,
borderTopColor: reference.field.textarea.borderTopColor } },
// The saved template (the server's default until one is saved) replaces the concept's sample text.
field: { ...reference.field, textarea: { ...reference.field.textarea, value: template } },
// D132: Download (for an engine already on disk, "Download again") and Unload replace the two simulated jobs.
@@ -198,12 +225,15 @@ test('auto-tagging comparison detects mutations', async ({ page, request }) => {
const expected = mapTaggingConcept(concept, saved);
const production = await productionTagging(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the section differs only where D132, D133 and D134 are recorded (and the saved template's text).
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 3).join('.'))
// Unmapped, the section differs only where D132, D133, D134 and D202 are recorded (and the saved template's text).
// (The concept has no Skipped tags block, so every path under it is one D202 difference.)
expect(diffInventory(concept, production).map(item => (item.path.startsWith('skipped') ? 'skipped'
: item.path.split('.').slice(0, 3).join('.')))
.filter((path, index, all) => all.indexOf(path) === index).sort()).toEqual([
'actions.buttons.1', 'actions.buttons.2', 'field.textarea.value', 'operations.0.actions', 'operations.0.description',
'operations.0.title', 'operations.0.tree', 'operations.length', 'rows.0.control', 'rows.0.text', 'rows.4.control', 'rows.4.controlBelow',
'rows.4.flexWrap', 'rows.4.text', 'rows.4.tree', 'rows.length', 'tree.6', 'tree.7', 'tree.8', 'tree.9', 'tree.length'].sort());
'rows.4.flexWrap', 'rows.4.text', 'rows.4.tree', 'rows.length', 'skipped', 'tree.6', 'tree.7', 'tree.8', 'tree.9', 'tree.10',
'tree.length'].sort());
const measure = () => page.evaluate(measureTagging, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
// A removed row.
@@ -418,3 +418,85 @@ test('a model that is not downloaded cannot be reviewed for a batch; the button
await expect(dialog(page)).toHaveCount(0);
expect(page.modelCalls.filter(call => call.includes('/batch/preview'))).toEqual([]);
});
// D202 (#431): Skipped tags: add (button and Enter), the preview over the library's tags and the WD14 label list, the
// per-chip counts, remove, Save preferences and a reload; a list the server refuses highlights the block.
const SKIPPED_PREVIEW = '/prompt_manager/autotag/skipped-tags/preview';
const skippedBlock = page => surface(page).locator(':scope > .setting-block[data-setting="tag-skipped"]');
const skippedInput = page => page.locator('#tag-skipped-input');
const skippedChips = page => skippedBlock(page).locator('.skip-chips .chip.tag-suggestion');
const skippedPreview = page => skippedBlock(page).locator('.skip-preview');
test('skipped tags add, preview, remove, save and reload (D202)', async ({ page, request }) => {
await openTagging(page);
await expect(skippedBlock(page).locator('label')).toHaveText('Skipped tags');
await expect(skippedBlock(page)).toHaveAttribute('data-key', 'autotag_skipped_tags');
await expect(skippedPreview(page)).toHaveText('No tags are skipped.');
await expect(skippedPreview(page)).toHaveAttribute('role', 'status');
// The label list on disk (blurry, blurry_background, signature, …) and the library tags (warm_light, …).
let answer = page.waitForResponse(item => item.url().endsWith(SKIPPED_PREVIEW));
await skippedInput(page).fill('blurry');
await skippedInput(page).press('Enter');
expect((await answer).status()).toBe(200);
await expect(skippedInput(page)).toHaveValue('');
await expect(skippedPreview(page)).toHaveText('Hides 2 tags from suggestions: blurry, blurry_background. '
+ 'Tags already on images are not changed.');
answer = page.waitForResponse(item => item.url().endsWith(SKIPPED_PREVIEW));
await skippedInput(page).fill('light');
await page.getByRole('button', { name: 'Add', exact: true }).click();
await answer;
await expect(skippedPreview(page)).toHaveText('Hides 3 tags from suggestions: warm_light, blurry, blurry_background. '
+ 'Tags already on images are not changed.');
await expect(skippedChips(page)).toHaveText(['blurry2×', 'light1×']);
await expect(skippedChips(page).first()).toHaveAttribute('title', 'Hides 2 tags');
// An entry that hides nothing is muted; a duplicate is not added and the line under the input says so.
answer = page.waitForResponse(item => item.url().endsWith(SKIPPED_PREVIEW));
await skippedInput(page).fill('zebra');
await skippedInput(page).press('Enter');
await answer;
await expect(skippedChips(page).last()).toHaveClass(/\bnone\b/);
await expect(skippedChips(page).last()).toHaveAttribute('title', 'Hides no known tag');
await skippedInput(page).fill('Blurry');
await skippedInput(page).press('Enter');
await expect(page.locator('#tag-skipped-problem')).toHaveText('Already in the list.');
await expect(skippedInput(page)).toHaveAttribute('aria-invalid', 'true');
await skippedInput(page).fill('a, b');
await skippedInput(page).press('Enter');
await expect(page.locator('#tag-skipped-problem')).toHaveText('No commas or line breaks.');
// Remove zebra, then save.
await skippedChips(page).last().getByRole('button', { name: 'Remove zebra', exact: true }).click();
await expect(skippedChips(page)).toHaveCount(2);
await expect(status(page)).toHaveText('Unsaved changes.');
await save(page).click();
await expect(status(page)).toHaveText('Saved to the server configuration.');
expect((await serverJson(request, SETTINGS_URL)).settings.autotag_skipped_tags).toEqual(['blurry', 'light']);
await openTagging(page);
await expect(skippedChips(page)).toHaveText(['blurry2×', 'light1×']);
// Removing a chip and adding it back is no change.
await skippedChips(page).last().getByRole('button', { name: 'Remove light', exact: true }).click();
await expect(status(page)).toHaveText('Unsaved changes.');
await skippedInput(page).fill('light');
await skippedInput(page).press('Enter');
await expect(status(page)).toHaveText('No unsaved changes.');
});
test('skipped tags preview checks library tags only without a label list, and a refused save highlights it', async ({ page, request }) => {
await expect(await request.post('/__test__/tagging/defaults', { data: { general: 0.35, character: 0.85, limit: 10,
labels: false } })).toBeOK();
await openTagging(page);
const answer = page.waitForResponse(item => item.url().endsWith(SKIPPED_PREVIEW));
await skippedInput(page).fill('blurry');
await skippedInput(page).press('Enter');
await answer;
await expect(skippedPreview(page)).toHaveText('Hides 0 tags from suggestions. Checked against library tags only; no WD14 '
+ 'label list is downloaded. Tags already on images are not changed.');
// The server is the authority: a list it refuses keeps the draft, says why and highlights the block.
await page.route(`**${SETTINGS_URL}`, route => (route.request().method() === 'POST'
? route.fulfill({ status: 400, contentType: 'application/json',
body: JSON.stringify({ success: false, error: 'Skipped tags: entry 1 is empty.' }) })
: route.fallback()));
await save(page).click();
await expect(status(page)).toHaveText('Skipped tags: entry 1 is empty.');
await expect(skippedBlock(page)).toHaveAttribute('aria-invalid', 'true');
await page.unrouteAll({ behavior: 'ignoreErrors' });
});
+12
View File
@@ -849,3 +849,15 @@ test('a failed listing re-read after a save says so and keeps the cards (M2)', a
await expect(page.locator('#gallery-continuation [aria-disabled="true"]')).toHaveCount(0);
await page.unroute(failing);
});
// #431: the saved Skipped tags hide matching suggestions before the limit (WD14 fills it from lower scores), and the
// chip hint says how many were not suggested.
test('suggestions omit skipped tags and say how many were hidden', async ({ page, request }) => {
await expect(await request.post('/__test__/tagging/defaults', { data: { general: 0.35, character: 0.85, limit: 4,
skipped: ['desert', 'rock'] } })).toBeOK();
await openTag(page);
const answer = await (await generated(page)).json();
expect(answer.skipped).toBe(2);
await expect(chips(page).locator(':scope > span:first-child')).toHaveText(['sandstone', 'arch', 'sky', 'dune']);
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(`${CHIP_HINT} 2 skipped tags were not suggested.`);
});
+52 -3
View File
@@ -226,6 +226,7 @@ def register_tagging_commands(
return download["result"]
_set_defaults(model, DEFAULTS) # the server starts on the standard ones
_write_labels(model, True)
@routes.post("/__test__/tagging/reset")
async def reset(request: web.Request) -> web.Response:
@@ -244,6 +245,7 @@ def register_tagging_commands(
model.loaded = None
restore_download()
_set_defaults(model, DEFAULTS)
await api._run_in_executor(_write_labels, model, True)
# A test that ends with the dialog open leaves its capture behind; drop
# every capture so the next test starts below the store's limits.
await api._run_in_executor(api.close_annotation_captures)
@@ -353,13 +355,22 @@ def register_tagging_commands(
``{"general": g, "character": c, "limit": n}``: the WD14 thresholds
(in [0, 1]) and the persisted suggestion count (1..100), so a test can
tell the server's values from the dialog's built-in ones. Reset
restores the standard 0.35 / 0.85 / 10.
tell the server's values from the dialog's built-in ones. Optional
``skipped`` sets the saved Skipped tags (#431) and ``labels`` (true or
false) writes or removes the fake WD14 SwinV2 label list the Skipped
tags preview reads. Reset restores the standard 0.35 / 0.85 / 10, no
skipped tags and the label list.
"""
data = await request.json() if request.can_read_body else None
if request.query or not _valid_defaults(data):
raise web.HTTPBadRequest()
_set_defaults(model, (data["general"], data["character"], data["limit"]))
if "skipped" in data:
from py.config import AutotagConfig
AutotagConfig.SKIPPED_TAGS = tuple(data["skipped"])
if "labels" in data:
await api._run_in_executor(_write_labels, model, data["labels"])
return web.json_response({"success": True})
@routes.get("/__test__/tagging/captures")
@@ -394,6 +405,33 @@ def _set_defaults(model, values) -> None:
AutotagConfig.MODEL_TYPE = "wd14-swinv2"
AutotagConfig.EXISTING = "skip"
AutotagConfig.TEMPLATE = model.default_prompt
AutotagConfig.SKIPPED_TAGS = ()
# The fake WD14 SwinV2 label list (general 0, character 4, rating 9): the
# names the Skipped tags preview checks beside the library's tags (#431).
FAKE_LABELS = (
("blurry", "0"),
("blurry_background", "0"),
("signature", "0"),
("watermark", "0"),
("t-shirt", "0"),
("half-closed_eyes", "0"),
("saber_(fate)", "4"),
("general", "9"),
)
def _write_labels(model, present: bool) -> None:
"""Write (or remove) the fake label list under the fake models folder."""
path = Path(model.models_dir) / "WD14-SwinV2" / "selected_tags.csv"
if not present:
path.unlink(missing_ok=True)
return
path.parent.mkdir(parents=True, exist_ok=True)
lines = ["tag_id,name,category,count"]
lines += [f"{i},{name},{cat},1" for i, (name, cat) in enumerate(FAKE_LABELS)]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _valid_defaults(data: object) -> bool:
@@ -402,9 +440,20 @@ def _valid_defaults(data: object) -> bool:
and not isinstance(value, bool)
and 0 <= value <= 1
)
optional = {"skipped", "labels"}
if isinstance(data, dict) and "skipped" in data:
try:
from py.tagging_defaults import validate_skipped_tags
validate_skipped_tags(data["skipped"])
except ValueError:
return False
if isinstance(data, dict) and not isinstance(data.get("labels", True), bool):
return False
return (
isinstance(data, dict)
and set(data) == {"general", "character", "limit"}
and {"general", "character", "limit"} <= set(data)
and set(data) <= {"general", "character", "limit"} | optional
and unit(data["general"])
and unit(data["character"])
and type(data["limit"]) is int
+2
View File
@@ -105,6 +105,7 @@
"POST /prompt_manager/autotag/batch/preview",
"POST /prompt_manager/autotag/download/{model_type}",
"POST /prompt_manager/autotag/single",
"POST /prompt_manager/autotag/skipped-tags/preview",
"POST /prompt_manager/autotag/start",
"POST /prompt_manager/autotag/unload",
"POST /prompt_manager/bulk/category",
@@ -188,6 +189,7 @@
"settings.autotag_existing",
"settings.autotag_general_threshold",
"settings.autotag_model_type",
"settings.autotag_skipped_tags",
"settings.autotag_suggestion_limit",
"settings.autotag_template",
"settings.cpu_count",
@@ -936,3 +936,17 @@ test('#515: text typed before Retry after a failed first observation follows the
assert.equal(c.state.manual, 'Minimal, warm_light, dunes');
assert.equal(c.state.statusText, '');
});
test('the chip hint says how many skipped tags were not suggested (#431)', async () => {
const { skippedNote } = await load();
assert.equal(skippedNote(0), '');
assert.equal(skippedNote(undefined), '');
assert.equal(skippedNote(1), ' 1 skipped tag was not suggested.');
assert.equal(skippedNote(2), ' 2 skipped tags were not suggested.');
for (const [skipped, hint] of [[2, 'Remove anything you do not want to add. 2 skipped tags were not suggested.'],
[0, 'Remove anything you do not want to add.'], ['3', 'Remove anything you do not want to add.']]) {
const { c } = await opened({ [`POST ${SUGGEST}`]: () => response(200, { ...suggested(), skipped }) });
await c.generate();
assert.equal(c.state.chipHint, hint, String(skipped));
}
});
@@ -0,0 +1,247 @@
// System › Auto-tagging "Skipped tags" (#431, D202): the add input, the chips, the client checks and the preview line.
const test = require('node:test');
const assert = require('node:assert/strict');
const skippedModule = import('../../web/js/workspace/system-skipped-tags.mjs');
const stateModule = import('../../web/js/workspace/system-state.mjs');
const coreModule = import('../../web/js/shared/toolkit/core.js');
/** A minimal DOM: elements with children, attributes, listeners (aborted by their signal) and focus. */
function fakeDOM() {
const previous = global.document;
class Element {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = [];
this.dataset = {};
this.className = '';
this.id = '';
this.value = '';
this.title = '';
this.disabled = false;
this._text = '';
}
get localName() { return this.tagName.toLowerCase(); }
get textContent() { return this._text + this.children.map(child => (typeof child === 'string' ? child : child.textContent)).join(''); }
set textContent(text) { this.children = []; this._text = String(text); }
append(...nodes) { for (const node of nodes) { if (typeof node !== 'string') node.parentNode = this; this.children.push(node); } }
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
querySelectorAll(selector) {
assert.equal(selector, 'button');
const found = [];
const walk = node => { for (const child of node.children) if (typeof child !== 'string') { if (child.tagName === 'BUTTON') found.push(child); walk(child); } };
walk(this);
return found;
}
focus() { global.document.activeElement = this; }
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
removeAttribute(name) { delete this.attributes[name]; }
addEventListener(type, callback, options = {}) {
if (options.signal?.aborted) return;
const entry = { type, callback };
this.listeners.push(entry);
options.signal?.addEventListener('abort', () => { this.listeners = this.listeners.filter(item => item !== entry); });
}
dispatch(type, event = {}) {
return Promise.all(this.listeners.filter(item => item.type === type).map(({ callback }) => callback({ type, target: this, ...event })));
}
}
global.document = { createElement: tag => new Element(tag), activeElement: null };
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => typeof child !== 'string' && walk(child, found)); return found; };
return { find: (root, predicate) => walk(root).find(predicate), restore() { global.document = previous; } };
}
const tick = () => new Promise(resolve => setTimeout(resolve, 5));
async function mounted({ saved = ['blurry'], answer, host = true } = {}) {
const { skippedTagsBlock } = await skippedModule;
const { createSystemDrafts } = await stateModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const drafts = createSystemDrafts({
readBrowser: () => ({}), readRuntime: () => ({}),
readHost: async () => ({ values: host ? { autotag_skipped_tags: saved } : {}, revision: 'sha256:one' }),
writeBrowser: async () => ({}), writeHost: async () => ({ success: true, persisted: true }), writeRuntime: async () => ({}),
});
const scope = createScope();
const posts = [];
const api = {
post: (url, body, options) => {
posts.push({ url, body, signal: options.signal });
if (answer) return answer(body, options);
return Promise.resolve({ success: true, count: body.entries.length, sample: body.entries, sample_limit: 12,
per_entry: body.entries.map((entry, index) => ({ entry, count: index })), sources: { library: 3, wd14: ['WD14 SwinV2'] } });
},
};
const block = skippedTagsBlock({ api, scope, drafts, delay: 1 });
scope.cleanup(drafts.subscribe(block.repaint));
block.repaint();
await drafts.reloadHost();
const byId = id => dom.find(block.node, node => node.id === id);
const chips = () => dom.find(block.node, node => node.className === 'tags skip-chips');
const preview = () => dom.find(block.node, node => node.className === 'small skip-preview');
const add = async text => { byId('tag-skipped-input').value = text; await byId('tag-skipped-add').dispatch('click'); };
return { dom, drafts, scope, block, byId, chips, preview, posts, add };
}
test('words split like the server: NFC, lower case, space, _ and - only', async () => {
const { entryWords } = await skippedModule;
assert.deepEqual(entryWords('Half-closed_eyes'), ['half', 'closed', 'eyes']);
assert.deepEqual(entryWords('saber_(fate)'), ['saber', '(fate)']);
assert.deepEqual(entryWords(' _ - '), []);
assert.deepEqual(entryWords('CAFÉ'), ['café']);
});
test('the client checks say why an entry is not added', async () => {
const { entryProblem, SKIPPED_COPY } = await skippedModule;
assert.equal(entryProblem(' sky ', ['blurry']), '');
assert.equal(entryProblem(' ', []), SKIPPED_COPY.empty);
assert.equal(entryProblem('_--', []), 'Enter a word.');
assert.equal(entryProblem('a, b', []), 'No commas or line breaks.');
assert.equal(entryProblem('a\nb', []), 'No commas or line breaks.');
assert.equal(entryProblem('x'.repeat(51), []), 'At most 50 characters.');
assert.equal(entryProblem('🏜'.repeat(50), []), '');
assert.equal(entryProblem('Blurry-Background', ['blurry background']), 'Already in the list.');
assert.equal(entryProblem('sky', Array.from({ length: 200 }, (_, i) => `w${i}`)), 'The list is full (200).');
});
test('the preview line states the count, the sample and the sources', async () => {
const { previewText, chipCount } = await skippedModule;
assert.deepEqual(previewText({ count: 15, sample: ['blurry', 'signature'], sources: { wd14: ['WD14 SwinV2'] } }), {
lead: 'Hides 15 tags from suggestions', rest: ': blurry, signature and 13 more. Tags already on images are not changed.' });
assert.deepEqual(previewText({ count: 1, sample: ['blurry'], sources: { wd14: [] } }), {
lead: 'Hides 1 tag from suggestions',
rest: ': blurry. Checked against library tags only; no WD14 label list is downloaded. Tags already on images are not changed.' });
assert.deepEqual(previewText({ count: 0, sample: [], sources: { wd14: ['WD14 ViT'] } }), {
lead: 'Hides 0 tags from suggestions', rest: '. Tags already on images are not changed.' });
assert.deepEqual(previewText(null).lead, 'Hides 0 tags from suggestions');
assert.deepEqual(chipCount(3), { title: 'Hides 3 tags', text: '3' });
assert.deepEqual(chipCount(1), { title: 'Hides 1 tag', text: '1' });
assert.deepEqual(chipCount(0), { title: 'Hides no known tag', text: 'none' });
assert.equal(chipCount(undefined), null);
});
test('the block shows the saved list, previews it and carries its setting keys', async () => {
const view = await mounted({ saved: ['blurry', 'signature'] });
try {
assert.equal(view.block.node.dataset.setting, 'tag-skipped');
assert.equal(view.block.node.dataset.key, 'autotag_skipped_tags');
assert.equal(view.preview().textContent, 'Checking…');
await tick();
assert.deepEqual(view.posts.map(post => [post.url, post.body]),
[['/prompt_manager/autotag/skipped-tags/preview', { entries: ['blurry', 'signature'] }]]);
assert.equal(view.preview().textContent, 'Hides 2 tags from suggestions: blurry, signature. Tags already on images are not changed.');
const chips = view.chips().children;
assert.deepEqual(chips.map(chip => [chip.className, chip.title, chip.textContent]), [
['chip tag-suggestion none', 'Hides no known tag', 'blurrynone×'],
['chip tag-suggestion', 'Hides 1 tag', 'signature1×']]);
assert.equal(chips[1].children[2].getAttribute('aria-label'), 'Remove signature');
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('add and remove draft the list; a list set back is clean; Enter adds', async () => {
const view = await mounted();
try {
await view.add(' closed eyes ');
assert.deepEqual(view.drafts.get('host', 'autotag_skipped_tags'), ['blurry', 'closed eyes']);
assert.equal(view.byId('tag-skipped-input').value, '');
assert.equal(view.drafts.dirty(), true);
view.byId('tag-skipped-input').value = 'sky';
let prevented = false;
await view.byId('tag-skipped-input').dispatch('keydown', { key: 'Enter', preventDefault: () => { prevented = true; } });
assert.equal(prevented, true);
await view.byId('tag-skipped-input').dispatch('keydown', { key: 'a' });
assert.deepEqual(view.drafts.get('host', 'autotag_skipped_tags'), ['blurry', 'closed eyes', 'sky']);
// Remove the two added chips: the list equals the saved one and nothing is unsaved (sameDraft).
const removeButton = index => view.chips().children[index].children.at(-1);
removeButton(2).focus();
await removeButton(2).dispatch('click');
assert.equal(global.document.activeElement, removeButton(1), 'focus moves to the neighbour');
await removeButton(1).dispatch('click');
assert.deepEqual(view.drafts.get('host', 'autotag_skipped_tags'), ['blurry']);
assert.equal(view.drafts.dirty(), false);
await tick();
// Only the last list of a quick series is asked for.
assert.deepEqual(view.posts.map(post => post.body.entries), [['blurry']]);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('a refused entry is not added and the line under the input says why', async () => {
const view = await mounted();
try {
const problem = () => view.byId('tag-skipped-problem').textContent;
await view.add('Blurry');
assert.equal(problem(), 'Already in the list.');
assert.equal(view.byId('tag-skipped-input').getAttribute('aria-invalid'), 'true');
assert.equal(view.byId('tag-skipped-input').value, 'Blurry');
await view.byId('tag-skipped-input').dispatch('input');
assert.equal(problem(), '');
assert.equal(view.byId('tag-skipped-input').getAttribute('aria-invalid'), null);
await view.add('a,b');
assert.equal(problem(), 'No commas or line breaks.');
await view.add(' ');
assert.equal(problem(), 'Enter a word.');
assert.deepEqual(view.drafts.get('host', 'autotag_skipped_tags'), ['blurry']);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('a full list disables Add and says so', async () => {
const view = await mounted({ saved: Array.from({ length: 200 }, (_, i) => `w${i}`) });
try {
assert.equal(view.byId('tag-skipped-add').disabled, true);
assert.equal(view.byId('tag-skipped-problem').textContent, 'The list is full (200).');
await view.chips().children[0].children.at(-1).dispatch('click');
assert.equal(view.byId('tag-skipped-add').disabled, false);
assert.equal(view.byId('tag-skipped-problem').textContent, '');
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('an empty list says no tags are skipped without a request', async () => {
const view = await mounted({ saved: [] });
try {
await tick();
assert.equal(view.preview().textContent, 'No tags are skipped.');
assert.deepEqual(view.posts, []);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('a failed preview says so; a newer list aborts the older request', async () => {
let calls = 0;
const view = await mounted({ answer: (body, options) => {
calls += 1;
if (calls === 1) return new Promise((_resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted'))));
return Promise.reject(Object.assign(new Error('busy'), { code: 'busy' }));
} });
try {
await tick();
assert.equal(view.posts.length, 1);
await view.add('sky');
assert.equal(view.posts[0].signal.aborted, true);
await tick();
assert.equal(view.preview().textContent, 'The preview could not be read.');
// The list stays editable.
assert.equal(view.byId('tag-skipped-input').disabled, false);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('before the settings are read the controls are disabled', async () => {
const view = await mounted({ host: false });
try {
assert.equal(view.byId('tag-skipped-input').disabled, true);
assert.equal(view.byId('tag-skipped-add').disabled, true);
await view.add('sky');
assert.equal(view.drafts.get('host', 'autotag_skipped_tags'), undefined);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('leaving the section aborts a request in flight and the pending timer', async () => {
const view = await mounted({ answer: (_body, options) => new Promise((_r, reject) => options.signal.addEventListener('abort', () => reject(new Error('x')))) });
await tick();
assert.equal(view.posts.length, 1);
view.scope.dispose();
assert.equal(view.posts[0].signal.aborted, true);
view.dom.restore();
});
+39 -2
View File
@@ -402,11 +402,12 @@ test('a partial host save keeps what saved, adopts its revision and fails only t
assert.equal(drafts.statusText(), 'missing can\'t be used: not found. Remove it or reconnect it, then save.');
// The next save carries the revision the saved part produced: no false "changed elsewhere".
domains.outcome.host = null;
drafts.set('host', 'gallery_root_paths', ['a']);
// (#431: a list equal to the saved one is no change, so a different one is drafted.)
drafts.set('host', 'gallery_root_paths', ['a', 'b']);
const second = await drafts.save();
assert.equal(second.host, 'saved');
assert.equal(domains.calls.at(-1)[2].expected_revision, 'sha256:lora');
assert.deepEqual(domains.calls.at(-1)[1], { gallery_root_paths: ['a'] });
assert.deepEqual(domains.calls.at(-1)[1], { gallery_root_paths: ['a', 'b'] });
// Without a server reason, the status says some were saved and the highlighted ones were not.
domains.outcome.host = () => { throw Object.assign(new Error('boom'), { partial: { saved: ['x'], revision: 'sha256:three' } }); };
drafts.set('host', 'x', 1);
@@ -429,3 +430,39 @@ test('runtime adapter maps config.level to runtime_level and back', async () =>
assert.deepEqual(runtimeBody({ runtime_level: 'WARNING' }), { level: 'WARNING' });
assert.deepEqual(runtimeBody({ runtime_level: 'CRITICAL', console_logging: true }), { level: 'CRITICAL', console_logging: true });
});
test('sameDraft compares arrays and plain objects by value, keys in any order (#431)', async () => {
const { sameDraft } = await source;
assert.equal(sameDraft(1, 1), true);
assert.equal(sameDraft(Number.NaN, Number.NaN), true);
assert.equal(sameDraft(0, -0), false);
assert.equal(sameDraft(['a', 'b'], ['a', 'b']), true);
assert.equal(sameDraft(['a', 'b'], ['b', 'a']), false);
assert.equal(sameDraft([], []), true);
assert.equal(sameDraft([], {}), false);
assert.equal(sameDraft({ gguf: 'x', hf: 'y' }, { hf: 'y', gguf: 'x' }), true);
assert.equal(sameDraft([{ id: 'a', name: 'n' }], [{ name: 'n', id: 'a' }]), true);
assert.equal(sameDraft({ a: 1 }, { a: 2 }), false);
assert.equal(sameDraft('a', ['a']), false);
assert.equal(sameDraft(null, {}), false);
assert.equal(sameDraft(new Date(0), new Date(0)), false);
});
test('a list draft set back to the saved list is clean, and a save cleans it (#431)', async () => {
const domains = adapters({ host: { autotag_skipped_tags: ['blurry'] } });
const drafts = await loaded(domains);
drafts.set('host', 'autotag_skipped_tags', ['blurry', 'signature']);
assert.equal(drafts.dirty('host'), true);
// Removing the added chip builds a new array equal to the saved one.
drafts.set('host', 'autotag_skipped_tags', ['blurry']);
assert.equal(drafts.dirty('host'), false);
drafts.set('host', 'autotag_skipped_tags', ['signature']);
await drafts.save();
assert.equal(drafts.dirty('host'), false);
assert.deepEqual(drafts.saved('host').autotag_skipped_tags, ['signature']);
// A reload whose list equals the draft's (a new array) keeps nothing dirty.
drafts.set('host', 'autotag_skipped_tags', ['sky']);
domains.readHost = async () => ({ values: { autotag_skipped_tags: ['sky'] }, revision: 'sha256:three' });
await drafts.load('host');
assert.equal(drafts.dirty('host'), false);
});
+1 -1
View File
@@ -201,7 +201,7 @@ test('opening the section reads the model status once; the select waits for it',
assert.deepEqual(byId('tag-model').options.map(option => option.textContent),
['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB']);
assert.deepEqual(surface.children.map(node => node.className),
['', '', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'field', 'row-actions', 'operation']);
['', '', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-block', 'field', 'row-actions', 'operation']);
} finally { scope.dispose(); dom.restore(); }
const failed = await mounted({ modelsFail: true });
try {
+7 -2
View File
@@ -73,6 +73,7 @@ class FakeTagger:
self.calls = []
self.read_paths = []
self.read_bytes = []
self.skips = []
self.download_model = MagicMock(side_effect=AssertionError("downloaded"))
self.load_error = None
self.block = None # (entered, proceed) events
@@ -115,8 +116,10 @@ class FakeTagger:
prompt=None,
general_threshold=None,
character_threshold=None,
skip=None,
):
self.read_paths.append(image_path)
self.skips.append(skip)
with open(image_path, "rb") as handle:
self.read_bytes.append(handle.read())
self.calls.append(
@@ -132,9 +135,11 @@ class FakeTagger:
entered, proceed = self.block
entered.set()
proceed.wait(WAIT)
# As the engine does (#431): a skipped tag uses up no slot.
kept = [pair for pair in SCORED if skip is None or not skip.matches(pair[0])]
if self.loaded.startswith("wd14"):
return list(SCORED[:limit])
return [(name, None) for name, _ in SCORED[:limit]]
return list(kept[:limit])
return [(name, None) for name, _ in kept[:limit]]
class SuggestTestCase(AnnotationAPITestCase):
+4 -1
View File
@@ -129,8 +129,10 @@ class FakeAutotagService:
prompt=None,
general_threshold=None,
character_threshold=None,
skip=None,
):
"""The suggestion entry point (#379): the fixed tags, at most ``limit``."""
"""The suggestion entry point (#379): the fixed tags, at most ``limit``,
without the ones ``skip`` hides (#431)."""
self.suggest_calls += 1
tags = self.generate_tags(
image_path,
@@ -138,6 +140,7 @@ class FakeAutotagService:
general_threshold=general_threshold,
character_threshold=character_threshold,
)
tags = [name for name in tags if skip is None or not skip.matches(name)]
return [(name, None) for name in tags[:limit]]
+2
View File
@@ -54,6 +54,8 @@ class PerImageTagger(HookedTagger):
names = self.by_bytes.get(self.read_bytes[-1])
if names is None:
return result
skip = kwargs.get("skip") # as the engine does (#431)
names = [name for name in names if skip is None or not skip.matches(name)]
return [(name, 0.9) for name in names][: kwargs["limit"]]
+27 -1
View File
@@ -444,6 +444,7 @@ class TestAutotagConfig(unittest.TestCase):
"CHARACTER_THRESHOLD",
"TEMPLATE",
"SUGGESTION_LIMIT",
"SKIPPED_TAGS",
)
}
@@ -487,6 +488,7 @@ class TestAutotagConfig(unittest.TestCase):
"general_threshold": 0.35,
"character_threshold": 0.85,
"suggestion_limit": 10,
"skipped_tags": [],
}
},
)
@@ -526,6 +528,7 @@ class TestAutotagConfig(unittest.TestCase):
"character_threshold": 0.7,
"template": "Tags only.",
"suggestion_limit": 15,
"skipped_tags": ("blurry", "closed eyes"),
}
fallback = {
"model_type": "wd14-swinv2",
@@ -534,6 +537,7 @@ class TestAutotagConfig(unittest.TestCase):
"character_threshold": 0.85,
"template": config_module.DEFAULT_TAG_TEMPLATE,
"suggestion_limit": 10,
"skipped_tags": (),
}
bad_values = {
"model_type": ["wd14-convnext", 3, None],
@@ -544,6 +548,8 @@ class TestAutotagConfig(unittest.TestCase):
# 4000 characters are at most 16,000 UTF-8 bytes, so the 16 KiB
# byte cap can only be met past the character cap.
"template": ["", " ", "x" * 4001, "\U0001f600" * 4097, "\ud800", 7],
# #431: the whole list falls back; entries are never dropped one by one.
"skipped_tags": ["blurry", ["a,b"], ["sky", "Sky"], [""], ["x"] * 201],
}
for key, values in bad_values.items():
for value in values:
@@ -552,7 +558,7 @@ class TestAutotagConfig(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "config.json")
with open(path, "w") as f:
json.dump({"autotag": {"defaults": saved}}, f)
json.dump({"autotag": {"defaults": saved}}, f, default=list)
with self.assertLogs(
"prompt_manager.config", level="WARNING"
) as logs:
@@ -565,6 +571,26 @@ class TestAutotagConfig(unittest.TestCase):
}
self.assertEqual(loaded, dict(good, **{key: fallback[key]}))
def test_skipped_tags_load_trimmed_and_warn_once_when_damaged(self):
"""#431: the list is kept as typed (trimmed); a damaged list is
replaced by no skipped tags with one warning naming the reason."""
PromptManagerConfig.update_config(
{"autotag": {"defaults": {"skipped_tags": [" blurry ", "(fate)"]}}}
)
self.assertEqual(self.config_class.SKIPPED_TAGS, ("blurry", "(fate)"))
with self.assertLogs("prompt_manager.config", level="WARNING") as logs:
PromptManagerConfig.update_config(
{"autotag": {"defaults": {"skipped_tags": ["sky", "a,b"]}}}
)
self.assertEqual(self.config_class.SKIPPED_TAGS, ())
self.assertEqual(
[record.getMessage() for record in logs.records],
[
"autotag.defaults.skipped_tags entry 2 must not contain a comma"
" or a line break; using no skipped tags"
],
)
def test_tagging_validators_moved_with_their_messages(self):
validate_threshold = config_module.validate_threshold
validate_template = config_module.validate_template
+63
View File
@@ -1025,6 +1025,7 @@ class TestAutotagDefaultsSettings(SourcesTestCase):
"CHARACTER_THRESHOLD",
"TEMPLATE",
"SUGGESTION_LIMIT",
"SKIPPED_TAGS",
)
SAVED = {
"autotag_model_type": "gguf",
@@ -1069,6 +1070,7 @@ class TestAutotagDefaultsSettings(SourcesTestCase):
"CHARACTER_THRESHOLD": 0.8,
"TEMPLATE": "Tags only.",
"SUGGESTION_LIMIT": 15,
"SKIPPED_TAGS": (),
},
)
settings = (await self._get())["settings"]
@@ -1166,3 +1168,64 @@ class TestAutotagDefaultsSettings(SourcesTestCase):
self.assertEqual(status, 400, data)
self.assertEqual(data["error"], message)
self.assertEqual((self._config_bytes(), self._runtime()), before)
async def test_skipped_tags_save_trimmed_and_read_back(self):
"""#431: the list is saved as typed (trimmed), published at once and
read back by GET /settings."""
self._write_config({"custom": {"keep": True}})
settings = (await self._get())["settings"]
self.assertEqual(settings["autotag_skipped_tags"], [])
revision = (await self._get())["revision"]
status, data = await self._post(
"/prompt_manager/settings",
{
"expected_revision": revision,
"autotag_skipped_tags": [" blurry ", "closed eyes", "(fate)"],
},
)
self.assertEqual(status, 200, data)
saved = self._read_config()
self.assertEqual(
saved["autotag"]["defaults"],
{"skipped_tags": ["blurry", "closed eyes", "(fate)"]},
)
self.assertEqual(saved["custom"], {"keep": True})
self.assertEqual(self.autotag.SKIPPED_TAGS, ("blurry", "closed eyes", "(fate)"))
settings = (await self._get())["settings"]
self.assertEqual(
settings["autotag_skipped_tags"], ["blurry", "closed eyes", "(fate)"]
)
async def test_bad_skipped_tags_are_refused_with_their_sentence(self):
self._write_config({"custom": {"keep": True}})
revision = (await self._get())["revision"]
before = (self._config_bytes(), self._runtime())
for value, message in (
(["sky", " "], "Skipped tags: entry 2 is empty."),
(["x" * 51], "Skipped tags: entry 1 must be at most 50 characters."),
(
["a, b"],
"Skipped tags: entry 1 must not contain a comma or a line break.",
),
(["blurry", "Blurry"], "Skipped tags: entry 2 repeats entry 1."),
([f"w{i}" for i in range(201)], "Skipped tags: at most 200 entries."),
):
with self.subTest(value=value[:3]):
status, data = await self._post(
"/prompt_manager/settings",
{"expected_revision": revision, "autotag_skipped_tags": value},
)
self.assertEqual(status, 400, data)
self.assertEqual(data["error"], message)
self.assertEqual((self._config_bytes(), self._runtime()), before)
async def test_skipped_tags_without_a_revision_are_refused(self):
"""A list saved without expected_revision could merge a stale copy."""
self._write_config({"custom": {"keep": True}})
before = (self._config_bytes(), self._runtime())
status, data = await self._post(
"/prompt_manager/settings", {"autotag_skipped_tags": ["blurry"]}
)
self.assertEqual(status, 400, data)
self.assertEqual(data["error"], "Reload the page and try again.")
self.assertEqual((self._config_bytes(), self._runtime()), before)
+281
View File
@@ -0,0 +1,281 @@
"""Skipped tags (#431): the words whose suggested tags are never offered.
The normalizer, the entry validation, the whole-word matcher, the WD14 label
reader and the filter in ``AutoTagService.generate_tag_suggestions``. No model
is loaded: ``_scored_tags`` is replaced by a fixed list.
"""
import csv
import os
import tempfile
import unicodedata
import unittest
from pathlib import Path
from unittest.mock import patch
from py.tagging_defaults import (
MAX_SKIPPED_TAG_CHARS,
MAX_SKIPPED_TAGS,
SkipMatcher,
tag_words,
validate_skipped_tags,
wd14_label_names,
)
# The design's matching table: (entry, tag, hidden).
MATCHING = (
("blurry", "blurry", True),
("blurry", "blurry_background", True),
("blurry", "very_blurry", True),
("blurry", "slightly_blurry_background", True),
("blurry", "unblurry", False),
("blur", "blurry", False),
("blurry background", "blurry_background", True),
("blurry background", "Blurry-Background", True),
("background blurry", "blurry_background", False),
("shirt", "t-shirt", True),
("shirt", "shirt_lift", True),
("closed eyes", "half-closed_eyes", True),
("girl", "1girl", False),
("girl", "2girls", False),
("girls", "multiple_girls", True),
("fate", "saber_(fate)", False),
("(fate)", "saber_(fate)", True),
("saber", "saber_(fate)", True),
(":d", ":d", True),
("café", unicodedata.normalize("NFD", "café"), True),
)
class TestWords(unittest.TestCase):
def test_words_split_only_on_space_underscore_and_hyphen(self):
self.assertEqual(tag_words("Half-closed_eyes"), ("half", "closed", "eyes"))
self.assertEqual(tag_words("saber_(fate)"), ("saber", "(fate)"))
self.assertEqual(tag_words(" a__b--c d "), ("a", "b", "c", "d"))
self.assertEqual(tag_words("_-- _"), ())
self.assertEqual(tag_words(":d"), (":d",))
def test_words_are_nfc_and_case_folded(self):
self.assertEqual(
tag_words("CAFÉ"), tag_words(unicodedata.normalize("NFD", "café"))
)
self.assertEqual(tag_words("Straße"), ("strasse",))
class TestMatcher(unittest.TestCase):
def test_every_row_of_the_matching_table(self):
for entry, tag, hidden in MATCHING:
with self.subTest(entry=entry, tag=tag):
self.assertIs(SkipMatcher([entry]).matches(tag), hidden)
def test_a_tag_without_words_never_matches(self):
matcher = SkipMatcher(["blurry"])
for tag in ("", "_", "- _"):
with self.subTest(tag=tag):
self.assertFalse(matcher.matches(tag))
def test_an_empty_matcher_hides_nothing_and_is_false(self):
self.assertFalse(SkipMatcher())
self.assertFalse(SkipMatcher([]).matches("blurry"))
self.assertTrue(SkipMatcher(["blurry"]))
def test_entries_of_different_lengths_are_all_checked(self):
matcher = SkipMatcher(["sky", "closed eyes", "very long entry here"])
self.assertTrue(matcher.matches("blue_sky"))
self.assertTrue(matcher.matches("half-closed_eyes"))
self.assertTrue(matcher.matches("a_very_long_entry_here_b"))
self.assertFalse(matcher.matches("very_long_entry"))
def test_counting_view_records_the_distinct_names_it_hid(self):
counting = SkipMatcher(["blurry"]).counting()
self.assertTrue(counting.matches("blurry"))
self.assertTrue(counting.matches("Blurry"))
self.assertTrue(counting.matches("blurry_background"))
self.assertFalse(counting.matches("sky"))
self.assertEqual(counting.hidden, {"blurry", "blurry_background"})
class TestValidation(unittest.TestCase):
def test_entries_are_trimmed_and_kept_as_typed(self):
self.assertEqual(
validate_skipped_tags([" Blurry ", "closed eyes", "(fate)"]),
["Blurry", "closed eyes", "(fate)"],
)
self.assertEqual(validate_skipped_tags([]), [])
def test_caps_count_code_points_after_trimming(self):
self.assertEqual(
validate_skipped_tags([" " + "🏜" * MAX_SKIPPED_TAG_CHARS + " "]),
["🏜" * MAX_SKIPPED_TAG_CHARS],
)
entries = [f"word{index}" for index in range(MAX_SKIPPED_TAGS)]
self.assertEqual(validate_skipped_tags(entries), entries)
def test_every_refusal_sentence(self):
cases = (
("blurry", "Skipped tags must be a list of words."),
(
[f"w{index}" for index in range(MAX_SKIPPED_TAGS + 1)],
"Skipped tags: at most 200 entries.",
),
(["ok", 3], "Skipped tags: entry 2 must be text."),
(["ok", " "], "Skipped tags: entry 2 is empty."),
(["_ --"], "Skipped tags: entry 1 is empty."),
(["x" * 51], "Skipped tags: entry 1 must be at most 50 characters."),
(
["a, b"],
"Skipped tags: entry 1 must not contain a comma or a line break.",
),
(
["a\nb"],
"Skipped tags: entry 1 must not contain a comma or a line break.",
),
(
["a\x7fb"],
"Skipped tags: entry 1 must not contain a comma or a line break.",
),
(
["blurry", "sky", "Blurry"],
"Skipped tags: entry 3 repeats entry 1.",
),
(
["blurry background", "blurry-Background"],
"Skipped tags: entry 2 repeats entry 1.",
),
)
for value, message in cases:
with self.subTest(value=value):
with self.assertRaises(ValueError) as caught:
validate_skipped_tags(value)
self.assertEqual(str(caught.exception), message)
class TestWd14Labels(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.path = Path(directory.name) / "selected_tags.csv"
def write(self, rows):
lines = ["tag_id,name,category,count"]
lines += [f"{i},{name},{category},1" for i, (name, category) in enumerate(rows)]
self.path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def test_only_general_and_character_names_are_read(self):
self.write(
[
("blurry", "0"),
("general", "9"),
("saber_(fate)", "4"),
("artist", "1"),
("café", "0"),
]
)
self.assertEqual(
wd14_label_names(self.path), ("blurry", "saber_(fate)", "café")
)
def test_the_list_is_read_again_only_when_the_file_changes(self):
self.write([("blurry", "0")])
with patch(
"py.tagging_defaults.read_wd14_label_rows",
wraps=__import__(
"py.tagging_defaults", fromlist=["x"]
).read_wd14_label_rows,
) as reader:
self.assertEqual(wd14_label_names(self.path), ("blurry",))
self.assertEqual(wd14_label_names(self.path), ("blurry",))
self.assertEqual(reader.call_count, 1)
self.write([("blurry", "0"), ("sky", "0")])
os.utime(self.path, ns=(1, 2))
self.assertEqual(wd14_label_names(self.path), ("blurry", "sky"))
self.assertEqual(reader.call_count, 2)
def test_a_damaged_list_is_a_value_error_for_the_caller_to_skip(self):
# A field over the csv module's size limit raises csv.Error.
big = "x" * (csv.field_size_limit() + 1)
self.path.write_text(
f"tag_id,name,category,count\n1,{big},0,1\n", encoding="utf-8"
)
with self.assertRaises(ValueError):
wd14_label_names(self.path)
def test_a_missing_list_raises_for_the_caller_to_skip(self):
with self.assertRaises(OSError):
wd14_label_names(self.path)
class FilterService:
"""``AutoTagService`` with a fixed model output instead of a model."""
def __init__(self, scored):
from py.autotag import AutoTagService
self.service = AutoTagService.__new__(AutoTagService)
self.service._scored_tags = lambda *args, **kwargs: list(scored)
def suggest(self, limit, skip=None):
return self.service.generate_tag_suggestions(
"image.png", limit=limit, skip=skip
)
class TestFilter(unittest.TestCase):
def test_skipped_tags_do_not_use_up_slots(self):
"""WD14: the next-best tags above the thresholds fill the limit."""
scored = [
("blurry", 0.99),
("sky", 0.9),
("blurry_background", 0.8),
("cloud", 0.7),
("sun", 0.6),
("sea", 0.5),
]
service = FilterService(scored)
self.assertEqual(
service.suggest(3, SkipMatcher(["blurry"])),
[("sky", 0.9), ("cloud", 0.7), ("sun", 0.6)],
)
self.assertEqual(service.suggest(3), scored[:3])
def test_a_language_model_may_return_fewer_than_the_limit(self):
service = FilterService([("blurry", None), ("sky", None), ("signature", None)])
self.assertEqual(
service.suggest(10, SkipMatcher(["blurry", "signature"])), [("sky", None)]
)
def test_order_is_invalid_name_then_skip_then_dedupe_then_limit(self):
hidden = []
class Recording:
def matches(self, name):
hidden.append(name)
return name.startswith("skip")
service = FilterService(
[
("bad,name", None),
(" Sky ", None),
("skip_me", None),
("sky", None),
("cloud", None),
("sun", None),
]
)
self.assertEqual(
service.suggest(2, Recording()), [("Sky", None), ("cloud", None)]
)
# The refused name never reaches the matcher; trimmed names do; the
# duplicate is checked after the skip; nothing after the limit.
self.assertEqual(hidden, ["Sky", "skip_me", "sky", "cloud"])
def test_the_counting_view_reports_hidden_names(self):
counting = SkipMatcher(["blurry"]).counting()
FilterService([("blurry", 0.9), ("sky", 0.8), ("Blurry", 0.7)]).suggest(
5, counting
)
self.assertEqual(counting.hidden, {"blurry"})
if __name__ == "__main__":
unittest.main()
+280
View File
@@ -0,0 +1,280 @@
"""Skipped tags on every suggestion path, and their preview (#431).
Tag image (``/image-annotations/suggest``), Auto-tag selected (the
``image-tags`` job), the System batch (the ``autotag`` job), the legacy
``/autotag/start`` and ``/autotag/single`` all hide a suggested tag whose
words contain a skipped entry; a job runs with the list it started with.
``POST /autotag/skipped-tags/preview`` counts what a list would hide among
the library's tags and the downloaded WD14 label lists, writing nothing.
"""
import tempfile
from pathlib import Path
try:
from test_autotag_legacy import LegacyTestCase
except ImportError:
from tests.test_autotag_legacy import LegacyTestCase
from py.config import AutotagConfig
PREVIEW = "/prompt_manager/autotag/skipped-tags/preview"
class SkippedTestCase(LegacyTestCase):
async def get_application(self):
app = await super().get_application()
self.addCleanup(
setattr, AutotagConfig, "SKIPPED_TAGS", AutotagConfig.SKIPPED_TAGS
)
AutotagConfig.SKIPPED_TAGS = ()
return app
def job_lines(self, job):
return [line.split(" ", 1)[1] for line in job.log]
class TestEveryPathHidesSkippedTags(SkippedTestCase):
async def test_tag_image_suggestions_omit_skipped_tags_and_count_them(self):
AutotagConfig.SKIPPED_TAGS = ("sandstone", "tag 001")
observed = await self.observed()
body = await self.suggest_ok(self.suggest_body(observed))
names = [row["name"] for row in body["suggestions"]]
self.assertNotIn("sandstone", names)
self.assertNotIn("tag_001", names)
# Skipped tags use up no slot: the next ones fill the limit.
self.assertEqual(len(names), 10)
self.assertEqual(names[:3], ["desert", "tag_000", "tag_002"])
self.assertEqual(body["skipped"], 2)
async def test_tag_image_without_skipped_tags_reports_zero(self):
observed = await self.observed()
body = await self.suggest_ok(self.suggest_body(observed))
self.assertEqual(body["suggestions"][0]["name"], "sandstone")
self.assertEqual(body["skipped"], 0)
async def test_auto_tag_selected_runs_with_the_list_of_its_start(self):
self.png("a.png")
self.png("b.png", color=(1, 2, 3))
AutotagConfig.SKIPPED_TAGS = ("desert",)
def save_mid_run(_path):
# A save while the job runs does not change that job.
AutotagConfig.SKIPPED_TAGS = ("sandstone",)
self.tagger.on_generate = save_mid_run
options = {
"general_threshold": 0.35,
"character_threshold": 0.85,
"suggestion_limit": 2,
}
job = await self.run_job(self.auto_body(["a.png", "b.png"], options=options))
self.assertEqual(job.state, "finished")
self.assertEqual(self.stored("a.png")["tags"], ["sandstone", "tag_000"])
self.assertEqual(self.stored("b.png")["tags"], ["sandstone", "tag_000"])
self.assertIn("Skipped tags: 1 entry", self.job_lines(job))
async def test_the_system_batch_hides_skipped_tags_and_logs_the_list(self):
self.image("a.png", (1, 2, 3), sees=["bird", "blurry_background", "sky"])
AutotagConfig.SKIPPED_TAGS = ("blurry", "watermark")
job = await self.run_batch(existing="merge")
self.assertEqual(self.stored("a.png")["tags"], ["bird", "sky"])
self.assertIn("Skipped tags: 2 entries", self.job_lines(job))
AutotagConfig.SKIPPED_TAGS = ()
self.image("b.png", (4, 5, 6), sees=["blurry"])
await self.relist()
job = await self.run_batch(existing="merge")
self.assertEqual(self.stored("b.png")["tags"], ["blurry"])
self.assertIn("Skipped tags: none", self.job_lines(job))
async def test_the_legacy_batch_hides_skipped_tags(self):
self.image("a.png", (1, 2, 3), sees=["bird", "signature"])
AutotagConfig.SKIPPED_TAGS = ("signature",)
response, frames = await self.legacy()
self.assertEqual(response.status, 200)
self.assertEqual(frames[-1]["type"], "complete", frames[-1])
self.assertEqual(self.stored("a.png")["tags"], ["bird"])
async def test_the_legacy_single_review_hides_skipped_tags(self):
prompt = self.prompt("a prompt")
image = self.link(
prompt, self.image("a.png", (1, 2, 3), sees=["bird", "t-shirt"])
)
AutotagConfig.SKIPPED_TAGS = ("shirt",)
status, data = await self.single({"image_id": image, "model_type": "wd14-vit"})
self.assertEqual(status, 200, data)
self.assertEqual(data["tags"], ["bird"])
async def test_hand_typed_and_stored_tags_are_never_filtered(self):
path = self.image("a.png", (1, 2, 3), sees=["bird"])
self.saved_set("a.png", path.read_bytes(), ["blurry_hand_tag"])
AutotagConfig.SKIPPED_TAGS = ("blurry",)
await self.run_batch(existing="merge")
job = await self.run_job(self.add_body(["a.png"], ["blurry"]))
self.assertEqual(job.state, "finished")
self.assertEqual(
self.stored("a.png")["tags"], ["blurry_hand_tag", "bird", "blurry"]
)
def write_labels(directory, subdir, rows):
path = Path(directory) / subdir / "selected_tags.csv"
path.parent.mkdir(parents=True, exist_ok=True)
lines = ["tag_id,name,category,count"]
lines += [f"{i},{name},{category},1" for i, (name, category) in enumerate(rows)]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
class TestPreview(SkippedTestCase):
async def asyncSetUp(self):
await super().asyncSetUp()
models = tempfile.TemporaryDirectory()
self.addCleanup(models.cleanup)
self.models_dir = Path(models.name)
self.tagger.models_dir = self.models_dir
for name in ("Blurry", "portrait", "my_signature_style"):
self.prompt(f"prompt with {name}", tags=[name])
async def preview(self, entries):
response = await self.client.post(PREVIEW, json={"entries": entries})
return response.status, await self.json_of(response)
async def test_library_tags_only_when_no_label_list_is_downloaded(self):
status, data = await self.preview(["blurry", "signature", "watermark"])
self.assertEqual(status, 200, data)
self.assertEqual(
data,
{
"success": True,
"count": 2,
"sample": ["Blurry", "my_signature_style"],
"sample_limit": 12,
"per_entry": [
{"entry": "blurry", "count": 1},
{"entry": "signature", "count": 1},
{"entry": "watermark", "count": 0},
],
"sources": {"library": 3, "wd14": []},
},
)
async def test_both_label_lists_join_the_library_tags(self):
write_labels(
self.models_dir,
"WD14-SwinV2",
[
("blurry", "0"),
("blurry_background", "0"),
("watermark", "0"),
("general", "9"),
("saber_(fate)", "4"),
],
)
write_labels(
self.models_dir,
"WD14-ViT",
[
("blurry_foreground", "0"),
("watermark", "0"),
("fate_(series)", "3"),
],
)
status, data = await self.preview(["blurry", "watermark", "(fate)", "general"])
self.assertEqual(status, 200, data)
# Library spellings first (case-folded duplicates are one tag), then the
# label lists' names, each part sorted case-folded; rating and copyright
# rows are never suggested, so never counted.
self.assertEqual(
data["sample"],
[
"Blurry",
"blurry_background",
"blurry_foreground",
"saber_(fate)",
"watermark",
],
)
self.assertEqual(data["count"], 5)
self.assertEqual(
data["per_entry"],
[
{"entry": "blurry", "count": 3},
{"entry": "watermark", "count": 1},
{"entry": "(fate)", "count": 1},
{"entry": "general", "count": 0},
],
)
self.assertEqual(
data["sources"], {"library": 3, "wd14": ["WD14 SwinV2", "WD14 ViT"]}
)
async def test_the_sample_holds_at_most_twelve(self):
write_labels(
self.models_dir,
"WD14-SwinV2",
[(f"sky_{index:02d}", "0") for index in range(20)],
)
status, data = await self.preview(["sky"])
self.assertEqual(status, 200, data)
self.assertEqual(data["count"], 20)
self.assertEqual(data["sample"], [f"sky_{index:02d}" for index in range(12)])
async def test_a_bad_list_is_refused_with_the_save_sentence(self):
for body, message in (
({"entries": ["sky", "Sky"]}, "Skipped tags: entry 2 repeats entry 1."),
(
{"entries": ["a,b"]},
"Skipped tags: entry 1 must not contain a comma or a line break.",
),
({"entries": "sky"}, "Skipped tags must be a list of words."),
):
with self.subTest(body=body):
response = await self.client.post(PREVIEW, json=body)
self.assertEqual(response.status, 400)
self.assertEqual((await response.json())["error"], message)
response = await self.client.post(PREVIEW, json={"entries": [], "x": 1})
self.assertEqual(response.status, 400)
async def test_preview_loads_no_model_opens_no_image_and_writes_nothing(self):
write_labels(self.models_dir, "WD14-SwinV2", [("blurry", "0")])
self.image("a.png", (1, 2, 3))
before = self.prompt_rows()
status, data = await self.preview(["blurry"])
self.assertEqual(status, 200, data)
self.assertEqual(self.tagger.load_calls, [])
self.assertEqual(self.tagger.read_paths, [])
self.assertEqual(self.prompt_rows(), before)
self.assertEqual(self.api._jobs.list(), [])
async def test_preview_is_busy_while_an_exclusive_job_runs(self):
async with self.api._jobs.hold(
"restore", "Restore library", admission="exclusive"
):
status, data = await self.preview(["blurry"])
self.assertEqual(status, 409, data)
self.assertEqual(data["code"], "busy")
self.assertEqual(data["running"]["title"], "Restore library")
if __name__ == "__main__":
import unittest
unittest.main()
+1
View File
@@ -164,6 +164,7 @@ ROUTE_AUDIT = {
"PATCH /prompt_manager/saved-views/{view_id}",
"PATCH /prompt_manager/vocabulary/tags/{tag_id}",
"POST /prompt_manager/autotag/download/{model_type}",
"POST /prompt_manager/autotag/skipped-tags/preview",
"POST /prompt_manager/autotag/unload",
"POST /prompt_manager/bulk/category",
"POST /prompt_manager/bulk/delete",
+6
View File
@@ -629,6 +629,12 @@
"functions": 95,
"statements": 98
},
"workspace/system-skipped-tags.mjs": {
"lines": 80,
"branches": 80,
"functions": 80,
"statements": 80
},
"workspace/system-state.mjs": {
"lines": 98,
"branches": 94,
+11 -3
View File
@@ -257,11 +257,18 @@ function initialState(source, preset) {
image: null, savedTags: [], tagRevision: 0, currentTags: null, manual: '',
model: preset?.model ?? MODEL_OPTIONS[0].id, options: {...DEFAULTS, ...preset?.options}, generated: false, suggestions: [],
suggestionsText: COPY.modelChanged, statusText: '', generateSupported: false, generateReason: null, toast: null,
skippedCount: 0,
// Bumped whenever the controller itself rewrites the option fields or the manual draft, never while typing.
optionsRevision: 0, draftRevision: 0,
};
}
/** #431: how many suggestions the saved Skipped tags hid, after the chip hint ('' when none). */
export function skippedNote(count) {
if (!Number.isSafeInteger(count) || count <= 0) return '';
return count === 1 ? ' 1 skipped tag was not suggested.' : ` ${count.toLocaleString('en-US')} skipped tags were not suggested.`;
}
/** Derived labels and permissions; never stored independently of the fields they follow. */
function derive(state) {
const editable = EDITABLE.has(state.phase);
@@ -269,7 +276,7 @@ function derive(state) {
...state,
primaryLabel: state.generated ? 'Approve selected tags' : 'Save manual tags',
countText: state.generated ? `${state.suggestions.length} ${noun(state.suggestions.length, 'suggestion')}` : 'Not generated',
chipHint: state.suggestions.length ? COPY.chipHint : '',
chipHint: state.suggestions.length ? COPY.chipHint + skippedNote(state.skippedCount) : '',
canSave: editable,
canGenerate: editable && state.generateSupported,
canRetry: RETRYABLE.has(state.phase),
@@ -445,13 +452,14 @@ export function createImageTaggingController({api, target, source = 'images', on
const options = requestOptions(model, state.options);
const stale = () => closed || serial !== generateSerial;
set({phase: 'generating', statusText: COPY.generating});
let rows;
let rows, skippedCount;
try {
const value = await api.post(SUGGEST_URL, {
target: {...imageTarget}, capture_token: capture.token, expected_epoch: epoch, model_type: model, options,
}, {signal: controller.signal});
if (stale()) return false;
rows = validateSuggestions(value, options.suggestion_limit);
skippedCount = Number.isSafeInteger(value.skipped) && value.skipped > 0 ? value.skipped : 0;
} catch (error) {
if (stale()) return false;
const phase = isApiError(error) && REOPEN[error.code];
@@ -460,7 +468,7 @@ export function createImageTaggingController({api, target, source = 'images', on
}
generateAbort = null;
set({
phase: 'ready', generated: true, suggestions: rows, suggestionsText: rows.length ? '' : COPY.empty,
phase: 'ready', generated: true, suggestions: rows, suggestionsText: rows.length ? '' : COPY.empty, skippedCount,
statusText: `Suggestions from ${modelOf(model).label}. Approve tags to save.`,
});
return true;
+246
View File
@@ -0,0 +1,246 @@
/**
* System › Auto-tagging "Skipped tags" (#431, D202): words whose suggested tags are never offered, on every suggestion
* path (the server filters; hand-typed and stored tags are never touched).
*
* A `.setting-block` after "Suggestions per image": the label and description, an "Add a word or words" input
* (`#tag-skipped-input`) with Add (Enter adds too), the entries as removable chips in the Tag image dialog's D83 chip
* markup, and a preview line (`role="status"`) that says how many known tags the list hides
* (`POST /prompt_manager/autotag/skipped-tags/preview`: the library's tags plus the downloaded WD14 label lists). The
* block carries `data-setting="tag-skipped"` and `data-key="autotag_skipped_tags"`, so a refused save highlights it.
* The list is the host draft `autotag_skipped_tags` (an array), saved by the one Save row. The client checks repeat the
* server's rules (the server is the authority): an empty, too long, comma or duplicate entry is not added and the line
* under the input says why; the list holds at most 200 entries.
*/
import { createButton } from '../shared/toolkit/core.js';
export const SKIPPED_KEY = 'autotag_skipped_tags';
export const PREVIEW_URL = '/prompt_manager/autotag/skipped-tags/preview';
export const MAX_ENTRIES = 200;
export const MAX_ENTRY_CHARS = 50;
export const PREVIEW_DELAY_MS = 300;
export const SAMPLE_LIMIT = 12;
export const SKIPPED_COPY = Object.freeze({
label: 'Skipped tags',
description: 'Suggested tags that contain one of these words are hidden. Words are separated by spaces, _ or -; '
+ 'other characters such as ( ) belong to the word.',
placeholder: 'Add a word or words',
add: 'Add',
chips: 'Skipped tags',
empty: 'Enter a word.',
comma: 'No commas or line breaks.',
long: `At most ${MAX_ENTRY_CHARS} characters.`,
duplicate: 'Already in the list.',
full: `The list is full (${MAX_ENTRIES}).`,
none: 'No tags are skipped.',
checking: 'Checking…',
failed: 'The preview could not be read.',
unchanged: 'Tags already on images are not changed.',
libraryOnly: 'Checked against library tags only; no WD14 label list is downloaded.',
hidesNothing: 'Hides no known tag',
});
const plural = (count, word) => `${count.toLocaleString('en-US')} ${word}${count === 1 ? '' : 's'}`;
/** The words of an entry or a tag as the server splits them: NFC, lower case, split on space, `_` and `-`. */
export const entryWords = text => String(text ?? '').normalize('NFC').toLowerCase().split(/[ _-]+/).filter(Boolean);
const entryKey = text => entryWords(text).join(' ');
/** Why `raw` cannot be added to `entries` (the line under the input), or '' when it can. */
export function entryProblem(raw, entries) {
const entry = String(raw ?? '').trim();
if (!entryWords(entry).length) return SKIPPED_COPY.empty;
// Commas and control characters (a pasted line break), as the server refuses them.
if (/[,\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(entry)) return SKIPPED_COPY.comma;
if ([...entry].length > MAX_ENTRY_CHARS) return SKIPPED_COPY.long;
const key = entryKey(entry);
if (entries.some(item => entryKey(item) === key)) return SKIPPED_COPY.duplicate;
if (entries.length >= MAX_ENTRIES) return SKIPPED_COPY.full;
return '';
}
/** A chip's title and count text for an entry the last preview counted (`count` undefined: not counted yet). */
export function chipCount(count) {
if (!Number.isSafeInteger(count)) return null;
return count ? { title: `Hides ${plural(count, 'tag')}`, text: count.toLocaleString('en-US') }
: { title: SKIPPED_COPY.hidesNothing, text: 'none' };
}
/**
* The preview line for a preview answer (`{count, sample, sources}`): the bold lead "Hides N tags from suggestions",
* then the sample (": a, b and K more. ") and the closing sentences. Returns `{lead, rest}`.
*/
export function previewText(result) {
const count = Number.isSafeInteger(result?.count) && result.count > 0 ? result.count : 0;
const sample = Array.isArray(result?.sample) ? result.sample.filter(name => typeof name === 'string') : [];
const more = count - sample.length;
const listed = count && sample.length ? `: ${sample.join(', ')}${more > 0 ? ` and ${more.toLocaleString('en-US')} more` : ''}. ` : '. ';
const labels = Array.isArray(result?.sources?.wd14) && result.sources.wd14.length > 0;
return {
lead: `Hides ${plural(count, 'tag')} from suggestions`,
rest: `${listed}${labels ? '' : `${SKIPPED_COPY.libraryOnly} `}${SKIPPED_COPY.unchanged}`,
};
}
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/**
* Build the block; `drafts` is the System draft store, `api` the workspace client. Returns `{node, repaint}`: System
* calls `repaint` whenever the drafts change (the chips follow the drafted list, and a changed list is previewed
* `PREVIEW_DELAY_MS` after the last change, one request at a time: a newer list aborts the older request).
*/
export function skippedTagsBlock({ api, scope, drafts, delay = PREVIEW_DELAY_MS }) {
const block = el('div', undefined, 'setting-block');
block.dataset.setting = 'tag-skipped';
block.dataset.key = SKIPPED_KEY;
const text = el('div', undefined, 'setting-block-text');
const label = el('label', SKIPPED_COPY.label);
label.htmlFor = 'tag-skipped-input';
text.append(label, el('p', SKIPPED_COPY.description));
const addRow = el('div', undefined, 'skip-add');
const input = el('input');
input.id = 'tag-skipped-input';
input.autocomplete = 'off';
input.placeholder = SKIPPED_COPY.placeholder;
input.setAttribute('aria-describedby', 'tag-skipped-problem');
const add = createButton(SKIPPED_COPY.add);
add.id = 'tag-skipped-add';
addRow.append(input, add);
const problem = el('p', '', 'small skip-problem');
problem.id = 'tag-skipped-problem';
problem.setAttribute('role', 'alert');
const chips = el('div', undefined, 'tags skip-chips');
chips.setAttribute('role', 'group');
chips.setAttribute('aria-label', SKIPPED_COPY.chips);
const preview = el('p', '', 'small skip-preview');
preview.setAttribute('role', 'status');
preview.setAttribute('aria-live', 'polite');
block.append(text, addRow, problem, chips, preview);
const entries = () => {
const value = drafts.get('host', SKIPPED_KEY);
return Array.isArray(value) ? value : null;
};
let counts = new Map();
let shown = null; // the list the chips show (JSON)
let pending = null; // the list the preview line is (or is being) made for (JSON)
let timer = null;
let request = null;
scope.cleanup(() => { clearTimeout(timer); request?.abort(); });
const say = message => {
problem.textContent = message;
if (message) input.setAttribute('aria-invalid', 'true');
else input.removeAttribute('aria-invalid');
};
const setList = next => drafts.set('host', SKIPPED_KEY, next);
function addEntry() {
const list = entries();
if (!list) return;
const message = entryProblem(input.value, list);
say(message);
if (message) return;
const entry = input.value.trim();
input.value = '';
setList([...list, entry]);
}
function removeEntry(index) {
const list = entries();
if (!list) return;
const buttons = [...chips.querySelectorAll?.('button') ?? []];
const focused = buttons.indexOf(document.activeElement);
setList(list.filter((_entry, at) => at !== index));
// The removed chip's focus moves to its neighbour's ×, else to the input.
if (focused >= 0) {
const left = [...chips.querySelectorAll?.('button') ?? []];
(left[Math.min(focused, left.length - 1)] ?? input).focus?.();
}
}
function renderChips(list) {
chips.replaceChildren(...list.map((entry, index) => {
const count = chipCount(counts.get(entry));
const chip = el('span', undefined, `chip tag-suggestion${count?.text === 'none' ? ' none' : ''}`);
if (count) chip.title = count.title;
chip.append(el('span', entry));
if (count) chip.append(el('span', count.text, 'tag-confidence'));
const remove = createButton('×');
remove.setAttribute('aria-label', `Remove ${entry}`);
scope.listen(remove, 'click', () => removeEntry(index));
chip.append(remove);
return chip;
}));
}
function showPreview(result) {
const { lead, rest } = previewText(result);
preview.replaceChildren(el('b', lead), rest);
}
async function runPreview(list) {
request?.abort();
const controller = new AbortController();
request = controller;
try {
const result = await api.post(PREVIEW_URL, { entries: list }, { signal: controller.signal });
if (controller.signal.aborted || scope.signal.aborted) return;
counts = new Map((Array.isArray(result?.per_entry) ? result.per_entry : [])
.filter(row => typeof row?.entry === 'string').map(row => [row.entry, row.count]));
showPreview(result);
renderChips(entries() ?? list);
} catch {
if (controller.signal.aborted || scope.signal.aborted) return;
preview.textContent = SKIPPED_COPY.failed;
} finally {
if (request === controller) request = null;
}
}
function schedule(list) {
clearTimeout(timer);
request?.abort();
request = null;
if (!list.length) {
counts = new Map();
preview.textContent = SKIPPED_COPY.none;
return;
}
preview.textContent = SKIPPED_COPY.checking;
timer = setTimeout(() => runPreview(list), delay);
}
function repaint() {
const list = entries();
input.disabled = add.disabled = list === null;
if (list === null) return;
const full = list.length >= MAX_ENTRIES;
add.disabled = full;
if (full && !problem.textContent) say(SKIPPED_COPY.full);
if (!full && problem.textContent === SKIPPED_COPY.full) say('');
const key = JSON.stringify(list);
if (key !== shown) {
shown = key;
renderChips(list);
}
if (key !== pending) {
pending = key;
schedule(list);
}
}
scope.listen(add, 'click', addEntry);
scope.listen(input, 'keydown', event => {
if (event.key !== 'Enter') return;
event.preventDefault?.();
addEntry();
});
scope.listen(input, 'input', () => { if (problem.textContent !== SKIPPED_COPY.full) say(''); });
return { node: block, repaint };
}
+19 -3
View File
@@ -76,6 +76,22 @@ export function statusText(outcome) {
return saved.join(' ') || SETTINGS_STATUS.clean;
}
const plain = value => value !== null && typeof value === 'object'
&& (Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype);
/** JSON with every object's keys sorted, so key order never makes two values differ. */
const canonical = value => JSON.stringify(value, (key, item) => (item && typeof item === 'object' && !Array.isArray(item)
? Object.fromEntries(Object.keys(item).sort().map(name => [name, item[name]])) : item));
/**
* #431: whether a draft equals a saved value: `Object.is`, or both are arrays or plain objects with the same JSON (keys
* sorted). A list setting (Skipped tags) whose added entry was removed again is clean, not a new array that differs.
*/
export function sameDraft(a, b) {
if (Object.is(a, b)) return true;
if (!plain(a) || !plain(b) || Array.isArray(a) !== Array.isArray(b)) return false;
return canonical(a) === canonical(b);
}
function requireDomain(domain) {
if (!DRAFT_DOMAINS.includes(domain)) throw new TypeError(`Unknown settings domain: ${domain}`);
}
@@ -112,7 +128,7 @@ export function createSystemDrafts(adapters) {
values = await (domain === 'browser' ? adapters.readBrowser() : adapters.readRuntime());
}
baseline[domain] = { ...(values ?? {}) };
for (const [key, value] of drafts[domain]) if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
for (const [key, value] of drafts[domain]) if (sameDraft(baseline[domain][key], value)) drafts[domain].delete(key);
notify();
return { ...baseline[domain] };
}
@@ -124,7 +140,7 @@ export function createSystemDrafts(adapters) {
function set(domain, key, value) {
requireDomain(domain);
if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
if (sameDraft(baseline[domain][key], value)) drafts[domain].delete(key);
else drafts[domain].set(key, value);
failedKeys[domain].delete(key);
outcome = null;
@@ -153,7 +169,7 @@ export function createSystemDrafts(adapters) {
function commit(domain, written) {
for (const [key, value] of written) {
baseline[domain][key] = value;
if (drafts[domain].has(key) && Object.is(drafts[domain].get(key), value)) drafts[domain].delete(key);
if (drafts[domain].has(key) && sameDraft(drafts[domain].get(key), value)) drafts[domain].delete(key);
}
}
+6
View File
@@ -9,6 +9,7 @@
* - "Existing tags", "General-tag confidence", "Character-tag confidence": the concept's labels and descriptions
* verbatim (they state the shipped defaults); host keys `autotag_existing`, `autotag_general_threshold`,
* `autotag_character_threshold`. D133 adds "Suggestions per image" (`autotag_suggestion_limit`, 1–100) after them.
* - D202 (#431): the "Skipped tags" block after it (system-skipped-tags.mjs, host key `autotag_skipped_tags`).
* - The `.field` "LLM tag template" (`#tag-template`, `autotag_template`).
* - `.row-actions`: "Preview tagging review" (`ca-primary`) opens the Tag image dialog for the image selected in Images
* with the section's current model and values, aria-disabled without one (the D115 sentence) or with an invalid
@@ -35,6 +36,7 @@ import { selectedImage } from './images.mjs';
import { NO_SELECTION } from './vocabulary.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { navigateTo } from './state.mjs';
import { skippedTagsBlock } from './system-skipped-tags.mjs';
export const MODELS_URL = '/prompt_manager/autotag/models';
export const UNLOAD_URL = '/prompt_manager/autotag/unload';
@@ -410,6 +412,9 @@ export function mountTagging(container, { api, activity, scope, drafts, notify =
// D133: the persisted suggestion count, which the concept does not show.
add(settingRow({ id: 'tag-suggestions', label: 'Suggestions per image', description: SUGGESTIONS_DESCRIPTION,
control: { type: 'number', min: 1, max: 100, step: 1 } }), 'tag-suggestions');
// D202 (#431): the words whose suggested tags are hidden on every suggestion path.
const skipped = skippedTagsBlock({ api, scope, drafts });
surface.append(skipped.node);
const field = el('div', undefined, 'field');
const templateLabel = el('label', 'LLM tag template');
@@ -454,6 +459,7 @@ export function mountTagging(container, { api, activity, scope, drafts, notify =
const known = () => MODEL_IDS.includes(chosen());
const repaint = () => {
skipped.repaint();
const values = current();
const unread = values.autotag_model_type === undefined;
const failed = drafts.hostState?.() === 'failed' && unread;
+22 -4
View File
@@ -443,12 +443,12 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
suggestions are removable chips, each the concept's `.chip` corner (4px; the later `.tag-suggestion` row rule would
give 5px) around the row's 12px text in its checked suggestion row's tokens (`.tag-suggestion:has(input:checked)`,
s1655): a name, the confidence and a × button. */
#tag-suggestions .chip.tag-suggestion { display: inline-flex; gap: 6px; padding: 2px 2px 2px 8px; margin-bottom: 0; cursor: default; color: inherit; border-color: var(--ca-accent); background: var(--soft-blue); border-radius: 4px; /* D83 */ }
#tag-suggestions .chip.tag-suggestion .tag-confidence { margin-inline-start: 0; /* D83 */ }
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 24px; min-inline-size: 24px; padding: 0 6px; /* D83 */ }
#tag-suggestions .chip.tag-suggestion, .skip-chips .chip.tag-suggestion { display: inline-flex; gap: 6px; padding: 2px 2px 2px 8px; margin-bottom: 0; cursor: default; color: inherit; border-color: var(--ca-accent); background: var(--soft-blue); border-radius: 4px; /* D83 */ }
#tag-suggestions .chip.tag-suggestion .tag-confidence, .skip-chips .chip.tag-suggestion .tag-confidence { margin-inline-start: 0; /* D83 */ }
.ca-tools #tag-suggestions .chip.tag-suggestion > button, .ca-tools .skip-chips .chip.tag-suggestion > button { min-block-size: 24px; min-inline-size: 24px; padding: 0 6px; /* D83 */ }
@media (pointer: coarse) {
/* D83: the chip's × keeps the concept's 40px coarse target. */
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
.ca-tools #tag-suggestions .chip.tag-suggestion > button, .ca-tools .skip-chips .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
}
/* Needs review (#249 PR F): the concept's Metadata review page (app.js a749–760) in its own classes. */
@@ -492,6 +492,24 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
@media (max-width: 700px) {
#workspace-main .setting-row :is(#send-destination, #renderer) { max-width: 160px !important; /* D106 */ }
}
/* D202 (#431): System › Auto-tagging "Skipped tags", a setting whose control (an add input, the chips and the preview
line) is wider than a `.setting-row` column: the row's label and description tokens over the full width, the chips
in the Tag image dialog's D83 chip markup (an entry that hides no known tag is muted). */
.setting-block { padding: 17px 0; border-top: 1px solid var(--line); /* D202 */ }
.setting-block-text label { font-size: 12px; font-weight: 500; display: block; /* D202 */ }
.setting-block-text p { font-size: 11px; color: var(--ca-muted); margin-top: 4px; max-width: 640px; /* D202 */ }
.setting-block[aria-invalid="true"] { box-shadow: inset 3px 0 0 var(--red); padding-inline-start: 12px; /* D202 */ }
.skip-add { display: flex; gap: 8px; margin-top: 12px; max-width: 460px; /* D202 */ }
.skip-add input { flex: 1; min-width: 0; font-size: 12px; /* D202 */ }
.skip-problem { color: var(--red); margin-top: 6px; /* D202 */ }
.skip-problem:empty, .skip-chips:empty { display: none; /* D202 */ }
.skip-chips .chip.tag-suggestion.none { border-color: var(--line); background: var(--ca-field); color: var(--ca-muted); /* D202 */ }
.skip-preview { margin-top: 12px; max-width: 760px; /* D202 */ }
.skip-preview b { font-weight: 500; color: var(--ca-text); /* D202 */ }
@media (max-width: 700px) {
.skip-add { max-width: none; /* D202 */ }
}
/* D132: at phone width the concept's 120px cap cuts every engine label ("WD14 SwinV2 · ~400 MB"); the Tagging model
select alone keeps its natural width there (its widest engine label) and, with no room beside the row text, wraps
under it. Existing tags (#398) never shrinks for its row text and, past the cap, keeps its natural width beside the