Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import asyncio | |
| import gc | |
| import io | |
| import json | |
| import os | |
| import re | |
| import threading | |
| import time | |
| import unicodedata | |
| from collections import defaultdict, deque | |
| from pathlib import Path | |
| from typing import Any, Callable | |
| import pytesseract | |
| import torch | |
| from fastapi import FastAPI, File, HTTPException, Request, UploadFile | |
| from fastapi.responses import FileResponse, JSONResponse, StreamingResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from PIL import Image, ImageEnhance, ImageFile, ImageOps, UnidentifiedImageError | |
| from transformers import ( | |
| AutoModelForSequenceClassification, | |
| AutoTokenizer, | |
| CLIPModel, | |
| CLIPProcessor, | |
| ViTForImageClassification, | |
| ViTImageProcessor, | |
| pipeline, | |
| ) | |
| APP_DIR = Path(__file__).resolve().parent | |
| STATIC_DIR = APP_DIR / "static" | |
| NSFW_MODEL = "Falconsai/nsfw_image_detection" | |
| VIOLENCE_MODEL = "jaranohaal/vit-base-violence-detection" | |
| HATE_MODEL = "openai/clip-vit-base-patch32" | |
| TOXIC_MODEL = "unitary/toxic-bert" | |
| NSFW_THRESHOLD = float(os.getenv("NSFW_THRESHOLD", "0.82")) | |
| VIOLENCE_THRESHOLD = float(os.getenv("VIOLENCE_THRESHOLD", "0.80")) | |
| HATE_THRESHOLD = float(os.getenv("HATE_SYMBOL_THRESHOLD", "0.90")) | |
| HATE_STRONG_THRESHOLD = float(os.getenv("HATE_SYMBOL_STRONG_THRESHOLD", "0.97")) | |
| HATE_MIN_MATCHED_VARIANTS = int(os.getenv("HATE_SYMBOL_MIN_MATCHED_VARIANTS", "2")) | |
| TOXIC_THRESHOLD = float(os.getenv("TOXIC_TEXT_THRESHOLD", "0.78")) | |
| TEXT_BLOCKLIST_THRESHOLD = float(os.getenv("TEXT_BLOCKLIST_THRESHOLD", "0.96")) | |
| MAX_UPLOAD_BYTES = int(float(os.getenv("MAX_UPLOAD_MB", "8")) * 1024 * 1024) | |
| MAX_IMAGE_PIXELS = 24_000_000 | |
| MAX_ANALYSIS_EDGE = int(os.getenv("MAX_ANALYSIS_EDGE", "640")) | |
| ENABLE_SLOW_OCR_FALLBACK = os.getenv("ENABLE_SLOW_OCR_FALLBACK", "0") == "1" | |
| TEXT_BLOCKLIST = { | |
| # French insults / sexual slurs | |
| "pute", | |
| "putain", | |
| "salope", | |
| "connard", | |
| "connasse", | |
| "encule", | |
| "enculer", | |
| "nique", | |
| "fdp", | |
| "ntm", | |
| "ta mere", | |
| "ta mere la pute", | |
| # English profanity / sexual slurs | |
| "fuck", | |
| "fucker", | |
| "fucking", | |
| "shit", | |
| "bitch", | |
| "whore", | |
| "slut", | |
| "cunt", | |
| "dick", | |
| "pussy", | |
| "nigger", | |
| "nigga", | |
| "faggot", | |
| "retard", | |
| # Extremist / hate text | |
| "nazi", | |
| "hitler", | |
| "heil hitler", | |
| "sieg heil", | |
| "swastika", | |
| "white power", | |
| "whitepower", | |
| "1488", | |
| "88", | |
| } | |
| TEXT_COMPACT_BLOCKLIST = { | |
| "".join(ch for ch in term if ch.isalnum()) | |
| for term in TEXT_BLOCKLIST | |
| if len("".join(ch for ch in term if ch.isalnum())) >= 3 | |
| } | |
| HATE_SAFE_PROMPTS = [ | |
| "a harmless ordinary image without hate symbols", | |
| "a normal avatar item or game asset with no extremist content", | |
| "a safe logo or clothing texture", | |
| "a safe video game clothing template", | |
| "a colorful shirt UV template with labeled body parts", | |
| "a blank avatar shirt template with colored rectangles and letters", | |
| "a red and white Axium brand logo", | |
| "a safe geometric brand logo", | |
| ] | |
| HATE_RISK_PROMPTS = [ | |
| "a nazi swastika symbol", | |
| "a nazi flag", | |
| "an image displaying a swastika", | |
| "an image displaying hate symbols", | |
| "an extremist supremacist logo", | |
| "a white supremacist symbol", | |
| "a hateful propaganda symbol", | |
| ] | |
| Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS | |
| ImageFile.LOAD_TRUNCATED_IMAGES = False | |
| app = FastAPI(title="Axium Image Moderation Lab", docs_url=None, redoc_url=None) | |
| app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") | |
| model_lock = threading.Lock() | |
| model_cache_lock = threading.Lock() | |
| rate_lock = threading.Lock() | |
| recent_requests: dict[str, deque[float]] = defaultdict(deque) | |
| model_cache: dict[str, Any] = {} | |
| def env_thresholds_are_valid() -> None: | |
| for name, value in { | |
| "NSFW_THRESHOLD": NSFW_THRESHOLD, | |
| "VIOLENCE_THRESHOLD": VIOLENCE_THRESHOLD, | |
| "HATE_SYMBOL_THRESHOLD": HATE_THRESHOLD, | |
| "HATE_SYMBOL_STRONG_THRESHOLD": HATE_STRONG_THRESHOLD, | |
| "TOXIC_TEXT_THRESHOLD": TOXIC_THRESHOLD, | |
| }.items(): | |
| if not 0.0 < value < 1.0: | |
| raise RuntimeError(f"{name} must be between 0 and 1") | |
| if HATE_MIN_MATCHED_VARIANTS < 1: | |
| raise RuntimeError("HATE_SYMBOL_MIN_MATCHED_VARIANTS must be 1 or higher") | |
| if MAX_UPLOAD_BYTES < 256 * 1024 or MAX_UPLOAD_BYTES > 20 * 1024 * 1024: | |
| raise RuntimeError("MAX_UPLOAD_MB must be between 0.25 and 20") | |
| env_thresholds_are_valid() | |
| async def security_headers(request: Request, call_next: Callable): | |
| response = await call_next(request) | |
| response.headers["X-Content-Type-Options"] = "nosniff" | |
| response.headers["Referrer-Policy"] = "no-referrer" | |
| response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" | |
| response.headers["Content-Security-Policy"] = ( | |
| "default-src 'self'; img-src 'self' blob: data:; " | |
| "style-src 'self'; script-src 'self'; connect-src 'self'; " | |
| "base-uri 'none'; form-action 'self'; " | |
| "frame-ancestors https://huggingface.co https://*.huggingface.co" | |
| ) | |
| if request.url.path.startswith("/api/"): | |
| response.headers["Cache-Control"] = "no-store" | |
| return response | |
| def enforce_rate_limit(request: Request) -> None: | |
| client = request.client.host if request.client else "unknown" | |
| now = time.monotonic() | |
| with rate_lock: | |
| bucket = recent_requests[client] | |
| while bucket and bucket[0] < now - 60: | |
| bucket.popleft() | |
| if len(bucket) >= 8: | |
| raise HTTPException(status_code=429, detail="Too many analyses. Try again in a minute.") | |
| bucket.append(now) | |
| async def read_image(file: UploadFile) -> Image.Image: | |
| if file.content_type not in {"image/jpeg", "image/png", "image/webp"}: | |
| raise HTTPException(status_code=415, detail="Only JPEG, PNG and WebP images are accepted.") | |
| data = await file.read(MAX_UPLOAD_BYTES + 1) | |
| await file.close() | |
| if not data: | |
| raise HTTPException(status_code=400, detail="The selected file is empty.") | |
| if len(data) > MAX_UPLOAD_BYTES: | |
| raise HTTPException(status_code=413, detail="The image is larger than the configured limit.") | |
| try: | |
| with Image.open(io.BytesIO(data)) as probe: | |
| probe.verify() | |
| with Image.open(io.BytesIO(data)) as decoded: | |
| if decoded.format not in {"JPEG", "PNG", "WEBP"}: | |
| raise HTTPException(status_code=415, detail="The decoded image format is not allowed.") | |
| if decoded.width < 32 or decoded.height < 32: | |
| raise HTTPException(status_code=400, detail="The image is too small to analyse.") | |
| if decoded.width * decoded.height > MAX_IMAGE_PIXELS: | |
| raise HTTPException(status_code=400, detail="The image dimensions are too large.") | |
| image = decoded.convert("RGB") | |
| image.thumbnail((MAX_ANALYSIS_EDGE, MAX_ANALYSIS_EDGE), Image.Resampling.LANCZOS) | |
| return image.copy() | |
| except (UnidentifiedImageError, OSError, Image.DecompressionBombError): | |
| raise HTTPException(status_code=400, detail="The file is not a valid, safe image.") | |
| def release_model(*objects: Any) -> None: | |
| for obj in objects: | |
| del obj | |
| gc.collect() | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| def cached_resource(key: str, factory: Callable[[], Any]) -> Any: | |
| with model_cache_lock: | |
| if key not in model_cache: | |
| model_cache[key] = factory() | |
| return model_cache[key] | |
| def normalized_label(label: str) -> str: | |
| return "".join(ch for ch in label.lower() if ch.isalnum() or ch == "_") | |
| def normalized_text(text: str) -> str: | |
| folded = unicodedata.normalize("NFKD", text) | |
| folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) | |
| folded = folded.lower() | |
| folded = folded.translate( | |
| str.maketrans( | |
| { | |
| "@": "a", | |
| "4": "a", | |
| "0": "o", | |
| "1": "i", | |
| "!": "i", | |
| "|": "i", | |
| "3": "e", | |
| "5": "s", | |
| "$": "s", | |
| "7": "t", | |
| "+": "t", | |
| } | |
| ) | |
| ) | |
| folded = re.sub(r"(.)\1{2,}", r"\1\1", folded) | |
| return re.sub(r"\s+", " ", folded).strip() | |
| def compact_text(text: str) -> str: | |
| return re.sub(r"[^a-z0-9]+", "", normalized_text(text)) | |
| def find_blocked_text(text: str) -> str | None: | |
| spaced = f" {normalized_text(text)} " | |
| compact = compact_text(text) | |
| for term in sorted(TEXT_BLOCKLIST, key=len, reverse=True): | |
| norm = normalized_text(term) | |
| compact_term = re.sub(r"[^a-z0-9]+", "", norm) | |
| if " " in norm and f" {norm} " in spaced: | |
| return term | |
| if len(norm) <= 3: | |
| if re.search(rf"(?<![a-z0-9]){re.escape(norm)}(?![a-z0-9])", spaced): | |
| return term | |
| continue | |
| if re.search(rf"(?<![a-z0-9]){re.escape(norm)}(?![a-z0-9])", spaced): | |
| return term | |
| if compact_term in TEXT_COMPACT_BLOCKLIST and compact_term in compact: | |
| return term | |
| return None | |
| def image_variants( | |
| image: Image.Image, | |
| *, | |
| include_crops: bool = False, | |
| include_right_angle_rotations: bool = False, | |
| include_half_turn: bool = True, | |
| ) -> list[Image.Image]: | |
| base = image.convert("RGB") | |
| variants = [base] | |
| if include_half_turn: | |
| variants.append(base.rotate(180, expand=True)) | |
| if include_right_angle_rotations: | |
| variants.extend([base.rotate(90, expand=True), base.rotate(270, expand=True)]) | |
| if include_crops and min(base.size) >= 96: | |
| width, height = base.size | |
| crop_boxes = [ | |
| (0, 0, width // 2, height // 2), | |
| (width // 2, 0, width, height // 2), | |
| (0, height // 2, width // 2, height), | |
| (width // 2, height // 2, width, height), | |
| (width // 5, height // 5, width * 4 // 5, height * 4 // 5), | |
| ] | |
| variants.extend(base.crop(box) for box in crop_boxes) | |
| return variants | |
| def run_nsfw(image: Image.Image) -> dict[str, Any]: | |
| classifier = cached_resource("nsfw_classifier", lambda: pipeline("image-classification", model=NSFW_MODEL, device=-1)) | |
| scores = classifier(image, top_k=None) | |
| risky = max( | |
| (float(row["score"]) for row in scores if "nsfw" in normalized_label(row["label"])), | |
| default=0.0, | |
| ) | |
| return test_result("Adult or NSFW content", risky, NSFW_THRESHOLD, NSFW_MODEL) | |
| def run_violence(image: Image.Image) -> dict[str, Any]: | |
| processor, model = cached_resource( | |
| "violence_model", | |
| lambda: (ViTImageProcessor.from_pretrained(VIOLENCE_MODEL), ViTForImageClassification.from_pretrained(VIOLENCE_MODEL)), | |
| ) | |
| inputs = processor(images=image, return_tensors="pt") | |
| with torch.inference_mode(): | |
| probabilities = model(**inputs).logits.softmax(dim=-1)[0] | |
| risky = 0.0 | |
| found_semantic_label = False | |
| for index, probability in enumerate(probabilities): | |
| label = normalized_label(str(model.config.id2label.get(index, index))) | |
| is_safe = label.startswith("non") or "nonviolence" in label or "safe" in label | |
| if "violence" in label and not is_safe: | |
| found_semantic_label = True | |
| risky = max(risky, float(probability)) | |
| # The upstream Apache-2.0 checkpoint omits semantic id2label values. | |
| # Its binary training order is class 0 non-violent, class 1 violent. | |
| if not found_semantic_label and len(probabilities) == 2: | |
| risky = float(probabilities[1]) | |
| return test_result("Violence", risky, VIOLENCE_THRESHOLD, VIOLENCE_MODEL) | |
| def run_hateful_symbols(image: Image.Image) -> dict[str, Any]: | |
| processor, model = cached_resource( | |
| "hate_clip_model", | |
| lambda: (CLIPProcessor.from_pretrained(HATE_MODEL), CLIPModel.from_pretrained(HATE_MODEL)), | |
| ) | |
| prompts = HATE_SAFE_PROMPTS + HATE_RISK_PROMPTS | |
| risky = 0.0 | |
| best_prompt = "" | |
| matched_variants = 0 | |
| variants = image_variants(image, include_half_turn=True, include_right_angle_rotations=False) | |
| inputs = processor(text=prompts, images=variants, return_tensors="pt", padding=True) | |
| with torch.inference_mode(): | |
| logits = model(**inputs).logits_per_image | |
| safe_logits = logits[:, : len(HATE_SAFE_PROMPTS)].max(dim=1).values | |
| for row_index in range(logits.shape[0]): | |
| variant_risky = 0.0 | |
| for index, prompt in enumerate(prompts[len(HATE_SAFE_PROMPTS) :], start=len(HATE_SAFE_PROMPTS)): | |
| binary = torch.stack((safe_logits[row_index], logits[row_index, index])).softmax(dim=0) | |
| score = float(binary[1]) | |
| variant_risky = max(variant_risky, score) | |
| if score > risky: | |
| risky = score | |
| best_prompt = prompt | |
| if variant_risky >= HATE_THRESHOLD: | |
| matched_variants += 1 | |
| verdict_score = risky | |
| if risky < HATE_STRONG_THRESHOLD and matched_variants < HATE_MIN_MATCHED_VARIANTS: | |
| verdict_score = min(risky, max(0.0, HATE_THRESHOLD - 0.01)) | |
| result = test_result("Hateful-symbol heuristic", verdict_score, HATE_THRESHOLD, HATE_MODEL) | |
| result["raw_score"] = round(risky, 4) | |
| result["matched_variants"] = matched_variants | |
| result["detail"] = f"Closest risky label: {best_prompt or 'none'}." | |
| result["warning"] = "Experimental CLIP heuristic; uncertain cases still require human review." | |
| return result | |
| def run_offensive_text(image: Image.Image) -> dict[str, Any]: | |
| text = extract_ocr_text(image) | |
| blocked = find_blocked_text(text) | |
| if blocked: | |
| result = test_result("Offensive text", TEXT_BLOCKLIST_THRESHOLD, TOXIC_THRESHOLD, "Tesseract OCR + Axium blocklist") | |
| result["detail"] = f'OCR detected blocked text "{blocked}" in: "{text[:180]}{"..." if len(text) > 180 else ""}"' | |
| return result | |
| if not text: | |
| result = test_result("Offensive text", 0.0, TOXIC_THRESHOLD, TOXIC_MODEL) | |
| result["detail"] = "No readable English or French text was found." | |
| return result | |
| classifier = cached_resource( | |
| "toxic_text_classifier", | |
| lambda: pipeline( | |
| "text-classification", | |
| model=AutoModelForSequenceClassification.from_pretrained(TOXIC_MODEL), | |
| tokenizer=AutoTokenizer.from_pretrained(TOXIC_MODEL), | |
| device=-1, | |
| top_k=None, | |
| ), | |
| ) | |
| rows = classifier(text, truncation=True, max_length=512) | |
| if rows and isinstance(rows[0], list): | |
| rows = rows[0] | |
| risky_labels = {"toxic", "severetoxic", "obscene", "threat", "insult", "identityhate"} | |
| matched_scores = [ | |
| float(row["score"]) | |
| for row in rows | |
| if normalized_label(str(row["label"])).replace("_", "") in risky_labels | |
| ] | |
| # Some hosted checkpoints expose generic LABEL_0/LABEL_1 names instead of | |
| # semantic labels. In that case, use the highest model score instead of | |
| # silently returning 0% for every text image. | |
| risky = max(matched_scores, default=max((float(row["score"]) for row in rows), default=0.0)) | |
| result = test_result("Offensive text", risky, TOXIC_THRESHOLD, TOXIC_MODEL) | |
| result["detail"] = f'OCR detected: "{text[:180]}{"..." if len(text) > 180 else ""}"' | |
| return result | |
| def extract_ocr_text(image: Image.Image) -> str: | |
| candidates: list[Image.Image] = [] | |
| variants = image_variants(image, include_crops=False, include_half_turn=True, include_right_angle_rotations=False) | |
| if ENABLE_SLOW_OCR_FALLBACK: | |
| variants = image_variants(image, include_crops=True, include_half_turn=True, include_right_angle_rotations=True) | |
| for variant in variants: | |
| base = variant.convert("RGB") | |
| gray = ImageOps.grayscale(base) | |
| wide = gray.resize((gray.width * 2, gray.height * 2), Image.Resampling.LANCZOS) | |
| contrast = ImageEnhance.Contrast(wide).enhance(2.4) | |
| thresholded = contrast.point(lambda px: 255 if px > 165 else 0) | |
| candidates.extend([gray, contrast, thresholded]) | |
| if ENABLE_SLOW_OCR_FALLBACK: | |
| sharp = ImageEnhance.Sharpness(contrast).enhance(2.0) | |
| inverted = ImageOps.invert(contrast) | |
| candidates.extend([base, sharp, inverted]) | |
| seen: set[str] = set() | |
| collected: list[str] = [] | |
| configs = ("--oem 3 --psm 6",) | |
| if ENABLE_SLOW_OCR_FALLBACK: | |
| configs = ("--oem 3 --psm 6", "--oem 3 --psm 11") | |
| for candidate in candidates: | |
| for config in configs: | |
| try: | |
| text = " ".join(pytesseract.image_to_string(candidate, lang="eng+fra", config=config).split()) | |
| except Exception: | |
| text = "" | |
| if not text: | |
| continue | |
| key = normalized_text(text) | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| collected.append(text) | |
| if find_blocked_text(text): | |
| return text[:2000] | |
| if not collected: | |
| return "" | |
| collected.sort(key=len, reverse=True) | |
| return " | ".join(collected[:6])[:2000] | |
| def test_result(name: str, score: float, threshold: float, model: str) -> dict[str, Any]: | |
| passed = score < threshold | |
| return { | |
| "name": name, | |
| "status": "passed" if passed else "rejected", | |
| "passed": passed, | |
| "score": round(score, 4), | |
| "threshold": threshold, | |
| "model": model, | |
| } | |
| TESTS: tuple[Callable[[Image.Image], dict[str, Any]], ...] = ( | |
| run_nsfw, | |
| run_violence, | |
| run_hateful_symbols, | |
| run_offensive_text, | |
| ) | |
| TEST_NAMES = { | |
| run_nsfw: "Adult or NSFW content", | |
| run_violence: "Violence", | |
| run_hateful_symbols: "Hateful-symbol heuristic", | |
| run_offensive_text: "Offensive text", | |
| } | |
| def test_name(test: Callable[[Image.Image], dict[str, Any]]) -> str: | |
| return TEST_NAMES.get(test, test.__name__.removeprefix("run_").replace("_", " ").title()) | |
| def analyse_sequentially(image: Image.Image) -> dict[str, Any]: | |
| results: list[dict[str, Any]] = [] | |
| with model_lock: | |
| for test in TESTS: | |
| try: | |
| result = test(image) | |
| except Exception as exc: | |
| message = " ".join(str(exc).split())[:300] | |
| result = { | |
| "name": test_name(test), | |
| "status": "error", | |
| "passed": False, | |
| "detail": ( | |
| f"The model could not complete this test: {type(exc).__name__}" | |
| f"{': ' + message if message else ''}" | |
| ), | |
| } | |
| results.append(result) | |
| return {"verdict": "error", "passed": False, "results": results} | |
| gc.collect() | |
| results.append(result) | |
| if not result["passed"]: | |
| return {"verdict": "rejected", "passed": False, "results": results} | |
| return {"verdict": "approved", "passed": True, "results": results} | |
| def ndjson_event(payload: dict[str, Any]) -> str: | |
| return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n" | |
| def analyse_stream_events(image: Image.Image): | |
| results: list[dict[str, Any]] = [] | |
| yield ndjson_event({"type": "progress", "active": None, "results": results}) | |
| with model_lock: | |
| for index, test in enumerate(TESTS): | |
| yield ndjson_event({ | |
| "type": "progress", | |
| "active": index, | |
| "activeName": test_name(test), | |
| "results": results, | |
| }) | |
| try: | |
| result = test(image) | |
| except Exception as exc: | |
| message = " ".join(str(exc).split())[:300] | |
| result = { | |
| "name": test_name(test), | |
| "status": "error", | |
| "passed": False, | |
| "detail": ( | |
| f"The model could not complete this test: {type(exc).__name__}" | |
| f"{': ' + message if message else ''}" | |
| ), | |
| } | |
| results.append(result) | |
| yield ndjson_event({"type": "complete", "report": {"verdict": "error", "passed": False, "results": results}}) | |
| return | |
| gc.collect() | |
| results.append(result) | |
| yield ndjson_event({"type": "progress", "active": None, "results": results}) | |
| if not result["passed"]: | |
| yield ndjson_event({"type": "complete", "report": {"verdict": "rejected", "passed": False, "results": results}}) | |
| return | |
| yield ndjson_event({"type": "complete", "report": {"verdict": "approved", "passed": True, "results": results}}) | |
| async def home() -> FileResponse: | |
| return FileResponse(STATIC_DIR / "index.html") | |
| async def health() -> dict[str, str]: | |
| return {"status": "ok"} | |
| async def analyse(request: Request, image: UploadFile = File(...)) -> JSONResponse: | |
| enforce_rate_limit(request) | |
| decoded = await read_image(image) | |
| report = await asyncio.to_thread(analyse_sequentially, decoded) | |
| return JSONResponse(report) | |
| async def analyse_stream(request: Request, image: UploadFile = File(...)) -> StreamingResponse: | |
| enforce_rate_limit(request) | |
| decoded = await read_image(image) | |
| return StreamingResponse( | |
| analyse_stream_events(decoded), | |
| media_type="application/x-ndjson", | |
| headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}, | |
| ) | |