minhvtt commited on
Commit
7dca8cd
·
verified ·
1 Parent(s): f018db8

Update app/services/classifier.py

Browse files
Files changed (1) hide show
  1. app/services/classifier.py +387 -361
app/services/classifier.py CHANGED
@@ -1,361 +1,387 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import os
5
- import re
6
- from pathlib import Path
7
- from typing import Any
8
- from urllib.parse import urlparse
9
-
10
- from app.core.config import settings
11
-
12
- MODELS_DIR = Path(__file__).resolve().parents[2] / "models"
13
- NSFW_CONFIG_PATH = MODELS_DIR / "config.json"
14
- NSFW_WEIGHTS_PATH = MODELS_DIR / "model.safetensors"
15
- NSFW_THRESHOLD = 0.75
16
-
17
- LLM_MODELS = [
18
- os.getenv("GAME_LLM_MODEL", "Qwen/Qwen2.5-7B-Instruct"),
19
- "microsoft/Phi-3.5-mini-instruct",
20
- ]
21
- LLM_MAX_CHARS = 3000
22
-
23
- _nsfw_runtime: dict[str, Any] | None = None
24
- _nsfw_error: str | None = None
25
- _ocr_reader: Any | None = None
26
- _ocr_error: str | None = None
27
-
28
- GAME_KEYWORDS = {
29
- "valorant",
30
- "steam",
31
- "roblox",
32
- "league",
33
- "dota",
34
- "cs2",
35
- "minecraft",
36
- "epicgames",
37
- "riot",
38
- "crazygames",
39
- "y8",
40
- "miniclip",
41
- }
42
-
43
- SENSITIVE_KEYWORDS = {
44
- "porn",
45
- "sex",
46
- "xxx",
47
- "nsfw",
48
- "adult",
49
- "nude",
50
- "erotic",
51
- }
52
-
53
- URL_REGEX = re.compile(r"(?:https?://)?(?:www\.)?[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:/[^\s]*)?")
54
-
55
- GAME_PROMPT = """
56
- You are a strict classifier for parental-control screenshots.
57
- Input fields:
58
- - suspected_game_signal: boolean
59
- - extracted_urls: list of urls/domains from OCR
60
- - ocr_text: raw OCR text from screenshot
61
- Task:
62
- - Decide if screenshot likely indicates gaming/web-game activity.
63
- - Return only compact JSON with this schema:
64
- {
65
- "verdict": "game" | "not_game" | "uncertain",
66
- "confidence": 0.0-1.0,
67
- "reason": "short reason"
68
- }
69
- Rules:
70
- - If clear game domain or game UI terms appear, lean game.
71
- - If evidence is weak or contradictory, return uncertain.
72
- - Never output markdown, prose, or extra keys.
73
- """.strip()
74
-
75
-
76
- def classify_screenshot(file_path: str, filename: str, suspected_game: bool) -> tuple[bool, float, str]:
77
- game_result = classify_game_with_ocr_llm(file_path, filename, suspected_game)
78
- verdict = game_result["verdict"]
79
- confidence = float(game_result["confidence"])
80
- reason = str(game_result["reason"])
81
- if verdict == "game":
82
- return True, confidence, reason
83
- if verdict == "uncertain":
84
- return True, max(confidence, 0.51), "uncertain-review"
85
- return False, confidence, reason
86
-
87
-
88
- def classify_game_with_ocr_llm(file_path: str, filename: str, suspected_game: bool) -> dict[str, Any]:
89
- ocr_text, urls = extract_ocr_text_and_urls(file_path)
90
- llm = _classify_game_with_llm(ocr_text, urls, suspected_game)
91
- if llm is not None:
92
- return {
93
- "verdict": llm["verdict"],
94
- "confidence": llm["confidence"],
95
- "reason": llm["reason"],
96
- "ocr_text": ocr_text,
97
- "urls": urls,
98
- "source": llm.get("source", "llm"),
99
- }
100
-
101
- # Fallback heuristic path when model call is unavailable.
102
- text_lower = ocr_text.lower()
103
- keyword_hit = any(word in text_lower for word in GAME_KEYWORDS)
104
- domain_hit = any(_domain_from_url(url) in GAME_KEYWORDS for url in urls)
105
- if domain_hit or (suspected_game and keyword_hit):
106
- return {
107
- "verdict": "game",
108
- "confidence": 0.78,
109
- "reason": "ocr-keyword-heuristic",
110
- "ocr_text": ocr_text,
111
- "urls": urls,
112
- "source": "heuristic",
113
- }
114
-
115
- if suspected_game:
116
- return {
117
- "verdict": "uncertain",
118
- "confidence": 0.55,
119
- "reason": "signal-without-clear-ocr",
120
- "ocr_text": ocr_text,
121
- "urls": urls,
122
- "source": "heuristic",
123
- }
124
-
125
- return {
126
- "verdict": "not_game",
127
- "confidence": 0.2,
128
- "reason": "no-game-evidence",
129
- "ocr_text": ocr_text,
130
- "urls": urls,
131
- "source": "heuristic",
132
- }
133
-
134
-
135
- def extract_ocr_text_and_urls(file_path: str) -> tuple[str, list[str]]:
136
- reader = _load_ocr_reader()
137
- if reader is None:
138
- return "", []
139
-
140
- try:
141
- results = reader.readtext(file_path, detail=0, paragraph=True)
142
- ocr_text = "\n".join(str(x) for x in results).strip()
143
- except Exception:
144
- return "", []
145
-
146
- raw_urls = URL_REGEX.findall(ocr_text)
147
- normalized = []
148
- for value in raw_urls:
149
- item = value.strip().rstrip(".,)")
150
- if not item:
151
- continue
152
- if not item.startswith("http://") and not item.startswith("https://"):
153
- item = f"https://{item}"
154
- normalized.append(item.lower())
155
-
156
- # Keep order stable while deduplicating.
157
- urls: list[str] = []
158
- seen: set[str] = set()
159
- for item in normalized:
160
- if item in seen:
161
- continue
162
- seen.add(item)
163
- urls.append(item)
164
-
165
- return ocr_text[:LLM_MAX_CHARS], urls
166
-
167
-
168
- def _load_ocr_reader() -> Any | None:
169
- global _ocr_reader
170
- global _ocr_error
171
-
172
- if _ocr_reader is not None:
173
- return _ocr_reader
174
- if _ocr_error is not None:
175
- return None
176
-
177
- try:
178
- import easyocr
179
-
180
- _ocr_reader = easyocr.Reader(["en"], gpu=False)
181
- return _ocr_reader
182
- except Exception as exc:
183
- _ocr_error = str(exc)
184
- return None
185
-
186
-
187
- def _classify_game_with_llm(ocr_text: str, urls: list[str], suspected_game: bool) -> dict[str, Any] | None:
188
- if not ocr_text and not urls:
189
- return None
190
-
191
- user_payload = {
192
- "suspected_game_signal": suspected_game,
193
- "extracted_urls": urls,
194
- "ocr_text": ocr_text,
195
- }
196
-
197
- try:
198
- from huggingface_hub import InferenceClient
199
- except Exception:
200
- return None
201
-
202
- token = settings.hf_token
203
- prompt_messages = [
204
- {"role": "system", "content": GAME_PROMPT},
205
- {"role": "user", "content": json.dumps(user_payload, ensure_ascii=True)},
206
- ]
207
-
208
- for model_name in LLM_MODELS:
209
- try:
210
- client = InferenceClient(model=model_name, token=token or None)
211
- response = client.chat_completion(
212
- messages=prompt_messages,
213
- max_tokens=160,
214
- temperature=0.1,
215
- top_p=0.9,
216
- )
217
- content = ""
218
- if response.choices:
219
- content = response.choices[0].message.content or ""
220
- parsed = _parse_llm_json(content)
221
- if parsed is None:
222
- continue
223
- parsed["source"] = f"llm:{model_name}"
224
- return parsed
225
- except Exception:
226
- continue
227
-
228
- return None
229
-
230
-
231
- def _parse_llm_json(content: str) -> dict[str, Any] | None:
232
- if not content:
233
- return None
234
-
235
- text = content.strip()
236
- try:
237
- data = json.loads(text)
238
- except json.JSONDecodeError:
239
- match = re.search(r"\{.*\}", text, re.DOTALL)
240
- if not match:
241
- return None
242
- try:
243
- data = json.loads(match.group(0))
244
- except json.JSONDecodeError:
245
- return None
246
-
247
- verdict = str(data.get("verdict", "")).lower()
248
- if verdict not in {"game", "not_game", "uncertain"}:
249
- return None
250
-
251
- confidence_raw = data.get("confidence", 0.5)
252
- try:
253
- confidence = float(confidence_raw)
254
- except (TypeError, ValueError):
255
- confidence = 0.5
256
-
257
- confidence = max(0.0, min(1.0, confidence))
258
- reason = str(data.get("reason", "llm-decision"))[:200]
259
-
260
- return {"verdict": verdict, "confidence": confidence, "reason": reason}
261
-
262
-
263
- def _domain_from_url(url: str) -> str:
264
- parsed = urlparse(url)
265
- host = parsed.netloc or parsed.path
266
- host = host.lower().replace("www.", "")
267
- parts = host.split(".")
268
- if not parts:
269
- return host
270
- return parts[0]
271
-
272
-
273
- def classify_sensitive_content(file_path: str, filename: str) -> tuple[bool, float, str]:
274
- # First pass with image model inference; fallback to keyword signal only if unavailable.
275
- model_result = _classify_sensitive_with_model(file_path)
276
- if model_result is not None:
277
- return model_result
278
-
279
- name_text = f"{Path(file_path).name} {filename}".lower()
280
- keyword_hit = any(word in name_text for word in SENSITIVE_KEYWORDS)
281
- if keyword_hit:
282
- return True, 0.65, "sensitive-keyword-fallback"
283
- return False, 0.05, "no-sensitive-signal"
284
-
285
-
286
- def _load_nsfw_runtime() -> dict[str, Any] | None:
287
- global _nsfw_runtime
288
- global _nsfw_error
289
-
290
- if _nsfw_runtime is not None:
291
- return _nsfw_runtime
292
- if _nsfw_error is not None:
293
- return None
294
-
295
- try:
296
- import timm
297
- import torch
298
- from PIL import Image
299
- from safetensors.torch import load_file
300
-
301
- if not NSFW_CONFIG_PATH.exists() or not NSFW_WEIGHTS_PATH.exists():
302
- _nsfw_error = "missing-local-model-files"
303
- return None
304
-
305
- config_data = json.loads(NSFW_CONFIG_PATH.read_text(encoding="utf-8"))
306
- architecture = str(config_data.get("architecture", "vit_tiny_patch16_384"))
307
- num_classes = int(config_data.get("num_classes", 2))
308
- label_names = [str(x).lower() for x in config_data.get("label_names", ["nsfw", "sfw"])]
309
- pretrained_cfg = config_data.get("pretrained_cfg", {})
310
-
311
- model = timm.create_model(architecture, pretrained=False, num_classes=num_classes).eval()
312
- state_dict = load_file(str(NSFW_WEIGHTS_PATH), device="cpu")
313
- model.load_state_dict(state_dict, strict=False)
314
-
315
- # Use local config for preprocessing so inference does not depend on remote metadata.
316
- model.pretrained_cfg = {**getattr(model, "pretrained_cfg", {}), **pretrained_cfg, "label_names": label_names}
317
-
318
- data_config = timm.data.resolve_model_data_config(model)
319
- transforms = timm.data.create_transform(**data_config, is_training=False)
320
-
321
- _nsfw_runtime = {
322
- "torch": torch,
323
- "Image": Image,
324
- "model": model,
325
- "transforms": transforms,
326
- "label_names": [str(x).lower() for x in label_names],
327
- }
328
- return _nsfw_runtime
329
- except Exception as exc:
330
- _nsfw_error = str(exc)
331
- return None
332
-
333
-
334
- def _classify_sensitive_with_model(file_path: str) -> tuple[bool, float, str] | None:
335
- runtime = _load_nsfw_runtime()
336
- if runtime is None:
337
- return None
338
-
339
- torch = runtime["torch"]
340
- Image = runtime["Image"]
341
- model = runtime["model"]
342
- transforms = runtime["transforms"]
343
- label_names = runtime["label_names"]
344
-
345
- with Image.open(file_path) as img:
346
- img = img.convert("RGB")
347
- with torch.no_grad():
348
- output = model(transforms(img).unsqueeze(0)).softmax(dim=-1).cpu()[0]
349
-
350
- scores = [float(x) for x in output.tolist()]
351
- nsfw_score = _extract_nsfw_score(scores, label_names)
352
- return (nsfw_score >= NSFW_THRESHOLD, nsfw_score, "timm-marqo-nsfw")
353
-
354
-
355
- def _extract_nsfw_score(scores: list[float], labels: list[str]) -> float:
356
- for idx, label in enumerate(labels):
357
- if "nsfw" in label:
358
- return scores[idx]
359
- if len(scores) >= 2:
360
- return scores[1]
361
- return scores[0] if scores else 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from urllib.parse import urlparse
9
+
10
+ from app.core.config import settings
11
+
12
+ MODELS_DIR = Path(__file__).resolve().parents[2] / "models"
13
+ NSFW_CONFIG_PATH = MODELS_DIR / "config.json"
14
+ NSFW_WEIGHTS_PATH = MODELS_DIR / "model.safetensors"
15
+ NSFW_THRESHOLD = 0.75
16
+
17
+ LLM_MODELS = [
18
+ os.getenv("GAME_LLM_MODEL", "Qwen/Qwen2.5-7B-Instruct"),
19
+ "microsoft/Phi-3.5-mini-instruct",
20
+ ]
21
+ LLM_MAX_CHARS = 3000
22
+
23
+ _nsfw_runtime: dict[str, Any] | None = None
24
+ _nsfw_error: str | None = None
25
+ _ocr_reader: Any | None = None
26
+ _ocr_error: str | None = None
27
+
28
+ GAME_KEYWORDS = {
29
+ "valorant",
30
+ "steam",
31
+ "roblox",
32
+ "league",
33
+ "dota",
34
+ "cs2",
35
+ "minecraft",
36
+ "epicgames",
37
+ "riot",
38
+ "crazygames",
39
+ "y8",
40
+ "miniclip",
41
+ "poki",
42
+ "friv",
43
+ "game",
44
+ "games",
45
+ "doodle",
46
+ }
47
+
48
+ SENSITIVE_KEYWORDS = {
49
+ "porn",
50
+ "sex",
51
+ "xxx",
52
+ "nsfw",
53
+ "adult",
54
+ "nude",
55
+ "erotic",
56
+ }
57
+
58
+ URL_REGEX = re.compile(r"(?:https?://)?(?:www\.)?[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:/[^\s]*)?")
59
+
60
+ GAME_PROMPT = """
61
+ You are a strict classifier for parental-control screenshots.
62
+ Input fields:
63
+ - suspected_game_signal: boolean
64
+ - extracted_urls: list of urls/domains from OCR
65
+ - ocr_text: raw OCR text from screenshot
66
+ Task:
67
+ - Decide if screenshot likely indicates gaming/web-game activity.
68
+ - Return only compact JSON with this schema:
69
+ {
70
+ "verdict": "game" | "not_game" | "uncertain",
71
+ "confidence": 0.0-1.0,
72
+ "reason": "short reason"
73
+ }
74
+ Rules:
75
+ - If clear game domain or game UI terms appear, lean game.
76
+ - If evidence is weak or contradictory, return uncertain.
77
+ - Never output markdown, prose, or extra keys.
78
+ """.strip()
79
+
80
+
81
+ def classify_screenshot(file_path: str, filename: str, suspected_game: bool) -> tuple[bool, float, str]:
82
+ game_result = classify_game_with_ocr_llm(file_path, filename, suspected_game)
83
+ verdict = game_result["verdict"]
84
+ confidence = float(game_result["confidence"])
85
+ reason = str(game_result["reason"])
86
+ if verdict == "game":
87
+ return True, confidence, reason
88
+ if verdict == "uncertain":
89
+ return True, max(confidence, 0.51), "uncertain-review"
90
+ return False, confidence, reason
91
+
92
+
93
+ def classify_game_with_ocr_llm(file_path: str, filename: str, suspected_game: bool) -> dict[str, Any]:
94
+ ocr_text, urls = extract_ocr_text_and_urls(file_path)
95
+ llm = _classify_game_with_llm(ocr_text, urls, suspected_game)
96
+ if llm is not None:
97
+ return {
98
+ "verdict": llm["verdict"],
99
+ "confidence": llm["confidence"],
100
+ "reason": llm["reason"],
101
+ "ocr_text": ocr_text,
102
+ "urls": urls,
103
+ "source": llm.get("source", "llm"),
104
+ }
105
+
106
+ # Fallback heuristic path when model call is unavailable.
107
+ text_lower = ocr_text.lower()
108
+ keyword_hit = any(word in text_lower for word in GAME_KEYWORDS)
109
+ domain_hit = any(_is_game_like_domain(url) for url in urls)
110
+ if domain_hit or (suspected_game and keyword_hit):
111
+ return {
112
+ "verdict": "game",
113
+ "confidence": 0.78,
114
+ "reason": "ocr-keyword-heuristic",
115
+ "ocr_text": ocr_text,
116
+ "urls": urls,
117
+ "source": "heuristic",
118
+ }
119
+
120
+ if suspected_game:
121
+ return {
122
+ "verdict": "uncertain",
123
+ "confidence": 0.55,
124
+ "reason": "signal-without-clear-ocr",
125
+ "ocr_text": ocr_text,
126
+ "urls": urls,
127
+ "source": "heuristic",
128
+ }
129
+
130
+ return {
131
+ "verdict": "not_game",
132
+ "confidence": 0.2,
133
+ "reason": "no-game-evidence",
134
+ "ocr_text": ocr_text,
135
+ "urls": urls,
136
+ "source": "heuristic",
137
+ }
138
+
139
+
140
+ def extract_ocr_text_and_urls(file_path: str) -> tuple[str, list[str]]:
141
+ reader = _load_ocr_reader()
142
+ if reader is None:
143
+ return "", []
144
+
145
+ try:
146
+ results = reader.readtext(file_path, detail=0, paragraph=True)
147
+ ocr_text = "\n".join(str(x) for x in results).strip()
148
+ except Exception:
149
+ return "", []
150
+
151
+ raw_urls = URL_REGEX.findall(ocr_text)
152
+ normalized = []
153
+ for value in raw_urls:
154
+ item = value.strip().rstrip(".,)")
155
+ if not item:
156
+ continue
157
+ if not item.startswith("http://") and not item.startswith("https://"):
158
+ item = f"https://{item}"
159
+ normalized.append(item.lower())
160
+
161
+ # Keep order stable while deduplicating.
162
+ urls: list[str] = []
163
+ seen: set[str] = set()
164
+ for item in normalized:
165
+ if item in seen:
166
+ continue
167
+ seen.add(item)
168
+ urls.append(item)
169
+
170
+ return ocr_text[:LLM_MAX_CHARS], urls
171
+
172
+
173
+ def _load_ocr_reader() -> Any | None:
174
+ global _ocr_reader
175
+ global _ocr_error
176
+
177
+ if _ocr_reader is not None:
178
+ return _ocr_reader
179
+ if _ocr_error is not None:
180
+ return None
181
+
182
+ try:
183
+ import easyocr
184
+
185
+ _ocr_reader = easyocr.Reader(["en"], gpu=False)
186
+ return _ocr_reader
187
+ except Exception as exc:
188
+ _ocr_error = str(exc)
189
+ return None
190
+
191
+
192
+ def _classify_game_with_llm(ocr_text: str, urls: list[str], suspected_game: bool) -> dict[str, Any] | None:
193
+ if not ocr_text and not urls:
194
+ return None
195
+
196
+ user_payload = {
197
+ "suspected_game_signal": suspected_game,
198
+ "extracted_urls": urls,
199
+ "ocr_text": ocr_text,
200
+ }
201
+
202
+ try:
203
+ from huggingface_hub import InferenceClient
204
+ except Exception:
205
+ return None
206
+
207
+ token = settings.hf_token
208
+ prompt_messages = [
209
+ {"role": "system", "content": GAME_PROMPT},
210
+ {"role": "user", "content": json.dumps(user_payload, ensure_ascii=True)},
211
+ ]
212
+
213
+ for model_name in LLM_MODELS:
214
+ try:
215
+ client = InferenceClient(model=model_name, token=token or None)
216
+ response = client.chat_completion(
217
+ messages=prompt_messages,
218
+ max_tokens=160,
219
+ temperature=0.1,
220
+ top_p=0.9,
221
+ )
222
+ content = ""
223
+ if response.choices:
224
+ content = response.choices[0].message.content or ""
225
+ parsed = _parse_llm_json(content)
226
+ if parsed is None:
227
+ continue
228
+ parsed["source"] = f"llm:{model_name}"
229
+ return parsed
230
+ except Exception:
231
+ continue
232
+
233
+ return None
234
+
235
+
236
+ def _parse_llm_json(content: str) -> dict[str, Any] | None:
237
+ if not content:
238
+ return None
239
+
240
+ text = content.strip()
241
+ try:
242
+ data = json.loads(text)
243
+ except json.JSONDecodeError:
244
+ match = re.search(r"\{.*\}", text, re.DOTALL)
245
+ if not match:
246
+ return None
247
+ try:
248
+ data = json.loads(match.group(0))
249
+ except json.JSONDecodeError:
250
+ return None
251
+
252
+ verdict = str(data.get("verdict", "")).lower()
253
+ if verdict not in {"game", "not_game", "uncertain"}:
254
+ return None
255
+
256
+ confidence_raw = data.get("confidence", 0.5)
257
+ try:
258
+ confidence = float(confidence_raw)
259
+ except (TypeError, ValueError):
260
+ confidence = 0.5
261
+
262
+ confidence = max(0.0, min(1.0, confidence))
263
+ reason = str(data.get("reason", "llm-decision"))[:200]
264
+
265
+ return {"verdict": verdict, "confidence": confidence, "reason": reason}
266
+
267
+
268
+ def _domain_from_url(url: str) -> str:
269
+ parsed = urlparse(url)
270
+ host = parsed.netloc or parsed.path
271
+ host = host.lower().replace("www.", "")
272
+ parts = host.split(".")
273
+ if not parts:
274
+ return host
275
+ return parts[0]
276
+
277
+
278
+ def _is_game_like_domain(url: str) -> bool:
279
+ parsed = urlparse(url)
280
+ host = (parsed.netloc or parsed.path).lower().replace("www.", "")
281
+ first_label = _domain_from_url(url)
282
+
283
+ if first_label in GAME_KEYWORDS:
284
+ return True
285
+
286
+ # Catch common browser game hosts such as *.games, *game*, play-* styles.
287
+ host_tokens = re.split(r"[^a-z0-9]+", host)
288
+ for token in host_tokens:
289
+ if not token:
290
+ continue
291
+ if token in GAME_KEYWORDS:
292
+ return True
293
+ if "game" in token:
294
+ return True
295
+
296
+ return host.endswith(".games")
297
+
298
+
299
+ def classify_sensitive_content(file_path: str, filename: str) -> tuple[bool, float, str]:
300
+ # First pass with image model inference; fallback to keyword signal only if unavailable.
301
+ model_result = _classify_sensitive_with_model(file_path)
302
+ if model_result is not None:
303
+ return model_result
304
+
305
+ name_text = f"{Path(file_path).name} {filename}".lower()
306
+ keyword_hit = any(word in name_text for word in SENSITIVE_KEYWORDS)
307
+ if keyword_hit:
308
+ return True, 0.65, "sensitive-keyword-fallback"
309
+ return False, 0.05, "no-sensitive-signal"
310
+
311
+
312
+ def _load_nsfw_runtime() -> dict[str, Any] | None:
313
+ global _nsfw_runtime
314
+ global _nsfw_error
315
+
316
+ if _nsfw_runtime is not None:
317
+ return _nsfw_runtime
318
+ if _nsfw_error is not None:
319
+ return None
320
+
321
+ try:
322
+ import timm
323
+ import torch
324
+ from PIL import Image
325
+ from safetensors.torch import load_file
326
+
327
+ if not NSFW_CONFIG_PATH.exists() or not NSFW_WEIGHTS_PATH.exists():
328
+ _nsfw_error = "missing-local-model-files"
329
+ return None
330
+
331
+ config_data = json.loads(NSFW_CONFIG_PATH.read_text(encoding="utf-8"))
332
+ architecture = str(config_data.get("architecture", "vit_tiny_patch16_384"))
333
+ num_classes = int(config_data.get("num_classes", 2))
334
+ label_names = [str(x).lower() for x in config_data.get("label_names", ["nsfw", "sfw"])]
335
+ pretrained_cfg = config_data.get("pretrained_cfg", {})
336
+
337
+ model = timm.create_model(architecture, pretrained=False, num_classes=num_classes).eval()
338
+ state_dict = load_file(str(NSFW_WEIGHTS_PATH), device="cpu")
339
+ model.load_state_dict(state_dict, strict=False)
340
+
341
+ # Use local config for preprocessing so inference does not depend on remote metadata.
342
+ model.pretrained_cfg = {**getattr(model, "pretrained_cfg", {}), **pretrained_cfg, "label_names": label_names}
343
+
344
+ data_config = timm.data.resolve_model_data_config(model)
345
+ transforms = timm.data.create_transform(**data_config, is_training=False)
346
+
347
+ _nsfw_runtime = {
348
+ "torch": torch,
349
+ "Image": Image,
350
+ "model": model,
351
+ "transforms": transforms,
352
+ "label_names": [str(x).lower() for x in label_names],
353
+ }
354
+ return _nsfw_runtime
355
+ except Exception as exc:
356
+ _nsfw_error = str(exc)
357
+ return None
358
+
359
+
360
+ def _classify_sensitive_with_model(file_path: str) -> tuple[bool, float, str] | None:
361
+ runtime = _load_nsfw_runtime()
362
+ if runtime is None:
363
+ return None
364
+
365
+ torch = runtime["torch"]
366
+ Image = runtime["Image"]
367
+ model = runtime["model"]
368
+ transforms = runtime["transforms"]
369
+ label_names = runtime["label_names"]
370
+
371
+ with Image.open(file_path) as img:
372
+ img = img.convert("RGB")
373
+ with torch.no_grad():
374
+ output = model(transforms(img).unsqueeze(0)).softmax(dim=-1).cpu()[0]
375
+
376
+ scores = [float(x) for x in output.tolist()]
377
+ nsfw_score = _extract_nsfw_score(scores, label_names)
378
+ return (nsfw_score >= NSFW_THRESHOLD, nsfw_score, "timm-marqo-nsfw")
379
+
380
+
381
+ def _extract_nsfw_score(scores: list[float], labels: list[str]) -> float:
382
+ for idx, label in enumerate(labels):
383
+ if "nsfw" in label:
384
+ return scores[idx]
385
+ if len(scores) >= 2:
386
+ return scores[1]
387
+ return scores[0] if scores else 0.0