fix(tagging): Tag output images tags each image, not its prompt (#379, PR 1)

The native "Tag output images" job now runs the image-tags loop over every
still image the output folders list, linked or not: each image gets its own
model tags (at most "Suggestions per image") in its image tag set, and its
bytes are marked auto-tagged in a new media_image_autotags table. No prompt
tag or prompt text is written.

- database/autotag_schema.py: one forward-only WITHOUT ROWID table, optional
  in old backups, refused without the annotation group.
- add_image_tags/replace_image_tags take mark_autotagged; autotag_state and
  autotagged_locators read the marks through the one current-record rule.
- "Skip already tagged" skips images whose current bytes are marked, counted
  in a stat-only pre-pass; hand tags do not count.
- The review counts images and estimates the time (WD14 on CPU or CUDA).
- System > Auto-tagging review rows and copy, D134 and the Auto-tagging
  contract follow.

Part of #379
This commit is contained in:
Vito Sansevero
2026-10-27 05:35:22 -07:00
parent d868745194
commit cfa54dc8ae
20 changed files with 2044 additions and 431 deletions
+32 -14
View File
@@ -33,7 +33,7 @@ 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 BATCH = 'Add tags to each prompt from its linked output images with the model above. Review the scope first.';
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. */
function measureTagging(mainSelector) {
@@ -272,11 +272,19 @@ function measureReview() {
}
const number = value => value.toLocaleString('en-US');
/** DEC11 (#379): the Time row the preview's estimate reads as (WD14 on the test server's processor). */
function timeRow(plan) {
const minutes = Math.max(1, Math.ceil(plan.estimate_seconds / 60));
const text = minutes < 90 ? `about ${minutes} min`
: `about ${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${minutes % 60} min` : ''}`;
return `${text} on a processor; a graphics card can be faster.`;
}
/**
* D134: the server's batch preview in the concept's review: "Review tag output images" with "Tag output images" as its
* `h3`, the model, policy, counts and settings in the concept's `dl.meta-grid` (Backup in the concept's wording), the
* upper-bound sentence in the concept's `p.small`, the D55 backup box and Cancel / "Start tagging".
* D134 (#379): the server's batch preview in the concept's review: "Review tag output images" with "Tag output images"
* as its `h3`, the model, policy, image counts, time estimate and settings in the concept's `dl.meta-grid` (Backup in
* the concept's wording), the estimate sentence in the concept's `p.small`, the D55 backup box and Cancel / "Start
* tagging".
*/
function mapBatchConcept(reference, plan) {
const [cancel, start] = reference.actions.buttons;
@@ -286,13 +294,14 @@ function mapBatchConcept(reference, plan) {
tree: ['h3', 'dl.meta-grid', 'p.small', 'div.notice', 'div.dialog-actions'],
heading: { ...reference.heading, text: 'Tag output images' },
grid: { ...reference.grid, rows: [['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Merge with existing'],
['Images linked', number(plan.images)], ['Prompts', number(plan.prompts)], ['Already tagged', number(plan.already_tagged)],
['Prompts to tag', `up to ${number(plan.to_tag)}`], ['Settings', 'General 0.35 · Character 0.85'],
['Images', number(plan.images)], ['Already auto-tagged', number(plan.already_tagged)],
['Images to tag', `about ${number(plan.to_tag)}`], ['Time', timeRow(plan)],
['Settings', `General 0.35 · Character 0.85 · up to ${number(plan.suggestion_limit)} tags per image`],
['Backup', 'Create and verify before changes']] },
backup: { tree: ['div', 'div.row-actions'], text: [['h3', 'Back up the database first (recommended)'],
['p', 'Downloads a copy of the prompts database before this operation changes any records.']], buttons: ['Back up now'] },
note: { ...reference.note, text: 'Nothing is written until you start. Missing files and files outside the output folders '
+ 'are counted, so the prompts to tag are an upper bound; Activity reports what was tagged.' },
note: { ...reference.note, text: 'Nothing is written until you start. The number to tag is an estimate: images changed '
+ 'since they were tagged are tagged again, and missing files are skipped. Activity reports what was tagged.' },
actions: { ...reference.actions, buttons: [cancel, { ...start, text: 'Start tagging' }] },
};
}
@@ -308,8 +317,8 @@ async function conceptReview(page, theme) {
async function productionReview(page, theme) {
await productionTagging(page, theme);
// Every concept-fixture prompt with an image is already tagged, so under the saved Skip there is nothing to start
// (the Cancel-only review). Merge reviews a batch with work: the full review the concept's shell is compared with.
// Merge reviews every listed still image (#379): a batch with work, the full review the concept's shell is compared
// with.
await page.locator('#workspace-main #tag-existing').selectOption('merge');
const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL));
await page.locator('#workspace-main .operation').getByRole('button', { name: 'Review', exact: true }).click();
@@ -326,8 +335,9 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
expect(concept.tree).toEqual(['p.demo-note', 'h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions']);
await page.screenshot({ path: testInfo.outputPath(`concept-plan-review-${theme}-${width}.png`) });
const { plan, production } = await productionReview(page, theme);
// Under Merge every prompt with a linked image is tagged again: the batch has work to review.
// Under Merge every listed still image is tagged again: the batch has work to review.
expect(plan.to_tag).toBeGreaterThan(0);
expect(plan.estimate_device).toBe('cpu');
await page.screenshot({ path: testInfo.outputPath(`production-tagging-review-${theme}-${width}.png`) });
expect(diffInventory(mapBatchConcept(concept, plan), production)).toEqual([]);
});
@@ -372,9 +382,17 @@ test('tag output images review comparison detects mutations', async ({ page }) =
const expected = mapBatchConcept(concept, plan);
const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path);
expect(await paths()).toEqual([]);
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[5].textContent = 'To tag'; });
expect(await paths()).toEqual(['grid.rows.5.0']);
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[5].textContent = 'Prompts to tag'; });
// Every label of the grid is compared (#379's rows included): a changed one is named by its row.
for (const [index, [term]] of expected.grid.rows.entries()) {
await page.evaluate(([at, label]) => {
document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[at].textContent = label;
}, [index, `${term}!`]);
expect(await paths()).toEqual([`grid.rows.${index}.0`]);
await page.evaluate(([at, label]) => {
document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[at].textContent = label;
}, [index, term]);
}
expect(await paths()).toEqual([]);
const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' });
expect(await paths()).toEqual(expect.arrayContaining(['grid.columnGap']));
await undoGrid();
+26 -17
View File
@@ -315,22 +315,28 @@ test('Preview tagging review without a selection toasts the D115 sentence', asyn
await expect(tagDialog(page)).toHaveCount(0);
});
test('Tag output images reviews the scope and starts a batch job', async ({ page, request }) => {
test('Tag output images reviews the images, tags each one and never a prompt (#379)', async ({ page, request }) => {
const before = await serverJson(request, '/__test__/tagging/snapshot');
await openTagging(page);
// The fixture's one prompt is already tagged: under Merge it is tagged again with what its images give.
await page.locator('#tag-existing').selectOption('merge');
// The saved policy is Skip: no image is auto-tagged yet, so every listed still image is to tag.
const previewed = page.waitForResponse(item => new URL(item.url()).pathname === PREVIEW_URL);
await reviewButton(page).click();
const answer = await previewed;
expect(answer.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'merge' });
expect(answer.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'skip' });
const plan = await answer.json();
// Four still images (two linked, a copy, an unlinked one); the video is not a target.
expect([plan.images, plan.already_tagged, plan.to_tag]).toEqual([4, 0, 4]);
expect(plan.prompts).toBeUndefined();
await expect(dialog(page).locator('#modal-title')).toHaveText('Review tag output images');
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Tag output images');
expect(await gridOf(page)).toEqual([['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Merge with existing'],
['Images linked', String(plan.images)], ['Prompts', String(plan.prompts)], ['Already tagged', String(plan.already_tagged)],
['Prompts to tag', `up to ${plan.to_tag}`], ['Settings', 'General 0.35 · Character 0.85'],
expect(await gridOf(page)).toEqual([['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Skip already tagged'],
['Images', '4'], ['Already auto-tagged', '0'], ['Images to tag', 'about 4'],
['Time', 'about 1 min on a processor; a graphics card can be faster.'],
['Settings', `General 0.35 · Character 0.85 · up to ${plan.suggestion_limit} tags per image`],
['Backup', 'Create and verify before changes']]);
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Nothing is written until you start. The number to '
+ 'tag is an estimate: images changed since they were tagged are tagged again, and missing files are skipped. '
+ 'Activity reports what was tagged.');
await expect(dialog(page).locator('#modal-body > .notice h3')).toHaveText('Back up the database first (recommended)');
// The review wrote nothing.
expect(await serverJson(request, '/__test__/tagging/snapshot')).toEqual(before);
@@ -338,15 +344,23 @@ test('Tag output images reviews the scope and starts a batch job', async ({ page
const started = page.waitForResponse(item => new URL(item.url()).pathname === '/prompt_manager/jobs/autotag');
await dialog(page).getByRole('button', { name: 'Start tagging', exact: true }).click();
const start = await started;
expect(start.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'merge',
expect(start.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'skip',
options: { general_threshold: 0.35, character_threshold: 0.85 } });
const { job } = await start.json();
await expect(page).toHaveURL(`${workspace}#/activity/${job.id}`);
await expect(page.locator('#workspace-main h1')).toHaveText('Tag output images');
const finished = await ended(request, job.id);
expect(finished.state).toBe('finished');
const after = await serverJson(request, '/__test__/tagging/snapshot');
expect(after.prompt_tags.length).toBeGreaterThan(before.prompt_tags.length);
expect(finished.status_line).toBe('Tagged 4 images');
// Each image got its own tags; no prompt, pair or link row changed.
expect(await serverJson(request, '/__test__/tagging/snapshot')).toEqual(before);
// Under Skip every image is now auto-tagged: nothing to start.
await openTagging(page);
await reviewButton(page).click();
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Every output image is already auto-tagged.');
expect((await gridOf(page)).slice(2, 5)).toEqual([['Images', '4'], ['Already auto-tagged', '4'], ['Images to tag', 'about 0']]);
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel']);
});
test('Replace after review shows only Cancel with the reason', async ({ page, request }) => {
@@ -355,18 +369,13 @@ test('Replace after review shows only Cancel with the reason', async ({ page, re
await page.locator('#tag-existing').selectOption('replace_after_review');
await reviewButton(page).click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Review tag output images');
expect((await gridOf(page)).map(([term]) => term)).toEqual(['Model', 'Existing tags', 'Images linked', 'Prompts', 'Already tagged',
'Prompts to tag', 'Settings', 'Backup']);
expect((await gridOf(page)).map(([term]) => term)).toEqual(['Model', 'Existing tags', 'Images', 'Already auto-tagged',
'Images to tag', 'Time', 'Settings', 'Backup']);
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Replacing tags needs a review of each image. '
+ 'Use Preview tagging review, or choose Skip or Merge for a batch run.');
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel']);
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
// Under Skip the fixture's only prompt is already tagged: nothing to start either.
await page.locator('#tag-existing').selectOption('skip');
await reviewButton(page).click();
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Every prompt with an output image is already tagged.');
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel']);
expect(await serverJson(request, '/__test__/tagging/snapshot')).toEqual(before);
expect((await serverJson(request, '/prompt_manager/jobs')).jobs.filter(item => item.kind === 'autotag')).toEqual([]);
});
+35 -9
View File
@@ -360,19 +360,23 @@ test('Preview tagging review opens the Tag image dialog with the section’s val
} finally { scope.dispose(); dom.restore(); }
});
const PLAN = { images: 3, prompts: 2, already_tagged: 1, to_tag: 1,
const PLAN = { images: 3, already_tagged: 1, to_tag: 2, estimate_seconds: 3.2, estimate_device: 'cpu', suggestion_limit: 10,
model: { type: 'wd14-swinv2', label: 'WD14 SwinV2', size_label: '~400 MB', downloaded: true }, existing: 'skip',
review_required: false, backup: { policy: 'on' }, disk: { free_bytes: 10 * 2 ** 30, required_bytes: 2 ** 20, sufficient: true } };
test('the batch review grid, body and Cancel-only reasons (D134, CUJ 4–5)', async () => {
const { batchGrid, batchBody, batchCloseNote, existingLabel, REPLACE_NOTE, NOTHING_TO_TAG, NO_LINKED_IMAGES } = await tagging;
test('the batch review grid, body and Cancel-only reasons (D134, #379, CUJ 4–5)', async () => {
const { batchGrid, batchBody, batchCloseNote, existingLabel, REPLACE_NOTE, NOTHING_TO_TAG, NO_IMAGES } = await tagging;
assert.deepEqual(batchGrid(PLAN, HOST), [['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Skip already tagged'],
['Images linked', '3'], ['Prompts', '2'], ['Already tagged', '1'], ['Prompts to tag', 'up to 1'],
['Settings', 'General 0.35 · Character 0.85'], ['Backup', 'Create and verify before changes']]);
['Images', '3'], ['Already auto-tagged', '1'], ['Images to tag', 'about 2'],
['Time', 'about 1 min on a processor; a graphics card can be faster.'],
['Settings', 'General 0.35 · Character 0.85 · up to 10 tags per image'], ['Backup', 'Create and verify before changes']]);
const llm = { ...HOST, autotag_model_type: 'gguf' };
assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'gguf' }, backup: { policy: 'off' } }, llm).slice(-2),
[['Settings', 'Template of 27 characters'], ['Backup', 'No safety backup (the policy is off)']]);
assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'gguf' }, backup: { policy: 'off' }, estimate_seconds: null,
estimate_device: null, suggestion_limit: 4 }, llm).slice(-3),
[['Time', 'Language-model engines take longer per image. Activity shows the time left once tagging starts.'],
['Settings', 'Template of 27 characters · up to 4 tags per image'], ['Backup', 'No safety backup (the policy is off)']]);
assert.equal(batchGrid({}, HOST)[2][1], '0');
assert.equal(batchGrid({}, HOST)[6][1], 'General 0.35 · Character 0.85');
assert.equal(existingLabel('odd'), 'odd');
assert.deepEqual(batchBody(HOST), { model_type: 'wd14-swinv2', existing: 'skip',
options: { general_threshold: 0.35, character_threshold: 0.85 } });
@@ -385,9 +389,31 @@ test('the batch review grid, body and Cancel-only reasons (D134, CUJ 4–5)', as
assert.equal(batchCloseNote({ ...PLAN, model: { ...PLAN.model, downloaded: false } }), 'WD14 SwinV2 is not downloaded. Download it first.');
assert.equal(batchCloseNote({ ...PLAN, disk: { free_bytes: 2 ** 20, required_bytes: 2 ** 21, sufficient: false } }),
'Not enough free disk space: the safety backup needs about 2.0 MB and 1.0 MB is free. Nothing is written until there is room.');
assert.equal(batchCloseNote({ ...PLAN, prompts: 0, to_tag: 0 }), NO_LINKED_IMAGES);
assert.equal(batchCloseNote({ ...PLAN, images: 0, to_tag: 0 }), NO_IMAGES);
assert.equal(batchCloseNote({ ...PLAN, images: undefined }), NO_IMAGES);
assert.equal(NO_IMAGES, 'The output folders contain no images to tag.');
assert.equal(batchCloseNote({ ...PLAN, to_tag: 0 }), NOTHING_TO_TAG);
assert.equal(NOTHING_TO_TAG, 'Every prompt with an output image is already tagged.');
assert.equal(NOTHING_TO_TAG, 'Every output image is already auto-tagged.');
});
test('the review Time row (DEC11, #379): minutes, hours and the engine it runs on', async () => {
const { durationText, timeText, TIME_UNKNOWN } = await tagging;
assert.equal(durationText(1), 'about 1 min');
assert.equal(durationText(0), 'about 1 min');
assert.equal(durationText(89), 'about 89 min');
assert.equal(durationText(90), 'about 1 h 30 min');
assert.equal(durationText(120), 'about 2 h');
assert.equal(durationText(631), 'about 10 h 31 min');
assert.equal(durationText(Number.NaN), 'about 1 min');
// 23,645 images at 1.6 s on a processor (the design's CUJ 1).
assert.equal(timeText({ estimate_seconds: 37832, estimate_device: 'cpu' }),
'about 10 h 31 min on a processor; a graphics card can be faster.');
assert.equal(timeText({ estimate_seconds: 59, estimate_device: 'gpu' }), 'about 1 min on the graphics card.');
assert.equal(timeText({ estimate_seconds: 0, estimate_device: 'cpu' }), 'about 1 min on a processor; a graphics card can be faster.');
for (const plan of [{ estimate_seconds: null }, {}, { estimate_seconds: -1 }, { estimate_seconds: Infinity }, null]) {
assert.equal(timeText(plan), TIME_UNKNOWN);
}
assert.equal(TIME_UNKNOWN, 'Language-model engines take longer per image. Activity shows the time left once tagging starts.');
});
test('showBatchPlan opens the review with the backup box, or the Cancel-only review', async () => {
+3 -1
View File
@@ -35,6 +35,8 @@ REVIEW_TABLES = (
"media_review_items",
"media_review_metadata",
)
# The auto-tag marks (#379) cite annotation bytes too; an old backup lacks them.
CITING_TABLES = REVIEW_TABLES + ("media_image_autotags",)
# The exact DDL the plan pins; the module must produce precisely these objects.
DDL = {
METADATA: (
@@ -214,7 +216,7 @@ class AnnotationSchemaTests(unittest.TestCase):
"""
with closing(sqlite3.connect(path)) as raw, raw:
raw.execute("PRAGMA foreign_keys=OFF")
for table in REVIEW_TABLES + tuple(reversed(TABLES)):
for table in CITING_TABLES + tuple(reversed(TABLES)):
raw.execute(f"DROP TABLE {table}")
def test_fresh_install_and_reopen_is_idempotent(self):
+136
View File
@@ -561,6 +561,142 @@ class CurrentImageTagsTests(AnnotationTestCase):
)
class AutotagMarkTests(AnnotationTestCase):
"""The auto-tagged mark (#379): per byte record, written with the tags."""
def marked(self):
conn = self.db.model.get_connection()
return sorted(
row[0] for row in conn.execute("SELECT * FROM media_image_autotags")
)
def byte_id(self, loc, digest=DIGEST_A):
conn = self.db.model.get_connection()
return conn.execute(
"SELECT b.id FROM media_annotation_bytes b JOIN media_annotation_files f "
"ON f.id=b.file_id WHERE f.locator=? AND b.sha256=?",
(loc, digest),
).fetchone()[0]
def test_hand_tags_never_mark(self):
loc = self.locator("a.png")
self.replace(loc, tags=["keeper"])
self.add(loc, tags=["more"])
self.assertEqual(self.marked(), [])
self.assertEqual(self.db.autotag_state({loc: (SIZE, MTIME)}), {loc: False})
self.assertEqual(self.db.autotagged_locators(), set())
def test_model_union_marks_including_replayed_and_empty_results(self):
loc = self.locator("a.png")
receipt = self.add(loc, tags=["bird"], mark_autotagged=True)
self.assertEqual((receipt["added"], receipt["replayed"]), (1, False))
self.assertEqual(self.marked(), [self.byte_id(loc)])
# A replayed union and an empty result still mark (idempotently).
other = self.locator("b.png")
self.replace(other, DIGEST_B, ["keeper"])
replayed = self.add(other, DIGEST_B, ["KEEPER"], mark_autotagged=True)
self.assertTrue(replayed["replayed"])
empty = self.locator("c.png")
nothing = self.add(empty, tags=[], mark_autotagged=True)
self.assertEqual((nothing["tags"], nothing["added"]), ([], 0))
self.add(empty, tags=[], mark_autotagged=True)
self.assertEqual(
self.marked(),
sorted(
[self.byte_id(loc), self.byte_id(other, DIGEST_B), self.byte_id(empty)]
),
)
self.assertEqual(
self.db.autotag_state(
{loc: (SIZE, MTIME), other: (SIZE, MTIME), empty: (SIZE, MTIME)}
),
{loc: True, other: True, empty: True},
)
self.schema_clean()
def test_empty_union_without_mark_is_refused(self):
with self.assertRaises(ValueError):
self.add(self.locator("a.png"), tags=[])
self.assertEqual(self.counts()["media_annotation_files"], 0)
def test_tag_limit_union_writes_no_tag_and_no_mark(self):
loc = self.locator("a.png")
self.replace(loc, tags=[f"t{i}" for i in range(100)])
before = self.rows(*ANNOTATION_TABLES, "tags")
with self.assertRaises(AnnotationCommandError) as ctx:
self.add(loc, tags=["one more"], mark_autotagged=True)
self.assertEqual(ctx.exception.code, "tag-limit")
self.assertEqual(self.rows(*ANNOTATION_TABLES, "tags"), before)
self.assertEqual(self.marked(), [])
def test_replace_marks_on_saved_and_replayed_receipts_not_on_conflict(self):
loc = self.locator("a.png")
saved = self.replace(loc, tags=["a"], mark_autotagged=True)
self.assertFalse(saved["replayed"])
self.assertEqual(self.marked(), [self.byte_id(loc)])
other = self.locator("b.png")
self.replace(other, DIGEST_B, ["x"])
replayed = self.replace(
other, DIGEST_B, ["x"], revision=1, mark_autotagged=True
)
self.assertTrue(replayed["replayed"])
third = self.locator("c.png")
self.replace(third, tags=["y"])
with self.assertRaises(AnnotationCommandError):
self.replace(third, tags=["z"], revision=5, mark_autotagged=True)
self.assertEqual(
self.marked(), sorted([self.byte_id(loc), self.byte_id(other, DIGEST_B)])
)
def test_mark_follows_the_bytes_not_the_file(self):
loc = self.locator("a.png")
self.add(loc, tags=["bird"], mark_autotagged=True)
# The file's bytes changed: the new record is current and unmarked.
self.replace(loc, DIGEST_B, ["hand"], mtime_ns=MTIME + 1)
self.assertEqual(self.db.autotag_state({loc: (SIZE, MTIME + 1)}), {loc: False})
self.assertEqual(self.db.autotag_state({loc: (SIZE, MTIME)}), {loc: True})
# The review's estimate counts any marked record of the locator.
self.assertEqual(self.db.autotagged_locators(), {loc})
missing = self.locator("none.png")
self.assertEqual(self.db.autotag_state({missing: (1, 1)}), {missing: False})
def test_state_and_tags_choose_the_same_current_record(self):
loc = self.locator("a.png")
# Two records with the same verified fingerprint: the newest wins.
self.replace(loc, DIGEST_A, ["old bytes"])
with patch.object(
annotations_module, "utc_now_iso", return_value="2099-01-01T00:00:00"
):
self.add(loc, DIGEST_B, ["new bytes"], mark_autotagged=True)
stats = {loc: (SIZE, MTIME)}
self.assertEqual(self.db.current_image_tags(stats), {loc: ["new bytes"]})
self.assertEqual(self.db.autotag_state(stats), {loc: True})
with patch.object(
annotations_module, "utc_now_iso", return_value="2199-01-01T00:00:00"
):
self.add(loc, DIGEST_A, ["older bytes again"])
self.assertEqual(
self.db.current_image_tags(stats), {loc: ["old bytes", "older bytes again"]}
)
self.assertEqual(self.db.autotag_state(stats), {loc: False})
def test_marks_never_touch_prompt_rows(self):
conn = self.db.model.get_connection()
prompt_id = self.db.save_prompt("a beach", tags=["beach"])
before = [
[tuple(row) for row in conn.execute(f"SELECT * FROM {table} ORDER BY 1")]
for table in ("prompts", "prompt_tags")
]
self.add(self.locator("a.png"), tags=["bird"], mark_autotagged=True)
self.replace(self.locator("b.png"), DIGEST_B, ["waves"], mark_autotagged=True)
after = [
[tuple(row) for row in conn.execute(f"SELECT * FROM {table} ORDER BY 1")]
for table in ("prompts", "prompt_tags")
]
self.assertIsNotNone(prompt_id)
self.assertEqual(after, before)
class AnnotationIsolationTests(AnnotationTestCase):
def test_cascade_from_vocabulary_delete_keeps_byte_records(self):
loc = self.locator("a.png")
+9 -163
View File
@@ -1893,13 +1893,15 @@ class TestTagOutputImagesPreview(BatchJobTestCase):
self.assertEqual(self.service.generate_calls, [])
self.assertEqual(self.service.load_calls, [])
async def test_preview_counts_skip_and_merge(self):
async def test_preview_counts_listed_still_images_not_prompts(self):
self._seed()
skip = await (await self._preview(existing="skip")).json()
# t, f1, f2 and m are listed; gone.png is missing, o.png is outside.
self.assertEqual(
{k: skip[k] for k in ("images", "prompts", "already_tagged", "to_tag")},
{"images": 6, "prompts": 5, "already_tagged": 1, "to_tag": 4},
{k: skip[k] for k in ("images", "already_tagged", "to_tag")},
{"images": 4, "already_tagged": 0, "to_tag": 4},
)
self.assertNotIn("prompts", skip)
self.assertEqual(
skip["model"],
{
@@ -1916,7 +1918,7 @@ class TestTagOutputImagesPreview(BatchJobTestCase):
set(skip["disk"]), {"free_bytes", "required_bytes", "sufficient"}
)
merge = await (await self._preview(existing="merge")).json()
self.assertEqual(merge["to_tag"], 5)
self.assertEqual(merge["to_tag"], 4)
leaked = [s for s in _strings(skip) if _absolute(s)]
self.assertEqual(leaked, [])
@@ -1924,7 +1926,7 @@ class TestTagOutputImagesPreview(BatchJobTestCase):
self._seed()
data = await (await self._preview(existing="replace_after_review")).json()
self.assertTrue(data["review_required"])
self.assertEqual(data["prompts"], 5)
self.assertEqual(data["images"], 4)
async def test_preview_refuses_bad_bodies(self):
for body in (
@@ -1979,119 +1981,6 @@ class TestTagOutputImagesJob(BatchJobTestCase):
"WD14 ViT is not downloaded. Download it in System › Auto-tagging.",
)
async def test_job_writes_what_legacy_writes(self):
ids = self._seed()
await self._run_job(existing="skip")
native = {name: sorted(self._tags(pid)) for name, pid in ids.items()}
# The same library through the legacy batch, in a fresh database.
for name, pid in ids.items():
self.api.db.update_prompt_metadata(
pid,
tags=(
["existing"]
if name == "tagged"
else (["auto-scanned"] if name == "marked" else [])
),
)
resp = await self.client.post(
"/prompt_manager/autotag/start?model_type=wd14-vit&skip_tagged=true"
)
self.assertEqual(self._sse_events(await resp.text())[-1]["type"], "complete")
legacy = {name: sorted(self._tags(pid)) for name, pid in ids.items()}
self.assertEqual(native, legacy)
self.assertEqual(native["tagged"], ["existing"])
self.assertEqual(native["fresh"], ["1girl", "smile"])
async def test_job_outcomes_follow_the_mapping(self):
ids = self._seed()
self.api.db.update_prompt_metadata(
ids["marked"], tags=["auto-scanned", "1girl", "smile"]
)
def details(job):
return {
group: sorted(row["detail"] for row in rows)
for group, rows in job.outcomes.items()
}
merged = await self._run_job(existing="merge")
self.assertEqual(merged.state, "finished")
self.assertEqual(
details(merged),
{
"tagged": ["Added 2 tags", "Added 2 tags"],
"unchanged": ["No new tags"],
"skipped": [
"File not found",
"Outside the output folders",
"Prompt handled by another image",
],
"errors": [],
},
)
self.assertEqual(merged.status_line, "Tagged 2 prompts")
skipped = await self._run_job(existing="skip")
self.assertEqual(details(skipped)["tagged"], [])
self.assertEqual(details(skipped)["skipped"].count("Prompt already tagged"), 3)
self.assertEqual(skipped.status_line, "Tagged 0 prompts")
async def test_tagging_errors_are_fixed_strings(self):
self._seed()
self.service.generate_error = RuntimeError("/srv/secret/model exploded")
job = await self._run_job(existing="merge")
self.assertTrue(job.outcomes["errors"])
self.assertEqual(
{r["detail"] for r in job.outcomes["errors"]}, {"Tagging failed"}
)
self.assertNotIn("/srv/secret", json.dumps(job.report()))
async def test_job_progress_is_determinate(self):
self._seed()
seen = []
original = self.service.generate_tags
def generate(*args, **kwargs):
job = self.api._jobs.active("autotag")
seen.append((job.processed, job.total))
return original(*args, **kwargs)
self.service.generate_tags = generate
job = await self._run_job(existing="merge")
self.assertEqual((job.processed, job.total, job.progress), (6, 6, 100))
self.assertTrue(all(total == 6 for _p, total in seen[1:]), seen)
self.assertTrue(job.cancellable)
async def test_job_cancel_stops_before_the_next_image_and_keeps_writes(self):
ids = self._seed()
started = threading.Event()
proceed = threading.Event()
calls = []
def slow(image_path, **kwargs):
calls.append(image_path)
started.set()
proceed.wait(30)
return ["slow"]
self.service.generate_tags = slow
resp = await self._start_job(existing="merge")
job = self.api._jobs.get((await resp.json())["job"]["id"])
try:
await asyncio.get_running_loop().run_in_executor(None, started.wait, 30)
cancel = await self.client.post(f"/prompt_manager/jobs/{job.id}/cancel")
self.assertTrue((await cancel.json())["cancellation_requested"])
finally:
proceed.set()
await asyncio.wait_for(asyncio.shield(job.task), 30)
self.assertEqual(job.state, "cancelled")
self.assertEqual(job.status_line, "Cancelled. Tags already added were kept.")
self.assertEqual(len(calls), 1)
tagged = [n for n, pid in ids.items() if "slow" in self._tags(pid)]
self.assertEqual(len(tagged), 1)
self.assertIsNone(model_gate.holder())
self.assertEqual(self.service.unload_calls, 0)
async def test_job_refused_while_legacy_batch_holds(self):
async with self.api._jobs.hold(
"autotag", "Tag output images (legacy page)", admission="records"
@@ -2103,41 +1992,6 @@ class TestTagOutputImagesJob(BatchJobTestCase):
self.assertEqual(data["running"]["title"], "Tag output images (legacy page)")
self.assertIsNone(model_gate.holder())
async def test_legacy_refused_while_job_runs(self):
self._seed()
started, proceed = threading.Event(), threading.Event()
def slow(image_path, **kwargs):
started.set()
proceed.wait(30)
return ["slow"]
self.service.generate_tags = slow
resp = await self._start_job(existing="merge")
job = self.api._jobs.get((await resp.json())["job"]["id"])
try:
await asyncio.get_running_loop().run_in_executor(None, started.wait, 30)
self.assertTrue(model_gate.holder().startswith("autotag-job:"))
legacy = await self.client.post("/prompt_manager/autotag/start")
# Both legacy pages read a stream: the refusal is one error frame
# that names the running job.
self.assertEqual(legacy.status, 200)
self.assertEqual(
self._sse_events(await legacy.text()),
[
{
"type": "error",
"status": "error",
"message": "Tag output images already running",
}
],
)
unload = await self.client.post("/prompt_manager/autotag/unload")
self.assertEqual((await unload.json())["operation"], "Tag output images")
finally:
proceed.set()
await asyncio.wait_for(asyncio.shield(job.task), 30)
async def test_job_releases_gate_when_start_is_refused(self):
class Running:
id = "other"
@@ -2185,7 +2039,7 @@ class TestTagOutputImagesJob(BatchJobTestCase):
("database folder", "the safety backup", 10, 1),
10,
)
with patch("py.api.autotag_routes.disk_plan", return_value=short):
with patch("py.api.image_tag_jobs.disk_plan", return_value=short):
resp = await self._start_job()
data = await resp.json()
self.assertEqual(resp.status, 507)
@@ -2203,14 +2057,6 @@ class TestTagOutputImagesJob(BatchJobTestCase):
inside = [row for row in rows if row["root_index"] == 0]
self.assertTrue(inside)
async def test_native_batch_leaves_custom_prompt_unchanged(self):
self._seed()
self.service.custom_prompt = "legacy page prompt"
await self._run_job(existing="merge", model_type="gguf")
self.assertEqual(self.service.custom_prompt, "legacy page prompt")
prompts = {call[1] for call in self.service.generate_calls}
self.assertEqual(prompts, {"Tags please."})
# The legacy batch's per-image frames over BatchJobTestCase's library, newest
# first, with every throttle check passing (pinned from main's generator).
@@ -2342,7 +2188,7 @@ class TestTagOutputImagesEdges(BatchJobTestCase):
async def test_unexpected_error_fails_the_job_with_a_reason(self):
self._seed()
with patch.object(
self.api.db, "autotag_batch_candidates", side_effect=[[], RuntimeError("x")]
self.api, "_autotag_prepass", side_effect=RuntimeError("/srv/x")
):
job = await self._run_job(existing="merge")
self.assertEqual(job.state, "failed")
+634
View File
@@ -0,0 +1,634 @@
"""Tag output images tags each image, never its prompt (#379, PR 1).
``POST /prompt_manager/jobs/autotag`` runs the ``image-tags`` loop over every
still image the output folders list, linked to a prompt or not: each image
gets its own model tags in its image tag set and is marked auto-tagged.
"Skip already tagged" skips images whose current bytes are marked, counted
before the loop without opening them. Prompt tags and prompt text never
change. No real model is loaded: the engine is a fake that answers per image.
"""
import asyncio
import json
import ntpath
import os
import posixpath
import re
import sqlite3
import threading
from pathlib import Path
from unittest.mock import patch
try:
from test_api_images import make_png
from test_image_tag_jobs import HookedTagger, ImageTagJobTestCase
except ImportError:
from tests.test_api_images import make_png
from tests.test_image_tag_jobs import HookedTagger, ImageTagJobTestCase
from database.execution_history_schema import rotate_history_epoch
from py import model_gate
from py.api import admin as admin_module
from py.api import image_tag_jobs as jobs_module
from py.config import PromptManagerConfig
from utils.hashing import generate_prompt_hash
JOB = "/prompt_manager/jobs/autotag"
PREVIEW = "/prompt_manager/autotag/batch/preview"
WAIT = 30.0
WD14 = {"general_threshold": 0.35, "character_threshold": 0.85}
CANCELLED = "Cancelled. Tags already added were kept."
_DRIVE = re.compile(r"^[A-Za-z]:[\\/]")
class PerImageTagger(HookedTagger):
"""Answers each image from its own bytes: ``by_bytes`` maps exact file
contents to the names the "model" sees; any other image gets SCORED."""
def __init__(self):
super().__init__()
self.by_bytes = {}
def generate_tag_suggestions(self, image_path, **kwargs):
result = super().generate_tag_suggestions(image_path, **kwargs)
names = self.by_bytes.get(self.read_bytes[-1])
if names is None:
return result
return [(name, 0.9) for name in names][: kwargs["limit"]]
def _strings(value):
if isinstance(value, str):
yield value
elif isinstance(value, dict):
for item in value.values():
yield from _strings(item)
elif isinstance(value, (list, tuple)):
for item in value:
yield from _strings(item)
def _absolute(text):
return posixpath.isabs(text) or ntpath.isabs(text) or bool(_DRIVE.match(text))
class BatchTestCase(ImageTagJobTestCase):
async def get_application(self):
app = await super().get_application()
self.tagger = PerImageTagger()
patcher = patch("py.autotag.get_autotag_service", return_value=self.tagger)
patcher.start()
self.addCleanup(patcher.stop)
backup = PromptManagerConfig.SAFETY_BACKUP
self.addCleanup(setattr, PromptManagerConfig, "SAFETY_BACKUP", backup)
PromptManagerConfig.SAFETY_BACKUP = False
return app
# -- helpers ---------------------------------------------------------
def image(self, name, color, sees=None):
path = make_png(self.output_dir / name, size=(8, 6), color=color)
if sees is not None:
self.tagger.by_bytes[path.read_bytes()] = list(sees)
return path
async def relist(self):
"""Walk the output folder again (a cached listing is used as it is)."""
await self.api._listing(self.output_dir, fresh=True)
def prompt(self, text, tags=()):
return self.api.db.save_prompt(
text=text,
category=None,
tags=list(tags),
rating=None,
prompt_hash=generate_prompt_hash(text),
)
def link(self, prompt_id, path):
return self.api.db.link_image_to_prompt(prompt_id, str(path))
def prompt_rows(self):
conn = sqlite3.connect(self._temp_db.name)
try:
return {
table: conn.execute(f"SELECT * FROM {table} ORDER BY 1,2").fetchall()
for table in ("prompts", "prompt_tags")
}
finally:
conn.close()
def marked(self):
conn = sqlite3.connect(self._temp_db.name)
try:
return {
row[0]
for row in conn.execute(
"SELECT f.locator FROM media_image_autotags m JOIN "
"media_annotation_bytes b ON b.id=m.byte_id JOIN "
"media_annotation_files f ON f.id=b.file_id"
)
}
finally:
conn.close()
async def preview(self, existing="skip", model_type="wd14-vit"):
response = await self.client.post(
PREVIEW, json={"model_type": model_type, "existing": existing}
)
return response.status, await self.json_of(response)
async def start_batch(self, existing="skip", model_type="wd14-vit", options=None):
if options is None:
options = (
dict(WD14)
if model_type.startswith("wd14")
else {"instructions": "Tags please."}
)
response = await self.client.post(
JOB,
json={"model_type": model_type, "existing": existing, "options": options},
)
return response.status, await self.json_of(response)
async def run_batch(self, **kwargs):
status, data = await self.start_batch(**kwargs)
self.assertEqual(status, 202, data)
job = self.api._jobs.get(data["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
return job
class TestPerImageWrites(BatchTestCase):
async def test_two_images_of_one_prompt_get_their_own_tags_and_no_prompt_changes(
self,
):
prompt_id = self.prompt("a girl at the beach", tags=["beach"])
a = self.image("a.png", (10, 20, 30), sees=["1girl", "bird"])
b = self.image("b.png", (40, 50, 60), sees=["1girl", "waves"])
self.link(prompt_id, a)
self.link(prompt_id, b)
before = self.prompt_rows()
job = await self.run_batch(existing="skip")
self.assertEqual(job.state, "finished", job.status_line)
self.assertEqual(self.stored("a.png")["tags"], ["1girl", "bird"])
self.assertEqual(self.stored("b.png")["tags"], ["1girl", "waves"])
self.assertEqual(self.prompt_rows(), before)
self.assertEqual(self.api.db.get_prompt_by_id(prompt_id)["tags"], ["beach"])
self.assertEqual(self.marked(), {self.locator("a.png"), self.locator("b.png")})
self.assertEqual(
job.counters, {"tagged": 2, "unchanged": 0, "skipped": 0, "errors": 0}
)
self.assertEqual(job.status_line, "Tagged 2 images")
self.assertEqual((job.processed, job.total, job.progress), (2, 2, 100))
self.assertEqual(job.scope["images"], 2)
# Inference read the private captures, one per image.
self.assertEqual(len(self.tagger.calls), 2)
async def test_unlinked_images_are_tagged_and_videos_are_not_targets(self):
self.image("free.png", (1, 2, 3), sees=["tree"])
(self.output_dir / "clip.mp4").write_bytes(b"\x00\x00\x00\x18ftypmp42" * 8)
job = await self.run_batch()
self.assertEqual(self.stored("free.png")["tags"], ["tree"])
self.assertEqual(job.total, 1)
self.assertEqual(job.status_line, "Tagged 1 image")
rows = [row["path"] for rows in job.outcomes.values() for row in rows]
self.assertNotIn("clip.mp4", rows)
async def test_hand_tags_do_not_block_a_skip_run(self):
path = self.image("c.png", (7, 7, 7), sees=["bird"])
self.saved_set("c.png", path.read_bytes(), ["keeper"])
job = await self.run_batch(existing="skip")
self.assertEqual(self.stored("c.png")["tags"], ["keeper", "bird"])
self.assertEqual(job.counters["tagged"], 1)
async def test_skip_counts_marked_images_without_opening_them_and_merge_retags(
self,
):
self.image("a.png", (1, 1, 1), sees=["bird"])
self.image("b.png", (2, 2, 2), sees=["cat"])
first = await self.run_batch()
self.assertEqual(first.counters["tagged"], 2)
self.tagger.calls.clear()
captures = []
store = self.api._capture_store()
real_capture = store.capture
def spy(*args, **kwargs):
captures.append(args)
return real_capture(*args, **kwargs)
with patch.object(store, "capture", side_effect=spy):
skipped = await self.run_batch(existing="skip")
self.assertEqual(captures, [])
self.assertEqual(self.tagger.calls, [])
self.assertEqual(
skipped.counters, {"tagged": 0, "unchanged": 0, "skipped": 2, "errors": 0}
)
self.assertEqual(
sorted(self.rows(skipped, "skipped")),
[("a.png", "Already auto-tagged"), ("b.png", "Already auto-tagged")],
)
self.assertFalse(any("Already auto-tagged" in line for line in skipped.log))
self.assertEqual((skipped.processed, skipped.total), (2, 2))
self.assertEqual(skipped.status_line, "Tagged 0 images")
self.tagger.by_bytes[(self.output_dir / "a.png").read_bytes()] = ["bird", "sky"]
merged = await self.run_batch(existing="merge")
self.assertEqual(len(self.tagger.calls), 2)
self.assertEqual(self.stored("a.png")["tags"], ["bird", "sky"])
self.assertEqual(self.rows(merged, "tagged"), [("a.png", "Added 1 tag")])
self.assertEqual(self.rows(merged, "unchanged"), [("b.png", "No new tags")])
async def test_changed_bytes_are_tagged_again(self):
self.image("a.png", (1, 1, 1), sees=["bird"])
await self.run_batch()
self.image("a.png", (9, 9, 9), sees=["cat"])
job = await self.run_batch(existing="skip")
self.assertEqual(self.rows(job, "tagged"), [("a.png", "Added 1 tag")])
self.assertEqual(self.stored("a.png")["tags"], ["cat"])
async def test_an_image_with_no_model_tags_is_marked_and_skipped_next_time(self):
self.image("plain.png", (3, 3, 3), sees=[])
job = await self.run_batch()
self.assertEqual(self.rows(job, "unchanged"), [("plain.png", "No new tags")])
self.assertEqual(self.marked(), {self.locator("plain.png")})
again = await self.run_batch()
self.assertEqual(
self.rows(again, "skipped"), [("plain.png", "Already auto-tagged")]
)
async def test_unsupported_still_is_marked_and_skipped_by_the_next_run(self):
(self.output_dir / "broken.png").write_bytes(b"not an image")
job = await self.run_batch()
self.assertEqual(
self.rows(job, "skipped"), [("broken.png", "Not a supported still image")]
)
self.assertEqual(self.marked(), {self.locator("broken.png")})
again = await self.run_batch()
self.assertEqual(
self.rows(again, "skipped"), [("broken.png", "Already auto-tagged")]
)
self.assertEqual(self.tagger.calls, [])
async def test_too_large_is_skipped_before_any_copy(self):
self.image("big.png", (5, 5, 5))
self.api._capture_store().max_file = 16
store = self.api._capture_store()
with patch.object(store, "capture", side_effect=AssertionError("copied")):
job = await self.run_batch()
self.assertEqual(
self.rows(job, "skipped"), [("big.png", "Too large to verify")]
)
self.assertEqual(self.marked(), set())
async def test_overlapping_roots_list_a_file_once(self):
self.image("sub/a.png", (4, 4, 4), sees=["bird"])
sub = self.output_dir / "sub"
self.api._get_all_output_dirs = lambda: [self.output_dir, sub]
job = await self.run_batch()
self.assertEqual(job.total, 1)
self.assertEqual(len(self.tagger.calls), 1)
self.assertEqual(self.rows(job, "tagged"), [("sub/a.png", "Added 1 tag")])
status, data = await self.preview()
self.assertEqual((status, data["images"]), (200, 1))
async def test_rows_log_and_status_carry_no_absolute_paths(self):
self.image("a.png", (1, 1, 1))
(self.output_dir / "broken.png").write_bytes(b"not an image")
job = await self.run_batch()
leaked = [s for s in _strings(job.report()) if _absolute(s)]
self.assertEqual(leaked, [])
self.assertNotIn(str(self.output_dir), json.dumps(job.report()))
class TestBatchLifecycle(BatchTestCase):
async def test_cancel_after_one_image_keeps_it_and_the_rerun_skips_it(self):
for n in range(3):
self.image(f"{n}.png", (n, n, n), sees=[f"t{n}"])
entered, proceed = threading.Event(), threading.Event()
self.tagger.block = (entered, proceed)
status, data = await self.start_batch()
self.assertEqual(status, 202, data)
job = self.api._jobs.get(data["job"]["id"])
try:
await self.wait_for(entered)
cancel = await self.client.post(f"/prompt_manager/jobs/{job.id}/cancel")
self.assertEqual(cancel.status, 200)
finally:
self.tagger.block = None
proceed.set()
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(job.state, "cancelled")
self.assertEqual(job.status_line, CANCELLED)
self.assertEqual(job.counters["tagged"], 1)
self.assertEqual(len(self.marked()), 1)
self.assertIsNone(model_gate.holder())
lines = []
real_update = type(job).update
def spy(self_job, **kwargs):
if kwargs.get("status_line"):
lines.append(kwargs["status_line"])
return real_update(self_job, **kwargs)
with patch.object(type(job), "update", spy):
rerun = await self.run_batch()
self.assertEqual(rerun.counters["skipped"], 1)
self.assertEqual(rerun.counters["tagged"], 2)
self.assertIn("Processing file 2 of 3", lines)
self.assertIn("Processing file 3 of 3", lines)
self.assertNotIn("Processing file 1 of 3", lines)
self.assertEqual((rerun.processed, rerun.total), (3, 3))
async def test_cancel_in_each_phase_writes_nothing(self):
self.image("a.png", (1, 1, 1))
for phase in (
"_safety_backup_step",
"_prepare_tagging_model",
"_autotag_targets",
):
with self.subTest(phase):
real = getattr(self.api, phase)
async def cancelling(*args, _real=real, **kwargs):
result = await _real(*args, **kwargs)
self.api._jobs.active("autotag").cancelled = True
return result
with patch.object(self.api, phase, side_effect=cancelling):
job = await self.run_batch()
self.assertEqual(job.state, "cancelled")
self.assertEqual(job.status_line, CANCELLED)
self.assertEqual(self.marked(), set())
self.assertEqual(self.tagger.calls, [])
self.assertIsNone(model_gate.holder())
async def test_restore_mid_run_fails_the_job(self):
self.image("a.png", (1, 1, 1))
self.image("b.png", (2, 2, 2))
def restore(_path):
conn = sqlite3.connect(self._temp_db.name)
try:
rotate_history_epoch(conn)
finally:
conn.close()
self.tagger.on_generate = restore
job = await self.run_batch()
self.assertEqual(job.state, "failed")
self.assertEqual(
job.status_line, "Prompt storage was restored; start the job again."
)
async def test_listing_error_fails_the_job(self):
self.image("a.png", (1, 1, 1))
with patch.object(
self.api, "_get_gallery_files", side_effect=OSError("/secret/out")
):
job = await self.run_batch()
self.assertEqual(job.state, "failed")
self.assertEqual(
job.status_line, "The output folders could not be listed. Try again."
)
self.assertNotIn("/secret", json.dumps(job.report()))
async def test_model_that_cannot_load_fails_the_job(self):
self.image("a.png", (1, 1, 1))
self.tagger.load_error = RuntimeError("no CUDA")
job = await self.run_batch()
self.assertEqual(job.state, "failed")
self.assertEqual(
job.status_line, "Auto-tagging failed. Check server logs for details."
)
self.assertIsNone(model_gate.holder())
async def test_zero_targets_finish_with_tagged_zero(self):
job = await self.run_batch()
self.assertEqual(job.state, "finished")
self.assertEqual(job.status_line, "Tagged 0 images")
self.assertEqual((job.processed, job.total), (0, 0))
async def test_runner_unloads_after_a_cancel_when_not_kept_in_memory(self):
self.image("a.png", (1, 1, 1))
owner = model_gate.new_owner("legacy-batch")
self.assertTrue(model_gate.try_acquire(owner))
run = jobs_module.AutotagRun(
model_type="wd14-vit",
existing="skip",
options={**WD14, "use_gpu": True, "suggestion_limit": 10},
keep_in_memory=False,
backup_on=False,
owner=owner,
)
from py.api.jobs import AUTOTAG_GROUP_NAMES, Job as RealJob
job = RealJob(
"autotag",
"Tag output images",
admission="records",
groups=AUTOTAG_GROUP_NAMES,
)
real_targets = self.api._autotag_targets
async def cancel_after_listing():
targets = await real_targets()
job.cancelled = True
return targets
with patch.object(
self.api, "_autotag_targets", side_effect=cancel_after_listing
):
await self.api._run_autotag_batch(run, job)
self.assertEqual(job.state, "cancelled")
self.assertEqual(self.tagger.unload_calls, 1)
self.assertIsNone(model_gate.holder())
async def test_time_left_uses_the_models_rate_only(self):
for n in range(4):
self.image(f"{n}.png", (n, n, n))
await self.run_batch()
for n in range(4, 8):
self.image(f"{n}.png", (n, n, n))
await self.relist()
calls = []
real = jobs_module._time_left
def spy(*args):
calls.append(args)
return real(*args)
with patch.object(jobs_module, "_time_left", side_effect=spy):
job = await self.run_batch()
self.assertEqual(job.counters["skipped"], 4)
# (done, total, batches, elapsed): over the 4 images the model runs on.
self.assertEqual(
[(c[0], c[1], c[2]) for c in calls], [(n, 4, n) for n in range(4)]
)
# The shared D56 text is unchanged.
self.assertEqual(admin_module._time_left(5, 10, 5, 300), " · about 5 min left")
class TestStartRefusals(BatchTestCase):
async def test_unavailable_model_and_bad_bodies(self):
self.tagger.downloaded["wd14-vit"] = False
status, data = await self.start_batch()
self.assertEqual((status, data["code"]), (409, "model-unavailable"))
status, data = await self.start_batch(existing="replace_after_review")
self.assertEqual((status, data["code"]), (400, "review-required"))
status, _data = await self.start_batch(options={**WD14, "suggestion_limit": 3})
self.assertEqual(status, 400)
self.assertEqual(self.api._jobs.list(), [])
self.assertIsNone(model_gate.holder())
async def test_model_busy_when_the_gate_is_held(self):
owner = model_gate.new_owner("image-annotations")
self.assertTrue(model_gate.try_acquire(owner))
try:
status, data = await self.start_batch()
finally:
model_gate.release(owner)
self.assertEqual((status, data["code"]), (409, "model-busy"))
class TestBatchBeside(BatchTestCase):
async def test_legacy_batch_and_unload_are_refused_while_the_job_runs(self):
self.image("a.png", (1, 1, 1))
entered, proceed = threading.Event(), threading.Event()
self.tagger.block = (entered, proceed)
status, data = await self.start_batch()
self.assertEqual(status, 202, data)
job = self.api._jobs.get(data["job"]["id"])
try:
await self.wait_for(entered)
self.assertTrue(model_gate.holder().startswith("autotag-job:"))
legacy = await self.client.post("/prompt_manager/autotag/start")
self.assertEqual(legacy.status, 200)
frames = [
json.loads(line[len("data: ") :])
for line in (await legacy.text()).splitlines()
if line.startswith("data: ")
]
self.assertEqual(
frames,
[
{
"type": "error",
"status": "error",
"message": "Tag output images already running",
}
],
)
unload = await self.client.post("/prompt_manager/autotag/unload")
self.assertEqual((await unload.json())["operation"], "Tag output images")
finally:
self.tagger.block = None
proceed.set()
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(job.state, "finished")
async def test_language_model_batch_passes_instructions_per_call(self):
self.image("a.png", (1, 1, 1))
job = await self.run_batch(model_type="gguf")
self.assertEqual(job.state, "finished", job.status_line)
self.assertEqual(self.tagger.calls[0]["prompt"], "Tags please.")
self.assertEqual(self.tagger.custom_prompt, "legacy prompt")
async def test_inference_errors_are_fixed_strings_and_not_marked(self):
self.image("a.png", (1, 1, 1))
def explode(_path):
raise RuntimeError("/srv/secret/model exploded")
self.tagger.on_generate = explode
job = await self.run_batch()
self.assertEqual(
self.rows(job, "errors"),
[("a.png", "Auto-tagging failed. Check server logs for details.")],
)
self.assertNotIn("/srv/secret", json.dumps(job.report()))
self.assertEqual(self.marked(), set())
class TestPreview(BatchTestCase):
async def test_preview_counts_images_and_estimates_time(self):
prompt_id = self.prompt("p")
a = self.image("a.png", (1, 1, 1))
self.image("b.png", (2, 2, 2))
self.image("c.png", (3, 3, 3))
self.link(prompt_id, a)
(self.output_dir / "clip.mp4").write_bytes(b"\x00\x00\x00\x18ftypmp42" * 8)
status, fresh = await self.preview()
self.assertEqual(status, 200, fresh)
self.assertEqual(
{k: fresh[k] for k in ("images", "already_tagged", "to_tag")},
{"images": 3, "already_tagged": 0, "to_tag": 3},
)
self.assertNotIn("prompts", fresh)
self.assertEqual(fresh["suggestion_limit"], 10)
self.assertEqual(fresh["estimate_device"], "cpu")
self.assertAlmostEqual(fresh["estimate_seconds"], 3 * 1.6)
self.tagger.block = None
await self.run_batch()
self.image("d.png", (4, 4, 4))
await self.relist()
skip = (await self.preview("skip"))[1]
self.assertEqual(
{k: skip[k] for k in ("images", "already_tagged", "to_tag")},
{"images": 4, "already_tagged": 3, "to_tag": 1},
)
merge = (await self.preview("merge"))[1]
self.assertEqual(merge["to_tag"], 4)
for model_type in ("gguf", "hf"):
llm = (await self.preview(model_type=model_type))[1]
self.assertIsNone(llm["estimate_seconds"])
self.assertIsNone(llm["estimate_device"])
self.assertEqual([s for s in _strings(skip) if _absolute(s)], [])
async def test_preview_uses_the_graphics_card_rate_when_wd14_runs_on_cuda(self):
self.image("a.png", (1, 1, 1))
self.tagger.wd14_device = lambda: "gpu"
data = (await self.preview())[1]
self.assertEqual(data["estimate_device"], "gpu")
from py.tagging_defaults import WD14_GPU_SECONDS_PER_IMAGE
self.assertAlmostEqual(data["estimate_seconds"], WD14_GPU_SECONDS_PER_IMAGE)
async def test_preview_writes_nothing_and_opens_no_media_file(self):
self.image("a.png", (1, 1, 1))
await self.run_batch()
self.image("b.png", (2, 2, 2))
await self.relist() # the listing, primed
files = [self._temp_db.name + suffix for suffix in ("", "-wal")]
before = [Path(f).read_bytes() if os.path.exists(f) else None for f in files]
media = str(self.output_dir)
real_open, real_stat = open, os.stat
def guarded_open(file, *args, **kwargs):
if str(file).startswith(media):
raise AssertionError(f"opened {file}")
return real_open(file, *args, **kwargs)
def guarded_stat(path, *args, **kwargs):
if str(path).startswith(media) and str(path) != media:
raise AssertionError(f"stat {path}")
return real_stat(path, *args, **kwargs)
with patch("builtins.open", guarded_open), patch("os.stat", guarded_stat):
status, data = await self.preview()
self.assertEqual(status, 200, data)
after = [Path(f).read_bytes() if os.path.exists(f) else None for f in files]
self.assertEqual(after, before)
self.assertEqual(data["to_tag"], 1)
self.assertEqual(self.tagger.calls[1:], [])
+26
View File
@@ -884,3 +884,29 @@ class TestImageConversion(EngineTestCase):
if __name__ == "__main__":
unittest.main()
class TestWd14Device(EngineTestCase):
"""Where WD14 runs, for the Tag output images estimate (#379)."""
def test_loaded_session_answers_from_its_first_provider(self):
cpu = FakeOnnxSession([0.5] * len(WD14_TAGS))
service = self._service(("wd14", (cpu, WD14_TAGS)))
_touch_model_files(self.models_dir, "wd14-vit")
service.load_model("wd14-vit")
self.assertEqual(service.wd14_device(), "cpu")
cpu.get_providers = lambda: ["CUDAExecutionProvider", "CPUExecutionProvider"]
self.assertEqual(service.wd14_device(), "gpu")
def test_without_a_session_the_available_providers_answer(self):
service = self._service(None)
fake = types.SimpleNamespace(
get_available_providers=lambda: ["CUDAExecutionProvider"]
)
with patch.dict(sys.modules, {"onnxruntime": fake}):
self.assertEqual(service.wd14_device(), "gpu")
fake.get_available_providers = lambda: ["CPUExecutionProvider"]
with patch.dict(sys.modules, {"onnxruntime": fake}):
self.assertEqual(service.wd14_device(), "cpu")
with patch.dict(sys.modules, {"onnxruntime": None}):
self.assertEqual(service.wd14_device(), "cpu")
+318
View File
@@ -0,0 +1,318 @@
"""Auto-tagged marks schema group (#379): install, validation and restore."""
import hashlib
import re
import shutil
import sqlite3
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
try:
from tests.open_handles import assert_closed
except ImportError: # discovered with tests/ as the top-level directory
from open_handles import assert_closed
from database.models import PromptModel
try: # guarded so a missing module fails assertions, not test loading
from database.autotag_schema import (
AUTOTAG_TABLE,
autotag_schema_error,
autotag_without_annotations_error,
migrate_autotag_state,
)
except ImportError:
AUTOTAG_TABLE = None
autotag_schema_error = None
autotag_without_annotations_error = None
migrate_autotag_state = None
TABLE = "media_image_autotags"
DDL = (
f"CREATE TABLE {TABLE} (byte_id TEXT PRIMARY KEY "
"REFERENCES media_annotation_bytes(id) ON DELETE CASCADE) WITHOUT ROWID"
)
ANNOTATION_TABLES = (
"media_annotation_metadata",
"media_annotation_files",
"media_annotation_bytes",
"media_image_tags",
)
REVIEW_TABLES = (
"media_review_metadata",
"media_review_items",
"media_review_decisions",
)
FILE_ID = "0f8e4b2a-1c3d-4e5f-8a9b-0c1d2e3f4a5b"
BYTE_ID = "6a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"
OTHER_ID = "7b2c3d4e-5f6a-4b7c-9d0e-1f2a3b4c5d6e"
DIGEST = hashlib.sha256(b"img").hexdigest()
NOW = "2026-10-27T00:00:00.000000+00:00"
NEEDS_ANNOTATIONS = "Image auto-tag marks need the image annotation tables"
INVALID_VALUES = "Image auto-tag marks contain invalid values"
INVALID_SCHEMA = "Image auto-tag marks have an invalid schema"
def _compact(sql):
return re.sub(r"\s+", "", sql or "").lower()
def catalog(conn):
return {
(kind, name, _compact(sql))
for kind, name, sql in conn.execute(
"SELECT type,name,sql FROM sqlite_master WHERE name=? OR tbl_name=?",
(TABLE, TABLE),
)
}
def marks(conn):
return [tuple(row) for row in conn.execute(f"SELECT byte_id FROM {TABLE}")]
def insert_byte(conn, file_id=FILE_ID, byte_id=BYTE_ID, locator="/out/img.png"):
conn.execute(
"INSERT INTO media_annotation_files VALUES(?,?,?)", (file_id, locator, NOW)
)
conn.execute(
"INSERT INTO media_annotation_bytes VALUES(?,?,?,?,?,?,?,0)",
(byte_id, file_id, DIGEST, 3, 1_700_000_000_000_000_000, NOW, NOW),
)
class AutotagSchemaTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.addCleanup(assert_closed, self, self.temp.name)
self.addCleanup(PromptModel.reset_schema_cache)
self.models = []
self.addCleanup(self.close_models)
self.path = str(Path(self.temp.name) / "live.db")
def close_models(self):
for model in self.models:
model.close_all()
def model(self, path=None):
model = PromptModel(path or self.path)
self.models.append(model)
return model
def require_module(self):
self.assertIsNotNone(
autotag_schema_error, "database.autotag_schema is not implemented"
)
def raw(self, path, *statements, foreign_keys=False):
with closing(sqlite3.connect(path)) as raw, raw:
raw.execute(f"PRAGMA foreign_keys={'ON' if foreign_keys else 'OFF'}")
for statement in statements:
if isinstance(statement, tuple):
raw.execute(*statement)
else:
raw.execute(statement)
def closed_current(self, name="current.db", *, with_mark=True):
"""A closed, current database with one byte record (and its mark)."""
path = str(Path(self.temp.name) / name)
model = self.model(path)
with model.get_connection() as conn:
insert_byte(conn)
if with_mark:
conn.execute(f"INSERT INTO {TABLE} VALUES(?)", (BYTE_ID,))
model.close_all()
return path
def test_fresh_install_has_exactly_the_pinned_table(self):
self.require_module()
self.assertEqual(AUTOTAG_TABLE, TABLE)
model = self.model()
with model.get_connection() as conn:
# WITHOUT ROWID with a primary key: no sqlite_autoindex row.
self.assertEqual(catalog(conn), {("table", TABLE, _compact(DDL))})
self.assertEqual(
[tuple(row) for row in conn.execute(f"PRAGMA table_info({TABLE})")],
[(0, "byte_id", "TEXT", 1, None, 1)],
)
self.assertIsNone(autotag_schema_error(conn, allow_missing=False))
self.assertEqual(marks(conn), [])
self.assertEqual(
PromptModel.verify_database_file(self.path, require_library_state=True),
(True, "ok"),
)
def test_reopen_and_rerun_are_idempotent(self):
self.require_module()
path = self.closed_current()
for _ in range(2):
PromptModel.reset_schema_cache()
model = self.model(path)
with model.get_connection() as conn:
migrate_autotag_state(conn)
self.assertEqual(marks(conn), [(BYTE_ID,)])
self.assertEqual(catalog(conn), {("table", TABLE, _compact(DDL))})
model.close_all()
def test_old_database_without_the_table_gains_it_and_keeps_its_rows(self):
self.require_module()
path = self.closed_current(with_mark=False)
self.raw(path, f"DROP TABLE {TABLE}")
with closing(sqlite3.connect(path)) as raw:
before = raw.execute("SELECT * FROM media_annotation_bytes").fetchall()
self.assertIsNone(autotag_schema_error(raw))
self.assertEqual(
autotag_schema_error(raw, allow_missing=False),
"Missing image auto-tag marks schema",
)
self.assertTrue(PromptModel.verify_database_file(path)[0])
self.assertEqual(
PromptModel.verify_database_file(path, require_library_state=True),
(False, "Missing image auto-tag marks schema"),
)
PromptModel.reset_schema_cache()
model = self.model(path)
with model.get_connection() as conn:
self.assertEqual(catalog(conn), {("table", TABLE, _compact(DDL))})
self.assertEqual(
[
tuple(row)
for row in conn.execute("SELECT * FROM media_annotation_bytes")
],
before,
)
model.close_all()
def test_table_without_annotation_group_is_refused(self):
self.require_module()
path = self.closed_current(with_mark=False)
self.raw(
path,
*(
f"DROP TABLE {t}"
for t in REVIEW_TABLES + tuple(reversed(ANNOTATION_TABLES))
),
)
with closing(sqlite3.connect(path)) as raw:
self.assertEqual(autotag_without_annotations_error(raw), NEEDS_ANNOTATIONS)
for allow_missing in (True, False):
self.assertEqual(
autotag_schema_error(raw, allow_missing=allow_missing),
NEEDS_ANNOTATIONS,
)
self.assertEqual(
PromptModel.verify_database_file(path), (False, NEEDS_ANNOTATIONS)
)
PromptModel.reset_schema_cache()
with self.assertRaises(sqlite3.DatabaseError) as raised:
self.model(path)
self.assertIn(NEEDS_ANNOTATIONS, str(raised.exception))
def test_invalid_values_are_refused_without_repair(self):
self.require_module()
cases = {
"not-a-uuid": (f"INSERT INTO {TABLE} VALUES('not-a-uuid')",),
"upper-uuid": ((f"INSERT INTO {TABLE} VALUES(?)", (OTHER_ID.upper(),)),),
"dangling": ((f"INSERT INTO {TABLE} VALUES(?)", (OTHER_ID,)),),
}
for name, statements in cases.items():
with self.subTest(name):
path = self.closed_current(f"{name}.db", with_mark=False)
self.raw(path, *statements)
with closing(sqlite3.connect(path)) as raw:
self.assertEqual(autotag_schema_error(raw), INVALID_VALUES)
before = marks(raw)
self.assertEqual(
PromptModel.verify_database_file(path), (False, INVALID_VALUES)
)
PromptModel.reset_schema_cache()
with self.assertRaises(sqlite3.DatabaseError):
self.model(path)
with closing(sqlite3.connect(path)) as raw:
self.assertEqual(marks(raw), before)
def test_altered_definitions_are_refused(self):
self.require_module()
cases = {
"rowid": f"CREATE TABLE {TABLE} (byte_id TEXT PRIMARY KEY "
"REFERENCES media_annotation_bytes(id) ON DELETE CASCADE)",
"restrict": f"CREATE TABLE {TABLE} (byte_id TEXT PRIMARY KEY "
"REFERENCES media_annotation_bytes(id) ON DELETE RESTRICT) WITHOUT ROWID",
"extra column": f"CREATE TABLE {TABLE} (byte_id TEXT PRIMARY KEY "
"REFERENCES media_annotation_bytes(id) ON DELETE CASCADE, engine TEXT) "
"WITHOUT ROWID",
}
for name, ddl in cases.items():
with self.subTest(name):
path = self.closed_current(f"{name}.db", with_mark=False)
self.raw(path, f"DROP TABLE {TABLE}", ddl)
with closing(sqlite3.connect(path)) as raw:
self.assertEqual(autotag_schema_error(raw), INVALID_SCHEMA)
self.assertFalse(PromptModel.verify_database_file(path)[0])
path = self.closed_current("index.db", with_mark=False)
self.raw(path, f"CREATE INDEX idx_extra ON {TABLE}(byte_id)")
with closing(sqlite3.connect(path)) as raw:
self.assertEqual(autotag_schema_error(raw), INVALID_SCHEMA)
def test_deleting_the_byte_record_cascades_to_its_mark(self):
self.require_module()
model = self.model()
with model.get_connection() as conn:
insert_byte(conn)
conn.execute(f"INSERT INTO {TABLE} VALUES(?)", (BYTE_ID,))
conn.execute(f"INSERT OR IGNORE INTO {TABLE} VALUES(?)", (BYTE_ID,))
self.assertEqual(marks(conn), [(BYTE_ID,)])
conn.execute("DELETE FROM media_annotation_files")
self.assertEqual(marks(conn), [])
def test_restore_of_a_pre_change_backup_migrates(self):
self.require_module()
src = self.closed_current("backup.db", with_mark=False)
self.raw(src, f"DROP TABLE {TABLE}")
live = self.model()
live.restore_from_file(src)
with closing(sqlite3.connect(self.path)) as raw:
self.assertEqual(catalog(raw), {("table", TABLE, _compact(DDL))})
self.assertEqual(
raw.execute("SELECT id FROM media_annotation_bytes").fetchall(),
[(BYTE_ID,)],
)
self.assertEqual(
PromptModel.verify_database_file(self.path, require_library_state=True),
(True, "ok"),
)
with closing(sqlite3.connect(src)) as raw: # the source is untouched
self.assertEqual(catalog(raw), set())
def test_install_failure_rolls_back(self):
self.require_module()
src = self.closed_current("copy.db", with_mark=False)
self.raw(src, f"DROP TABLE {TABLE}")
path = str(Path(self.temp.name) / "rollback.db")
shutil.copyfile(src, path)
with closing(sqlite3.connect(path)) as raw:
raw.execute("BEGIN")
raw.execute("CREATE TABLE marker(x)")
# An invalid group already present: nothing created, outer work kept.
raw.execute(f"CREATE TABLE {TABLE}(byte_id TEXT)")
with self.assertRaises(sqlite3.DatabaseError):
migrate_autotag_state(raw)
self.assertEqual(
raw.execute(
f"SELECT sql FROM sqlite_master WHERE name='{TABLE}'"
).fetchone()[0],
f"CREATE TABLE {TABLE}(byte_id TEXT)",
)
self.assertIsNotNone(
raw.execute(
"SELECT 1 FROM sqlite_master WHERE name='marker'"
).fetchone()
)
raw.rollback()
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -468,6 +468,7 @@ class TestRestoreFromFile(BackupTestCase):
def test_restore_old_backup_without_annotations(self):
tables = (
"media_image_autotags",
"media_review_decisions",
"media_review_items",
"media_review_metadata",
+4 -1
View File
@@ -371,7 +371,10 @@ class ReviewSchemaTests(unittest.TestCase):
self.require_module()
for label, dropped in (
("annotations only", TABLES),
("predates annotations", ANNOTATION_TABLES + TABLES),
(
"predates annotations",
("media_image_autotags",) + ANNOTATION_TABLES + TABLES,
),
):
with self.subTest(label):
path = str(Path(self.temp.name) / f"{label.replace(' ', '-')}.db")