This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
imagerepo/docs/superpowers/tag-suggestion-system-spec.md
bvandeusen 0f35a0c484 feat: ML tag suggestions, character/fandom integrity, underscores, modal polish
Consolidated merge of feat/tag-suggestions branch. Original 64-commit history
was lost to git-object corruption in a Nextcloud-synced checkout; this single
commit captures the equivalent diff.

Includes:
- pgvector-backed tag suggestion infra (WD14 + SigLIP centroids, ml-worker
  container, Celery tasks, suggestion service, accept/reject endpoints + modal
  UI with green/red chip buttons)
- Character/fandom integrity: title-case normalization on every write path,
  fandom-id backfill, maintenance task + settings button, migrations g26041901
  + h26041901 to canonicalize legacy rows with case-only duplicate merging
- Tag-underscores + modal polish: WD14 name canonicalization at emit + accept
  + add/bulk-add paths, migration i26041901 for legacy-row rename-or-merge
  across character/fandom/NULL kinds, suggestion-accept refresh parity via
  awaited loadTags, persistent chip tint
2026-04-19 19:50:58 -04:00

12 KiB

Tag Suggestion System - Technical Spec

Overview

Add an ML-powered tag suggestion system to the existing gallery application. The system suggests tags for images in two ways:

  1. Direct classification via WD14 for general tags, ratings, popular characters, and series
  2. Embedding similarity for OC/niche character identification based on previously-tagged reference images

The system produces suggestions only - never auto-applies tags. All suggestions require user confirmation. Artists are explicitly out of scope; they are set at import time from source metadata.

Scale & Context

  • ~62,000 existing images (anime/drawn art exclusively)
  • ~300 tagged characters currently, expected to grow
  • Single-user or small-user tagging workflow
  • Target hardware: NVIDIA Tesla P4 GPU (8GB VRAM, compute-only, no NVDEC needed for this workload)
  • Existing app is homegrown; this is an addition, not a rewrite

Out of Scope

  • Artist identification (handled at import from source metadata)
  • Auto-applying tags without user confirmation
  • Training or fine-tuning models (inference only; "learning" happens via reference set growth)
  • Multi-user permission models for suggestions

Models

Tagger: WD14 (SmilingWolf EVA02-Large variant)

  • Model: SmilingWolf/wd-eva02-large-tagger-v3 (or current best available)
  • Purpose: Produces confidence-scored tags across categories (general, character, copyright/series, rating, meta)
  • Runs on GPU via ONNX Runtime with CUDA execution provider
  • Output: tag name + category + confidence score (0.0-1.0)

Embedding Model: SigLIP or OpenCLIP

  • Recommended: google/siglip-so400m-patch14-384 (strong performance, reasonable size)
  • Alternative: laion/CLIP-ViT-H-14-laion2B-s32B-b79K if SigLIP integration is problematic
  • Output: Fixed-dimension embedding vector per image (SigLIP SO400M: 1152-dim)
  • Purpose: Enables similarity search for character matching against reference set

Model Versioning

Store the model identifier (name + version) alongside every output. Schema must support multiple model versions coexisting to allow migration without requiring immediate reprocessing of all 62K images.

Database Schema Changes

Assumes Postgres. If the existing app uses a different database, adapt accordingly - pgvector is strongly preferred for embedding storage at this scale.

New extension

CREATE EXTENSION IF NOT EXISTS vector;

New tables

-- Raw WD14 tagger output, one row per (image, tag, model_version)
CREATE TABLE image_tag_predictions (
    id BIGSERIAL PRIMARY KEY,
    image_id BIGINT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
    tag_name TEXT NOT NULL,
    tag_category TEXT NOT NULL,  -- 'general', 'character', 'copyright', 'rating', 'meta'
    confidence REAL NOT NULL,
    model_version TEXT NOT NULL,  -- e.g. 'wd-eva02-large-tagger-v3'
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_tag_predictions_image ON image_tag_predictions(image_id);
CREATE INDEX idx_tag_predictions_tag ON image_tag_predictions(tag_name);
CREATE INDEX idx_tag_predictions_model ON image_tag_predictions(model_version);

-- Image embeddings for similarity search
CREATE TABLE image_embeddings (
    image_id BIGINT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
    model_version TEXT NOT NULL,
    embedding vector(1152) NOT NULL,  -- adjust dimension to match chosen model
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (image_id, model_version)
);

-- HNSW index for fast nearest-neighbor search
CREATE INDEX idx_embeddings_hnsw ON image_embeddings 
    USING hnsw (embedding vector_cosine_ops);

-- Cached centroid embeddings per character, for fast suggestion queries
CREATE TABLE character_reference_embeddings (
    character_tag TEXT NOT NULL,
    model_version TEXT NOT NULL,
    centroid vector(1152) NOT NULL,
    reference_count INTEGER NOT NULL,  -- how many confirmed images contributed
    computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (character_tag, model_version)
);

-- Tracks user decisions on suggestions for future tuning/reranking
CREATE TABLE suggestion_feedback (
    id BIGSERIAL PRIMARY KEY,
    image_id BIGINT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
    tag_name TEXT NOT NULL,
    suggestion_source TEXT NOT NULL,  -- 'wd14' or 'embedding_similarity'
    confidence REAL NOT NULL,
    decision TEXT NOT NULL,  -- 'accepted' or 'rejected'
    decided_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_feedback_image ON suggestion_feedback(image_id);
CREATE INDEX idx_feedback_tag ON suggestion_feedback(tag_name);

Configuration table (or use existing config system)

Store these as tunable values, not hardcoded:

CREATE TABLE tag_suggestion_config (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL,
    description TEXT
);

-- Initial values (thresholds will need tuning)
INSERT INTO tag_suggestion_config VALUES
    ('threshold_general', '0.35', 'Min confidence for general tag suggestions'),
    ('threshold_character_wd14', '0.75', 'Min confidence for WD14 character tags'),
    ('threshold_copyright', '0.5', 'Min confidence for copyright/series tags'),
    ('threshold_rating', '0.5', 'Min confidence for rating tags'),
    ('threshold_meta', '0.5', 'Min confidence for meta tags'),
    ('threshold_embedding_character', '0.85', 'Min cosine similarity for character via embeddings'),
    ('min_reference_images_per_character', '5', 'Characters with fewer confirmed refs are excluded from embedding suggestions'),
    ('centroid_recompute_delta', '3', 'Recompute character centroid after N new confirmed references');

Inference Pipeline

On upload (per-image, near-realtime)

  1. WD14 inference → insert rows into image_tag_predictions
  2. Embedding model inference → insert row into image_embeddings
  3. Suggestions are generated on-demand at view time (see below); no storage of suggestions themselves needed

Initial backfill (one-time, for existing 62K images)

  • Background job that iterates all images without predictions/embeddings
  • Batched GPU inference (WD14 supports batch sizes; 8-16 is a reasonable starting point on P4)
  • Expect several hours of runtime; run overnight
  • Must be resumable (track last-processed image_id, handle failures gracefully)
  • Progress reporting (log every N images, estimated time remaining)

On confirmed character tag (user accepts a character tag on an image)

  • Increment a counter for that character's reference set
  • If delta since last centroid computation exceeds centroid_recompute_delta, schedule centroid recomputation for that character
  • Recomputation: fetch all embeddings for images confirmed to have this character tag, compute mean (or medoid if desired), update character_reference_embeddings

Suggestion generation (at view time or via background job)

When generating suggestions for an image:

From WD14 predictions:

  • Query image_tag_predictions for this image
  • Filter by per-category confidence thresholds
  • Exclude tags already confirmed on the image

From embedding similarity (for characters only):

  • Fetch this image's embedding
  • Query character_reference_embeddings for all characters with reference_count >= min_reference_images_per_character
  • Compute cosine similarity against each character's centroid
  • Filter by threshold_embedding_character
  • Exclude characters already confirmed on the image
  • Return top K (suggest K=5 initially)

Merging:

  • WD14 character suggestions and embedding-based character suggestions may overlap; dedupe by tag name, prefer whichever has higher normalized confidence
  • Return merged suggestion list grouped by category, sorted by confidence within each category

Background Jobs

Three scheduled/triggered jobs:

1. Backfill job (one-time initially, resumable)

  • Process any image missing WD14 predictions or embeddings for current model versions
  • Can be re-run after model version change to update stored data

2. Centroid recomputation (triggered or nightly)

  • For each character whose reference_count has changed by more than centroid_recompute_delta since last computation, recompute centroid
  • Fast operation; can run frequently

3. Upload processing (per-image, on upload)

  • Runs WD14 + embedding inference on newly uploaded images
  • Should not block the upload response; queue for async processing

API / Integration Points

The spec doesn't prescribe exact endpoints since it depends on the existing app's conventions, but the following operations must be exposed:

  • get_suggestions(image_id) -> list of suggested tags grouped by category - called when viewing an image's tag panel
  • accept_suggestion(image_id, tag_name, source) - records feedback, applies tag to image, triggers centroid recomputation if character
  • reject_suggestion(image_id, tag_name, source) - records feedback only
  • queue_backfill() - admin operation to process any unprocessed images
  • get_character_reference_stats() -> list of (character, reference_count) - admin visibility into which characters have enough references to participate in embedding suggestions

Implementation Notes & Decisions

Why not store suggestions in the database?

Suggestions are a function of (predictions + embeddings + current confirmed tags + current thresholds + current centroids). Any of these can change. Storing suggestions creates a cache invalidation problem. Instead, compute them on read - the operations are cheap once predictions and embeddings exist.

Why store raw WD14 predictions at all?

Two reasons:

  1. Threshold tuning without re-running inference. Change thresholds, suggestions update immediately.
  2. Model upgrade path. When moving from model v3 to v4, old predictions remain queryable until new ones are generated, avoiding a blackout period.

Why centroids instead of querying all reference images?

At 300 characters with potentially thousands of references each, querying per-image embeddings for every character at suggestion time gets expensive. Centroids reduce it to one vector per character. Trade-off: loses some nuance (a character with multiple distinct outfits is represented as one "average" point). If this becomes a problem, options include:

  • Multiple centroids per character via clustering (k-medoids with small k)
  • Fallback to top-K raw embedding search for characters with high intra-class variance

Not worth building upfront. Revisit if suggestion quality suffers.

Why track rejection feedback?

Stored feedback enables:

  • Per-tag or per-category threshold tuning based on acceptance rates
  • Future reranker training if suggestion quality plateaus
  • Diagnosing which characters consistently produce bad suggestions

Low cost to collect; high value if needed later.

Embedding dimension

Adjust vector(1152) in schema to match whatever model is actually selected. SigLIP SO400M is 1152-dim; ViT-H/14 CLIP is 1024-dim; some other variants differ. Mismatch will cause pgvector errors.

GPU considerations

  • P4 is compute-only (no NVDEC needed for this workload - we're doing ML inference, not video decode)
  • WD14 ONNX model fits comfortably in 8GB VRAM
  • SigLIP SO400M inference fits in 8GB with room to spare
  • If co-located with other GPU workloads (e.g., Ollama), use CUDA_VISIBLE_DEVICES to pin to a specific GPU and avoid contention

Tuning Phase (post-implementation)

After initial deployment, expect to spend time tuning:

  1. Per-category confidence thresholds (collect feedback data first, then analyze acceptance rate vs. confidence curves)
  2. Per-character embedding thresholds (some characters are distinctive, some generic-looking)
  3. Minimum reference count before a character enters the suggestion pool
  4. Centroid recomputation cadence

Build thresholds as config values (see tag_suggestion_config table), not constants. Expose them via whatever admin interface the existing app has.

Open Questions for Implementation

  • Which ONNX Runtime integration approach fits the existing app's stack (Python FastAPI, Rust, Node, etc.)?
  • Is there an existing background job system in the app, or does one need to be added?
  • What's the existing app's approach to config management - env vars, DB table, config file?

These should be answered by inspecting the existing codebase before implementation begins.