diff --git a/scripts/matrix_glitch_detect.py b/scripts/matrix_glitch_detect.py index 864f11f6..be9aeaa8 100644 --- a/scripts/matrix_glitch_detect.py +++ b/scripts/matrix_glitch_detect.py @@ -1,12 +1,599 @@ +#!/usr/bin/env python3 +""" +matrix_glitch_detect.py — 3D World Visual Artifact Detection for The Matrix. + +Scans screenshots or live pages for visual glitches: floating assets, z-fighting, +texture pop-in, clipping, broken meshes, lighting artifacts. Outputs structured +JSON, text, or standalone HTML report with annotated screenshots. + +Usage: + # Scan a screenshot + python scripts/matrix_glitch_detect.py --image screenshot.png + + # Scan with vision model + python scripts/matrix_glitch_detect.py --image screenshot.png --vision + + # HTML report + python scripts/matrix_glitch_detect.py --image screenshot.png --html report.html + + # Scan live Matrix page + python scripts/matrix_glitch_detect.py --url https://matrix.alexanderwhitestone.com + + # Batch scan a directory + python scripts/matrix_glitch_detect.py --batch ./screenshots/ --html batch-report.html + +Refs: timmy-config#491, #541, #543, #544 +""" + +from __future__ import annotations + +import argparse +import base64 +import html as html_module import json -from hermes_tools import browser_navigate, browser_vision +import os +import sys +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field, asdict +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Optional -def detect_glitches(): - browser_navigate(url="https://matrix.alexanderwhitestone.com") - analysis = browser_vision( - question="Scan the 3D world for visual artifacts, floating assets, or z-fighting. List all coordinates/descriptions of glitches found. Provide a PASS/FAIL." + +# === Configuration === + +OLLAMA_BASE = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434") +VISION_MODEL = os.environ.get("VISUAL_REVIEW_MODEL", "gemma3:12b") + + +class Severity(str, Enum): + CRITICAL = "critical" + MAJOR = "major" + MINOR = "minor" + COSMETIC = "cosmetic" + + +@dataclass +class Glitch: + """A single detected visual artifact.""" + type: str = "" # floating_asset, z_fighting, texture_pop, clipping, lighting, mesh_break + severity: Severity = Severity.MINOR + region: str = "" # "upper-left", "center", "bottom-right", or coordinates + description: str = "" + confidence: float = 0.0 # 0.0-1.0 + source: str = "" # "programmatic", "vision", "pixel_analysis" + + +@dataclass +class GlitchReport: + """Complete glitch detection report.""" + source: str = "" # file path or URL + timestamp: str = "" + status: str = "PASS" # PASS, WARN, FAIL + score: int = 100 + glitches: list[Glitch] = field(default_factory=list) + summary: str = "" + model_used: str = "" + width: int = 0 + height: int = 0 + + +# === Programmatic Analysis === + +def analyze_pixels(image_path: str) -> list[Glitch]: + """Programmatic pixel analysis for common 3D glitches.""" + glitches = [] + + try: + from PIL import Image + img = Image.open(image_path).convert("RGB") + w, h = img.size + pixels = img.load() + + # Check for solid-color regions (render failure) + corner_colors = [ + pixels[0, 0], pixels[w-1, 0], pixels[0, h-1], pixels[w-1, h-1] + ] + if all(c == corner_colors[0] for c in corner_colors): + # All corners same color — check if it's black (render failure) + if corner_colors[0] == (0, 0, 0): + glitches.append(Glitch( + type="render_failure", + severity=Severity.CRITICAL, + region="entire frame", + description="Entire frame is black — 3D scene failed to render", + confidence=0.9, + source="pixel_analysis" + )) + + # Check for horizontal tearing lines + tear_count = 0 + for y in range(0, h, max(1, h // 20)): + row_start = pixels[0, y] + same_count = sum(1 for x in range(w) if pixels[x, y] == row_start) + if same_count > w * 0.95: + tear_count += 1 + if tear_count > 3: + glitches.append(Glitch( + type="horizontal_tear", + severity=Severity.MAJOR, + region=f"{tear_count} lines", + description=f"Horizontal tearing detected — {tear_count} mostly-solid scanlines", + confidence=0.7, + source="pixel_analysis" + )) + + # Check for extreme brightness variance (lighting artifacts) + import statistics + brightness_samples = [] + for y in range(0, h, max(1, h // 50)): + for x in range(0, w, max(1, w // 50)): + r, g, b = pixels[x, y] + brightness_samples.append(0.299 * r + 0.587 * g + 0.114 * b) + if brightness_samples: + stdev = statistics.stdev(brightness_samples) + if stdev > 100: + glitches.append(Glitch( + type="lighting", + severity=Severity.MINOR, + region="global", + description=f"Extreme brightness variance (stdev={stdev:.0f}) — possible lighting artifacts", + confidence=0.5, + source="pixel_analysis" + )) + + except ImportError: + pass # PIL not available + except Exception as e: + pass + + return glitches + + +# === Vision Analysis === + +GLITCH_VISION_PROMPT = """You are a 3D world QA engineer. Analyze this screenshot from a Three.js 3D world (The Matrix) for visual glitches and artifacts. + +Look for these specific issues: + +1. FLOATING ASSETS: Objects hovering above surfaces where they should rest. Look for shadows detached from objects. + +2. Z-FIGHTING: Flickering or shimmering surfaces where two polygons overlap at the same depth. Usually appears as striped or dithered patterns. + +3. TEXTURE POP-IN: Low-resolution textures that haven't loaded, or textures that suddenly change quality between frames. + +4. CLIPPING: Objects passing through walls, floors, or other objects. Characters partially inside geometry. + +5. LIGHTING ARTIFACTS: Hard light seams, black patches, overexposed areas, lights not illuminating correctly. + +6. MESH BREAKS: Visible seams in geometry, missing faces on 3D objects, holes in surfaces. + +7. RENDER FAILURE: Black areas where geometry should be, missing skybox, incomplete frame rendering. + +8. UI OVERLAP: UI elements overlapping 3D viewport incorrectly. + +Respond as JSON: +{ + "glitches": [ + { + "type": "floating_asset|z_fighting|texture_pop|clipping|lighting|mesh_break|render_failure|ui_overlap", + "severity": "critical|major|minor|cosmetic", + "region": "description of where", + "description": "detailed description of the artifact", + "confidence": 0.0-1.0 + } + ], + "overall_quality": 0-100, + "summary": "brief assessment" +}""" + + +def run_vision_analysis(image_path: str, model: str = VISION_MODEL) -> tuple[list[Glitch], int]: + """Run vision model glitch analysis.""" + try: + b64 = base64.b64encode(Path(image_path).read_bytes()).decode() + payload = json.dumps({ + "model": model, + "messages": [{"role": "user", "content": [ + {"type": "text", "text": GLITCH_VISION_PROMPT}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}} + ]}], + "stream": False, + "options": {"temperature": 0.1} + }).encode() + + req = urllib.request.Request( + f"{OLLAMA_BASE}/api/chat", + data=payload, + headers={"Content-Type": "application/json"} + ) + with urllib.request.urlopen(req, timeout=120) as resp: + result = json.loads(resp.read()) + content = result.get("message", {}).get("content", "") + + parsed = _parse_json_response(content) + glitches = [] + for g in parsed.get("glitches", []): + glitches.append(Glitch( + type=g.get("type", "unknown"), + severity=Severity(g.get("severity", "minor")), + region=g.get("region", ""), + description=g.get("description", ""), + confidence=float(g.get("confidence", 0.5)), + source="vision" + )) + return glitches, parsed.get("overall_quality", 80) + + except Exception as e: + print(f" Vision analysis failed: {e}", file=sys.stderr) + return [], 50 + + +def _parse_json_response(text: str) -> dict: + cleaned = text.strip() + if cleaned.startswith("```"): + lines = cleaned.split("\n")[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + cleaned = "\n".join(lines) + try: + return json.loads(cleaned) + except json.JSONDecodeError: + start = cleaned.find("{") + end = cleaned.rfind("}") + if start >= 0 and end > start: + try: + return json.loads(cleaned[start:end + 1]) + except json.JSONDecodeError: + pass + return {} + + +# === Screenshot Capture === + +def capture_screenshot(url: str, output_path: str) -> bool: + """Take a screenshot of a URL.""" + try: + script = f""" +from playwright.sync_api import sync_playwright +with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={{"width": 1280, "height": 720}}) + page.goto("{url}", wait_until="networkidle", timeout=30000) + page.wait_for_timeout(3000) + page.screenshot(path="{output_path}") + browser.close() +""" + result = subprocess.run(["python3", "-c", script], capture_output=True, text=True, timeout=60) + return result.returncode == 0 and Path(output_path).exists() + except Exception: + return False + + +# === Detection Logic === + +def detect_glitches(image_path: str, use_vision: bool = False, + model: str = VISION_MODEL) -> GlitchReport: + """Run full glitch detection on an image.""" + report = GlitchReport( + source=image_path, + timestamp=datetime.now().isoformat(), + model_used=model if use_vision else "none" ) - return {"status": "PASS" if "PASS" in analysis.upper() else "FAIL", "analysis": analysis} -if __name__ == '__main__': - print(json.dumps(detect_glitches(), indent=2)) + if not Path(image_path).exists(): + report.status = "FAIL" + report.summary = f"File not found: {image_path}" + report.score = 0 + return report + + # Get image dimensions + try: + from PIL import Image + img = Image.open(image_path) + report.width, report.height = img.size + except Exception: + pass + + # Programmatic analysis + prog_glitches = analyze_pixels(image_path) + report.glitches.extend(prog_glitches) + + # Vision analysis + if use_vision: + print(f" Running vision analysis on {image_path}...", file=sys.stderr) + vision_glitches, quality = run_vision_analysis(image_path, model) + report.glitches.extend(vision_glitches) + report.score = quality + else: + # Score based on programmatic results + criticals = sum(1 for g in report.glitches if g.severity == Severity.CRITICAL) + majors = sum(1 for g in report.glitches if g.severity == Severity.MAJOR) + report.score = max(0, 100 - criticals * 40 - majors * 15) + + # Determine status + criticals = sum(1 for g in report.glitches if g.severity == Severity.CRITICAL) + majors = sum(1 for g in report.glitches if g.severity == Severity.MAJOR) + + if criticals > 0: + report.status = "FAIL" + elif majors > 0 or report.score < 70: + report.status = "WARN" + else: + report.status = "PASS" + + report.summary = ( + f"{report.status}: {len(report.glitches)} glitch(es) found " + f"({criticals} critical, {majors} major), score {report.score}/100" + ) + + return report + + +# === HTML Report Generator === + +def generate_html_report(reports: list[GlitchReport], title: str = "Glitch Detection Report") -> str: + """Generate a standalone HTML report with annotated details.""" + total_glitches = sum(len(r.glitches) for r in reports) + total_criticals = sum(sum(1 for g in r.glitches if g.severity == Severity.CRITICAL) for r in reports) + avg_score = sum(r.score for r in reports) // max(1, len(reports)) + + if total_criticals > 0: + overall_verdict = "FAIL" + verdict_color = "#f44336" + elif any(r.status == "WARN" for r in reports): + overall_verdict = "WARN" + verdict_color = "#ff9800" + else: + overall_verdict = "PASS" + verdict_color = "#4caf50" + + # Build HTML + parts = [] + parts.append(f""" + +
+ + +