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:
+19
-10
@@ -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)
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
@@ -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.`);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user