q6 commited on
Commit
9dcd21c
·
1 Parent(s): ae3f868

Add local NovelAI prompt scanner

Browse files
Files changed (1) hide show
  1. scan.py +228 -0
scan.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import json
3
+ import zlib
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ from PIL import Image
8
+
9
+ ROOT = Path(".")
10
+ OUTPUT = Path("novelai_prompts.json")
11
+ PNG_SIG = b"\x89PNG\r\n\x1a\n"
12
+ ALPHA_SIGS = (b"stealth_pnginfo", b"stealth_pngcomp")
13
+ RGB_SIGS = (b"stealth_rgbinfo", b"stealth_rgbcomp")
14
+ PROMPT_KEYS = ("prompt", "negative_prompt", "v4_prompt", "v4_negative_prompt")
15
+ NAI_KEYS = {
16
+ "cfg_rescale",
17
+ "legacy_v3_extend",
18
+ "noise_schedule",
19
+ "request_type",
20
+ "signed_hash",
21
+ "uc",
22
+ "v4_negative_prompt",
23
+ "v4_prompt",
24
+ }
25
+
26
+
27
+ def decode_text(data):
28
+ for enc in ("utf-8", "latin-1"):
29
+ try:
30
+ return data.decode(enc).strip()
31
+ except UnicodeDecodeError:
32
+ pass
33
+ return data.decode("utf-8", errors="replace").strip()
34
+
35
+
36
+ def parse_json(value):
37
+ if isinstance(value, dict):
38
+ return value
39
+ try:
40
+ return json.loads(value)
41
+ except (TypeError, json.JSONDecodeError):
42
+ return None
43
+
44
+
45
+ def parse_png(data):
46
+ if not data.startswith(PNG_SIG):
47
+ return None
48
+ fields = {}
49
+ index = len(PNG_SIG)
50
+ while index + 8 <= len(data):
51
+ size = int.from_bytes(data[index : index + 4], "big")
52
+ kind = data[index + 4 : index + 8]
53
+ index += 8
54
+ content = data[index : min(index + size, len(data))]
55
+ if kind == b"tEXt" and b"\0" in content:
56
+ key, text = content.split(b"\0", 1)
57
+ fields[decode_text(key)] = decode_text(text)
58
+ elif kind == b"iTXt" and b"\0" in content:
59
+ parts = content.split(b"\0", 5)
60
+ if len(parts) == 6:
61
+ key, flag, method, _, _, text = parts
62
+ if flag == b"\x01" and method == b"\x00":
63
+ try:
64
+ text = zlib.decompress(text)
65
+ except zlib.error:
66
+ text = b""
67
+ fields[decode_text(key)] = decode_text(text)
68
+ elif kind == b"zTXt" and b"\0" in content:
69
+ key, rest = content.split(b"\0", 1)
70
+ if rest[:1] == b"\x00":
71
+ try:
72
+ fields[decode_text(key)] = decode_text(zlib.decompress(rest[1:]))
73
+ except zlib.error:
74
+ pass
75
+ index += size + 4
76
+ return fields or None
77
+
78
+
79
+ def byteize(bits):
80
+ bits = bits[: (bits.shape[0] // 8) * 8]
81
+ bits = np.bitwise_and(bits, 1).reshape((-1, 8))
82
+ return np.packbits(bits, axis=1).flatten()
83
+
84
+
85
+ def parse_stealth_channel(bits, sigs):
86
+ raw = bytes(byteize(bits))
87
+ magic = next((sig for sig in sigs if raw.startswith(sig)), None)
88
+ if not magic:
89
+ return None, False
90
+ try:
91
+ start = len(magic)
92
+ bit_len = int.from_bytes(raw[start : start + 4], "big")
93
+ start += 4
94
+ if bit_len <= 0 or bit_len % 8 or start + bit_len // 8 > len(raw):
95
+ return None, True
96
+ payload = raw[start : start + bit_len // 8]
97
+ if magic.endswith(b"comp"):
98
+ payload = zlib.decompress(payload, wbits=31)
99
+ text = decode_text(payload)
100
+ fields = parse_json(text)
101
+ return (fields if isinstance(fields, dict) else {"Stealth": text}), True
102
+ except (UnicodeDecodeError, ValueError, zlib.error):
103
+ return None, True
104
+
105
+
106
+ def parse_stealth(data):
107
+ try:
108
+ image = Image.open(io.BytesIO(data))
109
+ detected = False
110
+ if "A" in image.getbands():
111
+ alpha = np.array(image.getchannel("A")).T.reshape((-1,))
112
+ fields, found = parse_stealth_channel(alpha, ALPHA_SIGS)
113
+ if fields:
114
+ return fields, True, None
115
+ detected = found
116
+ rgb = np.array(image.convert("RGB")).transpose((1, 0, 2)).reshape((-1,))
117
+ fields, found = parse_stealth_channel(rgb, RGB_SIGS)
118
+ return fields, detected or found, None
119
+ except Exception as e:
120
+ return None, False, str(e)
121
+
122
+
123
+ def get_field(fields, name, default=None):
124
+ wanted = name.casefold()
125
+ return next((value for key, value in fields.items() if key.casefold() == wanted), default)
126
+
127
+
128
+ def has_novelai_comment(value):
129
+ data = parse_json(value)
130
+ if not isinstance(data, dict):
131
+ return False
132
+ keys = set(data)
133
+ if keys & NAI_KEYS:
134
+ return True
135
+ return (
136
+ isinstance(data.get("prompt"), str)
137
+ and isinstance(data.get("steps"), int)
138
+ and ("scale" in data or "sampler" in data)
139
+ )
140
+
141
+
142
+ def is_novelai(fields):
143
+ if not fields:
144
+ return False
145
+ software = str(get_field(fields, "Software", "")).casefold()
146
+ source = str(get_field(fields, "Source", "")).casefold()
147
+ title = str(get_field(fields, "Title", ""))
148
+ comment = get_field(fields, "Comment", "")
149
+ return (
150
+ "novelai" in software
151
+ or "novelai" in source
152
+ or title == "AI generated image"
153
+ or has_novelai_comment(comment)
154
+ or bool({str(key).casefold() for key in fields} & NAI_KEYS)
155
+ )
156
+
157
+
158
+ def prompt_record(source, fields):
159
+ comment = parse_json(get_field(fields, "Comment"))
160
+ if not isinstance(comment, dict):
161
+ comment = fields if has_novelai_comment(fields) else {}
162
+ return {
163
+ "source": source,
164
+ "prompt": get_field(fields, "Description") or comment.get("prompt"),
165
+ "negative_prompt": comment.get("uc"),
166
+ "v4_prompt": comment.get("v4_prompt"),
167
+ "v4_negative_prompt": comment.get("v4_negative_prompt"),
168
+ }
169
+
170
+
171
+ def scan(path):
172
+ record = {
173
+ "file": path.relative_to(ROOT).as_posix(),
174
+ "novelai": False,
175
+ "prompt_found": False,
176
+ "stealth_detected": False,
177
+ "prompts": [],
178
+ "missing": None,
179
+ "error": None,
180
+ }
181
+ try:
182
+ data = path.read_bytes()
183
+ normal = parse_png(data)
184
+ stealth, detected, error = parse_stealth(data)
185
+ record["stealth_detected"] = detected
186
+ record["error"] = error
187
+ if is_novelai(normal):
188
+ record["prompts"].append(prompt_record("normal", normal))
189
+ if is_novelai(stealth):
190
+ record["prompts"].append(prompt_record("stealth", stealth))
191
+ record["novelai"] = bool(record["prompts"])
192
+ record["prompt_found"] = any(
193
+ any(item.get(key) not in (None, "", {}, []) for key in PROMPT_KEYS)
194
+ for item in record["prompts"]
195
+ )
196
+ if record["novelai"] and not record["prompt_found"]:
197
+ record["missing"] = "NovelAI metadata found without a prompt"
198
+ elif detected and not stealth:
199
+ record["missing"] = "Stealth metadata could not be decoded"
200
+ elif detected and not record["novelai"]:
201
+ record["missing"] = "Stealth metadata is not NovelAI"
202
+ elif not record["novelai"]:
203
+ record["missing"] = "No NovelAI metadata"
204
+ except Exception as e:
205
+ record["missing"] = "Could not scan PNG"
206
+ record["error"] = str(e)
207
+ return record
208
+
209
+
210
+ def main():
211
+ paths = sorted(
212
+ (path for path in ROOT.rglob("*") if path.is_file() and path.suffix.casefold() == ".png"),
213
+ key=lambda path: path.as_posix().casefold(),
214
+ )
215
+ files = [scan(path) for path in paths]
216
+ found = sum(item["prompt_found"] for item in files)
217
+ result = {
218
+ "root": ROOT.resolve().as_posix(),
219
+ "total": len(files),
220
+ "found": found,
221
+ "missing": len(files) - found,
222
+ "files": files,
223
+ }
224
+ OUTPUT.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
225
+ print(f"Scanned {len(files)} PNGs; found {found}; wrote {OUTPUT}")
226
+
227
+
228
+ main()