Spaces:
Paused
Paused
Upload 6 files
Browse files- Age_classifier.py +1202 -0
- app.py +556 -284
- esrgan.py +100 -0
- packages.txt +3 -0
- requirements.txt +3 -1
- security.py +14 -62
Age_classifier.py
ADDED
|
@@ -0,0 +1,1202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Age classifier: blocks prompts and images that depict or describe a minor.
|
| 3 |
+
|
| 4 |
+
Public API:
|
| 5 |
+
check_prompt_no_minors(text)
|
| 6 |
+
check_image_no_minors(pil_image, context="image")
|
| 7 |
+
warmup()
|
| 8 |
+
|
| 9 |
+
Both checks raise gr.Error on a hit. Runs on CPU by default; set GUARD_DEVICE=cuda
|
| 10 |
+
to use the GPU.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import csv
|
| 15 |
+
import datetime
|
| 16 |
+
import threading
|
| 17 |
+
import time
|
| 18 |
+
import gradio as gr
|
| 19 |
+
import torch
|
| 20 |
+
|
| 21 |
+
# CPU by default: the age model is small, and this keeps VRAM free for the
|
| 22 |
+
# main pipelines. Set GUARD_DEVICE=cuda to run it on the GPU instead.
|
| 23 |
+
device = "cuda" if (os.environ.get("GUARD_DEVICE", "cpu") == "cuda" and torch.cuda.is_available()) else "cpu"
|
| 24 |
+
|
| 25 |
+
import re
|
| 26 |
+
|
| 27 |
+
GUARD_ENABLED = os.environ.get("GUARD_ENABLED", "1") != "0"
|
| 28 |
+
AGE_MODEL_ID = os.environ.get("AGE_MODEL_ID", "AST-1320/vit-age-classifier")
|
| 29 |
+
_GUARD_PRESETS = {
|
| 30 |
+
"strict": {
|
| 31 |
+
"teen_confidence": 0.5, "teen_margin": 0.10,
|
| 32 |
+
"hard_bucket_min": 0.30, "hard_mass": 0.40,
|
| 33 |
+
"whole_image": 0.70, "no_face_hard": 0.40,
|
| 34 |
+
},
|
| 35 |
+
"balanced": {
|
| 36 |
+
"teen_confidence": 0.6, "teen_margin": 0.15,
|
| 37 |
+
"hard_bucket_min": 0.40, "hard_mass": 0.50,
|
| 38 |
+
"whole_image": 0.85, "no_face_hard": 0.60,
|
| 39 |
+
},
|
| 40 |
+
"lenient": {
|
| 41 |
+
"teen_confidence": 0.8, "teen_margin": 0.20,
|
| 42 |
+
"hard_bucket_min": 0.50, "hard_mass": 0.60,
|
| 43 |
+
"whole_image": 0.90, "no_face_hard": 0.75,
|
| 44 |
+
},
|
| 45 |
+
}
|
| 46 |
+
GUARD_PRESET = os.environ.get("GUARD_PRESET", "balanced").strip().lower()
|
| 47 |
+
if GUARD_PRESET not in _GUARD_PRESETS:
|
| 48 |
+
print(f"[guard] unknown GUARD_PRESET '{GUARD_PRESET}', using 'balanced'")
|
| 49 |
+
GUARD_PRESET = "balanced"
|
| 50 |
+
_PRESET = _GUARD_PRESETS[GUARD_PRESET]
|
| 51 |
+
# The age model's output buckets are: 0-2, 3-9, 10-19, 20-29, 30-39, ...
|
| 52 |
+
# "0-2" and "3-9" are blocked at ANY confidence — real photos of adults are
|
| 53 |
+
# essentially never misclassified into these buckets.
|
| 54 |
+
# "10-19" is the bucket most prone to false positives on youthful-looking
|
| 55 |
+
# adults (a known weakness of this classifier, especially on East Asian
|
| 56 |
+
# faces), so it only blocks when the model is reasonably confident — a
|
| 57 |
+
# narrow top-1 win over "20-29" is not enough on its own.
|
| 58 |
+
_HARD_MINOR_BUCKETS = {"0-2", "3-9"}
|
| 59 |
+
_SOFT_MINOR_BUCKET = "10-19"
|
| 60 |
+
_ADULT_COMPARISON_BUCKET = "20-29"
|
| 61 |
+
MINOR_TEEN_CONFIDENCE_THRESHOLD = float(os.environ.get("MINOR_TEEN_CONFIDENCE_THRESHOLD", _PRESET["teen_confidence"]))
|
| 62 |
+
# Beyond the raw confidence threshold above, also require the "10-19" bucket
|
| 63 |
+
# to win by at least this much over the adjacent "20-29" bucket. A narrow win
|
| 64 |
+
# (e.g. 0.51 vs 0.49) is a coin flip and shouldn't carry the same weight as a
|
| 65 |
+
# decisive one (e.g. 0.9 vs 0.05) — this cuts down on borderline false
|
| 66 |
+
# positives against youthful-looking adults without touching the threshold.
|
| 67 |
+
MINOR_TEEN_MARGIN = float(os.environ.get("MINOR_TEEN_MARGIN", _PRESET["teen_margin"]))
|
| 68 |
+
|
| 69 |
+
# --- Calibration for GENERATED images only (context="output") ----------------
|
| 70 |
+
# Input photos (any other context) keep the original strict rules. A generated
|
| 71 |
+
# image is different: stylised / AI faces are out-of-distribution for a model
|
| 72 |
+
# trained on real photos, and when no face is found the classifier is asked to
|
| 73 |
+
# score a whole scene, which is the least reliable thing it can be asked.
|
| 74 |
+
# * face crop: a hard bucket (0-2 / 3-9) blocks when it is the top label with
|
| 75 |
+
# score >= HARD_BUCKET_MIN_SCORE, OR when the two hard buckets together hold
|
| 76 |
+
# >= HARD_MASS_THRESHOLD of the probability (catches "spread out" results).
|
| 77 |
+
# * no face found: only block on a clear result (>= WHOLE_IMAGE_THRESHOLD).
|
| 78 |
+
HARD_BUCKET_MIN_SCORE = float(os.environ.get("HARD_BUCKET_MIN_SCORE", _PRESET["hard_bucket_min"]))
|
| 79 |
+
HARD_MASS_THRESHOLD = float(os.environ.get("HARD_MASS_THRESHOLD", _PRESET["hard_mass"]))
|
| 80 |
+
WHOLE_IMAGE_THRESHOLD = float(os.environ.get("WHOLE_IMAGE_THRESHOLD", _PRESET["whole_image"]))
|
| 81 |
+
NO_FACE_HARD_THRESHOLD = float(os.environ.get("NO_FACE_HARD_THRESHOLD", _PRESET["no_face_hard"]))
|
| 82 |
+
|
| 83 |
+
MEDIAPIPE_MODEL_URL = os.environ.get(
|
| 84 |
+
"MEDIAPIPE_FACE_MODEL_URL",
|
| 85 |
+
"https://storage.googleapis.com/mediapipe-models/face_detector/"
|
| 86 |
+
"blaze_face_short_range/float16/1/blaze_face_short_range.tflite",
|
| 87 |
+
)
|
| 88 |
+
MEDIAPIPE_MODEL_PATH = os.environ.get(
|
| 89 |
+
"MEDIAPIPE_FACE_MODEL_PATH",
|
| 90 |
+
os.path.join(os.environ.get("TMPDIR", "/tmp"), "blaze_face_short_range.tflite"),
|
| 91 |
+
)
|
| 92 |
+
MEDIAPIPE_MIN_CONFIDENCE = float(os.environ.get("MEDIAPIPE_MIN_CONFIDENCE", "0.5"))
|
| 93 |
+
|
| 94 |
+
_age_classifier = None
|
| 95 |
+
_face_cascade = None
|
| 96 |
+
_mediapipe_detector = None
|
| 97 |
+
_mediapipe_retry_at = 0.0
|
| 98 |
+
_mediapipe_init_lock = threading.Lock()
|
| 99 |
+
_mediapipe_lock = threading.Lock()
|
| 100 |
+
MEDIAPIPE_RETRY_SECONDS = float(os.environ.get("MEDIAPIPE_RETRY_SECONDS", "60"))
|
| 101 |
+
MEDIAPIPE_TILE_FRACTIONS = tuple(
|
| 102 |
+
float(v) for v in os.environ.get("MEDIAPIPE_TILE_FRACTIONS", "0.5,0.33").split(",") if v.strip()
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _load_age_classifier():
|
| 107 |
+
"""Lazy-load the age-estimation model. Loading fails closed: if it can't
|
| 108 |
+
load, generation is blocked rather than silently skipping the check."""
|
| 109 |
+
global _age_classifier
|
| 110 |
+
if _age_classifier is not None:
|
| 111 |
+
return _age_classifier
|
| 112 |
+
try:
|
| 113 |
+
from transformers import pipeline as hf_pipeline
|
| 114 |
+
_age_classifier = hf_pipeline(
|
| 115 |
+
"image-classification",
|
| 116 |
+
model=AGE_MODEL_ID,
|
| 117 |
+
device=0 if device == "cuda" else -1,
|
| 118 |
+
)
|
| 119 |
+
print(f"Loaded Guard age classifier: {AGE_MODEL_ID}")
|
| 120 |
+
return _age_classifier
|
| 121 |
+
except Exception as exc:
|
| 122 |
+
raise gr.Error(
|
| 123 |
+
"Guard filter failed to load (missing 'transformers' or model "
|
| 124 |
+
"download failed). Generation is blocked until this is fixed."
|
| 125 |
+
) from exc
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _load_face_cascade():
|
| 129 |
+
"""Lazy-load an OpenCV Haar cascade face detector (fallback path)."""
|
| 130 |
+
global _face_cascade
|
| 131 |
+
if _face_cascade is not None:
|
| 132 |
+
return _face_cascade
|
| 133 |
+
try:
|
| 134 |
+
import cv2
|
| 135 |
+
cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
|
| 136 |
+
cascade = cv2.CascadeClassifier(cascade_path)
|
| 137 |
+
_face_cascade = cascade
|
| 138 |
+
return _face_cascade
|
| 139 |
+
except Exception as exc:
|
| 140 |
+
raise gr.Error(
|
| 141 |
+
"Guard filter failed to load (missing 'opencv-python-headless'). "
|
| 142 |
+
"Generation is blocked until this is fixed."
|
| 143 |
+
) from exc
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _load_mediapipe_detector():
|
| 147 |
+
"""Lazy-load Google's mediapipe face detector. This is the primary
|
| 148 |
+
detector — it's meaningfully more robust than Haar cascade on angled,
|
| 149 |
+
partially-occluded, or unevenly-lit faces, which matters here because a
|
| 150 |
+
missed face falls back to scoring the whole image (noisier, worse for
|
| 151 |
+
both false positives and false negatives). Returns None if mediapipe
|
| 152 |
+
isn't installed, so callers can fall back to Haar cascade rather than
|
| 153 |
+
hard-failing — Haar cascade alone is still a valid, working detector."""
|
| 154 |
+
global _mediapipe_detector, _mediapipe_retry_at
|
| 155 |
+
if os.environ.get("MEDIAPIPE_ENABLED", "1") == "0":
|
| 156 |
+
return None
|
| 157 |
+
if _mediapipe_detector is not None:
|
| 158 |
+
return _mediapipe_detector
|
| 159 |
+
if time.time() < _mediapipe_retry_at:
|
| 160 |
+
return None
|
| 161 |
+
with _mediapipe_init_lock:
|
| 162 |
+
if _mediapipe_detector is not None:
|
| 163 |
+
return _mediapipe_detector
|
| 164 |
+
if time.time() < _mediapipe_retry_at:
|
| 165 |
+
return None
|
| 166 |
+
try:
|
| 167 |
+
from mediapipe.tasks import python as mp_python
|
| 168 |
+
from mediapipe.tasks.python import vision as mp_vision
|
| 169 |
+
|
| 170 |
+
if not os.path.exists(MEDIAPIPE_MODEL_PATH):
|
| 171 |
+
import urllib.request
|
| 172 |
+
tmp_path = MEDIAPIPE_MODEL_PATH + ".part"
|
| 173 |
+
urllib.request.urlretrieve(MEDIAPIPE_MODEL_URL, tmp_path)
|
| 174 |
+
os.replace(tmp_path, MEDIAPIPE_MODEL_PATH)
|
| 175 |
+
_mediapipe_detector = mp_vision.FaceDetector.create_from_options(
|
| 176 |
+
mp_vision.FaceDetectorOptions(
|
| 177 |
+
base_options=mp_python.BaseOptions(model_asset_path=MEDIAPIPE_MODEL_PATH),
|
| 178 |
+
running_mode=mp_vision.RunningMode.IMAGE,
|
| 179 |
+
min_detection_confidence=MEDIAPIPE_MIN_CONFIDENCE,
|
| 180 |
+
)
|
| 181 |
+
)
|
| 182 |
+
print("Loaded Guard face detector: mediapipe (Tasks API)")
|
| 183 |
+
except Exception as exc:
|
| 184 |
+
print(f"[guard] mediapipe unavailable ({exc}); using Haar cascade, retrying in {MEDIAPIPE_RETRY_SECONDS:.0f}s")
|
| 185 |
+
_mediapipe_detector = None
|
| 186 |
+
_mediapipe_retry_at = time.time() + MEDIAPIPE_RETRY_SECONDS
|
| 187 |
+
return _mediapipe_detector
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _mp_detect_array(detector, arr):
|
| 191 |
+
import numpy as _np
|
| 192 |
+
import mediapipe as mp
|
| 193 |
+
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=_np.ascontiguousarray(arr))
|
| 194 |
+
with _mediapipe_lock:
|
| 195 |
+
result = detector.detect(mp_image)
|
| 196 |
+
return [
|
| 197 |
+
(int(d.bounding_box.origin_x), int(d.bounding_box.origin_y),
|
| 198 |
+
int(d.bounding_box.width), int(d.bounding_box.height))
|
| 199 |
+
for d in result.detections
|
| 200 |
+
]
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _box_overlap(a, b):
|
| 204 |
+
ax, ay, aw, ah = a
|
| 205 |
+
bx, by, bw, bh = b
|
| 206 |
+
iw = min(ax + aw, bx + bw) - max(ax, bx)
|
| 207 |
+
ih = min(ay + ah, by + bh) - max(ay, by)
|
| 208 |
+
if iw <= 0 or ih <= 0:
|
| 209 |
+
return 0.0
|
| 210 |
+
return (iw * ih) / max(1, min(aw * ah, bw * bh))
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _merge_boxes(boxes, threshold=0.5):
|
| 214 |
+
kept = []
|
| 215 |
+
for box in sorted(boxes, key=lambda b: b[2] * b[3], reverse=True):
|
| 216 |
+
if all(_box_overlap(box, k) < threshold for k in kept):
|
| 217 |
+
kept.append(box)
|
| 218 |
+
return kept
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _detect_faces_mediapipe(rgb_image):
|
| 222 |
+
"""Return (x, y, w, h) boxes using mediapipe, or None if unavailable."""
|
| 223 |
+
detector = _load_mediapipe_detector()
|
| 224 |
+
if detector is None:
|
| 225 |
+
return None
|
| 226 |
+
import numpy as _np
|
| 227 |
+
arr = _np.array(rgb_image.convert("RGB"))
|
| 228 |
+
h_img, w_img = arr.shape[:2]
|
| 229 |
+
|
| 230 |
+
boxes = _mp_detect_array(detector, arr)
|
| 231 |
+
|
| 232 |
+
for frac in MEDIAPIPE_TILE_FRACTIONS:
|
| 233 |
+
if not 0.1 <= frac < 1.0:
|
| 234 |
+
continue
|
| 235 |
+
tw, th = max(64, int(w_img * frac)), max(64, int(h_img * frac))
|
| 236 |
+
if tw >= w_img and th >= h_img:
|
| 237 |
+
continue
|
| 238 |
+
step_x, step_y = max(1, tw // 2), max(1, th // 2)
|
| 239 |
+
xs = list(range(0, max(1, w_img - tw + 1), step_x))
|
| 240 |
+
ys = list(range(0, max(1, h_img - th + 1), step_y))
|
| 241 |
+
if xs[-1] + tw < w_img:
|
| 242 |
+
xs.append(w_img - tw)
|
| 243 |
+
if ys[-1] + th < h_img:
|
| 244 |
+
ys.append(h_img - th)
|
| 245 |
+
for ty in ys:
|
| 246 |
+
for tx in xs:
|
| 247 |
+
tile = arr[ty:ty + th, tx:tx + tw]
|
| 248 |
+
for (x, y, w, h) in _mp_detect_array(detector, tile):
|
| 249 |
+
edge = max(2, int(0.02 * max(tw, th)))
|
| 250 |
+
cut = (
|
| 251 |
+
(tx > 0 and x <= edge)
|
| 252 |
+
or (ty > 0 and y <= edge)
|
| 253 |
+
or (tx + tw < w_img and x + w >= tw - edge)
|
| 254 |
+
or (ty + th < h_img and y + h >= th - edge)
|
| 255 |
+
)
|
| 256 |
+
if not cut:
|
| 257 |
+
boxes.append((x + tx, y + ty, w, h))
|
| 258 |
+
|
| 259 |
+
return _merge_boxes(boxes)
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _detect_faces_haar(rgb_image):
|
| 263 |
+
"""Return (x, y, w, h) boxes using OpenCV Haar cascade (fallback)."""
|
| 264 |
+
import numpy as _np
|
| 265 |
+
cascade = _load_face_cascade()
|
| 266 |
+
gray = _np.array(rgb_image.convert("L"))
|
| 267 |
+
faces = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(40, 40))
|
| 268 |
+
return list(faces)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _extract_faces(pil_image):
|
| 272 |
+
"""Return (crop, is_face_crop) pairs: cropped face regions (with margin), preferring mediapipe and
|
| 273 |
+
falling back to Haar cascade if mediapipe isn't installed or finds
|
| 274 |
+
nothing. If no face is detected by either, fall back to checking the
|
| 275 |
+
whole image so nothing slips through unchecked.
|
| 276 |
+
|
| 277 |
+
NOTE: the whole image is only used as a last-resort fallback, not
|
| 278 |
+
checked in addition to a found face crop. This classifier is trained on
|
| 279 |
+
face-centric crops; feeding it a full scene (background, hands, objects)
|
| 280 |
+
is out of distribution and can produce unreliable — including
|
| 281 |
+
unconditionally-blocking "0-2" hard-bucket — false positives. Once a
|
| 282 |
+
face is found, only the face crop(s) are scored."""
|
| 283 |
+
rgb = pil_image.convert("RGB")
|
| 284 |
+
w_img, h_img = rgb.size
|
| 285 |
+
|
| 286 |
+
boxes = _detect_faces_mediapipe(rgb)
|
| 287 |
+
if not boxes:
|
| 288 |
+
boxes = _detect_faces_haar(rgb)
|
| 289 |
+
|
| 290 |
+
if not boxes:
|
| 291 |
+
return [(rgb, False)]
|
| 292 |
+
|
| 293 |
+
crops = []
|
| 294 |
+
for (x, y, w, h) in boxes:
|
| 295 |
+
pad = int(0.4 * max(w, h))
|
| 296 |
+
x0, y0 = max(0, x - pad), max(0, y - pad)
|
| 297 |
+
x1, y1 = min(w_img, x + w + pad), min(h_img, y + h + pad)
|
| 298 |
+
if x1 > x0 and y1 > y0:
|
| 299 |
+
crops.append(rgb.crop((x0, y0, x1, y1)))
|
| 300 |
+
|
| 301 |
+
return [(c, True) for c in crops] if crops else [(rgb, False)]
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# Shared, explicit user-facing message for every Guard block (image or
|
| 305 |
+
# prompt). Deliberately spells out *why* the request was blocked instead of a
|
| 306 |
+
# vague "content not allowed", so a user who is confused (like in the
|
| 307 |
+
# screenshot report) can immediately understand what triggered it, instead of
|
| 308 |
+
# guessing. Wording only — this does not change what gets blocked or when.
|
| 309 |
+
GUARD_BLOCK_MESSAGE = (
|
| 310 |
+
"Blocked by Guard: this image/prompt appears to depict or "
|
| 311 |
+
"describe someone under 18. This app does not generate, edit, or process "
|
| 312 |
+
"any content involving minors, for any reason or context — this is not "
|
| 313 |
+
"adjustable and applies even if the person is an adult who looks young, "
|
| 314 |
+
"a school uniform or similar styling is involved, or the intent is "
|
| 315 |
+
"non-sexual. If you believe this was flagged in error, try a different "
|
| 316 |
+
"photo/prompt that doesn't resemble a minor. If the match was a false "
|
| 317 |
+
"alarm (a name, a brand, or the age of an object rather than a person), "
|
| 318 |
+
"just rephrase that part and try again."
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
GUARD_NO_FACE_MESSAGE = (
|
| 323 |
+
"Blocked by Guard: no clear face was detected in this image, and the image "
|
| 324 |
+
"as a whole looked like it may involve someone under 18. If this is a photo "
|
| 325 |
+
"of an adult, try one where the face is clearly visible and well lit — the "
|
| 326 |
+
"check is much more accurate that way. This app does not generate, edit, or "
|
| 327 |
+
"process any content involving minors."
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
GUARD_LOG_PATH = os.environ.get("GUARD_LOG_PATH", "guard_blocks.csv")
|
| 331 |
+
_GUARD_LOG_FIELDS = [
|
| 332 |
+
"time_utc", "preset", "context", "face", "reason", "top_label", "top_score",
|
| 333 |
+
"hard_mass", "teen_score", "adult_score", "image_size",
|
| 334 |
+
]
|
| 335 |
+
_guard_log_lock = threading.Lock()
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def _write_guard_log(row):
|
| 339 |
+
if not GUARD_LOG_PATH:
|
| 340 |
+
return
|
| 341 |
+
row = {
|
| 342 |
+
"time_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
|
| 343 |
+
"preset": GUARD_PRESET,
|
| 344 |
+
**row,
|
| 345 |
+
}
|
| 346 |
+
try:
|
| 347 |
+
with _guard_log_lock:
|
| 348 |
+
is_new = not os.path.exists(GUARD_LOG_PATH) or os.path.getsize(GUARD_LOG_PATH) == 0
|
| 349 |
+
with open(GUARD_LOG_PATH, "a", newline="", encoding="utf-8") as fh:
|
| 350 |
+
writer = csv.DictWriter(fh, fieldnames=_GUARD_LOG_FIELDS, extrasaction="ignore")
|
| 351 |
+
if is_new:
|
| 352 |
+
writer.writeheader()
|
| 353 |
+
writer.writerow(row)
|
| 354 |
+
except Exception as exc:
|
| 355 |
+
print(f"[guard] could not write block log: {exc}")
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def _block_image(context, is_face, reason, scores, top_label, hard_mass, pil_image):
|
| 359 |
+
width, height = pil_image.size
|
| 360 |
+
_write_guard_log({
|
| 361 |
+
"context": context,
|
| 362 |
+
"face": is_face,
|
| 363 |
+
"reason": reason,
|
| 364 |
+
"top_label": top_label,
|
| 365 |
+
"top_score": round(scores[top_label], 3),
|
| 366 |
+
"hard_mass": round(hard_mass, 3),
|
| 367 |
+
"teen_score": round(scores.get(_SOFT_MINOR_BUCKET, 0.0), 3),
|
| 368 |
+
"adult_score": round(scores.get(_ADULT_COMPARISON_BUCKET, 0.0), 3),
|
| 369 |
+
"image_size": f"{width}x{height}",
|
| 370 |
+
})
|
| 371 |
+
raise gr.Error(GUARD_BLOCK_MESSAGE if is_face else GUARD_NO_FACE_MESSAGE)
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def check_image_no_minors(pil_image, context="image"):
|
| 375 |
+
"""Raise gr.Error if pil_image appears to depict anyone in a minor age
|
| 376 |
+
bracket.
|
| 377 |
+
|
| 378 |
+
context != "output" (e.g. "input", a user-supplied photo): the original
|
| 379 |
+
strict rules. "0-2"/"3-9" block unconditionally; "10-19" blocks only at
|
| 380 |
+
reasonably high confidence, to cut down on false positives against
|
| 381 |
+
youthful-looking adults while still catching clear cases.
|
| 382 |
+
|
| 383 |
+
context == "output" (a generated image): same idea, calibrated for the fact
|
| 384 |
+
that stylised / AI-generated faces are out-of-distribution for this model.
|
| 385 |
+
See HARD_BUCKET_MIN_SCORE / HARD_MASS_THRESHOLD / WHOLE_IMAGE_THRESHOLD.
|
| 386 |
+
"""
|
| 387 |
+
if not GUARD_ENABLED or pil_image is None:
|
| 388 |
+
return
|
| 389 |
+
classifier = _load_age_classifier()
|
| 390 |
+
lenient = (context == "output")
|
| 391 |
+
for face, is_face in _extract_faces(pil_image):
|
| 392 |
+
try:
|
| 393 |
+
preds = classifier(face, top_k=None)
|
| 394 |
+
except TypeError:
|
| 395 |
+
# Some pipeline versions use `top_k` differently; fall back.
|
| 396 |
+
try:
|
| 397 |
+
preds = classifier(face)
|
| 398 |
+
except Exception:
|
| 399 |
+
raise gr.Error(
|
| 400 |
+
"This request was blocked: the image could not be verified as safe."
|
| 401 |
+
)
|
| 402 |
+
except Exception:
|
| 403 |
+
# If the classifier can't score this crop, fail closed.
|
| 404 |
+
raise gr.Error(
|
| 405 |
+
"This request was blocked: the image could not be verified as safe."
|
| 406 |
+
)
|
| 407 |
+
if not preds:
|
| 408 |
+
continue
|
| 409 |
+
|
| 410 |
+
scores = {p["label"].strip(): p["score"] for p in preds}
|
| 411 |
+
top_label = max(scores, key=scores.get)
|
| 412 |
+
hard_mass = sum(scores.get(b, 0.0) for b in _HARD_MINOR_BUCKETS)
|
| 413 |
+
|
| 414 |
+
if lenient and is_face:
|
| 415 |
+
hard_hit = (
|
| 416 |
+
(top_label in _HARD_MINOR_BUCKETS and scores[top_label] >= HARD_BUCKET_MIN_SCORE)
|
| 417 |
+
or hard_mass >= HARD_MASS_THRESHOLD
|
| 418 |
+
)
|
| 419 |
+
elif lenient:
|
| 420 |
+
hard_hit = hard_mass >= WHOLE_IMAGE_THRESHOLD
|
| 421 |
+
elif not is_face:
|
| 422 |
+
hard_hit = hard_mass >= NO_FACE_HARD_THRESHOLD
|
| 423 |
+
else:
|
| 424 |
+
hard_hit = top_label in _HARD_MINOR_BUCKETS
|
| 425 |
+
if hard_hit:
|
| 426 |
+
print(
|
| 427 |
+
f"[guard] BLOCKED {context} image (hard bucket) label={top_label} "
|
| 428 |
+
f"score={scores[top_label]:.3f} hard_mass={hard_mass:.3f} face={is_face}"
|
| 429 |
+
)
|
| 430 |
+
_block_image(context, is_face, "hard_bucket", scores, top_label, hard_mass, pil_image)
|
| 431 |
+
|
| 432 |
+
if top_label == _SOFT_MINOR_BUCKET:
|
| 433 |
+
teen_score = scores[top_label]
|
| 434 |
+
adult_score = scores.get(_ADULT_COMPARISON_BUCKET, 0.0)
|
| 435 |
+
margin = teen_score - adult_score
|
| 436 |
+
threshold = MINOR_TEEN_CONFIDENCE_THRESHOLD
|
| 437 |
+
if lenient and not is_face:
|
| 438 |
+
threshold = max(threshold, WHOLE_IMAGE_THRESHOLD)
|
| 439 |
+
if teen_score >= threshold and margin >= MINOR_TEEN_MARGIN:
|
| 440 |
+
print(
|
| 441 |
+
f"[guard] BLOCKED {context} image (soft bucket) label={top_label} "
|
| 442 |
+
f"score={teen_score:.3f} threshold={threshold} "
|
| 443 |
+
f"margin={margin:.3f} (min {MINOR_TEEN_MARGIN}) face={is_face}"
|
| 444 |
+
)
|
| 445 |
+
_block_image(context, is_face, "soft_bucket", scores, top_label, hard_mass, pil_image)
|
| 446 |
+
else:
|
| 447 |
+
print(
|
| 448 |
+
f"[guard] soft bucket hit but not decisive enough — "
|
| 449 |
+
f"label={top_label} score={teen_score:.3f} margin={margin:.3f} "
|
| 450 |
+
f"(needs score>={threshold} and margin>={MINOR_TEEN_MARGIN})"
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
# Prompt-level filter — blocks prompts that describe a minor. This tool does not
|
| 455 |
+
# generate depictions of minors at all, so the policy itself is unchanged; what
|
| 456 |
+
# changed is HOW words are matched, so that harmless prompts stop colliding:
|
| 457 |
+
# * non-Latin keywords and a small core of English words ("child", "baby",
|
| 458 |
+
# "toddler", ...) use plain substring + compact (space-stripped) matching;
|
| 459 |
+
# * every other Latin-script keyword (Spanish, Vietnamese, Indonesian, ...) is
|
| 460 |
+
# matched at the START of a word, with spaced-out evasion ("b e b e") only
|
| 461 |
+
# accepted when it is a standalone token. Without that, the space-stripped
|
| 462 |
+
# text turned "to be better" into "bebe" and "them below" into "em be".
|
| 463 |
+
# Multilingual by design: prompts aren't only in English.
|
| 464 |
+
_MINOR_KEYWORDS = [
|
| 465 |
+
# --- English ---
|
| 466 |
+
"child", "children", "kid", "kids", "toddler", "infant", "baby", "babies",
|
| 467 |
+
"tween", "preteen", "pre-teen", "teen", "teens", "teenage", "teenager",
|
| 468 |
+
"minor", "underage", "under-age", "schoolgirl", "schoolboy",
|
| 469 |
+
"elementary school", "middle school", "high schooler",
|
| 470 |
+
"little girl", "little boy", "young girl", "young boy",
|
| 471 |
+
"sweet sixteen", "sweet 16",
|
| 472 |
+
|
| 473 |
+
# --- Spanish ---
|
| 474 |
+
"niño", "nina", "niña", "ninos", "niños", "ninas", "niñas", "nino",
|
| 475 |
+
"infante", "bebé", "bebe", "bebés", "bebes", "menor de edad", "menor",
|
| 476 |
+
"adolescente", "adolescentes", "colegiala", "colegial",
|
| 477 |
+
"niña pequeña", "niño pequeño",
|
| 478 |
+
|
| 479 |
+
# --- Portuguese ---
|
| 480 |
+
"criança", "crianca", "crianças", "criancas", "bebê", "bebes",
|
| 481 |
+
"menor de idade", "adolescente", "adolescentes", "colegial", "colegiala",
|
| 482 |
+
"garotinha", "garotinho",
|
| 483 |
+
|
| 484 |
+
# --- French ---
|
| 485 |
+
"enfant", "enfants", "bébé", "bebe", "bébés", "tout-petit",
|
| 486 |
+
"adolescent", "adolescente", "adolescents", "mineur", "mineure",
|
| 487 |
+
"mineurs", "écolière", "ecoliere", "écolier", "ecolier",
|
| 488 |
+
"petite fille", "petit garçon", "petit garcon",
|
| 489 |
+
|
| 490 |
+
# --- German ---
|
| 491 |
+
"kind", "kinder", "kleinkind", "säugling", "saugling", "baby",
|
| 492 |
+
"teenager", "minderjährig", "minderjahrig", "minderjährige",
|
| 493 |
+
"schulmädchen", "schulmadchen", "schuljunge",
|
| 494 |
+
"kleines mädchen", "kleines madchen", "kleiner junge",
|
| 495 |
+
|
| 496 |
+
# --- Italian ---
|
| 497 |
+
"bambino", "bambina", "bambini", "bambine", "neonato", "neonata",
|
| 498 |
+
"adolescente", "adolescenti", "minorenne", "minorenni",
|
| 499 |
+
"scolaretta", "scolaro",
|
| 500 |
+
"bambina piccola", "bambino piccolo",
|
| 501 |
+
|
| 502 |
+
# --- Dutch ---
|
| 503 |
+
"kind", "kinderen", "kleuter", "baby", "peuter",
|
| 504 |
+
"tiener", "tieners", "minderjarig", "minderjarige",
|
| 505 |
+
"schoolmeisje", "schooljongen",
|
| 506 |
+
|
| 507 |
+
# --- Russian ---
|
| 508 |
+
"ребёнок", "ребенок", "дети", "малыш", "малышка", "младенец",
|
| 509 |
+
"подросток", "подростки", "несовершеннолетний", "несовершеннолетняя",
|
| 510 |
+
"школьница", "школьник", "маленькая девочка", "маленький мальчик",
|
| 511 |
+
|
| 512 |
+
# --- Ukrainian ---
|
| 513 |
+
"дитина", "діти", "немовля", "малюк", "підліток",
|
| 514 |
+
"неповнолітній", "неповнолітня", "школярка", "школяр",
|
| 515 |
+
|
| 516 |
+
# --- Polish ---
|
| 517 |
+
"dziecko", "dzieci", "niemowlę", "niemowle", "maluch",
|
| 518 |
+
"nastolatek", "nastolatka", "niepełnoletni", "niepelnoletni",
|
| 519 |
+
"uczennica", "uczeń", "uczen", "mała dziewczynka", "mala dziewczynka",
|
| 520 |
+
"mały chłopiec", "maly chlopiec",
|
| 521 |
+
|
| 522 |
+
# --- Turkish ---
|
| 523 |
+
"çocuk", "cocuk", "çocuklar", "cocuklar", "bebek", "yürüyen çocuk",
|
| 524 |
+
"genç", "genc", "ergen", "reşit olmayan", "resit olmayan",
|
| 525 |
+
"okul kızı", "okul kizi", "okul çocuğu", "küçük kız", "kucuk kiz",
|
| 526 |
+
"küçük erkek", "kucuk erkek",
|
| 527 |
+
|
| 528 |
+
# --- Arabic ---
|
| 529 |
+
"طفل", "طفلة", "أطفال", "اطفال", "رضيع", "رضيعة",
|
| 530 |
+
"مراهق", "مراهقة", "قاصر", "قاصرة",
|
| 531 |
+
"تلميذة", "تلميذ", "فتاة صغيرة", "ولد صغير",
|
| 532 |
+
|
| 533 |
+
# --- Persian/Farsi ---
|
| 534 |
+
"کودک", "بچه", "نوزاد", "نوجوان", "خردسال", "صغیر",
|
| 535 |
+
"دختر بچه", "پسر بچه",
|
| 536 |
+
|
| 537 |
+
# --- Urdu ---
|
| 538 |
+
"بچہ", "بچی", "بچے", "شیر خوار", "نوعمر", "نابالغ",
|
| 539 |
+
"چھوٹی لڑکی", "چھوٹا لڑکا",
|
| 540 |
+
|
| 541 |
+
# --- Hindi ---
|
| 542 |
+
"बच्चा", "बच्ची", "बच्चे", "शिशु", "बालक", "बालिका",
|
| 543 |
+
"किशोर", "किशोरी", "नाबालिग",
|
| 544 |
+
"छोटी लड़की", "छोटा लड़का",
|
| 545 |
+
|
| 546 |
+
# --- Bengali ---
|
| 547 |
+
"শিশু", "বাচ্চা", "শিশুরা", "কিশোর", "কিশোরী", "নাবালক", "নাবালিকা",
|
| 548 |
+
"ছোট মেয়ে", "ছোট ছেলে",
|
| 549 |
+
|
| 550 |
+
# --- Punjabi ---
|
| 551 |
+
"ਬੱਚਾ", "ਬੱਚੀ", "ਬੱਚੇ", "ਨਾਬਾਲਗ",
|
| 552 |
+
|
| 553 |
+
# --- Marathi ---
|
| 554 |
+
"मूल", "मुलगी", "मुलगा", "बालक", "अल्पवयीन",
|
| 555 |
+
|
| 556 |
+
# --- Tamil ---
|
| 557 |
+
"குழந்தை", "சிறுவன்", "சிறுமி", "இளம்பருவத்தினர்", "வயது வராதவர்",
|
| 558 |
+
|
| 559 |
+
# --- Telugu ---
|
| 560 |
+
"పిల్లవాడు", "పిల్ల", "శిశువు", "కౌమారదశ", "మైనర్",
|
| 561 |
+
|
| 562 |
+
# --- Kannada ---
|
| 563 |
+
"ಮಗು", "ಮಕ್ಕಳು", "ಶಿಶು", "ಹದಿಹರೆಯದ", "ಅಪ್ರಾಪ್ತ",
|
| 564 |
+
|
| 565 |
+
# --- Malayalam ---
|
| 566 |
+
"കുട്ടി", "കുട്ടികൾ", "ശിശു", "കൗമാരക്കാരൻ", "പ്രായപൂർത്തിയാകാത്ത",
|
| 567 |
+
|
| 568 |
+
# --- Gujarati ---
|
| 569 |
+
"બાળક", "બાળકો", "શિશુ", "કિશોર", "સગીર",
|
| 570 |
+
|
| 571 |
+
# --- Nepali ---
|
| 572 |
+
"बालक", "बालि��ा", "शिशु", "किशोर", "नाबालिग",
|
| 573 |
+
|
| 574 |
+
# --- Sinhala ---
|
| 575 |
+
"දරුවා", "බිළිඳා", "නව යොවුන්", "අවයස්කරු",
|
| 576 |
+
|
| 577 |
+
# --- Chinese (Simplified & Traditional) ---
|
| 578 |
+
"孩子", "小孩", "儿童", "兒童", "婴儿", "嬰兒", "幼儿", "幼兒",
|
| 579 |
+
"少年", "少女", "青少年", "未成年", "未成年人",
|
| 580 |
+
"女童", "男童", "小女孩", "小男孩", "小学生", "小學生", "中学生", "中學生",
|
| 581 |
+
|
| 582 |
+
# --- Japanese ---
|
| 583 |
+
"子供", "こども", "子ども", "幼児", "赤ちゃん", "赤ん坊",
|
| 584 |
+
"少年", "少女", "十代", "未成年", "未成年者",
|
| 585 |
+
"女子小学生", "男子小学生", "小学生", "中学生", "女子中学生",
|
| 586 |
+
|
| 587 |
+
# --- Korean ---
|
| 588 |
+
"아이", "어린이", "아기", "유아", "영아",
|
| 589 |
+
"청소년", "미성년자", "미성년",
|
| 590 |
+
"여자아이", "남자아이", "초등학생", "여중생", "여고생",
|
| 591 |
+
|
| 592 |
+
# --- Vietnamese ---
|
| 593 |
+
"trẻ em", "tre em", "trẻ con", "tre con", "em bé", "em be",
|
| 594 |
+
"thiếu niên", "thieu nien", "vị thành niên", "vi thanh nien",
|
| 595 |
+
"trẻ vị thành niên", "học sinh", "hoc sinh",
|
| 596 |
+
"bé gái", "be gai", "bé trai", "be trai",
|
| 597 |
+
|
| 598 |
+
# --- Thai ---
|
| 599 |
+
"เด็ก", "เด็กๆ", "ทารก", "เด็กทารก",
|
| 600 |
+
"วัยรุ่น", "ผู้เยาว์", "เด็กหญิง", "เด็กชาย", "นักเรียนหญิง", "นักเรียนชาย",
|
| 601 |
+
|
| 602 |
+
# --- Indonesian / Malay ---
|
| 603 |
+
"anak", "anak-anak", "bayi", "balita",
|
| 604 |
+
"remaja", "di bawah umur", "dibawah umur",
|
| 605 |
+
"murid perempuan", "murid lelaki", "gadis kecil", "anak perempuan kecil",
|
| 606 |
+
"anak lelaki kecil", "budak perempuan", "budak lelaki",
|
| 607 |
+
|
| 608 |
+
# --- Tagalog / Filipino ---
|
| 609 |
+
"bata", "mga bata", "sanggol", "paslit",
|
| 610 |
+
"wala pang sapat na gulang",
|
| 611 |
+
"batang babae", "batang lalaki",
|
| 612 |
+
|
| 613 |
+
# --- Burmese ---
|
| 614 |
+
"ကလေး", "ကလေးများ", "နို့စို့", "လူငယ်", "အသက်မပြည့်သေးသူ",
|
| 615 |
+
|
| 616 |
+
# --- Khmer ---
|
| 617 |
+
"កុមារ", "ក្មេង", "ទារក", "យុវវ័យ", "អនីតិជន",
|
| 618 |
+
|
| 619 |
+
# --- Lao ---
|
| 620 |
+
"ເດັກນ້ອຍ", "ເດັກ", "ໄວລຸ້ນ", "ຜູ້ຍັງບໍ່ບັນລຸນິຕິພາວະ",
|
| 621 |
+
|
| 622 |
+
# --- Hebrew ---
|
| 623 |
+
"ילד", "ילדה", "ילדים", "תינוק", "תינוקת",
|
| 624 |
+
"מתבגר", "מתבגרת", "קטין", "קטינה",
|
| 625 |
+
"ילדה קטנה", "ילד קטן",
|
| 626 |
+
|
| 627 |
+
# --- Greek ---
|
| 628 |
+
"παιδί", "παιδι", "παιδιά", "παιδια", "μωρό", "μωρο", "βρέφος", "βρεφος",
|
| 629 |
+
"έφηβος", "εφηβος", "έφηβη", "εφηβη", "ανήλικος", "ανηλικος",
|
| 630 |
+
"ανήλικη", "ανηλικη", "μικρό κορίτσι", "μικρο κοριτσι",
|
| 631 |
+
"μικρό αγόρι", "μικρο αγορι",
|
| 632 |
+
|
| 633 |
+
# --- Swahili ---
|
| 634 |
+
"mtoto", "watoto", "mtoto mchanga", "kijana", "kijana mdogo",
|
| 635 |
+
"mtoto mdogo wa kike", "mtoto mdogo wa kiume",
|
| 636 |
+
|
| 637 |
+
# --- Hausa ---
|
| 638 |
+
"yaro", "yarinya", "yara", "jariri", "matashi",
|
| 639 |
+
|
| 640 |
+
# --- Amharic ---
|
| 641 |
+
"ልጅ", "ልጆች", "ጨቅላ", "ታዳጊ", "ታዳጊ ወጣት",
|
| 642 |
+
|
| 643 |
+
# --- Yoruba ---
|
| 644 |
+
"ọmọde", "omode", "ọmọ", "omo", "ọmọ ọwọ́", "omo owo",
|
| 645 |
+
|
| 646 |
+
# --- Romanian ---
|
| 647 |
+
"copil", "copilă", "copila", "copii", "bebeluș", "bebelus",
|
| 648 |
+
"adolescent", "adolescentă", "adolescenta", "minor", "minoră", "minora",
|
| 649 |
+
"fetiță", "fetita", "băiețel", "baietel",
|
| 650 |
+
|
| 651 |
+
# --- Hungarian ---
|
| 652 |
+
"gyermek", "gyerek", "gyerekek", "csecsemő", "csecsemo",
|
| 653 |
+
"tizenéves", "tizeneves", "kiskorú", "kiskoru",
|
| 654 |
+
"kislány", "kislany", "kisfiú", "kisfiu",
|
| 655 |
+
|
| 656 |
+
# --- Czech ---
|
| 657 |
+
"dítě", "dite", "děti", "deti", "kojenec", "batole",
|
| 658 |
+
"nezletilý", "nezletily", "nezletilá", "nezletila",
|
| 659 |
+
"školačka", "skolacka", "školák", "skolak",
|
| 660 |
+
|
| 661 |
+
# --- Swedish ---
|
| 662 |
+
"barn", "barnet", "spädbarn", "spadbarn", "tonåring", "tonaring",
|
| 663 |
+
"minderårig", "minderarig", "skolflicka", "skolpojke",
|
| 664 |
+
|
| 665 |
+
# --- Finnish ---
|
| 666 |
+
"lapsi", "lapset", "vauva", "taapero", "teini", "alaikäinen", "alaikainen",
|
| 667 |
+
"koulutyttö", "koulutytto", "koulupoika",
|
| 668 |
+
]
|
| 669 |
+
# "10 year old whisky" / "5 year old oak" are ages of OBJECTS, not people. Skip
|
| 670 |
+
# them only when the noun after "old" is a known non-person noun that is not
|
| 671 |
+
# itself followed by a person noun ("10 year old cat girl" still blocks).
|
| 672 |
+
_OLD_NONHUMAN = (
|
| 673 |
+
r"wine|whisk(?:e)?y|scotch|bourbon|cognac|brandy|cheese|oak|trees?|houses?|homes?|"
|
| 674 |
+
r"buildings?|churche?s?|castles?|barns?|cars?|trucks?|bikes?|boats?|ships?|"
|
| 675 |
+
r"compan(?:y|ies)|brands?|business(?:es)?|bottles?|ruins?|vines?|bonsai|"
|
| 676 |
+
r"manuscripts?|books?"
|
| 677 |
+
)
|
| 678 |
+
_OLD_PERSON = r"girls?|boys?|kids?|child(?:ren)?|maids?|ladies|lady|wom[ae]n|m[ae]n|guys?|females?|males?"
|
| 679 |
+
_NOT_NONHUMAN_OLD = (
|
| 680 |
+
r"(?![\s-]+(?:" + _OLD_NONHUMAN + r")(?![a-z])(?![\s-]+(?:" + _OLD_PERSON + r")(?![a-z])))"
|
| 681 |
+
)
|
| 682 |
+
_MINOR_AGE_PATTERN = re.compile(
|
| 683 |
+
r"\b(?:age[d]?\s*)?([0-9]|1[0-7])\s*[- ]?\s*(?:years?[\s-]?old" + _NOT_NONHUMAN_OLD + r"|yo\b|y\.o\.?)",
|
| 684 |
+
re.IGNORECASE,
|
| 685 |
+
)
|
| 686 |
+
|
| 687 |
+
# --- Multilingual numeric-age detection ------------------------------------
|
| 688 |
+
# The pattern above only ever caught the English construction ("15 years
|
| 689 |
+
# old", "15 yo"). Every other language expresses "age 15" differently
|
| 690 |
+
# ("อายุ 15ปี", "15 años", "15岁", "15세", "בת 15", "15 tuổi", ...), and none
|
| 691 |
+
# of those were covered — a prompt in any non-English language describing a
|
| 692 |
+
# specific under-18 age slipped straight through. This closes that gap
|
| 693 |
+
# generically: a list of "age" prefix/suffix words per language, matched
|
| 694 |
+
# immediately adjacent to a 0-17 number.
|
| 695 |
+
#
|
| 696 |
+
# Suffix words = come AFTER the number ("15 years old", "15ปี", "15岁").
|
| 697 |
+
# Prefix words = come BEFORE the number ("age 15", "อายุ 15", "בת 15").
|
| 698 |
+
_AGE_SUFFIX_WORDS = [
|
| 699 |
+
# Units that can only mean "N years old" for a person: always checked.
|
| 700 |
+
"jahre alt", "jährig", "jährige", "jahrig", "jahrige",
|
| 701 |
+
"jaar oud",
|
| 702 |
+
"yaşında", "yasinda", "yaş",
|
| 703 |
+
"岁", "歲", "歳",
|
| 704 |
+
"살",
|
| 705 |
+
"tuổi", "tuoi",
|
| 706 |
+
"ขวบ",
|
| 707 |
+
"ساله",
|
| 708 |
+
"வயது",
|
| 709 |
+
"വയസ്സ്",
|
| 710 |
+
"taong gulang",
|
| 711 |
+
"éves",
|
| 712 |
+
"årig",
|
| 713 |
+
"vuotias",
|
| 714 |
+
]
|
| 715 |
+
# Units that just mean "year(s)" ("10 ปี" = 10 years, "5 tahun lalu" = 5 years
|
| 716 |
+
# ago, "3 ans", "2 anos"). Matching these blindly flagged "cafe, 10th
|
| 717 |
+
# anniversary 10 ปี". They only count when the prompt is clearly about a person
|
| 718 |
+
# (see _PERSON_CUE_RE), the number is followed by a real word boundary, and the
|
| 719 |
+
# match is not a prefix of a longer word ("5 animals" != "5 ani").
|
| 720 |
+
_AGE_SUFFIX_AMBIGUOUS_WORDS = [
|
| 721 |
+
"años", "año", "anos", "ano", "ans", "anni", "jaar",
|
| 722 |
+
"лет", "года", "год", "років", "рік", "роки",
|
| 723 |
+
"lat", "let", "letá", "leta", "letý", "lety",
|
| 724 |
+
"세", "才", "ปี",
|
| 725 |
+
"سنة", "سنوات", "عاما", "عامًا", "سال",
|
| 726 |
+
"साल", "वर्ष", "বছর", "సంవత్సరాలు", "ఏళ్ళ", "ವರ್ಷ",
|
| 727 |
+
"tahun", "gulang", "שנים", "χρονών", "ετών", "χρόνων",
|
| 728 |
+
"miaka", "ani", "år",
|
| 729 |
+
]
|
| 730 |
+
_AGE_PREFIX_WORDS = [
|
| 731 |
+
# English "age"/"aged"/"age de" are handled by _EN_AGE_PREFIX_RE below
|
| 732 |
+
# (word-boundary aware). Matching them here on the compacted text made
|
| 733 |
+
# "image1" -> "age1" look like "age 1".
|
| 734 |
+
# Thai
|
| 735 |
+
"อายุ",
|
| 736 |
+
# Spanish / French
|
| 737 |
+
"edad de", "edad", "âgé de", "âgée de",
|
| 738 |
+
# German
|
| 739 |
+
"im alter von",
|
| 740 |
+
# Persian / Arabic
|
| 741 |
+
"سن", "بعمر", "في سن", "عمرها", "عمره",
|
| 742 |
+
# Hebrew (also literally "daughter of" / "son of", used for age)
|
| 743 |
+
"בגיל", "בת", "בן",
|
| 744 |
+
# Hindi / Urdu
|
| 745 |
+
"उम्र", "عمر",
|
| 746 |
+
# Indonesian / Malay
|
| 747 |
+
"berusia", "usia",
|
| 748 |
+
# Chinese
|
| 749 |
+
"年龄", "年齡",
|
| 750 |
+
]
|
| 751 |
+
|
| 752 |
+
# Non-Latin numeral systems (Arabic-Indic, Devanagari, Thai, etc.) map onto
|
| 753 |
+
# the same ASCII digits so "๑๕ปี" (Thai digits) is caught the same as
|
| 754 |
+
# "15ปี". Each of these Unicode blocks is 10 consecutive code points for
|
| 755 |
+
# digits 0-9.
|
| 756 |
+
_DIGIT_BLOCK_STARTS = [
|
| 757 |
+
0x0660, # Arabic-Indic
|
| 758 |
+
0x06F0, # Extended Arabic-Indic (Persian/Urdu)
|
| 759 |
+
0x0966, # Devanagari
|
| 760 |
+
0x09E6, # Bengali
|
| 761 |
+
0x0A66, # Gurmukhi
|
| 762 |
+
0x0AE6, # Gujarati
|
| 763 |
+
0x0B66, # Oriya
|
| 764 |
+
0x0BE6, # Tamil
|
| 765 |
+
0x0C66, # Telugu
|
| 766 |
+
0x0CE6, # Kannada
|
| 767 |
+
0x0D66, # Malayalam
|
| 768 |
+
0x0E50, # Thai
|
| 769 |
+
0x0ED0, # Lao
|
| 770 |
+
0x1040, # Myanmar
|
| 771 |
+
0x17E0, # Khmer
|
| 772 |
+
0xFF10, # Fullwidth
|
| 773 |
+
]
|
| 774 |
+
_DIGIT_TRANS = {}
|
| 775 |
+
for _start in _DIGIT_BLOCK_STARTS:
|
| 776 |
+
for _i in range(10):
|
| 777 |
+
_DIGIT_TRANS[_start + _i] = str(_i)
|
| 778 |
+
|
| 779 |
+
# --- Anti-evasion normalization -------------------------------------------
|
| 780 |
+
# Plain "kw in text" substring matching is easy to dodge with spacing tricks
|
| 781 |
+
# ("chil d"), invisible characters, stretched-out letters ("chiiiild"),
|
| 782 |
+
# leetspeak ("ch1ld"), or look-alike letters from other scripts ("сhild"
|
| 783 |
+
# with a Cyrillic с). This normalizes both the prompt and the keyword list
|
| 784 |
+
# into a "compact" form before matching, so those tricks collapse back to
|
| 785 |
+
# the plain word instead of slipping through. This is deliberately
|
| 786 |
+
# aggressive (matching against a version of the whole prompt with all
|
| 787 |
+
# spaces removed) — for this filter, an occasional false positive on an
|
| 788 |
+
# unlucky adjacent word is a far better outcome than a missed evasion.
|
| 789 |
+
import unicodedata
|
| 790 |
+
|
| 791 |
+
# Unicode format characters (zero-width space/joiner/non-joiner, word
|
| 792 |
+
# joiner, BOM, soft hyphen, directional marks, etc.) — invisible on screen,
|
| 793 |
+
# often used to split a blocked word into pieces that don't string-match.
|
| 794 |
+
_INVISIBLE_RE = re.compile(
|
| 795 |
+
"[" + "".join(
|
| 796 |
+
chr(c) for c in range(0x110000)
|
| 797 |
+
if unicodedata.category(chr(c)) == "Cf"
|
| 798 |
+
) + "\u00ad]"
|
| 799 |
+
)
|
| 800 |
+
|
| 801 |
+
# Visible separator/punctuation characters commonly inserted between
|
| 802 |
+
# letters to break up a word ("c-h-i-l-d", "c.h.i.l.d", "c_h_i_l_d").
|
| 803 |
+
_SEPARATOR_RE = re.compile(r"[\s\-_.,*/\\|+~^`'\"!?:;()\[\]{}<>=]+")
|
| 804 |
+
|
| 805 |
+
# Common leetspeak / symbol substitutions for Latin-script text.
|
| 806 |
+
_LEET_MAP = str.maketrans({
|
| 807 |
+
"0": "o", "1": "i", "3": "e", "4": "a", "5": "s",
|
| 808 |
+
"7": "t", "8": "b", "@": "a", "$": "s", "!": "i", "|": "l",
|
| 809 |
+
})
|
| 810 |
+
|
| 811 |
+
# A small set of frequently-abused look-alike letters from other scripts
|
| 812 |
+
# (Cyrillic, Greek) mapped to their Latin visual equivalent, so mixed-script
|
| 813 |
+
# spoofing of Latin keywords ("сhild" with Cyrillic с) still matches. This
|
| 814 |
+
# does not affect matching of the genuine Cyrillic/Greek keywords above,
|
| 815 |
+
# since the same mapping is applied consistently to both the prompt and the
|
| 816 |
+
# keyword list before comparison.
|
| 817 |
+
_HOMOGLYPH_MAP = str.maketrans({
|
| 818 |
+
"а": "a", "е": "e", "о": "o", "р": "p", "с": "c", "у": "y",
|
| 819 |
+
"х": "x", "і": "i", "ѕ": "s", "һ": "h", "ԁ": "d", "𝒌": "k",
|
| 820 |
+
"α": "a", "ο": "o", "ν": "n", "ρ": "p", "ι": "i", "κ": "k",
|
| 821 |
+
})
|
| 822 |
+
|
| 823 |
+
# Collapses runs of 3+ identical characters down to 1 ("chiiiild" ->
|
| 824 |
+
# "child"). Threshold of 3 (not 2) avoids touching legitimate doubled
|
| 825 |
+
# letters like "little" or "school".
|
| 826 |
+
_STRETCH_RE = re.compile(r"(.)\1{2,}")
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
def _normalize_for_age_matching(text):
|
| 830 |
+
"""Like _normalize_for_matching below, but preserves digits as digits
|
| 831 |
+
instead of running them through the leetspeak map — that map turns "1"
|
| 832 |
+
into "i" and "5" into "s", which would silently turn "15" into "is" and
|
| 833 |
+
defeat every numeric-age check. Non-Latin digit scripts (Thai, Arabic,
|
| 834 |
+
Devanagari, ...) are folded to ASCII 0-9 first so localized numerals
|
| 835 |
+
aren't a bypass either."""
|
| 836 |
+
text = unicodedata.normalize("NFKC", text).lower()
|
| 837 |
+
text = _INVISIBLE_RE.sub("", text)
|
| 838 |
+
text = text.translate(_HOMOGLYPH_MAP)
|
| 839 |
+
text = text.translate(_DIGIT_TRANS)
|
| 840 |
+
text = _SEPARATOR_RE.sub("", text)
|
| 841 |
+
text = _STRETCH_RE.sub(r"\1", text)
|
| 842 |
+
return text
|
| 843 |
+
|
| 844 |
+
|
| 845 |
+
def _build_age_regex(words, number_first):
|
| 846 |
+
alt = "|".join(re.escape(_normalize_for_age_matching(w)) for w in words if w)
|
| 847 |
+
number = r"(?:[0-9]|1[0-7])"
|
| 848 |
+
if number_first:
|
| 849 |
+
# number then unit word, e.g. "15ปี", "15años"
|
| 850 |
+
return re.compile(r"(?<!\d)(" + number + r")(?!\d)(?:" + alt + r")")
|
| 851 |
+
else:
|
| 852 |
+
# unit word then number, e.g. "age15", "อายุ15"
|
| 853 |
+
return re.compile(r"(?:" + alt + r")(" + number + r")(?!\d)")
|
| 854 |
+
|
| 855 |
+
|
| 856 |
+
_AGE_SUFFIX_PATTERN = _build_age_regex(_AGE_SUFFIX_WORDS, number_first=True)
|
| 857 |
+
_AGE_PREFIX_PATTERN = _build_age_regex(_AGE_PREFIX_WORDS, number_first=False)
|
| 858 |
+
|
| 859 |
+
|
| 860 |
+
# English "N year(s) old" on separator-stripped text (catches "1 5 y e a r s o l d"),
|
| 861 |
+
# with the same object-age exception as _MINOR_AGE_PATTERN.
|
| 862 |
+
_AGE_EN_COMPACT_PATTERN = re.compile(
|
| 863 |
+
r"(?<!\d)(?:[0-9]|1[0-7])(?!\d)(?:years?|yrs?)old"
|
| 864 |
+
r"(?!(?:" + _OLD_NONHUMAN + r")(?!(?:" + _OLD_PERSON + r")))"
|
| 865 |
+
)
|
| 866 |
+
|
| 867 |
+
|
| 868 |
+
def _normalize_age_keep_spaces(text):
|
| 869 |
+
"""Digit-preserving normalisation that keeps word boundaries."""
|
| 870 |
+
text = unicodedata.normalize("NFKC", text).lower()
|
| 871 |
+
text = _INVISIBLE_RE.sub("", text)
|
| 872 |
+
text = text.translate(_HOMOGLYPH_MAP)
|
| 873 |
+
return text.translate(_DIGIT_TRANS)
|
| 874 |
+
|
| 875 |
+
|
| 876 |
+
_AGE_SUFFIX_AMBIGUOUS_PATTERN = re.compile(
|
| 877 |
+
r"(?<!\d)(?:[0-9]|1[0-7])(?!\d)\s*(?:"
|
| 878 |
+
+ "|".join(
|
| 879 |
+
re.escape(unicodedata.normalize("NFKC", w).lower())
|
| 880 |
+
for w in sorted(_AGE_SUFFIX_AMBIGUOUS_WORDS, key=len, reverse=True)
|
| 881 |
+
)
|
| 882 |
+
+ r")(?![^\W\d_])"
|
| 883 |
+
)
|
| 884 |
+
|
| 885 |
+
_CUE_LATIN = (
|
| 886 |
+
r"girls?|boys?|wom[ae]n|m[ae]n|lady|ladies|guy|guys|females?|males?|persons?|persona|personas|"
|
| 887 |
+
r"pessoa|personne|people|students?|models?|daughter|son|sister|brother|portrait|selfie|"
|
| 888 |
+
r"bikini|swimsuit|lingerie|nude|naked|topless|uniform|wearing|dress|outfit|pose|posing|"
|
| 889 |
+
r"mujer|chica|chico|hombre|joven|jovem|garota|garoto|menina|menino|fille|garçon|garcon|"
|
| 890 |
+
r"femme|homme|mädchen|madchen|frau|mann|junge|ragazza|ragazzo|donna|uomo|perempuan|gadis"
|
| 891 |
+
)
|
| 892 |
+
_CUE_OTHER = (
|
| 893 |
+
"ผู้หญิง|ผู้ชาย|สาว|หนุ่ม|หญิง|ชาย|นักเรียน|น้อง|ลูกสาว|ลูกชาย|"
|
| 894 |
+
"女|男|妹|姐|弟|学生|學生|娘|息子|여자|남자|소녀|소년|학생"
|
| 895 |
+
)
|
| 896 |
+
_PERSON_CUE_RE = re.compile(
|
| 897 |
+
r"(?<![^\W\d_])(?:" + _CUE_LATIN + r")(?![^\W\d_])|" + _CUE_OTHER
|
| 898 |
+
)
|
| 899 |
+
|
| 900 |
+
# English/French "age N" with a real word boundary, run on text that still
|
| 901 |
+
# has its spaces. (?<![a-z0-9]) stops "image1", "page2", "message3",
|
| 902 |
+
# "usage5", "stage4" from being read as "age N".
|
| 903 |
+
_EN_AGE_PREFIX_RE = re.compile(
|
| 904 |
+
r"(?<![a-z0-9])(?:aged?|agee? de)\s*[:=\-]?\s*([0-9]|1[0-7])(?!\d)"
|
| 905 |
+
)
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
def _normalize_for_matching(text):
|
| 909 |
+
"""Return a compact, evasion-resistant form of `text` for keyword
|
| 910 |
+
matching: invisible characters and separators stripped out, leetspeak
|
| 911 |
+
and look-alike letters folded to their plain equivalent, stretched
|
| 912 |
+
letters collapsed. Applied identically to prompts and keywords so
|
| 913 |
+
genuine (non-evasive) matches in any covered language still work."""
|
| 914 |
+
text = unicodedata.normalize("NFKC", text).lower()
|
| 915 |
+
text = _INVISIBLE_RE.sub("", text)
|
| 916 |
+
text = text.translate(_HOMOGLYPH_MAP)
|
| 917 |
+
text = text.translate(_LEET_MAP)
|
| 918 |
+
text = _SEPARATOR_RE.sub("", text)
|
| 919 |
+
text = _STRETCH_RE.sub(r"\1", text)
|
| 920 |
+
return text
|
| 921 |
+
|
| 922 |
+
|
| 923 |
+
# ---------------------------------------------------------------------------
|
| 924 |
+
# Short keywords are matched as WHOLE WORDS, not as substrings.
|
| 925 |
+
#
|
| 926 |
+
# Why: short Latin-script keywords collide with ordinary words when matched as
|
| 927 |
+
# a substring, and even more so on the space-stripped "compact" text, where
|
| 928 |
+
# letters from neighbouring words join up ("see you tomorrow" -> "omo",
|
| 929 |
+
# "kindly" -> "kind", "sixteenth century" -> "teen", "edited" -> "dite",
|
| 930 |
+
# "agency" -> "genc", "did it elegantly" -> "dite", "between" -> "tween").
|
| 931 |
+
# That blocked harmless prompts and gave users no way to understand why.
|
| 932 |
+
#
|
| 933 |
+
# Whole-word mode keeps every real match AND the spacing / separator /
|
| 934 |
+
# leetspeak / look-alike-letter / stretched-letter evasion handling:
|
| 935 |
+
# * the text is folded first (invisible chars, look-alike letters, leetspeak,
|
| 936 |
+
# stretched letters), so "ch1ld", "k1d", "kiiid" still collapse to the word;
|
| 937 |
+
# * separators are allowed BETWEEN the letters of the keyword, so "k.i.d",
|
| 938 |
+
# "k i d", "t-e-e-n" still match;
|
| 939 |
+
# * only the edges must not touch other letters, so "kidney" / "tomorrow"
|
| 940 |
+
# no longer match, while the common glued forms listed in
|
| 941 |
+
# _WORD_SUFFIXES ("teengirl", "kiddo", "minors") still do.
|
| 942 |
+
# Longer keywords, non-Latin keywords and multi-word phrases keep the original
|
| 943 |
+
# (stricter) substring + compact matching.
|
| 944 |
+
# ---------------------------------------------------------------------------
|
| 945 |
+
_KEEP_AS_SUBSTRING = {"child", "baby"}
|
| 946 |
+
# Longer words that also need whole-word matching ("infantry", "barnet").
|
| 947 |
+
# The bare numbers thirteen..seventeen are NOT blocked on their own any more:
|
| 948 |
+
# "fifteen roses", "sixteen candles", "SEVENTEEN (kpop)" were being blocked.
|
| 949 |
+
# They are still caught when they are used as an age: "sixteen years old",
|
| 950 |
+
# "aged sixteen" (_SPELLED_AGE_RE) and "girl, sixteen" (_NUMWORD_NEAR_PERSON_RE).
|
| 951 |
+
_EXTRA_WORD_BOUND = {"infant", "barnet"}
|
| 952 |
+
_WORD_PREFIXES = {
|
| 953 |
+
"kind": "schul|klein|einzel|wunsch|pflege|stief|enkel|adoptiv|waisen|zwillings",
|
| 954 |
+
"kid": "little|lil|big|young|small|tiny|cute|baby|school|sexy|hot",
|
| 955 |
+
"teen": "little|lil|big|young|small|tiny|cute|baby|school|sexy|hot",
|
| 956 |
+
}
|
| 957 |
+
_WORD_SUFFIXES = {
|
| 958 |
+
"kind": "je|chen|lein|es",
|
| 959 |
+
"barn": "en|et|a|barn",
|
| 960 |
+
"kid": "s|do|dos|die|dies|dy|girl|girls|boy|boys",
|
| 961 |
+
"teen": "s|age|ager|agers|girl|girls|boy|boys|ie|ies|y|ish|hood|model|models",
|
| 962 |
+
"tween": "s|age|ager|agers",
|
| 963 |
+
"minor": "s",
|
| 964 |
+
"infant": "s|ile",
|
| 965 |
+
"genc": "ler|lik|ligi|lerin",
|
| 966 |
+
"ergen": "ler|lik|ligi|lerin",
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
|
| 970 |
+
# Short keywords that collide with ordinary English ("kid"->"kidney", "dite"->"edited",
|
| 971 |
+
# "genc"->"agency", "ergen"->"emergency", "omo"->"tomorrow", "bata"->"a bat and").
|
| 972 |
+
# Every other keyword, in every language, keeps the original substring matching, so
|
| 973 |
+
# inflected / compound forms (Spanish "menores", Indonesian "anaknya", Finnish
|
| 974 |
+
# "lapsille", German "Schulkind", Swedish "barnen") are still caught.
|
| 975 |
+
_ENGLISH_COLLIDING = {
|
| 976 |
+
"kid", "kids", "kind", "teen", "teens", "tween", "minor",
|
| 977 |
+
"omo", "omode", "barn", "bata", "dite", "genc", "ergen",
|
| 978 |
+
}
|
| 979 |
+
|
| 980 |
+
|
| 981 |
+
def _is_word_bound(kw):
|
| 982 |
+
if kw in _KEEP_AS_SUBSTRING:
|
| 983 |
+
return False
|
| 984 |
+
return kw in _EXTRA_WORD_BOUND or kw in _ENGLISH_COLLIDING
|
| 985 |
+
|
| 986 |
+
|
| 987 |
+
_WORD_BOUND_KEYWORDS = sorted({kw for kw in _MINOR_KEYWORDS if _is_word_bound(kw)} | _EXTRA_WORD_BOUND)
|
| 988 |
+
_SUBSTRING_KEYWORDS = [kw for kw in _MINOR_KEYWORDS if not _is_word_bound(kw)]
|
| 989 |
+
|
| 990 |
+
|
| 991 |
+
def _word_pattern(kw):
|
| 992 |
+
letters = r"[\W_]*".join(re.escape(ch) for ch in kw)
|
| 993 |
+
suffix = _WORD_SUFFIXES.get(kw)
|
| 994 |
+
tail = r"(?:" + suffix + r")?" if suffix else ""
|
| 995 |
+
prefix = _WORD_PREFIXES.get(kw)
|
| 996 |
+
head = r"(?:" + prefix + r")?" if prefix else ""
|
| 997 |
+
return re.compile(r"(?<![a-z])" + head + letters + tail + r"(?![a-z])")
|
| 998 |
+
|
| 999 |
+
|
| 1000 |
+
_WORD_BOUND_PATTERNS = [(kw, _word_pattern(kw)) for kw in _WORD_BOUND_KEYWORDS]
|
| 1001 |
+
|
| 1002 |
+
# Phrases that are plainly innocent and contain a keyword. They are removed
|
| 1003 |
+
# before matching. Narrow on purpose: "minor" is only excused when it is followed
|
| 1004 |
+
# by an editing word ("minor adjustments"), and "kind" only in ordinary English
|
| 1005 |
+
# phrases ("what kind of", "kind eyes").
|
| 1006 |
+
_INNOCENT_RE = re.compile(
|
| 1007 |
+
r"(?<![a-z])(?:"
|
| 1008 |
+
r"minors?[\s-]+(?:adjust|chang|tweak|detail|edit|fix|touch|retouch|improv|flaw|imperfect|"
|
| 1009 |
+
r"blemish|correct|variation|differenc|refine|modif|wrinkle|scratch|damage|issue|error|artifact|noise|blur)[a-z]*"
|
| 1010 |
+
r"|menorahs?|copilots?|proteins?|collapsing|collapsible|relapsing|elapsing"
|
| 1011 |
+
r"|kinds?[\s-]+(?:of|to|and|hearted)"
|
| 1012 |
+
r"|kind[\s-]+(?:eyes?|face|smile|expression|gaze|look(?:ing)?|soul|gentle|person|people|old|man|woman|lady)"
|
| 1013 |
+
r"|(?:old|red|wooden|farm|hay|big|white|empty|abandoned|rustic|country)[\s-]+barns?"
|
| 1014 |
+
r"|barns?[\s-]+(?:door|doors|owl|swallow|yard|house|dance|loft|wood|style|raising)"
|
| 1015 |
+
r"|(?:what|which|this|that|these|those|some|any|every|each|same|other|another|different|a|one|no|all)[\s-]+kinds?"
|
| 1016 |
+
# colours / products / franchises that only contain a keyword
|
| 1017 |
+
r"|baby[\s-]+(?:blue|pink|powder|breath|oil|yoda|shower)s?"
|
| 1018 |
+
r"|child[\s-]?(?:proof|care|less)"
|
| 1019 |
+
r"|anakin"
|
| 1020 |
+
r"|(?:ice|stone|bronze|iron|space|golden|dark|new|jazz|digital|information|atomic|machine)[\s-]+ages?"
|
| 1021 |
+
# bare "teen" is only excused next to clearly non-person nouns
|
| 1022 |
+
r"|teens?[\s-]+(?:spirit|slang|bedroom|room|decor|furniture|trends?|vogue|beat|choice)"
|
| 1023 |
+
# words in other scripts that merely START with / contain a keyword
|
| 1024 |
+
r"|아이(?:폰|돌|디|템|스|패드|라인|섀도|콘|브|맥)"
|
| 1025 |
+
r"|मूल्य|मूलभूत|मूलतः"
|
| 1026 |
+
r"|పిల్లి"
|
| 1027 |
+
r")(?![a-z])"
|
| 1028 |
+
)
|
| 1029 |
+
|
| 1030 |
+
# Spelled-out ages in English ("sixteen years old", "aged eight", "nine yo").
|
| 1031 |
+
# Digit ages were already covered; this closes the spelled-out version.
|
| 1032 |
+
_NUM_WORDS = "zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen"
|
| 1033 |
+
_TENS_GUARD = "".join(
|
| 1034 |
+
r"(?<!" + t + r"[\s-])" for t in ("twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety")
|
| 1035 |
+
)
|
| 1036 |
+
_SPELLED_AGE_RE = re.compile(
|
| 1037 |
+
r"(?<![a-z])" + _TENS_GUARD + r"(?:" + _NUM_WORDS + r")"
|
| 1038 |
+
r"(?:[\s-]*(?:years?|yrs?)[\s-]*old" + _NOT_NONHUMAN_OLD + r"|[\s-]*y\.?o\b)"
|
| 1039 |
+
r"|(?<![a-z])(?:age|aged)[\s:]*(?:" + _NUM_WORDS + r")(?![a-z])(?![\s-]*(?:hundred|thousand|million))"
|
| 1040 |
+
)
|
| 1041 |
+
|
| 1042 |
+
# "girl, sixteen" / "sixteen girl": a teen number glued to a (singular) person
|
| 1043 |
+
# noun. A following plural ("girl sixteen candles") reads as a count, not an age.
|
| 1044 |
+
_TEEN_NUM_WORDS = "thirteen|fourteen|fifteen|sixteen|seventeen"
|
| 1045 |
+
_PERSON_SINGULAR = r"girl|boy|female|male|student|schoolgirl|schoolboy|woman|man|lady|guy|person|kid"
|
| 1046 |
+
_NUMWORD_NEAR_PERSON_RE = re.compile(
|
| 1047 |
+
r"(?<![a-z])(?:" + _TEEN_NUM_WORDS + r")(?![a-z])[\s,:\-]*(?:" + _PERSON_SINGULAR + r")(?![a-z])"
|
| 1048 |
+
r"|(?<![a-z])(?:" + _PERSON_SINGULAR + r")(?![a-z])[\s,:\-]*(?:" + _TEEN_NUM_WORDS + r")(?![a-z])"
|
| 1049 |
+
r"(?![\s-]+[a-z]+s(?![a-z]))"
|
| 1050 |
+
)
|
| 1051 |
+
|
| 1052 |
+
|
| 1053 |
+
def _fold_keep_spaces(text, leet):
|
| 1054 |
+
"""Evasion-folded text that keeps word boundaries (unlike the compact form)."""
|
| 1055 |
+
text = unicodedata.normalize("NFKC", text).lower()
|
| 1056 |
+
text = _INVISIBLE_RE.sub("", text)
|
| 1057 |
+
text = text.translate(_HOMOGLYPH_MAP)
|
| 1058 |
+
if leet:
|
| 1059 |
+
text = text.translate(_LEET_MAP)
|
| 1060 |
+
return _STRETCH_RE.sub(r"\1", text)
|
| 1061 |
+
|
| 1062 |
+
|
| 1063 |
+
def _is_latin_word(kw):
|
| 1064 |
+
letters = [ch for ch in kw if ch.isalpha()]
|
| 1065 |
+
return bool(letters) and all(unicodedata.name(ch, "").startswith("LATIN") for ch in letters)
|
| 1066 |
+
|
| 1067 |
+
|
| 1068 |
+
# Core English words: keep plain substring + compact (space-stripped) matching.
|
| 1069 |
+
# They are long/unusual enough that stripping spaces does not create collisions.
|
| 1070 |
+
_COMPACT_CORE = {
|
| 1071 |
+
"child", "children", "toddler", "baby", "babies", "preteen", "pre-teen",
|
| 1072 |
+
"teenage", "teenager", "underage", "under-age", "schoolgirl", "schoolboy",
|
| 1073 |
+
"elementary school", "middle school", "high schooler",
|
| 1074 |
+
"little girl", "little boy", "young girl", "young boy",
|
| 1075 |
+
"sweet sixteen", "sweet 16",
|
| 1076 |
+
}
|
| 1077 |
+
|
| 1078 |
+
_PLAIN_KEYWORDS = [] # non-Latin scripts + the core English words above
|
| 1079 |
+
_LATIN_KEYWORDS = [] # every other Latin-script keyword (es, pt, fr, de, vi, id, ...)
|
| 1080 |
+
for _kw in dict.fromkeys(_SUBSTRING_KEYWORDS):
|
| 1081 |
+
if _kw in _COMPACT_CORE or not _is_latin_word(_kw):
|
| 1082 |
+
_PLAIN_KEYWORDS.append(_kw)
|
| 1083 |
+
else:
|
| 1084 |
+
_LATIN_KEYWORDS.append(_kw)
|
| 1085 |
+
|
| 1086 |
+
|
| 1087 |
+
def _latin_pattern(kw):
|
| 1088 |
+
"""Pattern for a Latin-script keyword that must not match across word
|
| 1089 |
+
boundaries by accident.
|
| 1090 |
+
|
| 1091 |
+
* 8+ letters: unbounded, separators allowed between letters (evasion-proof,
|
| 1092 |
+
and long enough that collisions are not a concern).
|
| 1093 |
+
* shorter: must START a word. Letters may be glued ("menores", "anaknya"
|
| 1094 |
+
still match) or spread out as a standalone token ("b e b e"), but the
|
| 1095 |
+
spread-out form also needs a word boundary at the end — so "to be better"
|
| 1096 |
+
no longer reads as "bebe" and "them below" no longer reads as "em be".
|
| 1097 |
+
"""
|
| 1098 |
+
words = [w for w in re.split(r"[\s\-]+", _fold_keep_spaces(kw, False)) if w]
|
| 1099 |
+
letters = [ch for w in words for ch in w]
|
| 1100 |
+
spaced = r"[\W_]*".join(re.escape(ch) for ch in letters)
|
| 1101 |
+
if len(letters) >= 8:
|
| 1102 |
+
return re.compile(spaced)
|
| 1103 |
+
start, end = r"(?<![^\W\d_])", r"(?![^\W\d_])"
|
| 1104 |
+
contiguous = r"[\W_]+".join("".join(re.escape(ch) for ch in w) for w in words)
|
| 1105 |
+
if len(words) == 1:
|
| 1106 |
+
return re.compile(start + "(?:" + contiguous + "|" + spaced + end + ")")
|
| 1107 |
+
return re.compile(start + "(?:" + contiguous + "|" + spaced + ")" + end)
|
| 1108 |
+
|
| 1109 |
+
|
| 1110 |
+
_WORD_BOUND_PATTERNS += [(kw, _latin_pattern(kw)) for kw in _LATIN_KEYWORDS]
|
| 1111 |
+
|
| 1112 |
+
# Precomputed compact form of every plain-matched keyword, built once at import time.
|
| 1113 |
+
_MINOR_KEYWORDS_COMPACT = [
|
| 1114 |
+
(kw, _normalize_for_matching(kw)) for kw in _PLAIN_KEYWORDS
|
| 1115 |
+
]
|
| 1116 |
+
|
| 1117 |
+
|
| 1118 |
+
def check_prompt_no_minors(prompt_text):
|
| 1119 |
+
if not GUARD_ENABLED or not prompt_text:
|
| 1120 |
+
return
|
| 1121 |
+
# Drop plainly innocent phrases first, then match on what is left.
|
| 1122 |
+
text = _INNOCENT_RE.sub(" ", unicodedata.normalize("NFKC", prompt_text).lower())
|
| 1123 |
+
compact_text = _normalize_for_matching(text)
|
| 1124 |
+
|
| 1125 |
+
# 1) Non-Latin keywords and the core English words: substring + compact matching.
|
| 1126 |
+
for kw in _PLAIN_KEYWORDS:
|
| 1127 |
+
if kw in text:
|
| 1128 |
+
print(f"[guard] BLOCKED prompt (keyword match: '{kw}')")
|
| 1129 |
+
raise gr.Error(GUARD_BLOCK_MESSAGE)
|
| 1130 |
+
for kw, compact_kw in _MINOR_KEYWORDS_COMPACT:
|
| 1131 |
+
if compact_kw and compact_kw in compact_text:
|
| 1132 |
+
print(f"[guard] BLOCKED prompt (normalized keyword match: '{kw}')")
|
| 1133 |
+
raise gr.Error(GUARD_BLOCK_MESSAGE)
|
| 1134 |
+
|
| 1135 |
+
# 2) Short English keywords and all other Latin-script keywords: whole-word /
|
| 1136 |
+
# start-of-word matching on evasion-folded text.
|
| 1137 |
+
for leet in (False, True):
|
| 1138 |
+
folded = _fold_keep_spaces(text, leet)
|
| 1139 |
+
for kw, pattern in _WORD_BOUND_PATTERNS:
|
| 1140 |
+
if pattern.search(folded):
|
| 1141 |
+
print(f"[guard] BLOCKED prompt (whole-word keyword match: '{kw}')")
|
| 1142 |
+
raise gr.Error(GUARD_BLOCK_MESSAGE)
|
| 1143 |
+
|
| 1144 |
+
# 3) Ages written in English: spelled out, number words next to a person
|
| 1145 |
+
# noun, and digits ("15 years old", "15yo", "age 15").
|
| 1146 |
+
folded_plain = _fold_keep_spaces(text, False)
|
| 1147 |
+
match = _SPELLED_AGE_RE.search(folded_plain)
|
| 1148 |
+
if match:
|
| 1149 |
+
print(f"[guard] BLOCKED prompt (spelled-out age: '{match.group(0)}')")
|
| 1150 |
+
raise gr.Error(GUARD_BLOCK_MESSAGE)
|
| 1151 |
+
|
| 1152 |
+
match = _NUMWORD_NEAR_PERSON_RE.search(folded_plain)
|
| 1153 |
+
if match:
|
| 1154 |
+
print(f"[guard] BLOCKED prompt (teen number next to person: '{match.group(0)}')")
|
| 1155 |
+
raise gr.Error(GUARD_BLOCK_MESSAGE)
|
| 1156 |
+
|
| 1157 |
+
for candidate in (text, compact_text):
|
| 1158 |
+
match = _MINOR_AGE_PATTERN.search(candidate)
|
| 1159 |
+
if match:
|
| 1160 |
+
print(f"[guard] BLOCKED prompt (age pattern match: '{match.group(0)}')")
|
| 1161 |
+
raise gr.Error(GUARD_BLOCK_MESSAGE)
|
| 1162 |
+
|
| 1163 |
+
# 4) Multilingual numeric-age check (e.g. Thai "อายุ 15ปี", Spanish "15
|
| 1164 |
+
# años", Chinese "15岁") — see _build_age_regex above. Uses its own
|
| 1165 |
+
# digit-preserving normalization, not `compact_text`, because the
|
| 1166 |
+
# leetspeak map used for word matching would corrupt digits.
|
| 1167 |
+
age_compact = _normalize_for_age_matching(text)
|
| 1168 |
+
for pattern in (_AGE_SUFFIX_PATTERN, _AGE_PREFIX_PATTERN, _AGE_EN_COMPACT_PATTERN):
|
| 1169 |
+
match = pattern.search(age_compact)
|
| 1170 |
+
if match:
|
| 1171 |
+
print(f"[guard] BLOCKED prompt (multilingual age match: '{match.group(0)}')")
|
| 1172 |
+
raise gr.Error(GUARD_BLOCK_MESSAGE)
|
| 1173 |
+
|
| 1174 |
+
# Units that only mean "year(s)" count when the prompt is about a person.
|
| 1175 |
+
age_spaced = _normalize_age_keep_spaces(text)
|
| 1176 |
+
if _PERSON_CUE_RE.search(age_spaced):
|
| 1177 |
+
match = _AGE_SUFFIX_AMBIGUOUS_PATTERN.search(age_spaced)
|
| 1178 |
+
if match:
|
| 1179 |
+
print(f"[guard] BLOCKED prompt (multilingual age + person: '{match.group(0)}')")
|
| 1180 |
+
raise gr.Error(GUARD_BLOCK_MESSAGE)
|
| 1181 |
+
|
| 1182 |
+
match = _EN_AGE_PREFIX_RE.search(folded_plain)
|
| 1183 |
+
if match:
|
| 1184 |
+
print(f"[guard] BLOCKED prompt (english age match: '{match.group(0)}')")
|
| 1185 |
+
raise gr.Error(GUARD_BLOCK_MESSAGE)
|
| 1186 |
+
# ============================================================================
|
| 1187 |
+
# END GUARD
|
| 1188 |
+
# ============================================================================
|
| 1189 |
+
|
| 1190 |
+
|
| 1191 |
+
def warmup():
|
| 1192 |
+
"""Optional: call once at app start so the first real request doesn't pay
|
| 1193 |
+
for model download/loading. Safe to call; failures are printed, not raised
|
| 1194 |
+
(the per-request checks still fail closed)."""
|
| 1195 |
+
if not GUARD_ENABLED:
|
| 1196 |
+
return
|
| 1197 |
+
try:
|
| 1198 |
+
_load_age_classifier()
|
| 1199 |
+
if _load_mediapipe_detector() is None:
|
| 1200 |
+
_load_face_cascade()
|
| 1201 |
+
except Exception as exc:
|
| 1202 |
+
print(f"[guard] warmup failed: {exc}")
|
app.py
CHANGED
|
@@ -55,6 +55,11 @@ if not hasattr(_torchao_quantization, "FqnToConfig"):
|
|
| 55 |
pass
|
| 56 |
_torchao_quantization.FqnToConfig = FqnToConfig
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
from huggingface_hub import hf_hub_download
|
| 59 |
from PIL import Image
|
| 60 |
from safetensors import safe_open
|
|
@@ -65,23 +70,24 @@ from qwenimage.pipeline_qwenimage_edit_plus import QwenImageEditPlusPipeline
|
|
| 65 |
from qwenimage.transformer_qwenimage import QwenImageTransformer2DModel
|
| 66 |
from qwenimage.qwen_fa3_processor import QwenDoubleStreamAttnProcessorFA3
|
| 67 |
|
|
|
|
|
|
|
|
|
|
| 68 |
from security import (
|
| 69 |
FloodGuard,
|
| 70 |
check_admin_password,
|
| 71 |
check_admin_tab_visibility,
|
| 72 |
-
check_user_password,
|
| 73 |
clear_all_admin_log,
|
| 74 |
delete_admin_log_entry,
|
| 75 |
-
end_session,
|
| 76 |
is_admin,
|
| 77 |
is_quota_error,
|
| 78 |
-
is_user,
|
| 79 |
load_admin_gallery,
|
| 80 |
load_rate_stats,
|
| 81 |
log_generation,
|
| 82 |
mark_browser_session,
|
| 83 |
rate_acquire,
|
| 84 |
rate_release,
|
|
|
|
| 85 |
save_input_images,
|
| 86 |
show_rate_message,
|
| 87 |
)
|
|
@@ -116,41 +122,12 @@ if torch.cuda.is_available():
|
|
| 116 |
torch.backends.cudnn.allow_tf32 = True
|
| 117 |
|
| 118 |
MODEL_QWEN = "qwen"
|
|
|
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
("1440×2560 (9:16)", 1440, 2560),
|
| 125 |
-
("1600×2560 (10:16)", 1600, 2560),
|
| 126 |
-
("1536×2728 (9:16)", 1536, 2728),
|
| 127 |
-
("1280×720 (16:9)", 1280, 720),
|
| 128 |
-
("1536×864 (16:9)", 1536, 864),
|
| 129 |
-
("1920×1080 (16:9)", 1920, 1080),
|
| 130 |
-
("2560×1440 (16:9)", 2560, 1440),
|
| 131 |
-
("2560×1600 (16:10)", 2560, 1600),
|
| 132 |
-
("2728×1536 (16:9)", 2728, 1536),
|
| 133 |
-
("768×1024 (3:4)", 768, 1024),
|
| 134 |
-
("1080×1440 (3:4)", 1080, 1440),
|
| 135 |
-
("1536×2048 (3:4)", 1536, 2048),
|
| 136 |
-
("1024×1536 (2:3)", 1024, 1536),
|
| 137 |
-
("1440×1800 (4:5)", 1440, 1800),
|
| 138 |
-
("1080×2400 (9:20)", 1080, 2400),
|
| 139 |
-
("1024×768 (4:3)", 1024, 768),
|
| 140 |
-
("1440×1080 (4:3)", 1440, 1080),
|
| 141 |
-
("2048×1536 (4:3)", 2048, 1536),
|
| 142 |
-
("1536×1024 (3:2)", 1536, 1024),
|
| 143 |
-
("1800×1440 (5:4)", 1800, 1440),
|
| 144 |
-
("2560×1080 (21:9)", 2560, 1080),
|
| 145 |
-
]
|
| 146 |
-
PRESET_RESOLUTION_MAP = {label: (w, h) for label, w, h in PRESET_RESOLUTIONS}
|
| 147 |
-
PRESET_TALL = [label for label, w, h in PRESET_RESOLUTIONS if w < h]
|
| 148 |
-
PRESET_WIDE = [label for label, w, h in PRESET_RESOLUTIONS if w > h]
|
| 149 |
-
PRESET_ALL = PRESET_TALL + PRESET_WIDE
|
| 150 |
-
PRESET_DEFAULT = "720×1280 (9:16)"
|
| 151 |
-
AUTO_SCALES = ["1x", "2x", "3x", "4x"]
|
| 152 |
-
AUTO_BASE_PIXELS = 1024 * 1024
|
| 153 |
-
AUTO_MAX_PIXELS = 4194304
|
| 154 |
|
| 155 |
from diffusers.utils import logging as diffusers_logging
|
| 156 |
|
|
@@ -163,6 +140,10 @@ gc.collect()
|
|
| 163 |
|
| 164 |
QWEN_TRANSFORMER_MODE = "fp8_exact"
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
class _FP8ExactLinear(torch.nn.Module):
|
| 168 |
def __init__(self, linear: torch.nn.Linear):
|
|
@@ -196,6 +177,30 @@ def _convert_linears_to_fp8_exact(model) -> int:
|
|
| 196 |
return len(targets)
|
| 197 |
|
| 198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
def _enable_vae_tiling(p, tile=None):
|
| 200 |
vae = getattr(p, "vae", None)
|
| 201 |
if vae is None:
|
|
@@ -218,7 +223,7 @@ def _enable_vae_tiling(p, tile=None):
|
|
| 218 |
print("[vae] tiling applied:", {k: getattr(vae, k, None) for k in ("tile_sample_min_height", "tile_sample_min_width", "tile_sample_stride_height", "tile_sample_stride_width")})
|
| 219 |
|
| 220 |
|
| 221 |
-
QWEN_VAE_TILE = int(os.environ.get("QWEN_VAE_TILE", "
|
| 222 |
|
| 223 |
|
| 224 |
pipe = QwenImageEditPlusPipeline.from_pretrained(
|
|
@@ -248,11 +253,93 @@ else:
|
|
| 248 |
quantize_(pipe.transformer, Float8DynamicActivationFloat8WeightConfig())
|
| 249 |
print("[qwen] FP8 quantization: SUCCESS (transformer)")
|
| 250 |
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
|
| 253 |
pipe.transformer.__class__ = QwenImageTransformer2DModel
|
| 254 |
pipe.transformer.set_attn_processor(QwenDoubleStreamAttnProcessorFA3())
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
gc.collect()
|
| 257 |
|
| 258 |
|
|
@@ -294,33 +381,44 @@ def _round_size(width, height, multiple=8):
|
|
| 294 |
return max(multiple, (int(width) // multiple) * multiple), max(multiple, (int(height) // multiple) * multiple)
|
| 295 |
|
| 296 |
|
| 297 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
src = image_1 if image_1 is not None else image_2
|
| 299 |
if src is None:
|
| 300 |
-
return
|
| 301 |
try:
|
| 302 |
with Image.open(src if isinstance(src, str) else src.name) as im:
|
| 303 |
-
width
|
| 304 |
except Exception:
|
| 305 |
-
return
|
| 306 |
-
factor = _to_int(str(scale_choice).rstrip("x"), 1, 4, 1)
|
| 307 |
-
pixels = min(AUTO_BASE_PIXELS * factor, AUTO_MAX_PIXELS)
|
| 308 |
-
ratio = width / height
|
| 309 |
-
out_w = (pixels * ratio) ** 0.5
|
| 310 |
-
return _round_size(int(out_w), int(out_w / ratio), multiple=16)
|
| 311 |
|
| 312 |
|
| 313 |
-
def _resolve_size(
|
| 314 |
try:
|
| 315 |
-
if
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
if mode == "Auto":
|
| 320 |
-
return _auto_size(auto_scale, image_1, image_2)
|
| 321 |
-
except (TypeError, ValueError, OverflowError):
|
| 322 |
pass
|
| 323 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
|
| 325 |
|
| 326 |
def _load_image(img):
|
|
@@ -360,6 +458,7 @@ def log_vram(label):
|
|
| 360 |
|
| 361 |
|
| 362 |
def _generate_core(
|
|
|
|
| 363 |
image_1,
|
| 364 |
image_2,
|
| 365 |
prompt,
|
|
@@ -369,7 +468,6 @@ def _generate_core(
|
|
| 369 |
num_inference_steps,
|
| 370 |
height,
|
| 371 |
width,
|
| 372 |
-
duplicate_input=True,
|
| 373 |
progress=gr.Progress(track_tqdm=True),
|
| 374 |
):
|
| 375 |
if randomize_seed:
|
|
@@ -385,40 +483,65 @@ def _generate_core(
|
|
| 385 |
except Exception:
|
| 386 |
continue
|
| 387 |
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
pil_images
|
| 391 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
|
| 393 |
res_txt = f"{width}x{height}" if (width and height) else "auto"
|
| 394 |
-
print(
|
|
|
|
|
|
|
| 395 |
|
| 396 |
if torch.cuda.is_available():
|
| 397 |
torch.cuda.reset_peak_memory_stats(device)
|
| 398 |
-
log_vram(f"{
|
| 399 |
|
| 400 |
try:
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
|
| 413 |
except torch.cuda.OutOfMemoryError:
|
| 414 |
_free_vram_cache()
|
| 415 |
raise
|
| 416 |
-
log_vram(f"{
|
| 417 |
|
| 418 |
return {
|
| 419 |
"images": images_pil, "seed": seed, "width": width, "height": height,
|
| 420 |
-
"
|
| 421 |
-
"guidance": true_guidance_scale, "model":
|
| 422 |
}
|
| 423 |
|
| 424 |
|
|
@@ -438,6 +561,7 @@ def _finalize(res, image_1, image_2, prompt, output_format):
|
|
| 438 |
else:
|
| 439 |
img.save(output_path)
|
| 440 |
output_paths.append(output_path)
|
|
|
|
| 441 |
log_generation(
|
| 442 |
path=output_path,
|
| 443 |
prompt=prompt,
|
|
@@ -449,42 +573,147 @@ def _finalize(res, image_1, image_2, prompt, output_format):
|
|
| 449 |
height=res["height"],
|
| 450 |
out_width=img.width,
|
| 451 |
out_height=img.height,
|
| 452 |
-
duplicated=res["duplicated"],
|
| 453 |
inputs=input_paths,
|
| 454 |
)
|
| 455 |
has_output = bool(output_paths)
|
| 456 |
return output_paths, seed, gr.update(visible=has_output)
|
| 457 |
|
| 458 |
|
| 459 |
-
def
|
| 460 |
-
def
|
| 461 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
return spaces.GPU(duration=duration_seconds, size=size)(
|
| 466 |
|
| 467 |
|
| 468 |
-
|
| 469 |
|
| 470 |
|
| 471 |
-
def
|
| 472 |
requested = _to_int(gpu_duration, GPU_DURATION_MIN, GPU_DURATION_MAX, GPU_DURATION_DEFAULT)
|
| 473 |
chosen = next((d for d in _GPU_DURATION_STEPS if d >= requested), _GPU_DURATION_STEPS[-1])
|
| 474 |
size = gpu_size if gpu_size in GPU_SIZE_OPTIONS else GPU_SIZE_DEFAULT
|
| 475 |
-
gpu_fn =
|
| 476 |
|
| 477 |
-
def _call(image_1, image_2, prompt,
|
| 478 |
-
|
| 479 |
-
|
|
|
|
| 480 |
|
| 481 |
return _call
|
| 482 |
|
| 483 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
def _run_limited(request, seed, run):
|
| 485 |
-
if not is_user(request):
|
| 486 |
-
gr.Warning("Please log in first.")
|
| 487 |
-
return gr.update(), seed, gr.update()
|
| 488 |
ok, key, msg = rate_acquire(request)
|
| 489 |
if not ok:
|
| 490 |
show_rate_message(msg)
|
|
@@ -499,7 +728,7 @@ def _run_limited(request, seed, run):
|
|
| 499 |
rate_release(key, refund=refund)
|
| 500 |
|
| 501 |
|
| 502 |
-
def
|
| 503 |
image_1,
|
| 504 |
image_2,
|
| 505 |
prompt,
|
|
@@ -509,36 +738,47 @@ def infer_qwen_only(
|
|
| 509 |
num_inference_steps,
|
| 510 |
gpu_duration,
|
| 511 |
gpu_size,
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
auto_scale,
|
| 517 |
output_format,
|
| 518 |
-
|
| 519 |
progress=gr.Progress(track_tqdm=True),
|
| 520 |
request: gr.Request = None,
|
| 521 |
):
|
|
|
|
|
|
|
|
|
|
| 522 |
if not (isinstance(prompt, str) and prompt.strip()):
|
| 523 |
return gr.update(), seed, gr.update()
|
| 524 |
|
| 525 |
def run():
|
| 526 |
clean_seed, duration, fmt = _sanitize_common(seed, gpu_duration, output_format)
|
| 527 |
-
width, height = _resolve_size(
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
image_2,
|
| 531 |
-
|
| 532 |
-
seed=clean_seed,
|
| 533 |
-
randomize_seed=bool(randomize_seed),
|
| 534 |
true_guidance_scale=_to_float(true_guidance_scale, LIMIT_GUIDANCE_MIN, LIMIT_GUIDANCE_MAX, 1.0),
|
| 535 |
num_inference_steps=int(num_inference_steps),
|
| 536 |
-
height=height,
|
| 537 |
-
width=width,
|
| 538 |
-
output_format=fmt,
|
| 539 |
-
duplicate_input=bool(duplicate_input),
|
| 540 |
progress=progress,
|
| 541 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
|
| 543 |
return _run_limited(request, seed, run)
|
| 544 |
|
|
@@ -550,7 +790,7 @@ def use_output_as_input(output_images):
|
|
| 550 |
|
| 551 |
|
| 552 |
def _sync_result_followups(output_images):
|
| 553 |
-
return gr.update(visible=bool(output_images))
|
| 554 |
|
| 555 |
|
| 556 |
def _clear_all_admin_images(request: gr.Request):
|
|
@@ -583,58 +823,135 @@ css = """
|
|
| 583 |
-webkit-text-fill-color: transparent;
|
| 584 |
color: transparent;
|
| 585 |
animation: ast-title-flash 10s linear infinite;
|
|
|
|
| 586 |
}
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
.preset-grid .wrap {
|
| 590 |
-
display: grid !important;
|
| 591 |
-
grid-template-columns: 1fr 1fr !important;
|
| 592 |
-
gap: 8px !important;
|
| 593 |
}
|
| 594 |
-
#
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
#adv-settings:has(#res-mode input[value="Custom"]:checked) #res-auto-scale,
|
| 599 |
-
#adv-settings:has(#res-mode input[value="Auto"]:checked) #res-preset,
|
| 600 |
-
#adv-settings:has(#res-mode input[value="Auto"]:checked) #res-custom-height,
|
| 601 |
-
#adv-settings:has(#res-mode input[value="Auto"]:checked) #res-custom-width {
|
| 602 |
-
display: none !important;
|
| 603 |
}
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
}
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
margin: 0 !important;
|
| 614 |
-
box-sizing: border-box !important;
|
| 615 |
}
|
| 616 |
-
#
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
}
|
| 621 |
-
#
|
| 622 |
-
|
| 623 |
-
margin: 0 !important;
|
| 624 |
-
box-sizing: border-box !important;
|
| 625 |
-
justify-content: center !important;
|
| 626 |
}
|
| 627 |
-
#
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
}
|
| 632 |
-
#
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 637 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
#gpu-size .wrap {
|
| 639 |
display: grid !important;
|
| 640 |
grid-template-columns: repeat(2, 1fr) !important;
|
|
@@ -661,38 +978,14 @@ css = """
|
|
| 661 |
margin: 0 !important;
|
| 662 |
font-family: inherit !important;
|
| 663 |
font-size: var(--checkbox-label-text-size, var(--text-md, 1rem)) !important;
|
| 664 |
-
font-size: min(var(--checkbox-label-text-size, var(--text-md, 1rem)),
|
| 665 |
white-space: nowrap !important;
|
| 666 |
font-style: normal !important;
|
| 667 |
line-height: 1.5 !important;
|
| 668 |
opacity: 0.7;
|
| 669 |
}
|
| 670 |
-
#res-auto-scale .wrap {
|
| 671 |
-
display: grid !important;
|
| 672 |
-
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
| 673 |
-
gap: 6px !important;
|
| 674 |
-
}
|
| 675 |
-
#res-auto-scale label {
|
| 676 |
-
width: 100% !important;
|
| 677 |
-
min-width: 0 !important;
|
| 678 |
-
margin: 0 !important;
|
| 679 |
-
padding-left: 8px !important;
|
| 680 |
-
padding-right: 8px !important;
|
| 681 |
-
box-sizing: border-box !important;
|
| 682 |
-
justify-content: center !important;
|
| 683 |
-
}
|
| 684 |
"""
|
| 685 |
|
| 686 |
-
_div_a = f'.preset-grid label:has(input[value="{PRESET_WIDE[0]}"])'
|
| 687 |
-
_div_b = f'.preset-grid label:has(input[value="{PRESET_WIDE[1]}"])'
|
| 688 |
-
css += (
|
| 689 |
-
f"{_div_a}, {_div_b} {{ position: relative !important; margin-top: 12px !important; overflow: visible !important; }}\n"
|
| 690 |
-
f"{_div_a}::before, {_div_b}::before {{ content: \"\"; position: absolute; top: -10px; height: 1px; "
|
| 691 |
-
f"background: rgba(128, 128, 128, 0.55); pointer-events: none; }}\n"
|
| 692 |
-
f"{_div_a}::before {{ left: 0; right: -4px; }}\n"
|
| 693 |
-
f"{_div_b}::before {{ left: -4px; right: 0; }}\n"
|
| 694 |
-
)
|
| 695 |
-
|
| 696 |
_theme_dir = os.path.dirname(os.path.abspath(__file__))
|
| 697 |
_theme_pick = os.environ.get("UI_THEME", "")
|
| 698 |
if _theme_pick == "off":
|
|
@@ -709,22 +1002,28 @@ if _theme_name and os.path.isfile(os.path.join(_theme_dir, _theme_name)):
|
|
| 709 |
with gr.Blocks(**({} if _GR6 else {"css": css})) as demo:
|
| 710 |
with gr.Tabs():
|
| 711 |
with gr.Tab("Editor"):
|
| 712 |
-
with gr.
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 718 |
with gr.Row():
|
| 719 |
with gr.Column():
|
| 720 |
with gr.Row():
|
| 721 |
image_1 = gr.Image(label="Image 1", type="filepath", interactive=True)
|
| 722 |
-
|
| 723 |
-
image_2 = gr.Image(label="Image 2 (optional)", type="filepath", interactive=True)
|
| 724 |
-
duplicate_input = gr.Checkbox(
|
| 725 |
-
label="Duplicate image 1",
|
| 726 |
-
value=False,
|
| 727 |
-
)
|
| 728 |
|
| 729 |
prompt = gr.Text(
|
| 730 |
label="Prompt 🪄",
|
|
@@ -732,22 +1031,33 @@ with gr.Blocks(**({} if _GR6 else {"css": css})) as demo:
|
|
| 732 |
placeholder="Enter your prompt here...",
|
| 733 |
)
|
| 734 |
run_button = gr.Button("Edit!", variant="primary", interactive=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
|
| 736 |
with gr.Group():
|
| 737 |
-
|
| 738 |
-
label="
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
gr.Markdown(
|
| 744 |
-
"⚠️ Note: Xlarge is faster but uses 2× quota",
|
| 745 |
-
elem_id="gpu-size-note",
|
| 746 |
)
|
| 747 |
|
| 748 |
with gr.Group():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
output_format = gr.Radio(
|
| 750 |
label="Output Format",
|
|
|
|
| 751 |
choices=["PNG", "JPG", "WebP"],
|
| 752 |
value="PNG",
|
| 753 |
elem_id="output-format",
|
|
@@ -781,40 +1091,21 @@ with gr.Blocks(**({} if _GR6 else {"css": css})) as demo:
|
|
| 781 |
)
|
| 782 |
|
| 783 |
with gr.Group():
|
| 784 |
-
|
| 785 |
-
label="
|
| 786 |
-
choices=["
|
| 787 |
-
value=
|
| 788 |
-
elem_id="
|
| 789 |
-
)
|
| 790 |
-
resolution_preset = gr.Radio(
|
| 791 |
-
label="Resolution",
|
| 792 |
-
choices=PRESET_ALL,
|
| 793 |
-
value=PRESET_DEFAULT,
|
| 794 |
-
elem_classes=["preset-grid"],
|
| 795 |
-
elem_id="res-preset",
|
| 796 |
)
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
)
|
| 801 |
-
custom_width = gr.Slider(
|
| 802 |
-
label="Width", minimum=256, maximum=2048, step=8, value=256,
|
| 803 |
-
buttons=[], elem_id="res-custom-width",
|
| 804 |
-
)
|
| 805 |
-
auto_scale = gr.Radio(
|
| 806 |
-
label="Auto resolution",
|
| 807 |
-
choices=AUTO_SCALES,
|
| 808 |
-
value="1x",
|
| 809 |
-
elem_id="res-auto-scale",
|
| 810 |
)
|
| 811 |
|
| 812 |
with gr.Column():
|
| 813 |
result = gr.Gallery(label="Result", show_label=False, type="filepath")
|
| 814 |
-
|
| 815 |
-
use_output_btn = gr.Button("↗️ Use as input", variant="secondary", size="sm", visible=False)
|
| 816 |
|
| 817 |
-
gr.HTML('<div style="height:1024px"></div>', elem_id="login-spacer")
|
| 818 |
|
| 819 |
with gr.Tab("🔒", visible=False) as admin_tab:
|
| 820 |
with gr.Group(visible=True) as admin_lock_group:
|
|
@@ -871,9 +1162,23 @@ with gr.Blocks(**({} if _GR6 else {"css": css})) as demo:
|
|
| 871 |
**_PRIVATE,
|
| 872 |
)
|
| 873 |
|
| 874 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 875 |
triggers=[run_button.click, prompt.submit],
|
| 876 |
-
fn=
|
| 877 |
inputs=[
|
| 878 |
image_1,
|
| 879 |
image_2,
|
|
@@ -884,34 +1189,24 @@ with gr.Blocks(**({} if _GR6 else {"css": css})) as demo:
|
|
| 884 |
num_inference_steps,
|
| 885 |
gpu_duration,
|
| 886 |
gpu_size,
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
auto_scale,
|
| 892 |
output_format,
|
| 893 |
-
|
| 894 |
],
|
| 895 |
outputs=[result, seed, use_output_btn],
|
| 896 |
**({"show_progress_on": [result]} if _SHOW_PROGRESS_ON else {}),
|
| 897 |
-
)
|
|
|
|
| 898 |
fn=_sync_result_followups,
|
| 899 |
inputs=result,
|
| 900 |
outputs=use_output_btn,
|
| 901 |
show_progress="hidden",
|
| 902 |
)
|
| 903 |
|
| 904 |
-
prompt.change(
|
| 905 |
-
fn=None,
|
| 906 |
-
inputs=prompt,
|
| 907 |
-
outputs=run_button,
|
| 908 |
-
js="(text) => ({__type__: 'update', interactive: !!(text && text.trim())})",
|
| 909 |
-
show_progress="hidden",
|
| 910 |
-
**_PRIVATE,
|
| 911 |
-
)
|
| 912 |
-
|
| 913 |
use_output_btn.click(fn=use_output_as_input, inputs=[result], outputs=[image_1])
|
| 914 |
-
|
| 915 |
result.change(
|
| 916 |
fn=_sync_result_followups,
|
| 917 |
inputs=result,
|
|
@@ -919,49 +1214,24 @@ with gr.Blocks(**({} if _GR6 else {"css": css})) as demo:
|
|
| 919 |
show_progress="hidden",
|
| 920 |
)
|
| 921 |
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
**_PRIVATE,
|
| 928 |
-
).then(
|
| 929 |
-
fn=None, inputs=None, outputs=None,
|
| 930 |
-
js="""() => {
|
| 931 |
-
const nudge = () => {
|
| 932 |
-
window.dispatchEvent(new Event("resize"));
|
| 933 |
-
try { if (window.parentIFrame && window.parentIFrame.size) window.parentIFrame.size(); } catch (e) {}
|
| 934 |
-
};
|
| 935 |
-
setTimeout(() => {
|
| 936 |
-
const sp = document.getElementById("login-spacer");
|
| 937 |
-
const col = document.getElementById("col-container");
|
| 938 |
-
if (sp && col && col.offsetParent !== null) sp.style.display = "none";
|
| 939 |
-
nudge();
|
| 940 |
-
}, 600);
|
| 941 |
-
[900, 1500, 3000].forEach((ms) => setTimeout(nudge, ms));
|
| 942 |
-
}""",
|
| 943 |
show_progress="hidden",
|
| 944 |
**_PRIVATE,
|
| 945 |
)
|
| 946 |
|
| 947 |
-
|
| 948 |
-
fn=None,
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
try { if (window.parentIFrame && window.parentIFrame.size) window.parentIFrame.size(); } catch (e) {}
|
| 953 |
-
};
|
| 954 |
-
[0, 100, 300, 800].forEach((ms) => setTimeout(nudge, ms));
|
| 955 |
-
}""",
|
| 956 |
show_progress="hidden",
|
| 957 |
**_PRIVATE,
|
| 958 |
)
|
| 959 |
|
| 960 |
-
try:
|
| 961 |
-
demo.unload(end_session)
|
| 962 |
-
except Exception as e:
|
| 963 |
-
print(f"[auth] demo.unload not available, sessions persist until restart: {e}")
|
| 964 |
-
|
| 965 |
demo.load(fn=mark_browser_session, inputs=None, outputs=None, **_PRIVATE)
|
| 966 |
demo.load(fn=check_admin_tab_visibility, inputs=None, outputs=[admin_tab], **_PRIVATE)
|
| 967 |
|
|
@@ -985,6 +1255,8 @@ with gr.Blocks(**({} if _GR6 else {"css": css})) as demo:
|
|
| 985 |
)
|
| 986 |
|
| 987 |
if __name__ == "__main__":
|
|
|
|
|
|
|
| 988 |
demo.queue(max_size=int(os.environ.get("QUEUE_MAX_SIZE", "30")))
|
| 989 |
launch_kwargs = dict(
|
| 990 |
ssr_mode=False,
|
|
|
|
| 55 |
pass
|
| 56 |
_torchao_quantization.FqnToConfig = FqnToConfig
|
| 57 |
|
| 58 |
+
from diffusers import Flux2KleinPipeline
|
| 59 |
+
try:
|
| 60 |
+
from diffusers import Flux2KleinKVPipeline
|
| 61 |
+
except ImportError:
|
| 62 |
+
Flux2KleinKVPipeline = None
|
| 63 |
from huggingface_hub import hf_hub_download
|
| 64 |
from PIL import Image
|
| 65 |
from safetensors import safe_open
|
|
|
|
| 70 |
from qwenimage.transformer_qwenimage import QwenImageTransformer2DModel
|
| 71 |
from qwenimage.qwen_fa3_processor import QwenDoubleStreamAttnProcessorFA3
|
| 72 |
|
| 73 |
+
import Age_classifier
|
| 74 |
+
|
| 75 |
+
from esrgan import load_esrgan, upscale_to_pixels
|
| 76 |
from security import (
|
| 77 |
FloodGuard,
|
| 78 |
check_admin_password,
|
| 79 |
check_admin_tab_visibility,
|
|
|
|
| 80 |
clear_all_admin_log,
|
| 81 |
delete_admin_log_entry,
|
|
|
|
| 82 |
is_admin,
|
| 83 |
is_quota_error,
|
|
|
|
| 84 |
load_admin_gallery,
|
| 85 |
load_rate_stats,
|
| 86 |
log_generation,
|
| 87 |
mark_browser_session,
|
| 88 |
rate_acquire,
|
| 89 |
rate_release,
|
| 90 |
+
record_age_block,
|
| 91 |
save_input_images,
|
| 92 |
show_rate_message,
|
| 93 |
)
|
|
|
|
| 122 |
torch.backends.cudnn.allow_tf32 = True
|
| 123 |
|
| 124 |
MODEL_QWEN = "qwen"
|
| 125 |
+
MODEL_FLUX = "flux"
|
| 126 |
|
| 127 |
+
ASPECT_RATIOS = ["Default", "1:1", "3:2", "2:3", "9:16", "16:9", "3:4", "4:3", "21:9", "9:21"]
|
| 128 |
+
RATIO_DEFAULT = "Default"
|
| 129 |
+
QWEN_TARGET_PIXELS = 2073600
|
| 130 |
+
UPSCALE_PIXELS = {"Basic": 3686400, "Medium": 5760000, "High": 8294400}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
from diffusers.utils import logging as diffusers_logging
|
| 133 |
|
|
|
|
| 140 |
|
| 141 |
QWEN_TRANSFORMER_MODE = "fp8_exact"
|
| 142 |
|
| 143 |
+
QWEN_TE_QUANT_MODE = "int8_weight_only"
|
| 144 |
+
|
| 145 |
+
FLUX_QUANT_MODE = os.environ.get("FLUX_QUANT_MODE", "int8_weight_only").strip().lower()
|
| 146 |
+
|
| 147 |
|
| 148 |
class _FP8ExactLinear(torch.nn.Module):
|
| 149 |
def __init__(self, linear: torch.nn.Linear):
|
|
|
|
| 177 |
return len(targets)
|
| 178 |
|
| 179 |
|
| 180 |
+
def _apply_quant(module, label, mode):
|
| 181 |
+
order = {
|
| 182 |
+
"int8_weight_only": ["int8_weight_only", "fp8_weight_only", "dynamic"],
|
| 183 |
+
"fp8_weight_only": ["fp8_weight_only", "dynamic"],
|
| 184 |
+
"dynamic": ["dynamic"],
|
| 185 |
+
}[mode]
|
| 186 |
+
for m in order:
|
| 187 |
+
try:
|
| 188 |
+
if m == "int8_weight_only":
|
| 189 |
+
from torchao.quantization import Int8WeightOnlyConfig
|
| 190 |
+
cfg = Int8WeightOnlyConfig()
|
| 191 |
+
elif m == "fp8_weight_only":
|
| 192 |
+
from torchao.quantization import Float8WeightOnlyConfig
|
| 193 |
+
cfg = Float8WeightOnlyConfig()
|
| 194 |
+
else:
|
| 195 |
+
cfg = Float8DynamicActivationFloat8WeightConfig()
|
| 196 |
+
except ImportError as e_cfg:
|
| 197 |
+
print(f"[quant] {label}: {m} unavailable ({e_cfg}); trying the next scheme")
|
| 198 |
+
continue
|
| 199 |
+
quantize_(module, cfg)
|
| 200 |
+
return m
|
| 201 |
+
raise RuntimeError(f"no usable quantization scheme for {label}")
|
| 202 |
+
|
| 203 |
+
|
| 204 |
def _enable_vae_tiling(p, tile=None):
|
| 205 |
vae = getattr(p, "vae", None)
|
| 206 |
if vae is None:
|
|
|
|
| 223 |
print("[vae] tiling applied:", {k: getattr(vae, k, None) for k in ("tile_sample_min_height", "tile_sample_min_width", "tile_sample_stride_height", "tile_sample_stride_width")})
|
| 224 |
|
| 225 |
|
| 226 |
+
QWEN_VAE_TILE = int(os.environ.get("QWEN_VAE_TILE", "0"))
|
| 227 |
|
| 228 |
|
| 229 |
pipe = QwenImageEditPlusPipeline.from_pretrained(
|
|
|
|
| 253 |
quantize_(pipe.transformer, Float8DynamicActivationFloat8WeightConfig())
|
| 254 |
print("[qwen] FP8 quantization: SUCCESS (transformer)")
|
| 255 |
|
| 256 |
+
if getattr(pipe, "text_encoder", None) is not None:
|
| 257 |
+
try:
|
| 258 |
+
_te_mode_used = _apply_quant(pipe.text_encoder, "qwen text_encoder", QWEN_TE_QUANT_MODE)
|
| 259 |
+
print(f"[qwen] text_encoder quantization ({_te_mode_used}): SUCCESS")
|
| 260 |
+
except Exception as e:
|
| 261 |
+
print(f"[qwen] text_encoder quantization FAILED, staying bf16 (~+8GB VRAM): {e}")
|
| 262 |
+
|
| 263 |
+
if QWEN_VAE_TILE > 0:
|
| 264 |
+
_enable_vae_tiling(pipe, QWEN_VAE_TILE)
|
| 265 |
+
else:
|
| 266 |
+
print("[vae] Qwen VAE tiling OFF (full-frame decode)")
|
| 267 |
|
| 268 |
pipe.transformer.__class__ = QwenImageTransformer2DModel
|
| 269 |
pipe.transformer.set_attn_processor(QwenDoubleStreamAttnProcessorFA3())
|
| 270 |
|
| 271 |
+
FLUX_REPO = os.environ.get("FLUX_REPO", "black-forest-labs/FLUX.2-klein-9b-kv")
|
| 272 |
+
FLUX_REFINE_PROMPT = os.environ.get("FLUX_REFINE_PROMPT", "An ultra-high-resolution masterpiece with exceptional sharpness and detail, featuring only a subtle, barely perceptible amount of noise on the skin’s surface. The image remains entirely unzoomed and unaltered, preserving its original composition and proportions, with lighting and shadows meticulously controlled to remain perfectly faithful to the original")
|
| 273 |
+
FLUX_REFINE_GUIDANCE_SCALE = float(os.environ.get("FLUX_REFINE_GUIDANCE_SCALE", "1"))
|
| 274 |
+
FLUX_NUM_INFERENCE_STEPS = int(os.environ.get("FLUX_NUM_INFERENCE_STEPS", "4"))
|
| 275 |
+
FLUX_SEED = int(os.environ.get("FLUX_SEED", "42"))
|
| 276 |
+
FLUX_MAX_PIXELS = 2073600
|
| 277 |
+
|
| 278 |
+
_flux_use_kv = "kv" in FLUX_REPO.lower()
|
| 279 |
+
if _flux_use_kv and Flux2KleinKVPipeline is None:
|
| 280 |
+
raise RuntimeError(
|
| 281 |
+
"FLUX_REPO is a KV checkpoint but this diffusers has no Flux2KleinKVPipeline. "
|
| 282 |
+
"Install diffusers from GitHub: pip install git+https://github.com/huggingface/diffusers.git"
|
| 283 |
+
)
|
| 284 |
+
_FluxPipeCls = Flux2KleinKVPipeline if _flux_use_kv else Flux2KleinPipeline
|
| 285 |
+
_flux_repo_lc = FLUX_REPO.lower()
|
| 286 |
+
_flux_size = "9B" if "9b" in _flux_repo_lc else ("4B" if "4b" in _flux_repo_lc else "")
|
| 287 |
+
if _flux_size:
|
| 288 |
+
FLUX_SHORT_NAME = f"Flux.2 {_flux_size}" + (" KV" if _flux_use_kv else "") + (" Base" if "base" in _flux_repo_lc else "")
|
| 289 |
+
else:
|
| 290 |
+
FLUX_SHORT_NAME = FLUX_REPO.split("/")[-1]
|
| 291 |
+
print(f"[flux] loading {FLUX_REPO} with {_FluxPipeCls.__name__}")
|
| 292 |
+
flux_pipe = _FluxPipeCls.from_pretrained(FLUX_REPO, torch_dtype=dtype)
|
| 293 |
+
|
| 294 |
+
with torch.no_grad():
|
| 295 |
+
_flux_prompt_embeds, _ = flux_pipe.encode_prompt(
|
| 296 |
+
prompt=FLUX_REFINE_PROMPT, device="cpu", num_images_per_prompt=1
|
| 297 |
+
)
|
| 298 |
+
_flux_prompt_embeds = _flux_prompt_embeds.to(dtype).cpu()
|
| 299 |
+
flux_pipe.text_encoder = None
|
| 300 |
+
gc.collect()
|
| 301 |
+
|
| 302 |
+
flux_pipe = flux_pipe.to(device)
|
| 303 |
+
if FLUX_QUANT_MODE == "none":
|
| 304 |
+
print("[flux] quantization disabled (FLUX_QUANT_MODE=none), running in bf16")
|
| 305 |
+
else:
|
| 306 |
+
try:
|
| 307 |
+
_flux_mode_used = _apply_quant(flux_pipe.transformer, "flux transformer", FLUX_QUANT_MODE)
|
| 308 |
+
print(f"[flux] quantization ({_flux_mode_used}): SUCCESS")
|
| 309 |
+
except Exception as e:
|
| 310 |
+
print(f"[flux] quantization FAILED, continuing in bf16 (roughly 2x the VRAM/transfer time): {e}")
|
| 311 |
+
|
| 312 |
+
if hasattr(flux_pipe, "vae") and flux_pipe.vae is not None:
|
| 313 |
+
try:
|
| 314 |
+
flux_pipe.vae.enable_slicing()
|
| 315 |
+
except Exception:
|
| 316 |
+
pass
|
| 317 |
+
|
| 318 |
+
_flux_ip = getattr(flux_pipe, "image_processor", None)
|
| 319 |
+
if _flux_ip is not None and hasattr(_flux_ip, "_resize_to_target_area"):
|
| 320 |
+
_orig_resize_to_target_area = _flux_ip._resize_to_target_area
|
| 321 |
+
|
| 322 |
+
def _flux_resize_with_higher_cap(image, target_area):
|
| 323 |
+
if target_area == 1024 * 1024:
|
| 324 |
+
w, h = image.size
|
| 325 |
+
if w * h <= FLUX_MAX_PIXELS:
|
| 326 |
+
return image
|
| 327 |
+
return _orig_resize_to_target_area(image, FLUX_MAX_PIXELS)
|
| 328 |
+
return _orig_resize_to_target_area(image, target_area)
|
| 329 |
+
|
| 330 |
+
_flux_ip._resize_to_target_area = _flux_resize_with_higher_cap
|
| 331 |
+
print(f"[flux] reference-image cap raised: no downscale up to {FLUX_MAX_PIXELS} px")
|
| 332 |
+
else:
|
| 333 |
+
print("[flux] WARNING: image_processor._resize_to_target_area not found; "
|
| 334 |
+
"FLUX will use its built-in ~1MP reference-image downscale")
|
| 335 |
+
|
| 336 |
+
esrgan_model = load_esrgan(device)
|
| 337 |
+
print("[esrgan] RealESRGAN_x2plus loaded")
|
| 338 |
+
|
| 339 |
+
# FLUX VAE tiling is off by default because tiled encode/decode shifted skin colour.
|
| 340 |
+
if os.environ.get("FLUX_VAE_TILING", "0") == "1":
|
| 341 |
+
_enable_vae_tiling(flux_pipe)
|
| 342 |
+
|
| 343 |
gc.collect()
|
| 344 |
|
| 345 |
|
|
|
|
| 381 |
return max(multiple, (int(width) // multiple) * multiple), max(multiple, (int(height) // multiple) * multiple)
|
| 382 |
|
| 383 |
|
| 384 |
+
def _ratio_size(ratio, pixels=QWEN_TARGET_PIXELS, multiple=16):
|
| 385 |
+
width = (pixels * ratio) ** 0.5
|
| 386 |
+
height = width / ratio
|
| 387 |
+
return (
|
| 388 |
+
max(multiple, int(width) // multiple * multiple),
|
| 389 |
+
max(multiple, int(height) // multiple * multiple),
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def _image_ratio(image_1, image_2):
|
| 394 |
src = image_1 if image_1 is not None else image_2
|
| 395 |
if src is None:
|
| 396 |
+
return 1.0
|
| 397 |
try:
|
| 398 |
with Image.open(src if isinstance(src, str) else src.name) as im:
|
| 399 |
+
return im.width / im.height
|
| 400 |
except Exception:
|
| 401 |
+
return 1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
|
| 403 |
|
| 404 |
+
def _resolve_size(ratio_label, image_1, image_2):
|
| 405 |
try:
|
| 406 |
+
if ratio_label in ASPECT_RATIOS and ratio_label != RATIO_DEFAULT:
|
| 407 |
+
w, h = ratio_label.split(":")
|
| 408 |
+
return _ratio_size(int(w) / int(h))
|
| 409 |
+
except (TypeError, ValueError, ZeroDivisionError):
|
|
|
|
|
|
|
|
|
|
| 410 |
pass
|
| 411 |
+
return _ratio_size(_image_ratio(image_1, image_2))
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def _fit_size(width, height, max_pixels=FLUX_MAX_PIXELS, multiple=16):
|
| 415 |
+
if width * height <= max_pixels:
|
| 416 |
+
return width, height
|
| 417 |
+
scale = (max_pixels / (width * height)) ** 0.5
|
| 418 |
+
return (
|
| 419 |
+
max(multiple, int(width * scale) // multiple * multiple),
|
| 420 |
+
max(multiple, int(height * scale) // multiple * multiple),
|
| 421 |
+
)
|
| 422 |
|
| 423 |
|
| 424 |
def _load_image(img):
|
|
|
|
| 458 |
|
| 459 |
|
| 460 |
def _generate_core(
|
| 461 |
+
model_choice,
|
| 462 |
image_1,
|
| 463 |
image_2,
|
| 464 |
prompt,
|
|
|
|
| 468 |
num_inference_steps,
|
| 469 |
height,
|
| 470 |
width,
|
|
|
|
| 471 |
progress=gr.Progress(track_tqdm=True),
|
| 472 |
):
|
| 473 |
if randomize_seed:
|
|
|
|
| 483 |
except Exception:
|
| 484 |
continue
|
| 485 |
|
| 486 |
+
if model_choice == MODEL_FLUX:
|
| 487 |
+
_flux_inputs = []
|
| 488 |
+
for im in pil_images:
|
| 489 |
+
if im.width * im.height > FLUX_MAX_PIXELS:
|
| 490 |
+
new_size = _fit_size(*im.size)
|
| 491 |
+
print(f"[flux] input {im.width}x{im.height} exceeds {FLUX_MAX_PIXELS} px, downscaled to {new_size[0]}x{new_size[1]}")
|
| 492 |
+
im = im.resize(new_size, Image.LANCZOS)
|
| 493 |
+
_flux_inputs.append(im)
|
| 494 |
+
pil_images = _flux_inputs
|
| 495 |
+
if width and height:
|
| 496 |
+
width, height = _fit_size(width, height)
|
| 497 |
|
| 498 |
res_txt = f"{width}x{height}" if (width and height) else "auto"
|
| 499 |
+
print()
|
| 500 |
+
_model_desc = FLUX_SHORT_NAME if model_choice == MODEL_FLUX else model_choice
|
| 501 |
+
print(f"[gen] model={_model_desc} guidance={true_guidance_scale} steps={num_inference_steps} resolution={res_txt}")
|
| 502 |
|
| 503 |
if torch.cuda.is_available():
|
| 504 |
torch.cuda.reset_peak_memory_stats(device)
|
| 505 |
+
log_vram(f"{model_choice} before generation")
|
| 506 |
|
| 507 |
try:
|
| 508 |
+
if model_choice == MODEL_FLUX:
|
| 509 |
+
_flux_kwargs = {}
|
| 510 |
+
if not _flux_use_kv:
|
| 511 |
+
_flux_kwargs["guidance_scale"] = true_guidance_scale
|
| 512 |
+
images_pil = flux_pipe(
|
| 513 |
+
image=pil_images or None,
|
| 514 |
+
prompt=None,
|
| 515 |
+
prompt_embeds=_flux_prompt_embeds.to(device),
|
| 516 |
+
height=height,
|
| 517 |
+
width=width,
|
| 518 |
+
num_inference_steps=num_inference_steps,
|
| 519 |
+
generator=generator,
|
| 520 |
+
num_images_per_prompt=1,
|
| 521 |
+
**_flux_kwargs,
|
| 522 |
+
).images
|
| 523 |
+
else:
|
| 524 |
+
images_pil = pipe(
|
| 525 |
+
image=pil_images or None,
|
| 526 |
+
prompt=prompt,
|
| 527 |
+
height=height,
|
| 528 |
+
width=width,
|
| 529 |
+
negative_prompt=" ",
|
| 530 |
+
num_inference_steps=num_inference_steps,
|
| 531 |
+
generator=generator,
|
| 532 |
+
true_cfg_scale=true_guidance_scale,
|
| 533 |
+
num_images_per_prompt=1,
|
| 534 |
+
).images
|
| 535 |
|
| 536 |
except torch.cuda.OutOfMemoryError:
|
| 537 |
_free_vram_cache()
|
| 538 |
raise
|
| 539 |
+
log_vram(f"{model_choice} after generation")
|
| 540 |
|
| 541 |
return {
|
| 542 |
"images": images_pil, "seed": seed, "width": width, "height": height,
|
| 543 |
+
"steps": num_inference_steps,
|
| 544 |
+
"guidance": true_guidance_scale, "model": model_choice,
|
| 545 |
}
|
| 546 |
|
| 547 |
|
|
|
|
| 561 |
else:
|
| 562 |
img.save(output_path)
|
| 563 |
output_paths.append(output_path)
|
| 564 |
+
print(f"[out] resolution={img.width}x{img.height}")
|
| 565 |
log_generation(
|
| 566 |
path=output_path,
|
| 567 |
prompt=prompt,
|
|
|
|
| 573 |
height=res["height"],
|
| 574 |
out_width=img.width,
|
| 575 |
out_height=img.height,
|
|
|
|
| 576 |
inputs=input_paths,
|
| 577 |
)
|
| 578 |
has_output = bool(output_paths)
|
| 579 |
return output_paths, seed, gr.update(visible=has_output)
|
| 580 |
|
| 581 |
|
| 582 |
+
def _make_qwen_flux_gpu_variant(duration_seconds, size):
|
| 583 |
+
def _qwen_then_flux_gpu_call(qwen_kwargs, scale_up):
|
| 584 |
+
_free_vram_cache()
|
| 585 |
+
q = _generate_core(MODEL_QWEN, **qwen_kwargs)
|
| 586 |
+
if not q["images"]:
|
| 587 |
+
return q, None
|
| 588 |
+
_free_vram_cache()
|
| 589 |
+
img = q["images"][0]
|
| 590 |
+
w, h = _round_size(*img.size, multiple=16)
|
| 591 |
+
f = _generate_core(
|
| 592 |
+
MODEL_FLUX, image_1=img, image_2=None, prompt=FLUX_REFINE_PROMPT,
|
| 593 |
+
seed=FLUX_SEED, randomize_seed=False,
|
| 594 |
+
true_guidance_scale=FLUX_REFINE_GUIDANCE_SCALE,
|
| 595 |
+
num_inference_steps=FLUX_NUM_INFERENCE_STEPS,
|
| 596 |
+
height=h, width=w,
|
| 597 |
+
progress=qwen_kwargs["progress"],
|
| 598 |
+
)
|
| 599 |
+
if scale_up and f["images"]:
|
| 600 |
+
_free_vram_cache()
|
| 601 |
+
f = {**f, "images": [upscale_to_pixels(esrgan_model, f["images"][0], scale_up)]}
|
| 602 |
+
return q, f
|
| 603 |
|
| 604 |
+
_qwen_then_flux_gpu_call.__name__ = f"_qwen_then_flux_gpu_{size}_{duration_seconds}s"
|
| 605 |
+
_qwen_then_flux_gpu_call.__qualname__ = _qwen_then_flux_gpu_call.__name__
|
| 606 |
+
return spaces.GPU(duration=duration_seconds, size=size)(_qwen_then_flux_gpu_call)
|
| 607 |
|
| 608 |
|
| 609 |
+
_qwen_then_flux_variants = {(s, d): _make_qwen_flux_gpu_variant(d, s) for s in GPU_SIZE_OPTIONS for d in _GPU_DURATION_STEPS}
|
| 610 |
|
| 611 |
|
| 612 |
+
def _pick_qwen_flux_variant(gpu_duration, gpu_size=GPU_SIZE_DEFAULT):
|
| 613 |
requested = _to_int(gpu_duration, GPU_DURATION_MIN, GPU_DURATION_MAX, GPU_DURATION_DEFAULT)
|
| 614 |
chosen = next((d for d in _GPU_DURATION_STEPS if d >= requested), _GPU_DURATION_STEPS[-1])
|
| 615 |
size = gpu_size if gpu_size in GPU_SIZE_OPTIONS else GPU_SIZE_DEFAULT
|
| 616 |
+
gpu_fn = _qwen_then_flux_variants[(size, chosen)]
|
| 617 |
|
| 618 |
+
def _call(image_1, image_2, prompt, output_format, qwen_kwargs, scale_up):
|
| 619 |
+
q, f = gpu_fn(qwen_kwargs, scale_up)
|
| 620 |
+
final = f if (f is not None and f["images"]) else q
|
| 621 |
+
return _finalize({**q, "images": final["images"]}, image_1, image_2, prompt, output_format)
|
| 622 |
|
| 623 |
return _call
|
| 624 |
|
| 625 |
|
| 626 |
+
_BLOCK_IMAGE_PATH = os.path.join("outputs", "blocked", "blocked_notice.png")
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
def _block_image():
|
| 630 |
+
if os.path.exists(_BLOCK_IMAGE_PATH):
|
| 631 |
+
return _BLOCK_IMAGE_PATH
|
| 632 |
+
import textwrap
|
| 633 |
+
from PIL import ImageDraw, ImageFont
|
| 634 |
+
|
| 635 |
+
def font(size):
|
| 636 |
+
try:
|
| 637 |
+
return ImageFont.load_default(size=size)
|
| 638 |
+
except TypeError:
|
| 639 |
+
return ImageFont.load_default()
|
| 640 |
+
|
| 641 |
+
size = 768
|
| 642 |
+
img = Image.new("RGB", (size, size), (28, 30, 38))
|
| 643 |
+
d = ImageDraw.Draw(img)
|
| 644 |
+
d.polygon([(384, 40), (500, 250), (268, 250)], fill=(255, 200, 0))
|
| 645 |
+
f_mark = font(130)
|
| 646 |
+
d.text((384 - d.textlength("!", font=f_mark) / 2, 92), "!", font=f_mark, fill=(28, 30, 38))
|
| 647 |
+
f_head = font(64)
|
| 648 |
+
head = "BLOCKED"
|
| 649 |
+
d.text(((size - d.textlength(head, font=f_head)) / 2, 290), head, font=f_head, fill=(255, 200, 0))
|
| 650 |
+
f_body = font(36)
|
| 651 |
+
y = 400
|
| 652 |
+
for line in textwrap.wrap("This request appears to involve a minor, so it can't be processed.", width=30):
|
| 653 |
+
d.text(((size - d.textlength(line, font=f_body)) / 2, y), line, font=f_body, fill=(225, 228, 238))
|
| 654 |
+
y += 48
|
| 655 |
+
os.makedirs(os.path.dirname(_BLOCK_IMAGE_PATH), exist_ok=True)
|
| 656 |
+
img.save(_BLOCK_IMAGE_PATH)
|
| 657 |
+
return _BLOCK_IMAGE_PATH
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
def _is_age_block(exc):
|
| 661 |
+
return getattr(exc, "message", str(exc)) == Age_classifier.GUARD_BLOCK_MESSAGE
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
def _is_block_image(gallery_items):
|
| 665 |
+
try:
|
| 666 |
+
return os.path.basename(str(_first_path(gallery_items))).startswith("blocked_notice")
|
| 667 |
+
except Exception:
|
| 668 |
+
return False
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
def _screen_inputs(request, prompt, *images):
|
| 672 |
+
try:
|
| 673 |
+
Age_classifier.check_prompt_no_minors(prompt)
|
| 674 |
+
except gr.Error as exc:
|
| 675 |
+
if _is_age_block(exc):
|
| 676 |
+
record_age_block("prompt", request)
|
| 677 |
+
raise
|
| 678 |
+
for img in images:
|
| 679 |
+
if img is None:
|
| 680 |
+
continue
|
| 681 |
+
try:
|
| 682 |
+
pil = _load_image(img)
|
| 683 |
+
except Exception:
|
| 684 |
+
continue
|
| 685 |
+
try:
|
| 686 |
+
Age_classifier.check_image_no_minors(pil, context="input")
|
| 687 |
+
except gr.Error as exc:
|
| 688 |
+
if _is_age_block(exc):
|
| 689 |
+
record_age_block("input", request)
|
| 690 |
+
raise
|
| 691 |
+
|
| 692 |
+
|
| 693 |
+
def _screen_outputs(result, request=None):
|
| 694 |
+
paths = result[0] if isinstance(result, (tuple, list)) and result else None
|
| 695 |
+
if not isinstance(paths, (list, tuple)):
|
| 696 |
+
return result
|
| 697 |
+
try:
|
| 698 |
+
for path in paths:
|
| 699 |
+
with Image.open(path) as im:
|
| 700 |
+
im.load()
|
| 701 |
+
Age_classifier.check_image_no_minors(im, context="output")
|
| 702 |
+
except gr.Error as exc:
|
| 703 |
+
if _is_age_block(exc):
|
| 704 |
+
record_age_block("output", request)
|
| 705 |
+
for path in paths:
|
| 706 |
+
try:
|
| 707 |
+
delete_admin_log_entry(path)
|
| 708 |
+
if os.path.exists(path):
|
| 709 |
+
os.remove(path)
|
| 710 |
+
except Exception as rm_exc:
|
| 711 |
+
print(f"[age] failed to remove blocked output: {rm_exc}")
|
| 712 |
+
raise
|
| 713 |
+
return result
|
| 714 |
+
|
| 715 |
+
|
| 716 |
def _run_limited(request, seed, run):
|
|
|
|
|
|
|
|
|
|
| 717 |
ok, key, msg = rate_acquire(request)
|
| 718 |
if not ok:
|
| 719 |
show_rate_message(msg)
|
|
|
|
| 728 |
rate_release(key, refund=refund)
|
| 729 |
|
| 730 |
|
| 731 |
+
def run_qwen_and_flux(
|
| 732 |
image_1,
|
| 733 |
image_2,
|
| 734 |
prompt,
|
|
|
|
| 738 |
num_inference_steps,
|
| 739 |
gpu_duration,
|
| 740 |
gpu_size,
|
| 741 |
+
aspect_ratio,
|
| 742 |
+
scale_basic,
|
| 743 |
+
scale_medium,
|
| 744 |
+
scale_high,
|
|
|
|
| 745 |
output_format,
|
| 746 |
+
age_confirmed=False,
|
| 747 |
progress=gr.Progress(track_tqdm=True),
|
| 748 |
request: gr.Request = None,
|
| 749 |
):
|
| 750 |
+
if not age_confirmed:
|
| 751 |
+
raise gr.Error("Please tick the box to confirm you are 18 or older.")
|
| 752 |
+
|
| 753 |
if not (isinstance(prompt, str) and prompt.strip()):
|
| 754 |
return gr.update(), seed, gr.update()
|
| 755 |
|
| 756 |
def run():
|
| 757 |
clean_seed, duration, fmt = _sanitize_common(seed, gpu_duration, output_format)
|
| 758 |
+
width, height = _resolve_size(aspect_ratio, image_1, image_2)
|
| 759 |
+
clean_prompt = prompt if isinstance(prompt, str) else ""
|
| 760 |
+
qwen_kwargs = dict(
|
| 761 |
+
image_1=image_1, image_2=image_2, prompt=clean_prompt,
|
| 762 |
+
seed=clean_seed, randomize_seed=bool(randomize_seed),
|
|
|
|
|
|
|
| 763 |
true_guidance_scale=_to_float(true_guidance_scale, LIMIT_GUIDANCE_MIN, LIMIT_GUIDANCE_MAX, 1.0),
|
| 764 |
num_inference_steps=int(num_inference_steps),
|
| 765 |
+
height=height, width=width,
|
|
|
|
|
|
|
|
|
|
| 766 |
progress=progress,
|
| 767 |
)
|
| 768 |
+
upscale_pixels = next(
|
| 769 |
+
(UPSCALE_PIXELS[name] for name, on in (("High", scale_high), ("Medium", scale_medium), ("Basic", scale_basic)) if on),
|
| 770 |
+
0,
|
| 771 |
+
)
|
| 772 |
+
try:
|
| 773 |
+
_screen_inputs(request, clean_prompt, image_1, image_2)
|
| 774 |
+
return _screen_outputs(
|
| 775 |
+
_pick_qwen_flux_variant(duration, gpu_size)(image_1, image_2, clean_prompt, fmt, qwen_kwargs, upscale_pixels),
|
| 776 |
+
request,
|
| 777 |
+
)
|
| 778 |
+
except gr.Error as exc:
|
| 779 |
+
if not _is_age_block(exc):
|
| 780 |
+
raise
|
| 781 |
+
return [_block_image()], seed, gr.update(visible=False)
|
| 782 |
|
| 783 |
return _run_limited(request, seed, run)
|
| 784 |
|
|
|
|
| 790 |
|
| 791 |
|
| 792 |
def _sync_result_followups(output_images):
|
| 793 |
+
return gr.update(visible=bool(output_images) and not _is_block_image(output_images))
|
| 794 |
|
| 795 |
|
| 796 |
def _clear_all_admin_images(request: gr.Request):
|
|
|
|
| 823 |
-webkit-text-fill-color: transparent;
|
| 824 |
color: transparent;
|
| 825 |
animation: ast-title-flash 10s linear infinite;
|
| 826 |
+
white-space: nowrap;
|
| 827 |
}
|
| 828 |
+
@media (max-width: 480px) {
|
| 829 |
+
#ast-main-title { font-size: 5.8vw !important; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 830 |
}
|
| 831 |
+
#logo-title h1#ast-nfaa-title {
|
| 832 |
+
margin: 0 -0.3em 0 0 !important;
|
| 833 |
+
padding: 0 0.3em 0 0 !important;
|
| 834 |
+
line-height: 1.15 !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 835 |
}
|
| 836 |
+
#ast-nfaa-title {
|
| 837 |
+
font-style: italic;
|
| 838 |
+
display: inline-block;
|
| 839 |
+
white-space: nowrap;
|
| 840 |
+
zoom: 0.8;
|
| 841 |
+
background: linear-gradient(90deg, #FF0000, #FF2A2A, #E00000, #FF1010, #FF0000);
|
| 842 |
+
background-size: 400% 400%;
|
| 843 |
+
-webkit-background-clip: text;
|
| 844 |
+
background-clip: text;
|
| 845 |
+
-webkit-text-fill-color: transparent;
|
| 846 |
+
color: transparent;
|
| 847 |
+
animation: ast-title-flash 10s linear infinite;
|
| 848 |
}
|
| 849 |
+
@media (max-width: 400px) {
|
| 850 |
+
#ast-nfaa-title { zoom: 0.7; }
|
|
|
|
|
|
|
| 851 |
}
|
| 852 |
+
#ast-notice {
|
| 853 |
+
width: 100%;
|
| 854 |
+
max-width: 560px;
|
| 855 |
+
margin: 11px auto 0 !important;
|
| 856 |
+
padding: 1px 0 1px 14px !important;
|
| 857 |
+
border-left: 2px solid transparent;
|
| 858 |
+
border-image: linear-gradient(180deg, #FF3CAC, #7B2FF7, #00E5FF) 1;
|
| 859 |
+
text-align: left;
|
| 860 |
}
|
| 861 |
+
#ast-notice, #ast-notice p, #ast-notice b {
|
| 862 |
+
font-family: "Space Grotesk", "DM Sans", system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans Thai", sans-serif !important;
|
|
|
|
|
|
|
|
|
|
| 863 |
}
|
| 864 |
+
#ast-notice p {
|
| 865 |
+
margin: 0 0 9px 0 !important;
|
| 866 |
+
text-indent: 1.6em;
|
| 867 |
+
font-size: 11.5px !important;
|
| 868 |
+
font-weight: 400 !important;
|
| 869 |
+
line-height: 1.75 !important;
|
| 870 |
+
letter-spacing: 0.015em !important;
|
| 871 |
+
color: var(--body-text-color-subdued, #9aa0a6) !important;
|
| 872 |
+
text-wrap: pretty;
|
| 873 |
}
|
| 874 |
+
#ast-notice p:last-child {
|
| 875 |
+
margin-bottom: 0 !important;
|
| 876 |
+
}
|
| 877 |
+
#ast-notice b {
|
| 878 |
+
font-weight: 600 !important;
|
| 879 |
+
letter-spacing: 0.02em;
|
| 880 |
+
color: var(--body-text-color, #e5e7eb) !important;
|
| 881 |
+
}
|
| 882 |
+
#age-confirm {
|
| 883 |
+
padding: 10px 12px !important;
|
| 884 |
+
min-height: 0 !important;
|
| 885 |
+
}
|
| 886 |
+
#age-confirm label, #age-confirm label span, #age-confirm span {
|
| 887 |
+
font-size: 14px !important;
|
| 888 |
+
font-weight: 400 !important;
|
| 889 |
+
line-height: 1.35 !important;
|
| 890 |
+
color: var(--body-text-color, #e5e7eb) !important;
|
| 891 |
+
}
|
| 892 |
+
#age-confirm input[type="checkbox"] {
|
| 893 |
+
width: 18px !important;
|
| 894 |
+
height: 18px !important;
|
| 895 |
+
min-width: 18px !important;
|
| 896 |
+
border: 2px solid #FF4040 !important;
|
| 897 |
+
border-radius: 5px !important;
|
| 898 |
+
cursor: pointer;
|
| 899 |
+
}
|
| 900 |
+
#age-confirm input[type="checkbox"]:checked {
|
| 901 |
+
background-color: #FF1010 !important;
|
| 902 |
+
border-color: #FF1010 !important;
|
| 903 |
+
}
|
| 904 |
+
#seed-slider .wrap.generating,
|
| 905 |
+
#seed-slider .wrap.pending,
|
| 906 |
+
#seed-slider .progress-text,
|
| 907 |
+
#seed-slider .progress-level {
|
| 908 |
+
display: none !important;
|
| 909 |
}
|
| 910 |
+
"""
|
| 911 |
+
|
| 912 |
+
_RATIO_ICON_MAX = 30
|
| 913 |
+
_RATIO_ICON_BOX = 34
|
| 914 |
+
_RATIO_ACCENT = "var(--checkbox-background-color-selected, var(--color-accent, var(--primary-500)))"
|
| 915 |
+
|
| 916 |
+
|
| 917 |
+
def _ratio_css():
|
| 918 |
+
acc = _RATIO_ACCENT
|
| 919 |
+
rules = [
|
| 920 |
+
"#aspect-ratio .wrap { display: grid !important; grid-template-columns: repeat(5, minmax(0, 1fr)) !important; gap: 6px !important; }",
|
| 921 |
+
"#aspect-ratio label { display: flex !important; flex-direction: column !important; align-items: center !important; "
|
| 922 |
+
"justify-content: center !important; gap: 0 !important; width: 100% !important; min-width: 0 !important; "
|
| 923 |
+
"margin: 0 !important; padding: 8px 2px 6px !important; box-sizing: border-box !important; "
|
| 924 |
+
"border-radius: 12px !important; cursor: pointer; }",
|
| 925 |
+
"#aspect-ratio label input { display: none !important; }",
|
| 926 |
+
"#aspect-ratio label span { font-size: 13px !important; line-height: 1.2 !important; white-space: nowrap; margin: 0 !important; padding: 0 !important; text-indent: 0 !important; text-align: center !important; }",
|
| 927 |
+
"#aspect-ratio label::before { content: \"\"; display: block; flex: none; border-radius: 4px; background: rgba(128, 128, 128, 0.38); }",
|
| 928 |
+
f"#aspect-ratio label:has(input:checked), #aspect-ratio label.selected {{ border-color: color-mix(in srgb, {acc} 90%, transparent) !important; background: color-mix(in srgb, {acc} 14%, transparent) !important; }}",
|
| 929 |
+
f"#aspect-ratio label:has(input:checked)::before, #aspect-ratio label.selected::before {{ background: color-mix(in srgb, {acc} 70%, transparent); }}",
|
| 930 |
+
'#aspect-ratio label:has(input[value="Default"])::before { background: transparent !important; border: 1.5px dashed rgba(150, 150, 150, 0.8); box-sizing: border-box; }',
|
| 931 |
+
f'#aspect-ratio label:has(input[value="Default"]:checked)::before, #aspect-ratio label.selected:has(input[value="Default"])::before {{ border-color: color-mix(in srgb, {acc} 95%, transparent); }}',
|
| 932 |
+
]
|
| 933 |
+
for label in ASPECT_RATIOS:
|
| 934 |
+
w, h = (4, 3) if label == RATIO_DEFAULT else (int(x) for x in label.split(":"))
|
| 935 |
+
k = _RATIO_ICON_MAX / max(w, h)
|
| 936 |
+
iw, ih = round(w * k), round(h * k)
|
| 937 |
+
pad = (_RATIO_ICON_BOX - ih) / 2
|
| 938 |
+
rules.append(
|
| 939 |
+
f'#aspect-ratio label:has(input[value="{label}"])::before '
|
| 940 |
+
f'{{ width: {iw}px; height: {ih}px; margin: {pad:g}px 0; }}'
|
| 941 |
+
)
|
| 942 |
+
return "\n".join(rules) + "\n"
|
| 943 |
+
|
| 944 |
+
|
| 945 |
+
css += _ratio_css()
|
| 946 |
+
css += f"""
|
| 947 |
+
#output-format {{ background: transparent !important; border: none !important; box-shadow: none !important; padding: 0 !important; }}
|
| 948 |
+
#output-format .wrap {{ display: flex !important; justify-content: center !important; gap: 1px !important; background: transparent !important; padding: 0 !important; margin: 0 !important; }}
|
| 949 |
+
#output-format label {{ flex: 1 1 0 !important; min-width: 0 !important; justify-content: center !important; align-items: center !important; height: 38px !important; min-height: 0 !important; margin: 0 !important; padding: 0 8px !important; border: none !important; border-radius: 0 !important; box-shadow: none !important; background: var(--block-background-fill) !important; }}
|
| 950 |
+
#output-format label:has(input:checked), #output-format label.selected, #lanczos-row .block:has(input:checked) {{ background-image: linear-gradient(color-mix(in srgb, {_RATIO_ACCENT} 14%, transparent), color-mix(in srgb, {_RATIO_ACCENT} 14%, transparent)) !important; }}
|
| 951 |
+
.lanczos-title {{ padding: 4px 0 3px 6px !important; background: var(--block-background-fill) !important; }}
|
| 952 |
+
"""
|
| 953 |
+
|
| 954 |
+
css += """
|
| 955 |
#gpu-size .wrap {
|
| 956 |
display: grid !important;
|
| 957 |
grid-template-columns: repeat(2, 1fr) !important;
|
|
|
|
| 978 |
margin: 0 !important;
|
| 979 |
font-family: inherit !important;
|
| 980 |
font-size: var(--checkbox-label-text-size, var(--text-md, 1rem)) !important;
|
| 981 |
+
font-size: min(var(--checkbox-label-text-size, var(--text-md, 1rem)), 4.8cqw) !important;
|
| 982 |
white-space: nowrap !important;
|
| 983 |
font-style: normal !important;
|
| 984 |
line-height: 1.5 !important;
|
| 985 |
opacity: 0.7;
|
| 986 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 987 |
"""
|
| 988 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 989 |
_theme_dir = os.path.dirname(os.path.abspath(__file__))
|
| 990 |
_theme_pick = os.environ.get("UI_THEME", "")
|
| 991 |
if _theme_pick == "off":
|
|
|
|
| 1002 |
with gr.Blocks(**({} if _GR6 else {"css": css})) as demo:
|
| 1003 |
with gr.Tabs():
|
| 1004 |
with gr.Tab("Editor"):
|
| 1005 |
+
with gr.Column(elem_id="col-container"):
|
| 1006 |
+
gr.HTML(
|
| 1007 |
+
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap">'
|
| 1008 |
+
'<div id="logo-title">'
|
| 1009 |
+
'<h1 id="ast-main-title">Multi Ratio Image Editor</h1>'
|
| 1010 |
+
'<br>'
|
| 1011 |
+
'<h1 id="ast-nfaa-title">Not For All Audiences</h1>'
|
| 1012 |
+
'<div id="ast-notice">'
|
| 1013 |
+
'<p><b>Adults only:</b> Zero tolerance for any content involving minors. '
|
| 1014 |
+
'Images and prompts are screened, and violations are blocked.</p>'
|
| 1015 |
+
'<p>Please upload only images of adults who have consented. '
|
| 1016 |
+
'Under-18s must not use this Space.</p>'
|
| 1017 |
+
'<p>We apologize if your adult photo is blocked because it looks young, '
|
| 1018 |
+
'or if it is blocked by error. Automated screening is not perfect.</p>'
|
| 1019 |
+
'</div>'
|
| 1020 |
+
'</div>'
|
| 1021 |
+
)
|
| 1022 |
with gr.Row():
|
| 1023 |
with gr.Column():
|
| 1024 |
with gr.Row():
|
| 1025 |
image_1 = gr.Image(label="Image 1", type="filepath", interactive=True)
|
| 1026 |
+
image_2 = gr.Image(label="Image 2 (optional)", type="filepath", interactive=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1027 |
|
| 1028 |
prompt = gr.Text(
|
| 1029 |
label="Prompt 🪄",
|
|
|
|
| 1031 |
placeholder="Enter your prompt here...",
|
| 1032 |
)
|
| 1033 |
run_button = gr.Button("Edit!", variant="primary", interactive=False)
|
| 1034 |
+
age_confirm = gr.Checkbox(
|
| 1035 |
+
label="I am 18+ and will use only images of consenting adults",
|
| 1036 |
+
value=False,
|
| 1037 |
+
elem_id="age-confirm",
|
| 1038 |
+
)
|
| 1039 |
|
| 1040 |
with gr.Group():
|
| 1041 |
+
aspect_ratio = gr.Radio(
|
| 1042 |
+
label="Aspect Ratio",
|
| 1043 |
+
show_label=True,
|
| 1044 |
+
choices=ASPECT_RATIOS,
|
| 1045 |
+
value=RATIO_DEFAULT,
|
| 1046 |
+
elem_id="aspect-ratio",
|
|
|
|
|
|
|
|
|
|
| 1047 |
)
|
| 1048 |
|
| 1049 |
with gr.Group():
|
| 1050 |
+
gr.Markdown("Lanczos Upscale", elem_classes=["lanczos-title"])
|
| 1051 |
+
with gr.Row(elem_id="lanczos-row"):
|
| 1052 |
+
scale_basic = gr.Checkbox(label="Basic", value=False, min_width=0)
|
| 1053 |
+
scale_medium = gr.Checkbox(label="Medium", value=False, min_width=0)
|
| 1054 |
+
scale_high = gr.Checkbox(label="High", value=False, min_width=0)
|
| 1055 |
+
|
| 1056 |
+
with gr.Group():
|
| 1057 |
+
gr.Markdown("Output Format", elem_classes=["lanczos-title"])
|
| 1058 |
output_format = gr.Radio(
|
| 1059 |
label="Output Format",
|
| 1060 |
+
show_label=False,
|
| 1061 |
choices=["PNG", "JPG", "WebP"],
|
| 1062 |
value="PNG",
|
| 1063 |
elem_id="output-format",
|
|
|
|
| 1091 |
)
|
| 1092 |
|
| 1093 |
with gr.Group():
|
| 1094 |
+
gpu_size = gr.Radio(
|
| 1095 |
+
label="GPU Size",
|
| 1096 |
+
choices=[("Large", "large"), ("Xlarge", "xlarge")],
|
| 1097 |
+
value=GPU_SIZE_DEFAULT,
|
| 1098 |
+
elem_id="gpu-size",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1099 |
)
|
| 1100 |
+
gr.Markdown(
|
| 1101 |
+
"⚠️ Note: Xlarge is faster but uses 2× quota",
|
| 1102 |
+
elem_id="gpu-size-note",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1103 |
)
|
| 1104 |
|
| 1105 |
with gr.Column():
|
| 1106 |
result = gr.Gallery(label="Result", show_label=False, type="filepath")
|
| 1107 |
+
use_output_btn = gr.Button("↗️ Use as input", variant="secondary", size="sm", visible=False)
|
|
|
|
| 1108 |
|
|
|
|
| 1109 |
|
| 1110 |
with gr.Tab("🔒", visible=False) as admin_tab:
|
| 1111 |
with gr.Group(visible=True) as admin_lock_group:
|
|
|
|
| 1162 |
**_PRIVATE,
|
| 1163 |
)
|
| 1164 |
|
| 1165 |
+
for _box, _js in (
|
| 1166 |
+
(scale_basic, "(on) => [on, false, false]"),
|
| 1167 |
+
(scale_medium, "(on) => [false, on, false]"),
|
| 1168 |
+
(scale_high, "(on) => [false, false, on]"),
|
| 1169 |
+
):
|
| 1170 |
+
_box.input(
|
| 1171 |
+
fn=None,
|
| 1172 |
+
inputs=[_box],
|
| 1173 |
+
outputs=[scale_basic, scale_medium, scale_high],
|
| 1174 |
+
js=_js,
|
| 1175 |
+
show_progress="hidden",
|
| 1176 |
+
**_PRIVATE,
|
| 1177 |
+
)
|
| 1178 |
+
|
| 1179 |
+
edit_event = gr.on(
|
| 1180 |
triggers=[run_button.click, prompt.submit],
|
| 1181 |
+
fn=run_qwen_and_flux,
|
| 1182 |
inputs=[
|
| 1183 |
image_1,
|
| 1184 |
image_2,
|
|
|
|
| 1189 |
num_inference_steps,
|
| 1190 |
gpu_duration,
|
| 1191 |
gpu_size,
|
| 1192 |
+
aspect_ratio,
|
| 1193 |
+
scale_basic,
|
| 1194 |
+
scale_medium,
|
| 1195 |
+
scale_high,
|
|
|
|
| 1196 |
output_format,
|
| 1197 |
+
age_confirm,
|
| 1198 |
],
|
| 1199 |
outputs=[result, seed, use_output_btn],
|
| 1200 |
**({"show_progress_on": [result]} if _SHOW_PROGRESS_ON else {}),
|
| 1201 |
+
)
|
| 1202 |
+
edit_event.then(
|
| 1203 |
fn=_sync_result_followups,
|
| 1204 |
inputs=result,
|
| 1205 |
outputs=use_output_btn,
|
| 1206 |
show_progress="hidden",
|
| 1207 |
)
|
| 1208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1209 |
use_output_btn.click(fn=use_output_as_input, inputs=[result], outputs=[image_1])
|
|
|
|
| 1210 |
result.change(
|
| 1211 |
fn=_sync_result_followups,
|
| 1212 |
inputs=result,
|
|
|
|
| 1214 |
show_progress="hidden",
|
| 1215 |
)
|
| 1216 |
|
| 1217 |
+
age_confirm.change(
|
| 1218 |
+
fn=None,
|
| 1219 |
+
inputs=[age_confirm, prompt],
|
| 1220 |
+
outputs=run_button,
|
| 1221 |
+
js="(confirmed, text) => ({__type__: 'update', interactive: !!(confirmed && text && text.trim())})",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1222 |
show_progress="hidden",
|
| 1223 |
**_PRIVATE,
|
| 1224 |
)
|
| 1225 |
|
| 1226 |
+
prompt.change(
|
| 1227 |
+
fn=None,
|
| 1228 |
+
inputs=[prompt, age_confirm],
|
| 1229 |
+
outputs=run_button,
|
| 1230 |
+
js="(text, confirmed) => ({__type__: 'update', interactive: !!(confirmed && text && text.trim())})",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1231 |
show_progress="hidden",
|
| 1232 |
**_PRIVATE,
|
| 1233 |
)
|
| 1234 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1235 |
demo.load(fn=mark_browser_session, inputs=None, outputs=None, **_PRIVATE)
|
| 1236 |
demo.load(fn=check_admin_tab_visibility, inputs=None, outputs=[admin_tab], **_PRIVATE)
|
| 1237 |
|
|
|
|
| 1255 |
)
|
| 1256 |
|
| 1257 |
if __name__ == "__main__":
|
| 1258 |
+
Age_classifier.warmup()
|
| 1259 |
+
_block_image()
|
| 1260 |
demo.queue(max_size=int(os.environ.get("QUEUE_MAX_SIZE", "30")))
|
| 1261 |
launch_kwargs = dict(
|
| 1262 |
ssr_mode=False,
|
esrgan.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import torch
|
| 3 |
+
import torch.nn as nn
|
| 4 |
+
import torch.nn.functional as F
|
| 5 |
+
from huggingface_hub import HfApi, hf_hub_download
|
| 6 |
+
from PIL import Image
|
| 7 |
+
from safetensors.torch import load_file
|
| 8 |
+
|
| 9 |
+
ESRGAN_REPO = "AST-1320/RealESRGAN_x2plus"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ResidualDenseBlock(nn.Module):
|
| 13 |
+
def __init__(self, num_feat=64, num_grow_ch=32):
|
| 14 |
+
super().__init__()
|
| 15 |
+
self.conv1 = nn.Conv2d(num_feat, num_grow_ch, 3, 1, 1)
|
| 16 |
+
self.conv2 = nn.Conv2d(num_feat + num_grow_ch, num_grow_ch, 3, 1, 1)
|
| 17 |
+
self.conv3 = nn.Conv2d(num_feat + 2 * num_grow_ch, num_grow_ch, 3, 1, 1)
|
| 18 |
+
self.conv4 = nn.Conv2d(num_feat + 3 * num_grow_ch, num_grow_ch, 3, 1, 1)
|
| 19 |
+
self.conv5 = nn.Conv2d(num_feat + 4 * num_grow_ch, num_feat, 3, 1, 1)
|
| 20 |
+
self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True)
|
| 21 |
+
|
| 22 |
+
def forward(self, x):
|
| 23 |
+
x1 = self.lrelu(self.conv1(x))
|
| 24 |
+
x2 = self.lrelu(self.conv2(torch.cat((x, x1), 1)))
|
| 25 |
+
x3 = self.lrelu(self.conv3(torch.cat((x, x1, x2), 1)))
|
| 26 |
+
x4 = self.lrelu(self.conv4(torch.cat((x, x1, x2, x3), 1)))
|
| 27 |
+
x5 = self.conv5(torch.cat((x, x1, x2, x3, x4), 1))
|
| 28 |
+
return x5 * 0.2 + x
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class RRDB(nn.Module):
|
| 32 |
+
def __init__(self, num_feat, num_grow_ch=32):
|
| 33 |
+
super().__init__()
|
| 34 |
+
self.rdb1 = ResidualDenseBlock(num_feat, num_grow_ch)
|
| 35 |
+
self.rdb2 = ResidualDenseBlock(num_feat, num_grow_ch)
|
| 36 |
+
self.rdb3 = ResidualDenseBlock(num_feat, num_grow_ch)
|
| 37 |
+
|
| 38 |
+
def forward(self, x):
|
| 39 |
+
out = self.rdb3(self.rdb2(self.rdb1(x)))
|
| 40 |
+
return out * 0.2 + x
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class RRDBNet(nn.Module):
|
| 44 |
+
def __init__(self, num_in_ch=3, num_out_ch=3, num_feat=64, num_block=23, num_grow_ch=32):
|
| 45 |
+
super().__init__()
|
| 46 |
+
self.conv_first = nn.Conv2d(num_in_ch * 4, num_feat, 3, 1, 1)
|
| 47 |
+
self.body = nn.Sequential(*[RRDB(num_feat, num_grow_ch) for _ in range(num_block)])
|
| 48 |
+
self.conv_body = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
|
| 49 |
+
self.conv_up1 = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
|
| 50 |
+
self.conv_up2 = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
|
| 51 |
+
self.conv_hr = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
|
| 52 |
+
self.conv_last = nn.Conv2d(num_feat, num_out_ch, 3, 1, 1)
|
| 53 |
+
self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True)
|
| 54 |
+
|
| 55 |
+
def forward(self, x):
|
| 56 |
+
feat = self.conv_first(F.pixel_unshuffle(x, 2))
|
| 57 |
+
feat = feat + self.conv_body(self.body(feat))
|
| 58 |
+
feat = self.lrelu(self.conv_up1(F.interpolate(feat, scale_factor=2, mode="nearest")))
|
| 59 |
+
feat = self.lrelu(self.conv_up2(F.interpolate(feat, scale_factor=2, mode="nearest")))
|
| 60 |
+
return self.conv_last(self.lrelu(self.conv_hr(feat)))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def load_esrgan(device):
|
| 64 |
+
files = sorted(f for f in HfApi().list_repo_files(ESRGAN_REPO) if f.endswith(".safetensors"))
|
| 65 |
+
if not files:
|
| 66 |
+
raise FileNotFoundError(f"no .safetensors file in {ESRGAN_REPO}")
|
| 67 |
+
state = load_file(hf_hub_download(repo_id=ESRGAN_REPO, filename=files[0]))
|
| 68 |
+
for prefix in ("params_ema.", "params.", "model."):
|
| 69 |
+
if any(k.startswith(prefix) for k in state):
|
| 70 |
+
state = {(k[len(prefix):] if k.startswith(prefix) else k): v for k, v in state.items()}
|
| 71 |
+
net = RRDBNet()
|
| 72 |
+
net.load_state_dict(state, strict=True)
|
| 73 |
+
dtype = torch.float16 if str(device).startswith("cuda") else torch.float32
|
| 74 |
+
return net.eval().to(device=device, dtype=dtype)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@torch.no_grad()
|
| 78 |
+
def esrgan_x2(model, image):
|
| 79 |
+
param = next(model.parameters())
|
| 80 |
+
arr = np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0
|
| 81 |
+
x = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0).to(device=param.device, dtype=param.dtype)
|
| 82 |
+
h, w = x.shape[2], x.shape[3]
|
| 83 |
+
if h % 2 or w % 2:
|
| 84 |
+
x = F.pad(x, (0, w % 2, 0, h % 2), mode="reflect")
|
| 85 |
+
y = model(x)[:, :, : h * 2, : w * 2].float().clamp_(0, 1)
|
| 86 |
+
out = (y.squeeze(0).permute(1, 2, 0).cpu().numpy() * 255.0).round().astype(np.uint8)
|
| 87 |
+
return Image.fromarray(out)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def upscale_to_pixels(model, image, target_pixels):
|
| 91 |
+
w, h = image.size
|
| 92 |
+
scale = (target_pixels / (w * h)) ** 0.5
|
| 93 |
+
if scale <= 1.0:
|
| 94 |
+
return image
|
| 95 |
+
target = (round(w * scale), round(h * scale))
|
| 96 |
+
if model is not None:
|
| 97 |
+
image = esrgan_x2(model, image)
|
| 98 |
+
if image.size != target:
|
| 99 |
+
image = image.resize(target, Image.LANCZOS)
|
| 100 |
+
return image
|
packages.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
libgles2
|
| 2 |
+
libegl1
|
| 3 |
+
libgl1
|
requirements.txt
CHANGED
|
@@ -7,4 +7,6 @@ torchvision==0.28.0
|
|
| 7 |
torchao==0.18.0
|
| 8 |
huggingface-hub==1.33.0
|
| 9 |
tokenizers==0.23.3
|
| 10 |
-
pillow==12.3.0
|
|
|
|
|
|
|
|
|
| 7 |
torchao==0.18.0
|
| 8 |
huggingface-hub==1.33.0
|
| 9 |
tokenizers==0.23.3
|
| 10 |
+
pillow==12.3.0
|
| 11 |
+
opencv-python-headless==4.13.0.92
|
| 12 |
+
mediapipe==0.10.35
|
security.py
CHANGED
|
@@ -88,7 +88,7 @@ def save_input_images(sources, seed):
|
|
| 88 |
|
| 89 |
|
| 90 |
def log_generation(path, prompt, seed, steps, guidance, model, width, height,
|
| 91 |
-
out_width=None, out_height=None,
|
| 92 |
try:
|
| 93 |
os.makedirs("outputs", exist_ok=True)
|
| 94 |
entry = {
|
|
@@ -102,7 +102,6 @@ def log_generation(path, prompt, seed, steps, guidance, model, width, height,
|
|
| 102 |
"height": height,
|
| 103 |
"out_width": out_width,
|
| 104 |
"out_height": out_height,
|
| 105 |
-
"duplicated_input": duplicated,
|
| 106 |
"inputs": list(inputs or []),
|
| 107 |
"timestamp": time.time(),
|
| 108 |
}
|
|
@@ -229,64 +228,6 @@ def check_admin_password(password, request: gr.Request):
|
|
| 229 |
return (*locked, "❌ Wrong password")
|
| 230 |
|
| 231 |
|
| 232 |
-
APP_PASSWORD = os.environ.get("APP_PASSWORD", "")
|
| 233 |
-
_USER_SESSIONS = set()
|
| 234 |
-
_USER_LOCK = threading.Lock()
|
| 235 |
-
_USER_FAILS = {}
|
| 236 |
-
_USER_FAILS_ALL = []
|
| 237 |
-
_USER_MAX_FAILS = 5
|
| 238 |
-
_USER_MAX_FAILS_ALL = 60
|
| 239 |
-
_USER_WINDOW_S = 600
|
| 240 |
-
_USER_FAILS_MAX_KEYS = 5000
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
def is_user(request):
|
| 244 |
-
sid = _session_id(request)
|
| 245 |
-
if not sid:
|
| 246 |
-
return False
|
| 247 |
-
with _USER_LOCK:
|
| 248 |
-
return sid in _USER_SESSIONS
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
def check_user_password(password, request: gr.Request):
|
| 252 |
-
locked = (gr.update(visible=True), gr.update(visible=False))
|
| 253 |
-
if not APP_PASSWORD:
|
| 254 |
-
return (*locked, "⚠️ No APP_PASSWORD secret set on this Space — app stays locked.")
|
| 255 |
-
sid = _session_id(request)
|
| 256 |
-
ckey = _client_key(request)
|
| 257 |
-
now = time.time()
|
| 258 |
-
with _USER_LOCK:
|
| 259 |
-
_USER_FAILS_ALL[:] = [t for t in _USER_FAILS_ALL if now - t < _USER_WINDOW_S]
|
| 260 |
-
count, start = _USER_FAILS.get(ckey, [0, now])
|
| 261 |
-
if now - start > _USER_WINDOW_S:
|
| 262 |
-
count, start = 0, now
|
| 263 |
-
if count >= _USER_MAX_FAILS or len(_USER_FAILS_ALL) >= _USER_MAX_FAILS_ALL:
|
| 264 |
-
return (*locked, "⏳ Too many attempts — try again later.")
|
| 265 |
-
if sid and hmac.compare_digest(str(password or "").encode("utf-8"), APP_PASSWORD.encode("utf-8")):
|
| 266 |
-
with _USER_LOCK:
|
| 267 |
-
_USER_SESSIONS.add(sid)
|
| 268 |
-
_USER_FAILS.pop(ckey, None)
|
| 269 |
-
return gr.update(visible=False), gr.update(visible=True), ""
|
| 270 |
-
with _USER_LOCK:
|
| 271 |
-
_USER_FAILS[ckey] = [count + 1, start]
|
| 272 |
-
_USER_FAILS_ALL.append(now)
|
| 273 |
-
if len(_USER_FAILS) > _USER_FAILS_MAX_KEYS:
|
| 274 |
-
for k in [k for k, v in _USER_FAILS.items() if now - v[1] > _USER_WINDOW_S]:
|
| 275 |
-
_USER_FAILS.pop(k, None)
|
| 276 |
-
time.sleep(1.0)
|
| 277 |
-
return (*locked, "❌ Wrong password")
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
def end_session(request: gr.Request):
|
| 281 |
-
sid = _session_id(request)
|
| 282 |
-
if not sid:
|
| 283 |
-
return
|
| 284 |
-
with _USER_LOCK:
|
| 285 |
-
_USER_SESSIONS.discard(sid)
|
| 286 |
-
_ADMIN_SESSIONS.discard(sid)
|
| 287 |
-
_ADMIN_URL_OK_SESSIONS.discard(sid)
|
| 288 |
-
|
| 289 |
-
|
| 290 |
_BROWSER_SESSIONS = {}
|
| 291 |
_BROWSER_SESSIONS_MAX = 20000
|
| 292 |
|
|
@@ -324,6 +265,9 @@ _RATE_STATS = {
|
|
| 324 |
"blocked_locked": 0,
|
| 325 |
"lockouts": 0,
|
| 326 |
"lockouts_extended": 0,
|
|
|
|
|
|
|
|
|
|
| 327 |
"since": time.time(),
|
| 328 |
}
|
| 329 |
|
|
@@ -401,6 +345,13 @@ def show_rate_message(msg):
|
|
| 401 |
gr.Warning(msg)
|
| 402 |
|
| 403 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
def rate_acquire(request):
|
| 405 |
if is_admin(request):
|
| 406 |
return True, None, ""
|
|
@@ -427,7 +378,7 @@ def rate_acquire(request):
|
|
| 427 |
pen["spam"] = 0
|
| 428 |
pen["until"] = min(pen["until"] + SPAM_EXTRA_LOCK_S, now + LOCKOUT_MAX_S)
|
| 429 |
_RATE_STATS["lockouts_extended"] += 1
|
| 430 |
-
print(f"[rate] lock extended by {SPAM_EXTRA_LOCK_S}s
|
| 431 |
return False, None, _paused_msg(pen["until"] - now)
|
| 432 |
|
| 433 |
hits = [t for t in _RATE_HITS.get(key, []) if now - t < RATE_WINDOW_S]
|
|
@@ -548,7 +499,7 @@ def _flood_retry_after(ip, bucket="join", limit=None):
|
|
| 548 |
until = min(until + SPAM_EXTRA_LOCK_S, now + LOCKOUT_MAX_S)
|
| 549 |
_FLOOD_BANNED[ip] = until
|
| 550 |
_FLOOD_STATS["extended"] += 1
|
| 551 |
-
print(f"[flood]
|
| 552 |
_FLOOD_SPAM[ip] = spam
|
| 553 |
return int(until - now) + 1
|
| 554 |
if until:
|
|
@@ -644,6 +595,7 @@ def load_rate_stats(request: gr.Request):
|
|
| 644 |
f"- Blocked - locked-out IP: {st['blocked_locked']}",
|
| 645 |
f"- Lockouts issued: {st['lockouts']}",
|
| 646 |
f"- Lockouts extended (spam while locked): {st['lockouts_extended']}",
|
|
|
|
| 647 |
f"- Browser sessions tracked: {sessions}",
|
| 648 |
f"- IPs locked right now: {len(locked)}",
|
| 649 |
f"- Flood-guard bans issued: {flood_bans}",
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
def log_generation(path, prompt, seed, steps, guidance, model, width, height,
|
| 91 |
+
out_width=None, out_height=None, inputs=None):
|
| 92 |
try:
|
| 93 |
os.makedirs("outputs", exist_ok=True)
|
| 94 |
entry = {
|
|
|
|
| 102 |
"height": height,
|
| 103 |
"out_width": out_width,
|
| 104 |
"out_height": out_height,
|
|
|
|
| 105 |
"inputs": list(inputs or []),
|
| 106 |
"timestamp": time.time(),
|
| 107 |
}
|
|
|
|
| 228 |
return (*locked, "❌ Wrong password")
|
| 229 |
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
_BROWSER_SESSIONS = {}
|
| 232 |
_BROWSER_SESSIONS_MAX = 20000
|
| 233 |
|
|
|
|
| 265 |
"blocked_locked": 0,
|
| 266 |
"lockouts": 0,
|
| 267 |
"lockouts_extended": 0,
|
| 268 |
+
"age_prompt": 0,
|
| 269 |
+
"age_input": 0,
|
| 270 |
+
"age_output": 0,
|
| 271 |
"since": time.time(),
|
| 272 |
}
|
| 273 |
|
|
|
|
| 345 |
gr.Warning(msg)
|
| 346 |
|
| 347 |
|
| 348 |
+
def record_age_block(kind, request=None):
|
| 349 |
+
kind = f"age_{kind}"
|
| 350 |
+
with _RATE_LOCK:
|
| 351 |
+
if kind in _RATE_STATS:
|
| 352 |
+
_RATE_STATS[kind] += 1
|
| 353 |
+
|
| 354 |
+
|
| 355 |
def rate_acquire(request):
|
| 356 |
if is_admin(request):
|
| 357 |
return True, None, ""
|
|
|
|
| 378 |
pen["spam"] = 0
|
| 379 |
pen["until"] = min(pen["until"] + SPAM_EXTRA_LOCK_S, now + LOCKOUT_MAX_S)
|
| 380 |
_RATE_STATS["lockouts_extended"] += 1
|
| 381 |
+
print(f"[rate] lock extended by {SPAM_EXTRA_LOCK_S}s (spam while locked)")
|
| 382 |
return False, None, _paused_msg(pen["until"] - now)
|
| 383 |
|
| 384 |
hits = [t for t in _RATE_HITS.get(key, []) if now - t < RATE_WINDOW_S]
|
|
|
|
| 499 |
until = min(until + SPAM_EXTRA_LOCK_S, now + LOCKOUT_MAX_S)
|
| 500 |
_FLOOD_BANNED[ip] = until
|
| 501 |
_FLOOD_STATS["extended"] += 1
|
| 502 |
+
print(f"[flood] ban extended by {SPAM_EXTRA_LOCK_S}s (spam while banned)")
|
| 503 |
_FLOOD_SPAM[ip] = spam
|
| 504 |
return int(until - now) + 1
|
| 505 |
if until:
|
|
|
|
| 595 |
f"- Blocked - locked-out IP: {st['blocked_locked']}",
|
| 596 |
f"- Lockouts issued: {st['lockouts']}",
|
| 597 |
f"- Lockouts extended (spam while locked): {st['lockouts_extended']}",
|
| 598 |
+
f"- Age classifier blocks - prompt: {st['age_prompt']}, input: {st['age_input']}, output: {st['age_output']}",
|
| 599 |
f"- Browser sessions tracked: {sessions}",
|
| 600 |
f"- IPs locked right now: {len(locked)}",
|
| 601 |
f"- Flood-guard bans issued: {flood_bans}",
|