fix: filter noisy AI tags

Add an editable AI tag removal list and apply it to WD/JoyTag output before tags are stored. Clean existing generated AI tags during database migration while leaving manual user tags untouched.
This commit is contained in:
2026-06-29 11:24:21 +01:00
parent 1685134116
commit e4a63c8bb0
4 changed files with 90 additions and 4 deletions
+45
View File
@@ -0,0 +1,45 @@
/// AI-generated tags that are too broad/noisy to be useful in this gallery.
/// Edit this list to change what the tagger removes. Manual user tags are not
/// affected.
pub const AI_TAG_REMOVAL_LIST: &[&str] = &["1girl", "1boy", "no humans", "2girls", "2boys"];
fn normalize_tag_for_removal(tag: &str) -> String {
tag.trim()
.chars()
.filter(|c| !matches!(c, ' ' | '_' | '-'))
.flat_map(char::to_lowercase)
.collect()
}
pub fn is_removed_ai_tag(tag: &str) -> bool {
let normalized = normalize_tag_for_removal(tag);
AI_TAG_REMOVAL_LIST
.iter()
.any(|blocked| normalize_tag_for_removal(blocked) == normalized)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn removed_ai_tags_match_common_spellings() {
for tag in [
"1girl",
"1 girl",
"1_girl",
"1-girl",
"NO_HUMANS",
"no humans",
] {
assert!(is_removed_ai_tag(tag), "{tag} should be removed");
}
}
#[test]
fn removed_ai_tags_do_not_match_unrelated_tags() {
for tag in ["girl", "boy", "humans", "solo", "landscape"] {
assert!(!is_removed_ai_tag(tag), "{tag} should be kept");
}
}
}
+37 -1
View File
@@ -1,4 +1,4 @@
use crate::vector; use crate::{ai_tag_filter, vector};
use anyhow::Result; use anyhow::Result;
use r2d2::Pool; use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager; use r2d2_sqlite::SqliteConnectionManager;
@@ -378,6 +378,42 @@ pub fn migrate(conn: &Connection) -> Result<()> {
)?; )?;
vector::migrate(conn)?; vector::migrate(conn)?;
remove_filtered_ai_tags(conn)?;
Ok(())
}
fn remove_filtered_ai_tags(conn: &Connection) -> Result<()> {
let mut stmt = conn.prepare("SELECT id, tag FROM image_tags WHERE source = 'ai'")?;
let filtered_ids = stmt
.query_map([], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})?
.filter_map(|row| match row {
Ok((id, tag)) if ai_tag_filter::is_removed_ai_tag(&tag) => Some(Ok(id)),
Ok(_) => None,
Err(error) => Some(Err(error)),
})
.collect::<rusqlite::Result<Vec<_>>>()?;
drop(stmt);
if filtered_ids.is_empty() {
return Ok(());
}
let tx = conn.unchecked_transaction()?;
{
let mut delete_stmt = tx.prepare("DELETE FROM image_tags WHERE id = ?1")?;
for id in &filtered_ids {
delete_stmt.execute([id])?;
}
}
tx.execute("DELETE FROM tag_cloud_cache", [])?;
tx.commit()?;
log::info!(
"Removed {} filtered AI tag(s) from existing library data",
filtered_ids.len()
);
Ok(()) Ok(())
} }
+1
View File
@@ -1,3 +1,4 @@
mod ai_tag_filter;
mod captioner; mod captioner;
mod color; mod color;
mod commands; mod commands;
+7 -3
View File
@@ -1,3 +1,4 @@
use crate::ai_tag_filter;
use anyhow::Result; use anyhow::Result;
use hf_hub::{api::sync::Api, Repo, RepoType}; use hf_hub::{api::sync::Api, Repo, RepoType};
use image::{imageops::FilterType, DynamicImage, ImageReader}; use image::{imageops::FilterType, DynamicImage, ImageReader};
@@ -759,6 +760,7 @@ impl WdTagger {
.filter(|(entry, prob)| { .filter(|(entry, prob)| {
(entry.category == GENERAL_CATEGORY || entry.category == CHARACTER_CATEGORY) (entry.category == GENERAL_CATEGORY || entry.category == CHARACTER_CATEGORY)
&& **prob >= threshold && **prob >= threshold
&& !ai_tag_filter::is_removed_ai_tag(&entry.name)
}) })
.map(|(entry, prob)| TagResult { .map(|(entry, prob)| TagResult {
tag: entry.name.clone(), tag: entry.name.clone(),
@@ -1137,9 +1139,11 @@ fn joytag_tags_from_logits(
.zip(logits.iter()) .zip(logits.iter())
.filter_map(|(name, logit)| { .filter_map(|(name, logit)| {
let confidence = sigmoid(*logit); let confidence = sigmoid(*logit);
(confidence >= threshold).then(|| TagResult { (confidence >= threshold && !ai_tag_filter::is_removed_ai_tag(name)).then(|| {
tag: name.clone(), TagResult {
confidence, tag: name.clone(),
confidence,
}
}) })
}) })
.collect(); .collect();