ImageStudio Maintainer Claude Opus 4.8 (1M context) commited on
Commit
2e9546f
·
1 Parent(s): f4bbab1

feat(safety): adultify realistic prompts, strengthen minor-block negatives, dedicated Qwen guard VLM

Browse files

- _adultify_prompt rewrites girl->woman / 1girl->1woman on models flagged
realistic (samanima, realism-illustrious, the 3 z-image); anime keeps 1girl.
- MINOR_NEG force-prepended in _resolve_negative for realistic models even when
the caller supplies a negative; seed loli/child/shota into anime negatives too.
- Split VLM: Gemma stays the prompt assistant; a dedicated GUARD_MODEL_ID
(default Qwen3.5-4B, 8bit) runs _moderate_image_inner, with graceful fallback
to the main VLM on load failure. eos/pad pinned for no-generation_config Qwen.
- Log peak VRAM in the generate GPU window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +146 -29
app.py CHANGED
@@ -117,12 +117,57 @@ NOOBXL_PREFIX = (
117
  "very aesthetic, depth of field, highres"
118
  )
119
  NOOBXL_NEGATIVE = (
 
120
  "worst quality, normal quality, anatomical nonsense, bad anatomy, "
121
  "interlocked fingers, extra fingers, watermark, simple background, transparent, "
122
  "low quality, logo, text, signature, face backlighting, backlighting"
123
  )
124
 
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  def _apply_prefix(prompt, prefix):
127
  """Prepend ``prefix`` to ``prompt``, dropping any prefix tag the prompt already
128
  contains (case-insensitive, per comma-separated token). Empty prefix is a no-op."""
@@ -171,6 +216,7 @@ IMAGE_MODELS = {
171
  "realism-illustrious": dict(
172
  label="Realism Illustrious (Stable Yogi)", family="illustrious",
173
  checkpoint="checkpoints/il/realismIllustriousBy_v55FP16.safetensors",
 
174
  steps=30, guidance=5.0, height=1152, width=896, negative=True,
175
  prefix="masterpiece, best quality, amazing quality, very aesthetic, absurdres, highres, ultra detailed",
176
  default_negative=(
@@ -182,16 +228,19 @@ IMAGE_MODELS = {
182
  "moody-pro-mix": dict(
183
  label=MODEL_ZIMAGE, family="zimageturbo",
184
  checkpoint="checkpoints/zimageturbo/moodyProMix_zitV12DPO.safetensors",
 
185
  steps=9, guidance=0.0, height=1024, width=1024, negative=False, prefix="",
186
  ),
187
  "pornmaster-zimage": dict(
188
  label="PornMaster Z-Image", family="zimageturbo",
189
  checkpoint="checkpoints/zimageturbo/pornmasterZImage_turboV35Bf16.safetensors",
 
190
  steps=9, guidance=0.0, height=1024, width=1024, negative=False, prefix="",
191
  ),
192
  "zimage-nsfw-yogi": dict(
193
  label="Z-Image NSFW (Stable Yogi)", family="zimageturbo",
194
  checkpoint="checkpoints/zimageturbo/zimageTurboNSFWBy_2602NSFWBF16.safetensors",
 
195
  steps=9, guidance=0.0, height=1024, width=1024, negative=False, prefix="",
196
  ),
197
  "wai-anima": dict(
@@ -200,12 +249,14 @@ IMAGE_MODELS = {
200
  steps=24, guidance=0.0, height=1024, width=1024, negative=True,
201
  prefix="masterpiece, best quality,score_7,",
202
  default_negative=(
 
203
  "worst quality, low quality, score_1, score_2, score_3, artist name,"
204
  "blurry, jpeg artifacts, lowres,censor"),
205
  ),
206
  "samanima": dict(
207
  label="Realistic", family="anima",
208
  checkpoint="checkpoints/anima/samANIMA_v20.safetensors",
 
209
  steps=24, guidance=0.0, height=1024, width=1024, negative=True,
210
  prefix="masterpiece, best quality, score_7,",
211
  default_negative=(
@@ -235,10 +286,17 @@ def _model_entry(model_name):
235
  def _resolve_negative(entry, negative_prompt, use_negative_prompt, model_name):
236
  """The negative prompt to actually use: the caller's (when they enabled it and
237
  this model honors negatives), else the model's `default_negative`. Disabled or
238
- unsupported -> empty."""
 
 
 
 
239
  if not (use_negative_prompt and _supports_negative(model_name)):
240
  return ""
241
- return (negative_prompt or "").strip() or entry.get("default_negative", "")
 
 
 
242
 
243
 
244
  # Which models to load this boot. Unknown keys are ignored; an empty / all-invalid
@@ -396,21 +454,16 @@ print(f"Image models loaded: {list(PIPELINES)}")
396
  VLM_MODEL_ID = os.environ.get("VLM_MODEL_ID", "prithivMLmods/gemma-4-E4B-it-Uncensored-MAX")
397
  VLM_LOAD_8BIT = os.environ.get("VLM_LOAD_8BIT", "0").lower() not in ("0", "false", "no", "")
398
 
399
- print(f"Loading assistant: {VLM_MODEL_ID} (8bit={VLM_LOAD_8BIT}) ...")
400
- vlm_processor = AutoProcessor.from_pretrained(
401
- VLM_MODEL_ID, token=HF_TOKEN, trust_remote_code=True
402
- )
403
- _vlm_kwargs = dict(token=HF_TOKEN, trust_remote_code=True, torch_dtype=torch.bfloat16)
404
- if VLM_LOAD_8BIT:
405
- # bitsandbytes quantizes the weights on GPU as they load; device_map places them
406
- # there directly, so we must NOT call .to("cuda") afterwards (it errors on a
407
- # quantized model).
408
- _vlm_kwargs["quantization_config"] = BitsAndBytesConfig(load_in_8bit=True)
409
- _vlm_kwargs["device_map"] = "auto"
410
- vlm_model = AutoModelForImageTextToText.from_pretrained(VLM_MODEL_ID, **_vlm_kwargs)
411
- if not VLM_LOAD_8BIT:
412
- vlm_model.to("cuda")
413
- vlm_model.eval()
414
 
415
 
416
  def _resolve_vlm_eos_ids(tokenizer, model):
@@ -441,14 +494,54 @@ def _resolve_vlm_eos_ids(tokenizer, model):
441
  return sorted(ids)
442
 
443
 
444
- _vlm_tokenizer = getattr(vlm_processor, "tokenizer", vlm_processor)
445
- _VLM_EOS_IDS = _resolve_vlm_eos_ids(_vlm_tokenizer, vlm_model)
446
- _VLM_PAD_ID = (
447
- _vlm_tokenizer.pad_token_id
448
- if _vlm_tokenizer.pad_token_id is not None
449
- else (_VLM_EOS_IDS[0] if _VLM_EOS_IDS else None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  )
451
- print(f"Assistant loaded! (eos_ids={_VLM_EOS_IDS}, pad_id={_VLM_PAD_ID})")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
 
453
 
454
  # =============================================================================
@@ -574,7 +667,10 @@ def _parse_moderation_json(text):
574
 
575
 
576
  def _moderate_image_inner(image, max_new_tokens=320):
577
- """Synchronously run the VLM moderation check on a PIL image (GPU-side)."""
 
 
 
578
  messages = [{
579
  "role": "user",
580
  "content": [
@@ -582,16 +678,19 @@ def _moderate_image_inner(image, max_new_tokens=320):
582
  {"type": "text", "text": MODERATION_PROMPT},
583
  ],
584
  }]
585
- inputs = vlm_processor.apply_chat_template(
586
  messages, tokenize=True, add_generation_prompt=True,
587
  return_dict=True, return_tensors="pt", enable_thinking=False,
588
- ).to(vlm_model.device)
589
  with torch.inference_mode():
590
- generated = vlm_model.generate(
591
  **inputs, max_new_tokens=int(max_new_tokens), do_sample=False,
 
 
 
592
  )
593
  trimmed = generated[0][inputs["input_ids"].shape[-1]:]
594
- tokenizer = getattr(vlm_processor, "tokenizer", vlm_processor)
595
  text = tokenizer.decode(trimmed, skip_special_tokens=True)
596
  return _parse_moderation_json(text)
597
 
@@ -859,6 +958,14 @@ def generate_image(
859
  if improved:
860
  effective_prompt = improved
861
 
 
 
 
 
 
 
 
 
862
  q = queue.Queue()
863
  result = {}
864
 
@@ -904,6 +1011,16 @@ def generate_image(
904
  except Exception as exc: # noqa: BLE001 - never let moderation break generation
905
  moderation = {"ok": False, "rating": None, "confidence": None,
906
  "flags": None, "raw": "", "error": f"{type(exc).__name__}: {exc}"}
 
 
 
 
 
 
 
 
 
 
907
  yield ("image", image, result["seed"], moderation, effective_prompt)
908
 
909
 
 
117
  "very aesthetic, depth of field, highres"
118
  )
119
  NOOBXL_NEGATIVE = (
120
+ "loli, child, shota, toddler, "
121
  "worst quality, normal quality, anatomical nonsense, bad anatomy, "
122
  "interlocked fingers, extra fingers, watermark, simple background, transparent, "
123
  "low quality, logo, text, signature, face backlighting, backlighting"
124
  )
125
 
126
 
127
+ # ---------------------------------------------------------------------------
128
+ # Minor-safety guards (gated on a model's ``realistic`` registry flag).
129
+ #
130
+ # Photoreal checkpoints inherit a youthful "anime girl" prior from booru tags
131
+ # like ``1girl`` (age-neutral in tag-space, but young-skewing in practice). On a
132
+ # stylized anime model that reads as harmless convention; rendered photoreal the
133
+ # same prior can look like a real minor. Two defenses, both realistic-only so the
134
+ # anime models keep their intended ``1girl`` behaviour:
135
+ # 1. Rewrite youthful subject tokens in the POSITIVE prompt to adult ones
136
+ # (``girl`` -> ``woman``, ``1girl`` -> ``1woman``) via _adultify_prompt.
137
+ # 2. Force a strong minor-blocking NEGATIVE (MINOR_NEG) that is applied even
138
+ # when the caller supplies their own negative (see _resolve_negative).
139
+ MINOR_NEG = (
140
+ "child, children, kid, toddler, infant, baby, loli, shota, preteen, teen, "
141
+ "teenager, adolescent, underage, minor, young girl, little girl, schoolgirl, "
142
+ "child-like face, childlike body, childish proportions, chibi"
143
+ )
144
+
145
+ # Female subject counters/tokens: standalone ``girl``/``girls`` and the booru
146
+ # ``Ngirl(s)`` forms. The lookbehind ignores letters/digits already attached so
147
+ # ``girlfriend`` and ``cowgirl`` are left intact; the trailing lookahead keeps it
148
+ # from biting into ``girlish`` etc.
149
+ _GIRL_RE = re.compile(r"(?<![a-z0-9])(\d*)\s*girls?(?![a-z])", re.IGNORECASE)
150
+
151
+
152
+ def _adultify_prompt(prompt):
153
+ """Replace youthful female subject tokens with adult equivalents.
154
+
155
+ ``girl`` -> ``woman``, ``girls`` / ``2girls`` -> ``women`` / ``2women``,
156
+ ``1girl`` -> ``1woman``. Only the bare subject token is touched; compounds
157
+ like ``girlfriend`` and ``cowgirl`` are preserved by the regex boundaries.
158
+ """
159
+ if not prompt:
160
+ return prompt
161
+
162
+ def _repl(match):
163
+ digits = match.group(1) or ""
164
+ had_s = match.group(0).rstrip().lower().endswith("s")
165
+ plural = had_s or (digits not in ("", "1"))
166
+ return f"{digits}{'women' if plural else 'woman'}"
167
+
168
+ return _GIRL_RE.sub(_repl, prompt)
169
+
170
+
171
  def _apply_prefix(prompt, prefix):
172
  """Prepend ``prefix`` to ``prompt``, dropping any prefix tag the prompt already
173
  contains (case-insensitive, per comma-separated token). Empty prefix is a no-op."""
 
216
  "realism-illustrious": dict(
217
  label="Realism Illustrious (Stable Yogi)", family="illustrious",
218
  checkpoint="checkpoints/il/realismIllustriousBy_v55FP16.safetensors",
219
+ realistic=True,
220
  steps=30, guidance=5.0, height=1152, width=896, negative=True,
221
  prefix="masterpiece, best quality, amazing quality, very aesthetic, absurdres, highres, ultra detailed",
222
  default_negative=(
 
228
  "moody-pro-mix": dict(
229
  label=MODEL_ZIMAGE, family="zimageturbo",
230
  checkpoint="checkpoints/zimageturbo/moodyProMix_zitV12DPO.safetensors",
231
+ realistic=True,
232
  steps=9, guidance=0.0, height=1024, width=1024, negative=False, prefix="",
233
  ),
234
  "pornmaster-zimage": dict(
235
  label="PornMaster Z-Image", family="zimageturbo",
236
  checkpoint="checkpoints/zimageturbo/pornmasterZImage_turboV35Bf16.safetensors",
237
+ realistic=True,
238
  steps=9, guidance=0.0, height=1024, width=1024, negative=False, prefix="",
239
  ),
240
  "zimage-nsfw-yogi": dict(
241
  label="Z-Image NSFW (Stable Yogi)", family="zimageturbo",
242
  checkpoint="checkpoints/zimageturbo/zimageTurboNSFWBy_2602NSFWBF16.safetensors",
243
+ realistic=True,
244
  steps=9, guidance=0.0, height=1024, width=1024, negative=False, prefix="",
245
  ),
246
  "wai-anima": dict(
 
249
  steps=24, guidance=0.0, height=1024, width=1024, negative=True,
250
  prefix="masterpiece, best quality,score_7,",
251
  default_negative=(
252
+ "loli, child, shota, toddler, "
253
  "worst quality, low quality, score_1, score_2, score_3, artist name,"
254
  "blurry, jpeg artifacts, lowres,censor"),
255
  ),
256
  "samanima": dict(
257
  label="Realistic", family="anima",
258
  checkpoint="checkpoints/anima/samANIMA_v20.safetensors",
259
+ realistic=True,
260
  steps=24, guidance=0.0, height=1024, width=1024, negative=True,
261
  prefix="masterpiece, best quality, score_7,",
262
  default_negative=(
 
286
  def _resolve_negative(entry, negative_prompt, use_negative_prompt, model_name):
287
  """The negative prompt to actually use: the caller's (when they enabled it and
288
  this model honors negatives), else the model's `default_negative`. Disabled or
289
+ unsupported -> empty.
290
+
291
+ Realistic-leaned models additionally PREPEND the minor-safety block
292
+ (``MINOR_NEG``) unconditionally — even when the caller passes their own
293
+ negative, which would otherwise replace the default and drop the guard."""
294
  if not (use_negative_prompt and _supports_negative(model_name)):
295
  return ""
296
+ neg = (negative_prompt or "").strip() or entry.get("default_negative", "")
297
+ if entry.get("realistic"):
298
+ neg = f"{MINOR_NEG}, {neg}" if neg else MINOR_NEG
299
+ return neg
300
 
301
 
302
  # Which models to load this boot. Unknown keys are ignored; an empty / all-invalid
 
454
  VLM_MODEL_ID = os.environ.get("VLM_MODEL_ID", "prithivMLmods/gemma-4-E4B-it-Uncensored-MAX")
455
  VLM_LOAD_8BIT = os.environ.get("VLM_LOAD_8BIT", "0").lower() not in ("0", "false", "no", "")
456
 
457
+ # Dedicated image-moderation guard VLM, kept SEPARATE from the prompt-assistant
458
+ # VLM so each can be specialized: Gemma (above) writes/improves prompts — fluent
459
+ # and fast — while a stricter rater judges generated images. Defaults to a
460
+ # Qwen3.5-4B in 8-bit (~4.5 GB) so it co-resides with Gemma + the diffusion
461
+ # pipelines on the zero-a10g. The guard MUST be vision-capable
462
+ # (AutoModelForImageTextToText) — confirm any override is a Qwen3.5-VL / other
463
+ # multimodal checkpoint. Set GUARD_MODEL_ID="" to disable the split and moderate
464
+ # with the main VLM, and GUARD_LOAD_8BIT=0 to load it in bf16.
465
+ GUARD_MODEL_ID = os.environ.get("GUARD_MODEL_ID", "rodrigomt/Qwen3.5-4B-Uncensored-Aggressive")
466
+ GUARD_LOAD_8BIT = os.environ.get("GUARD_LOAD_8BIT", "1").lower() not in ("0", "false", "no", "")
 
 
 
 
 
467
 
468
 
469
  def _resolve_vlm_eos_ids(tokenizer, model):
 
494
  return sorted(ids)
495
 
496
 
497
+ def _load_vlm(model_id, load_8bit, role="VLM"):
498
+ """Load a multimodal VLM (processor + model) and resolve its stop/pad ids.
499
+
500
+ bf16 by default; ``load_8bit`` uses bitsandbytes — which quantizes on GPU as
501
+ the weights load via ``device_map='auto'``, so we must NOT call ``.to('cuda')``
502
+ afterwards (it errors on a quantized model). Returns
503
+ ``(processor, model, eos_ids, pad_id)``."""
504
+ print(f"Loading {role}: {model_id} (8bit={load_8bit}) ...")
505
+ processor = AutoProcessor.from_pretrained(model_id, token=HF_TOKEN, trust_remote_code=True)
506
+ kwargs = dict(token=HF_TOKEN, trust_remote_code=True, torch_dtype=torch.bfloat16)
507
+ if load_8bit:
508
+ kwargs["quantization_config"] = BitsAndBytesConfig(load_in_8bit=True)
509
+ kwargs["device_map"] = "auto"
510
+ model = AutoModelForImageTextToText.from_pretrained(model_id, **kwargs)
511
+ if not load_8bit:
512
+ model.to("cuda")
513
+ model.eval()
514
+ tokenizer = getattr(processor, "tokenizer", processor)
515
+ eos_ids = _resolve_vlm_eos_ids(tokenizer, model)
516
+ pad_id = (tokenizer.pad_token_id if tokenizer.pad_token_id is not None
517
+ else (eos_ids[0] if eos_ids else None))
518
+ print(f"{role} loaded! (eos_ids={eos_ids}, pad_id={pad_id})")
519
+ return processor, model, eos_ids, pad_id
520
+
521
+
522
+ # Prompt-assistant VLM (Gemma): the "Prompt Assistant" tab + the prompt-improve
523
+ # pass. One-turn Q&A with an optional image; turns rough ideas into rich prompts.
524
+ vlm_processor, vlm_model, _VLM_EOS_IDS, _VLM_PAD_ID = _load_vlm(
525
+ VLM_MODEL_ID, VLM_LOAD_8BIT, role="assistant VLM"
526
  )
527
+
528
+ # Image-safety guard VLM (moderation only). Falls back to the main VLM when
529
+ # disabled (GUARD_MODEL_ID="" or == the assistant) or if loading fails (e.g. OOM)
530
+ # so moderation never silently breaks and the Space always boots.
531
+ if GUARD_MODEL_ID and GUARD_MODEL_ID != VLM_MODEL_ID:
532
+ try:
533
+ guard_processor, guard_model, _GUARD_EOS_IDS, _GUARD_PAD_ID = _load_vlm(
534
+ GUARD_MODEL_ID, GUARD_LOAD_8BIT, role="guard VLM"
535
+ )
536
+ except Exception as exc: # noqa: BLE001 - degrade gracefully, never block boot
537
+ print(f"[guard] failed to load {GUARD_MODEL_ID}: {type(exc).__name__}: {exc}; "
538
+ f"moderation will use the main VLM ({VLM_MODEL_ID})", flush=True)
539
+ guard_processor, guard_model = vlm_processor, vlm_model
540
+ _GUARD_EOS_IDS, _GUARD_PAD_ID = _VLM_EOS_IDS, _VLM_PAD_ID
541
+ else:
542
+ guard_processor, guard_model = vlm_processor, vlm_model
543
+ _GUARD_EOS_IDS, _GUARD_PAD_ID = _VLM_EOS_IDS, _VLM_PAD_ID
544
+ print(f"Guard VLM: using main VLM ({VLM_MODEL_ID}) for moderation")
545
 
546
 
547
  # =============================================================================
 
667
 
668
 
669
  def _moderate_image_inner(image, max_new_tokens=320):
670
+ """Synchronously run the safety-guard VLM moderation check on a PIL image
671
+ (GPU-side). Uses the dedicated guard model (``guard_model``) — which may be a
672
+ different, stricter VLM than the prompt assistant — with greedy decoding and
673
+ NO repetition penalties (those mangle the repetitive rating JSON)."""
674
  messages = [{
675
  "role": "user",
676
  "content": [
 
678
  {"type": "text", "text": MODERATION_PROMPT},
679
  ],
680
  }]
681
+ inputs = guard_processor.apply_chat_template(
682
  messages, tokenize=True, add_generation_prompt=True,
683
  return_dict=True, return_tensors="pt", enable_thinking=False,
684
+ ).to(guard_model.device)
685
  with torch.inference_mode():
686
+ generated = guard_model.generate(
687
  **inputs, max_new_tokens=int(max_new_tokens), do_sample=False,
688
+ # Pin stop/pad ids so a guard model without a generation_config (e.g.
689
+ # the Qwen3.5 finetunes) terminates instead of rambling to the budget.
690
+ eos_token_id=_GUARD_EOS_IDS, pad_token_id=_GUARD_PAD_ID,
691
  )
692
  trimmed = generated[0][inputs["input_ids"].shape[-1]:]
693
+ tokenizer = getattr(guard_processor, "tokenizer", guard_processor)
694
  text = tokenizer.decode(trimmed, skip_special_tokens=True)
695
  return _parse_moderation_json(text)
696
 
 
958
  if improved:
959
  effective_prompt = improved
960
 
961
+ # Minor-safety: on realistic-leaned models, rewrite youthful subject tokens to
962
+ # adult ones (e.g. the photoreal `1girl` -> `1woman`). Applied after any
963
+ # prompt-improve pass so the rewrite can't be reintroduced, and echoed back as
964
+ # `effective_prompt`. Anime models are not flagged `realistic`, so they keep
965
+ # `1girl` unchanged.
966
+ if _model_entry(model_name).get("realistic"):
967
+ effective_prompt = _adultify_prompt(effective_prompt)
968
+
969
  q = queue.Queue()
970
  result = {}
971
 
 
1011
  except Exception as exc: # noqa: BLE001 - never let moderation break generation
1012
  moderation = {"ok": False, "rating": None, "confidence": None,
1013
  "flags": None, "raw": "", "error": f"{type(exc).__name__}: {exc}"}
1014
+ # VRAM report: peak usage in this GPU window, with the diffusion pipeline +
1015
+ # both VLMs (assistant + guard) resident. Lets us see what the guard split
1016
+ # actually costs on the zero-a10g.
1017
+ if torch.cuda.is_available():
1018
+ print(
1019
+ f"[ImageStudio] VRAM peak={torch.cuda.max_memory_allocated()/2**30:.2f} GB "
1020
+ f"reserved={torch.cuda.memory_reserved()/2**30:.2f} GB "
1021
+ f"(guard={GUARD_MODEL_ID or 'main-vlm'} 8bit={GUARD_LOAD_8BIT})",
1022
+ flush=True,
1023
+ )
1024
  yield ("image", image, result["seed"], moderation, effective_prompt)
1025
 
1026