MpakoImageAIbot/moderation.py

351 lines
7.8 KiB
Python

from __future__ import annotations
import logging
import re
from pathlib import Path
logger = logging.getLogger(__name__)
DIGIT_MAP = str.maketrans(
{
"0": "o",
"1": "i",
"3": "e",
"4": "a",
"5": "s",
"7": "t",
}
)
CSAM_TERMS = {
"child",
"kid",
"minor",
"teen",
"young",
"loli",
"shota",
"school",
"underage",
"ребен",
"ребён",
"дети",
"детск",
"школь",
"малолет",
"подрост",
}
NSFW_TERMS = {
"nude",
"naked",
"sex",
"erotic",
"explicit",
"porn",
"nsfw",
"нюд",
"голый",
"голая",
"голое",
"голые",
"голую",
"голой",
"голых",
"секс",
"эрот",
"порн",
"обнаж",
"раздет",
}
ANIMAL_TERMS = {
"animal",
"dog",
"cat",
"horse",
"beast",
"животн",
"собак",
"кошк",
"лошад",
"звер",
}
PUBLIC_FIGURES = {
"trump",
"biden",
"putin",
"zelensky",
"obama",
"clinton",
"harris",
"macron",
"merkel",
"modi",
"netanyahu",
"kimjongun",
"elonmusk",
"taylorswift",
"beyonce",
"kanyewest",
"ronaldo",
"messi",
"madonna",
"bieber",
"kardashian",
"spears",
"michaeljackson",
"queenelizabeth",
"kingcharles",
"princewilliam",
"katemiddleton",
"трамп",
"байден",
"путин",
"зеленский",
"обама",
"клинтон",
"маск",
}
REAL_PERSON_TERMS = {
"nude",
"naked",
"sex",
"undress",
"deepfake",
"нюд",
"голый",
"голая",
"голое",
"голые",
"голую",
"голой",
"голых",
"секс",
"раздет",
"дипфейк",
}
NONCONSENSUAL_TERMS = {
"undress",
"nude",
"naked",
"раздет",
"нюд",
"голый",
"голая",
"голое",
"голые",
"голую",
"голой",
"голых",
}
PERSON_TERMS = {
"photo",
"real",
"person",
"woman",
"man",
"фото",
"реальн",
"человек",
"женщин",
"мужчин",
"девуш",
"парн",
}
VIOLENCE_TERMS = {
"gore",
"snuff",
"torture",
"dismember",
"blood",
"кров",
"пытк",
"расчлен",
}
EXTREMISM_TERMS = {
"terrorist",
"nazi",
"isis",
"jihad",
"bomb",
"террорист",
"нацист",
"игил",
"джихад",
"бомб",
}
INSTRUCTION_TERMS = {
"how",
"instruction",
"instructions",
"guide",
"tutorial",
"symbol",
"symbols",
"flag",
"uniform",
"manifesto",
"recruit",
"attack",
"make",
"build",
"инструкц",
"символ",
"флаг",
"как",
"сделать",
"создать",
"собрать",
}
OBFUSCATED_PATTERN = re.compile(
r"l[0o1]l[i1]|sh[0o]t[a4]|ch[i1]ld|n[uv]de|undr[e3]ss",
re.IGNORECASE,
)
class PromptBlocked(RuntimeError):
def __init__(self, reason: str) -> None:
super().__init__(f"Промпт заблокирован: {reason}")
self.reason = reason
def _normalize(prompt: str) -> tuple[str, str, set[str]]:
lowered = prompt.lower()
normalized = lowered.translate(DIGIT_MAP)
compact = re.sub(r"[\s_\-]+", "", normalized)
tokens = re.split(r"[\s_\-]+", normalized)
merged: list[str] = []
buffer = ""
for token in tokens:
if not token:
continue
if len(token) == 1:
buffer += token
continue
if buffer:
merged.append(buffer)
buffer = ""
merged.append(token)
if buffer:
merged.append(buffer)
return lowered, compact, set(merged)
def _has(term: str, compact: str, words: set[str]) -> bool:
return term in compact or term in words
def is_prompt_allowed(
prompt: str,
*,
mode: str = "t2i",
has_person: bool = False,
is_admin: bool = False,
extra_public_figures: set[str] | frozenset[str] | None = None,
) -> tuple[bool, str | None]:
"""Проверка промпта. Возвращает (allowed, reason_code)."""
if is_admin:
return True, None
lowered, compact, words = _normalize(prompt)
figures = PUBLIC_FIGURES
if extra_public_figures:
extra = {
re.sub(r"[\s_\-]+", "", name.lower().translate(DIGIT_MAP))
for name in extra_public_figures
if name.strip()
}
figures = PUBLIC_FIGURES | extra
nsfw = any(_has(term, compact, words) for term in NSFW_TERMS)
csam = any(_has(term, compact, words) for term in CSAM_TERMS)
if csam and nsfw:
return False, "csam"
if _has("loli", compact, words) or _has("shota", compact, words):
return False, "csam"
if (
any(_has(term, compact, words) for term in ("sex", "nude", "naked"))
and any(_has(term, compact, words) for term in ANIMAL_TERMS)
):
return False, "bestiality"
if any(name in compact for name in figures) and any(
_has(term, compact, words) for term in REAL_PERSON_TERMS
):
return False, "real_person"
if any(_has(term, compact, words) for term in NONCONSENSUAL_TERMS) and any(
_has(term, compact, words) for term in PERSON_TERMS
):
return False, "nonconsensual_sexualization"
if mode == "i2i" and has_person and any(
_has(term, compact, words) for term in ("nude", "naked", "undress", "sex")
):
return False, "i2i_person_nsfw"
if any(_has(term, compact, words) for term in VIOLENCE_TERMS) and nsfw:
return False, "violence_sexual"
if any(_has(term, compact, words) for term in EXTREMISM_TERMS) and any(
_has(term, compact, words) for term in INSTRUCTION_TERMS
):
return False, "extremism"
if re.search(r"[0-9_\-]", lowered) and OBFUSCATED_PATTERN.search(compact):
return False, "obfuscated_nsfw"
return True, None
def detect_person(path: str | Path) -> bool:
"""Ищет лицо или человека через OpenCV.
Если OpenCV не установлен, консервативно считаем, что человек есть:
NSFW-запросы в i2i будут блокироваться.
"""
try:
import cv2 # type: ignore[import-not-found]
except ImportError:
logger.warning(
"OpenCV не установлен: для i2i считаем, что на фото есть человек"
)
return True
try:
image = cv2.imread(str(path))
if image is None:
return False
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
cascade = cv2.CascadeClassifier(
str(Path(cv2.data.haarcascades) / "haarcascade_frontalface_default.xml")
)
faces = cascade.detectMultiScale(
gray,
scaleFactor=1.1,
minNeighbors=5,
minSize=(24, 24),
)
if len(faces) > 0:
return True
height, width = image.shape[:2]
scale = min(1.0, 640.0 / max(height, width))
if scale < 1.0:
image = cv2.resize(image, (int(width * scale), int(height * scale)))
hog = cv2.HOGDescriptor()
hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
_, weights = hog.detectMultiScale(
image,
winStride=(8, 8),
padding=(8, 8),
scale=1.05,
)
return any(float(weight) > 0.5 for weight in weights)
except Exception:
logger.warning("Ошибка детекции человека в %s", path, exc_info=True)
return False