AST-1320 commited on
Commit
d28ebd6
·
verified ·
1 Parent(s): be83e6d

Upload 6 files

Browse files
Files changed (6) hide show
  1. Age_classifier.py +1202 -0
  2. app.py +556 -284
  3. esrgan.py +100 -0
  4. packages.txt +3 -0
  5. requirements.txt +3 -1
  6. 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
- PRESET_RESOLUTIONS = [
121
- ("720×1280 (9:16)", 720, 1280),
122
- ("864×1536 (9:16)", 864, 1536),
123
- ("1080×1920 (9:16)", 1080, 1920),
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", "1600"))
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
- _enable_vae_tiling(pipe, QWEN_VAE_TILE)
 
 
 
 
 
 
 
 
 
 
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 _auto_size(scale_choice, image_1, image_2):
 
 
 
 
 
 
 
 
 
298
  src = image_1 if image_1 is not None else image_2
299
  if src is None:
300
- return None, None
301
  try:
302
  with Image.open(src if isinstance(src, str) else src.name) as im:
303
- width, height = im.size
304
  except Exception:
305
- return None, None
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(mode, preset, custom_width, custom_height, auto_scale, image_1, image_2):
314
  try:
315
- if mode == "Preset" and preset in PRESET_RESOLUTION_MAP:
316
- return _round_size(*PRESET_RESOLUTION_MAP[preset])
317
- if mode == "Custom":
318
- return _round_size(int(custom_width), int(custom_height))
319
- if mode == "Auto":
320
- return _auto_size(auto_scale, image_1, image_2)
321
- except (TypeError, ValueError, OverflowError):
322
  pass
323
- return None, None
 
 
 
 
 
 
 
 
 
 
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
- duplicated = False
389
- if duplicate_input and len(pil_images) == 1:
390
- pil_images.append(pil_images[0].copy())
391
- duplicated = True
 
 
 
 
 
 
 
392
 
393
  res_txt = f"{width}x{height}" if (width and height) else "auto"
394
- print(f"[gen] model={MODEL_QWEN} guidance={true_guidance_scale} steps={num_inference_steps} resolution={res_txt}")
 
 
395
 
396
  if torch.cuda.is_available():
397
  torch.cuda.reset_peak_memory_stats(device)
398
- log_vram(f"{MODEL_QWEN} before generation")
399
 
400
  try:
401
- images_pil = pipe(
402
- image=pil_images or None,
403
- prompt=prompt,
404
- height=height,
405
- width=width,
406
- negative_prompt=" ",
407
- num_inference_steps=num_inference_steps,
408
- generator=generator,
409
- true_cfg_scale=true_guidance_scale,
410
- num_images_per_prompt=1,
411
- ).images
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
 
413
  except torch.cuda.OutOfMemoryError:
414
  _free_vram_cache()
415
  raise
416
- log_vram(f"{MODEL_QWEN} after generation")
417
 
418
  return {
419
  "images": images_pil, "seed": seed, "width": width, "height": height,
420
- "duplicated": duplicated, "steps": num_inference_steps,
421
- "guidance": true_guidance_scale, "model": MODEL_QWEN,
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 _make_gpu_variant(duration_seconds, size):
460
- def _infer_gpu_call(*args, **kwargs):
461
- return _generate_core(*args, **kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
 
463
- _infer_gpu_call.__name__ = f"_infer_impl_gpu_{size}_{duration_seconds}s"
464
- _infer_gpu_call.__qualname__ = _infer_gpu_call.__name__
465
- return spaces.GPU(duration=duration_seconds, size=size)(_infer_gpu_call)
466
 
467
 
468
- _gpu_variants = {(s, d): _make_gpu_variant(d, s) for s in GPU_SIZE_OPTIONS for d in _GPU_DURATION_STEPS}
469
 
470
 
471
- def _pick_gpu_variant(gpu_duration, gpu_size=GPU_SIZE_DEFAULT):
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 = _gpu_variants[(size, chosen)]
476
 
477
- def _call(image_1, image_2, prompt, *args, output_format="PNG", **kwargs):
478
- res = gpu_fn(image_1, image_2, prompt, *args, **kwargs)
479
- return _finalize(res, image_1, image_2, prompt, output_format)
 
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 infer_qwen_only(
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
- resolution_mode,
513
- resolution_preset,
514
- custom_width,
515
- custom_height,
516
- auto_scale,
517
  output_format,
518
- duplicate_input,
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(resolution_mode, resolution_preset, custom_width, custom_height, auto_scale, image_1, image_2)
528
- return _pick_gpu_variant(duration, gpu_size)(
529
- image_1,
530
- image_2,
531
- prompt if isinstance(prompt, str) else "",
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
- /* Force a 2-column grid for the preset radio (Gradio's default flex-wrap stacks into one column on mobile). */
588
- .preset-grid > .wrap,
589
- .preset-grid .wrap {
590
- display: grid !important;
591
- grid-template-columns: 1fr 1fr !important;
592
- gap: 8px !important;
593
  }
594
- #adv-settings:has(#res-mode input[value="Preset"]:checked) #res-custom-height,
595
- #adv-settings:has(#res-mode input[value="Preset"]:checked) #res-custom-width,
596
- #adv-settings:has(#res-mode input[value="Preset"]:checked) #res-auto-scale,
597
- #adv-settings:has(#res-mode input[value="Custom"]:checked) #res-preset,
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
- /* Seed is an output component: hide Gradio's progress/status overlay so it never covers the number box. */
605
- #seed-slider .wrap.generating,
606
- #seed-slider .wrap.pending,
607
- #seed-slider .progress-text,
608
- #seed-slider .progress-level {
609
- display: none !important;
 
 
 
 
 
 
610
  }
611
- .preset-grid label {
612
- width: 100% !important;
613
- margin: 0 !important;
614
- box-sizing: border-box !important;
615
  }
616
- #res-mode .wrap {
617
- display: grid !important;
618
- grid-template-columns: repeat(3, 1fr) !important;
619
- gap: 8px !important;
 
 
 
 
620
  }
621
- #res-mode label {
622
- width: 100% !important;
623
- margin: 0 !important;
624
- box-sizing: border-box !important;
625
- justify-content: center !important;
626
  }
627
- #output-format .wrap {
628
- display: grid !important;
629
- grid-template-columns: repeat(3, 1fr) !important;
630
- gap: 8px !important;
 
 
 
 
 
631
  }
632
- #output-format label {
633
- width: 100% !important;
634
- margin: 0 !important;
635
- box-sizing: border-box !important;
636
- justify-content: center !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)), 5.2cqw) !important;
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.Group(visible=True) as login_group:
713
- login_password = gr.Textbox(label="Password", type="password", placeholder="Enter password")
714
- login_btn = gr.Button("Login", variant="primary")
715
- login_status = gr.Markdown("")
716
- with gr.Column(elem_id="col-container", visible=False) as app_col:
717
- gr.HTML('<div id="logo-title"><h1 id="ast-main-title">Qwen 2511 Image Edit</h1></div>')
 
 
 
 
 
 
 
 
 
 
 
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
- with gr.Column():
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
- gpu_size = gr.Radio(
738
- label="GPU Size",
739
- choices=[("Large", "large"), ("Xlarge", "xlarge")],
740
- value=GPU_SIZE_DEFAULT,
741
- elem_id="gpu-size",
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
- resolution_mode = gr.Radio(
785
- label="Resolution mode",
786
- choices=["Auto", "Preset", "Custom"],
787
- value="Auto",
788
- elem_id="res-mode",
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
- custom_height = gr.Slider(
798
- label="Height", minimum=256, maximum=2048, step=8, value=256,
799
- buttons=[], elem_id="res-custom-height",
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
- with gr.Row():
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
- gr.on(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
875
  triggers=[run_button.click, prompt.submit],
876
- fn=infer_qwen_only,
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
- resolution_mode,
888
- resolution_preset,
889
- custom_width,
890
- custom_height,
891
- auto_scale,
892
  output_format,
893
- duplicate_input,
894
  ],
895
  outputs=[result, seed, use_output_btn],
896
  **({"show_progress_on": [result]} if _SHOW_PROGRESS_ON else {}),
897
- ).then(
 
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
- gr.on(
923
- triggers=[login_btn.click, login_password.submit],
924
- fn=check_user_password,
925
- inputs=[login_password],
926
- outputs=[login_group, app_col, login_status],
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
- resolution_mode.change(
948
- fn=None, inputs=None, outputs=None,
949
- js="""() => {
950
- const nudge = () => {
951
- window.dispatchEvent(new Event("resize"));
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, duplicated=False, inputs=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 on {key} (spam while locked)")
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] {ip} kept sending while banned - ban extended by {SPAM_EXTRA_LOCK_S}s")
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}",