remove scan.py
Browse files
scan.py
DELETED
|
@@ -1,227 +0,0 @@
|
|
| 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 |
-
files = [item for item in files if item["prompt_found"]]
|
| 217 |
-
result = {
|
| 218 |
-
"root": ROOT.resolve().as_posix(),
|
| 219 |
-
"scanned": len(paths),
|
| 220 |
-
"found": len(files),
|
| 221 |
-
"files": files,
|
| 222 |
-
}
|
| 223 |
-
OUTPUT.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 224 |
-
print(f"Scanned {len(paths)} PNGs; found {len(files)}; wrote {OUTPUT}")
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|