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).
This commit is contained in:
Vito Sansevero
2026-10-31 16:43:15 -07:00
parent c3c75c3369
commit a20e59f674
32 changed files with 1837 additions and 58 deletions
+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)
+32 -4
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.placeholder ?? null, 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', '', '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,13 @@ 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).
// Unmapped, the section differs only where D132, D133, D134 and D202 are recorded (and the saved template's text).
expect(diffInventory(concept, production).map(item => 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)
+271
View File
@@ -0,0 +1,271 @@
"""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 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_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()