[Admin] Add NCII prompt guard for image-conditioned generations

#4
by akhaliq HF Staff - opened
Files changed (2) hide show
  1. app.py +17 -0
  2. ncii_guard.py +90 -0
app.py CHANGED
@@ -80,6 +80,18 @@ LOADED_IN: float | None = None
80
  LORA_STATUS: str | None = None
81
 
82
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  def status() -> str:
84
  if LOAD_ERROR:
85
  return LOAD_ERROR
@@ -318,6 +330,8 @@ def generate(prompt, image_path=None, last_image_path=None, canvas=DEFAULT_CANVA
318
  raise Exception("The denoiser is still loading.")
319
  if not prompt or not prompt.strip():
320
  raise Exception("MiniMax-H3 always takes a prompt, keyframes or not.")
 
 
321
 
322
  from PIL import Image, ImageOps
323
 
@@ -438,6 +452,9 @@ def homepage():
438
  return f.read()
439
 
440
 
 
 
 
441
  load_models()
442
 
443
  if __name__ == "__main__":
 
80
  LORA_STATUS: str | None = None
81
 
82
 
83
+ def check_prompt(prompt: str) -> None:
84
+ """The NCII guard, on requests carrying a keyframe — the edit-on-a-real-photo case. It runs before the
85
+ conditioner call and the denoise booking, so a refused prompt costs no GPU time on either half. The
86
+ classifier lives in `ncii_guard`'s spawned subprocess — in the main process it kills every GPU worker."""
87
+ import ncii_guard
88
+
89
+ flag = ncii_guard.classify(prompt)
90
+ if flag["label"] == "ncii":
91
+ print(f"[guard] prompt refused (ncii {flag['score']:.2f}): {prompt!r}", flush=True)
92
+ raise Exception("This prompt was flagged by a content filter and wasn't run.")
93
+
94
+
95
  def status() -> str:
96
  if LOAD_ERROR:
97
  return LOAD_ERROR
 
330
  raise Exception("The denoiser is still loading.")
331
  if not prompt or not prompt.strip():
332
  raise Exception("MiniMax-H3 always takes a prompt, keyframes or not.")
333
+ if image_path or last_image_path:
334
+ check_prompt(prompt)
335
 
336
  from PIL import Image, ImageOps
337
 
 
452
  return f.read()
453
 
454
 
455
+ import ncii_guard
456
+
457
+ ncii_guard.start()
458
  load_models()
459
 
460
  if __name__ == "__main__":
ncii_guard.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The NCII prompt guard, in its own process.
2
+
3
+ [`hfmlsoc/ncii-light-guard-v01`](https://huggingface.co/hfmlsoc/ncii-light-guard-v01) is a 270M CPU text
4
+ classifier scoring the NCII risk of an edit prompt. It cannot live in the main process: with it loaded there,
5
+ every subsequent `@spaces.GPU` worker dies at `worker_init` with `RuntimeError: No CUDA GPUs are available` —
6
+ the fork inherits whatever CUDA driver state the classifier's torch activity left behind, and a factory reboot
7
+ does not clear it. It cannot be a `multiprocessing.spawn` child either: spawn re-imports the parent's main
8
+ module, and on a Space that main module is `app.py` — the child would re-run the whole startup, `start()`
9
+ included. So the classifier runs this file as a plain subprocess — a fresh interpreter that never sees `spaces`
10
+ — and answers over stdin/stdout, one JSON object per line.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import select
18
+ import subprocess
19
+ import sys
20
+ import threading
21
+
22
+ GUARD_REPO = "hfmlsoc/ncii-light-guard-v01"
23
+
24
+ _lock = threading.Lock()
25
+ _process: subprocess.Popen | None = None
26
+
27
+
28
+ def _read(timeout: float) -> dict:
29
+ readable, _, _ = select.select([_process.stdout], [], [], timeout)
30
+ if not readable:
31
+ raise TimeoutError(f"the guard did not answer within {timeout}s")
32
+ line = _process.stdout.readline()
33
+ if not line:
34
+ raise EOFError("the guard process died")
35
+ return json.loads(line)
36
+
37
+
38
+ def _spawn() -> None:
39
+ global _process
40
+ _process = subprocess.Popen(
41
+ [sys.executable, os.path.abspath(__file__)],
42
+ stdin=subprocess.PIPE,
43
+ stdout=subprocess.PIPE,
44
+ text=True,
45
+ bufsize=1,
46
+ )
47
+ # Generous: a cold cache downloads the checkpoint first.
48
+ assert _read(300.0) == {"status": "ready"}
49
+
50
+
51
+ def start() -> None:
52
+ """Launch the worker and block until its model is up. Called once at startup; `classify` revives it if it dies."""
53
+ with _lock:
54
+ _spawn()
55
+
56
+
57
+ def classify(prompt: str, timeout: float = 60.0) -> dict:
58
+ """`{'label': 'safe' | 'ncii', 'score': ...}` for one prompt, replacing a dead or wedged worker once."""
59
+ with _lock:
60
+ for attempt in (0, 1):
61
+ try:
62
+ if _process is None or _process.poll() is not None:
63
+ _spawn()
64
+ _process.stdin.write(json.dumps({"prompt": prompt}) + "\n")
65
+ _process.stdin.flush()
66
+ return _read(timeout)
67
+ except Exception:
68
+ if attempt:
69
+ raise
70
+ if _process is not None and _process.poll() is None:
71
+ _process.kill()
72
+
73
+
74
+ def _serve() -> None:
75
+ """The child: plain torch on CPU. The protocol keeps the real stdout to itself — everything else
76
+ (download progress, warnings) is pushed over to stderr so it cannot corrupt a reply."""
77
+ protocol = os.fdopen(os.dup(1), "w", buffering=1)
78
+ os.dup2(2, 1)
79
+
80
+ from transformers import pipeline
81
+
82
+ classifier = pipeline("text-classification", model=GUARD_REPO, device="cpu")
83
+ protocol.write(json.dumps({"status": "ready"}) + "\n")
84
+ for line in sys.stdin:
85
+ result = classifier(json.loads(line)["prompt"], truncation=True)[0]
86
+ protocol.write(json.dumps({"label": result["label"], "score": float(result["score"])}) + "\n")
87
+
88
+
89
+ if __name__ == "__main__":
90
+ _serve()