minhvtt commited on
Commit
d55727c
·
verified ·
1 Parent(s): 546dc46

Update app/services/classifier.py

Browse files
Files changed (1) hide show
  1. app/services/classifier.py +199 -18
app/services/classifier.py CHANGED
@@ -18,18 +18,42 @@ def _parse_csv_env(raw_value: str, default_csv: str) -> list[str]:
18
 
19
 
20
  GAME_ZERO_SHOT_MODEL = os.getenv("GAME_ZERO_SHOT_MODEL", "google/siglip-so400m-patch14-384")
 
 
21
  GAME_ZERO_SHOT_LABELS = _parse_csv_env(
22
  os.getenv("GAME_ZERO_SHOT_LABELS", ""),
23
- "game screen,browser with work,code editor,video streaming,idle desktop",
24
  )
25
- GAME_ZERO_SHOT_GAME_LABEL = os.getenv("GAME_ZERO_SHOT_GAME_LABEL", "game screen").strip().lower()
26
- GAME_ZERO_SHOT_GAME_THRESHOLD = float(os.getenv("GAME_ZERO_SHOT_GAME_THRESHOLD", "0.78"))
27
- GAME_ZERO_SHOT_NOT_GAME_THRESHOLD = float(os.getenv("GAME_ZERO_SHOT_NOT_GAME_THRESHOLD", "0.72"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
  _nsfw_runtime: dict[str, Any] | None = None
30
  _nsfw_error: str | None = None
31
  _game_zero_shot_runtime: dict[str, Any] | None = None
32
  _game_zero_shot_error: str | None = None
 
 
33
 
34
  logger = logging.getLogger(__name__)
35
 
@@ -52,6 +76,13 @@ def warmup_game_classifiers() -> dict[str, str]:
52
  else:
53
  status["vision"] = f"unavailable:{_game_zero_shot_error or 'unknown'}"
54
 
 
 
 
 
 
 
 
55
  logger.info("game-classifier warmup status=%s", status)
56
  return status
57
 
@@ -162,6 +193,94 @@ def _load_game_zero_shot_runtime() -> dict[str, Any] | None:
162
  return None
163
 
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  def _classify_game_with_zero_shot(file_path: str, suspected_game: bool) -> dict[str, Any] | None:
166
  runtime = _load_game_zero_shot_runtime()
167
  if runtime is None:
@@ -174,12 +293,14 @@ def _classify_game_with_zero_shot(file_path: str, suspected_game: bool) -> dict[
174
  device = runtime["device"]
175
 
176
  try:
 
 
177
  # Load and prepare image
178
  with Image.open(file_path) as img:
179
  image = img.convert("RGB")
180
 
181
- # Prepare texts with the required template format
182
- texts = [f"This is a photo of {label}." for label in GAME_ZERO_SHOT_LABELS]
183
 
184
  # Process inputs
185
  inputs = processor(text=texts, images=image, padding="max_length", return_tensors="pt")
@@ -190,7 +311,8 @@ def _classify_game_with_zero_shot(file_path: str, suspected_game: bool) -> dict[
190
  outputs = model(**inputs)
191
 
192
  logits_per_image = outputs.logits_per_image
193
- probs = torch.sigmoid(logits_per_image)[0].cpu().tolist()
 
194
 
195
  except Exception as exc:
196
  logger.warning("game zero-shot inference failed err=%s", repr(exc))
@@ -199,31 +321,59 @@ def _classify_game_with_zero_shot(file_path: str, suspected_game: bool) -> dict[
199
  if not probs or len(probs) == 0:
200
  return None
201
 
 
 
 
 
 
 
 
 
 
 
 
202
  # Normalize results to match expected format
203
  normalized: list[dict[str, Any]] = []
204
- for label, score in zip(GAME_ZERO_SHOT_LABELS, probs):
205
- normalized.append({"label": label.lower(), "score": float(score)})
206
 
207
  normalized.sort(key=lambda x: float(x["score"]), reverse=True)
208
  top = normalized[0]
209
- game_score = 0.0
210
- for row in normalized:
211
- if row["label"] == GAME_ZERO_SHOT_GAME_LABEL:
212
- game_score = float(row["score"])
213
- break
214
 
215
- visual_summary = "; ".join(f"{row['label']}={row['score']:.3f}" for row in normalized[:5])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
 
 
217
  if game_score >= GAME_ZERO_SHOT_GAME_THRESHOLD:
218
  return {
219
  "verdict": "game",
220
  "confidence": game_score,
221
- "reason": "game_ui_terms",
222
  "source": f"zero-shot:{GAME_ZERO_SHOT_MODEL}",
223
  "visual_summary": visual_summary,
 
224
  }
225
 
226
- if top["label"] in {"browser with work", "code editor", "idle desktop"} and float(top["score"]) >= GAME_ZERO_SHOT_NOT_GAME_THRESHOLD:
 
227
  return {
228
  "verdict": "not_game",
229
  "confidence": float(top["score"]),
@@ -232,7 +382,38 @@ def _classify_game_with_zero_shot(file_path: str, suspected_game: bool) -> dict[
232
  "visual_summary": visual_summary,
233
  }
234
 
235
- if suspected_game and game_score >= 0.45:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  return {
237
  "verdict": "uncertain",
238
  "confidence": max(game_score, 0.55),
 
18
 
19
 
20
  GAME_ZERO_SHOT_MODEL = os.getenv("GAME_ZERO_SHOT_MODEL", "google/siglip-so400m-patch14-384")
21
+ # Game-focused labels for SigLIP text-image matching (optimized for game detection)
22
+ # Multiple game types to capture diverse game screenshots, final decision delegated to Qwen LLM
23
  GAME_ZERO_SHOT_LABELS = _parse_csv_env(
24
  os.getenv("GAME_ZERO_SHOT_LABELS", ""),
25
+ "a video game with 3D graphics and player UI overlay,a casual mobile or browser game with colorful graphics,a multiplayer online game with HUD and game interface,a fast-paced action or FPS game with weapons and objectives,a puzzle or strategy game with game board or mechanics,a horror or dark-themed game with spooky visuals,a sports or racing game with athletic UI,an esports or competitive game interface,a role-playing game or RPG with character stats,a browser window with website and text,a desktop or idle screen",
26
  )
27
+ # Short label names for logic matching (primary: game labels, contrast: non-game)
28
+ GAME_ZERO_SHOT_LABEL_NAMES = [
29
+ "game_3d", # 0: game with 3D graphics
30
+ "game_casual", # 1: casual/mobile games
31
+ "game_mmo", # 2: multiplayer online
32
+ "game_action", # 3: action/FPS
33
+ "game_puzzle", # 4: puzzle/strategy
34
+ "game_horror", # 5: horror/dark
35
+ "game_sports", # 6: sports/racing
36
+ "game_esports", # 7: esports/competitive
37
+ "game_rpg", # 8: RPG
38
+ "browser_work", # 9: contrast - browser
39
+ "idle_desktop", # 10: contrast - idle
40
+ ]
41
+ # Aggregate game score: max of all game labels
42
+ GAME_ZERO_SHOT_GAME_LABEL_INDICES = [0, 1, 2, 3, 4, 5, 6, 7, 8] # indices of game labels
43
+ GAME_ZERO_SHOT_GAME_THRESHOLD = float(os.getenv("GAME_ZERO_SHOT_GAME_THRESHOLD", "0.35")) # max of game labels
44
+ GAME_ZERO_SHOT_NOT_GAME_THRESHOLD = float(os.getenv("GAME_ZERO_SHOT_NOT_GAME_THRESHOLD", "0.30")) # contrast labels
45
+ GAME_ZERO_SHOT_AMBIGUOUS_THRESHOLD = float(os.getenv("GAME_ZERO_SHOT_AMBIGUOUS_THRESHOLD", "0.20")) # when scores too close
46
+
47
+ # Qwen model for final decision gate
48
+ QWEN_MODEL = os.getenv("QWEN_MODEL", "Qwen/Qwen2-0.5B-Instruct")
49
+ QWEN_ENABLE = os.getenv("QWEN_ENABLE", "true").lower() == "true"
50
 
51
  _nsfw_runtime: dict[str, Any] | None = None
52
  _nsfw_error: str | None = None
53
  _game_zero_shot_runtime: dict[str, Any] | None = None
54
  _game_zero_shot_error: str | None = None
55
+ _qwen_runtime: dict[str, Any] | None = None
56
+ _qwen_error: str | None = None
57
 
58
  logger = logging.getLogger(__name__)
59
 
 
76
  else:
77
  status["vision"] = f"unavailable:{_game_zero_shot_error or 'unknown'}"
78
 
79
+ if QWEN_ENABLE:
80
+ qwen_runtime = _load_qwen_runtime()
81
+ if qwen_runtime is not None:
82
+ status["decision"] = f"ready:{QWEN_MODEL}"
83
+ else:
84
+ status["decision"] = f"unavailable:{_qwen_error or 'unknown'}"
85
+
86
  logger.info("game-classifier warmup status=%s", status)
87
  return status
88
 
 
193
  return None
194
 
195
 
196
+ def _load_qwen_runtime() -> dict[str, Any] | None:
197
+ """Load Qwen 0.5B model for game decision gate."""
198
+ global _qwen_runtime
199
+ global _qwen_error
200
+
201
+ if not QWEN_ENABLE:
202
+ return None
203
+ if _qwen_runtime is not None:
204
+ return _qwen_runtime
205
+ if _qwen_error is not None:
206
+ return None
207
+
208
+ try:
209
+ import torch
210
+ from transformers import AutoModelForCausalLM, AutoTokenizer
211
+
212
+ device = "cuda" if torch.cuda.is_available() else "cpu"
213
+ tokenizer = AutoTokenizer.from_pretrained(QWEN_MODEL)
214
+ model = AutoModelForCausalLM.from_pretrained(
215
+ QWEN_MODEL,
216
+ torch_dtype="auto",
217
+ device_map="auto" if torch.cuda.is_available() else "cpu"
218
+ )
219
+ model.eval()
220
+
221
+ _qwen_runtime = {
222
+ "model": model,
223
+ "tokenizer": tokenizer,
224
+ "torch": torch,
225
+ "device": device,
226
+ }
227
+ logger.info("qwen runtime initialized model=%s device=%s", QWEN_MODEL, device)
228
+ return _qwen_runtime
229
+ except Exception as exc:
230
+ _qwen_error = str(exc)
231
+ logger.warning("qwen runtime init failed model=%s err=%s", QWEN_MODEL, _qwen_error)
232
+ return None
233
+
234
+
235
+ def _classify_with_qwen_decision(file_path: str, siglip_breakdown: str, game_score: float) -> str:
236
+ """Use Qwen to make final game decision based on SigLIP breakdown."""
237
+ runtime = _load_qwen_runtime()
238
+ if runtime is None:
239
+ return "uncertain" # fallback to manual review
240
+
241
+ model = runtime["model"]
242
+ tokenizer = runtime["tokenizer"]
243
+ torch = runtime["torch"]
244
+
245
+ try:
246
+ # Build prompt with SigLIP visual analysis
247
+ prompt = f"""You are a game detection AI. Analyze the following image classification scores and determine if this is a game screen.
248
+
249
+ SigLIP Classification Results:
250
+ {siglip_breakdown}
251
+
252
+ Max Game Score: {game_score:.2%}
253
+
254
+ Question: Is this a screenshot of a video game? Answer with only one word: "GAME" if it's a game, "NOT_GAME" if it's not.
255
+
256
+ Answer:"""
257
+
258
+ # Tokenize and generate
259
+ inputs = tokenizer(prompt, return_tensors="pt").to(runtime["device"])
260
+ with torch.no_grad():
261
+ outputs = model.generate(
262
+ **inputs,
263
+ max_new_tokens=10,
264
+ temperature=0.3,
265
+ top_p=0.9,
266
+ )
267
+
268
+ response = tokenizer.decode(outputs[0], skip_special_tokens=True)
269
+
270
+ # Extract answer
271
+ answer = response.strip().split('\n')[-1].strip().upper()
272
+ if "GAME" in answer and "NOT_GAME" not in answer:
273
+ return "game"
274
+ elif "NOT_GAME" in answer:
275
+ return "not_game"
276
+ else:
277
+ return "uncertain"
278
+
279
+ except Exception as exc:
280
+ logger.warning("qwen decision failed err=%s", repr(exc))
281
+ return "uncertain"
282
+
283
+
284
  def _classify_game_with_zero_shot(file_path: str, suspected_game: bool) -> dict[str, Any] | None:
285
  runtime = _load_game_zero_shot_runtime()
286
  if runtime is None:
 
293
  device = runtime["device"]
294
 
295
  try:
296
+ import torch.nn.functional as F
297
+
298
  # Load and prepare image
299
  with Image.open(file_path) as img:
300
  image = img.convert("RGB")
301
 
302
+ # Use detailed visual descriptions as labels (no template needed)
303
+ texts = GAME_ZERO_SHOT_LABELS
304
 
305
  # Process inputs
306
  inputs = processor(text=texts, images=image, padding="max_length", return_tensors="pt")
 
311
  outputs = model(**inputs)
312
 
313
  logits_per_image = outputs.logits_per_image
314
+ # Use softmax to normalize scores (sum=1.0, labels compete)
315
+ probs = F.softmax(logits_per_image[0], dim=0).cpu().tolist()
316
 
317
  except Exception as exc:
318
  logger.warning("game zero-shot inference failed err=%s", repr(exc))
 
321
  if not probs or len(probs) == 0:
322
  return None
323
 
324
+ # Calculate game score BEFORE sorting (using fixed indices of original probs)
325
+ game_score = 0.0
326
+ game_scores_breakdown = []
327
+ for idx in GAME_ZERO_SHOT_GAME_LABEL_INDICES:
328
+ if idx < len(probs):
329
+ score = float(probs[idx])
330
+ label = GAME_ZERO_SHOT_LABEL_NAMES[idx]
331
+ game_scores_breakdown.append(f"{label}={score:.3f}")
332
+ if score > game_score:
333
+ game_score = score
334
+
335
  # Normalize results to match expected format
336
  normalized: list[dict[str, Any]] = []
337
+ for label_name, full_desc, score in zip(GAME_ZERO_SHOT_LABEL_NAMES, GAME_ZERO_SHOT_LABELS, probs):
338
+ normalized.append({"label": label_name, "description": full_desc, "score": float(score)})
339
 
340
  normalized.sort(key=lambda x: float(x["score"]), reverse=True)
341
  top = normalized[0]
 
 
 
 
 
342
 
343
+ visual_summary = "; ".join(f"{row['label']}={row['score']:.3f}" for row in normalized)
344
+ game_summary = " + ".join(game_scores_breakdown[:3]) # top 3 game scores
345
+
346
+ # Ambiguous case detection
347
+ # Check if multiple game labels have close scores
348
+ top_game_score = game_score
349
+ second_game_score = 0.0
350
+ for idx in GAME_ZERO_SHOT_GAME_LABEL_INDICES:
351
+ if idx < len(probs):
352
+ score = float(probs[idx])
353
+ if score < top_game_score and score > second_game_score:
354
+ second_game_score = score
355
+
356
+ score_gap = top_game_score - second_game_score if top_game_score > 0 else 1.0
357
+ is_ambiguous = (
358
+ (game_score < GAME_ZERO_SHOT_GAME_THRESHOLD and
359
+ score_gap < GAME_ZERO_SHOT_AMBIGUOUS_THRESHOLD) or
360
+ (game_score >= 0.15 and game_score < GAME_ZERO_SHOT_GAME_THRESHOLD and
361
+ float(top["score"]) < GAME_ZERO_SHOT_NOT_GAME_THRESHOLD)
362
+ )
363
 
364
+ # Clear game: high confidence
365
  if game_score >= GAME_ZERO_SHOT_GAME_THRESHOLD:
366
  return {
367
  "verdict": "game",
368
  "confidence": game_score,
369
+ "reason": "game_ui_detected",
370
  "source": f"zero-shot:{GAME_ZERO_SHOT_MODEL}",
371
  "visual_summary": visual_summary,
372
+ "game_breakdown": game_summary,
373
  }
374
 
375
+ # Clear non-game: strong contrast signal
376
+ if top["label"] in {"browser_work", "idle_desktop"} and float(top["score"]) >= GAME_ZERO_SHOT_NOT_GAME_THRESHOLD:
377
  return {
378
  "verdict": "not_game",
379
  "confidence": float(top["score"]),
 
382
  "visual_summary": visual_summary,
383
  }
384
 
385
+ # Ambiguous case: close game scores, low overall scores
386
+ # Delegate to Qwen for final decision
387
+ if is_ambiguous:
388
+ logger.info(
389
+ "game-detect ambiguous case game_score=%.2f score_gap=%.3f top_label=%s top_score=%.2f, delegating to qwen",
390
+ game_score,
391
+ score_gap,
392
+ top["label"],
393
+ float(top["score"]),
394
+ )
395
+
396
+ qwen_verdict = _classify_with_qwen_decision(file_path, visual_summary, game_score)
397
+ if qwen_verdict == "game":
398
+ return {
399
+ "verdict": "game",
400
+ "confidence": max(game_score, 0.45),
401
+ "reason": "qwen_game_decision",
402
+ "source": f"qwen:{QWEN_MODEL}+{GAME_ZERO_SHOT_MODEL}",
403
+ "visual_summary": visual_summary,
404
+ "game_breakdown": game_summary,
405
+ }
406
+ elif qwen_verdict == "not_game":
407
+ return {
408
+ "verdict": "not_game",
409
+ "confidence": max(float(top["score"]), 0.3),
410
+ "reason": "qwen_not_game_decision",
411
+ "source": f"qwen:{QWEN_MODEL}+{GAME_ZERO_SHOT_MODEL}",
412
+ "visual_summary": visual_summary,
413
+ }
414
+
415
+ # Final fallback if not ambiguous or Qwen uncertain
416
+ if suspected_game and game_score >= 0.25:
417
  return {
418
  "verdict": "uncertain",
419
  "confidence": max(game_score, 0.55),