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""" + + + + +{html_module.escape(title)} + + + +
+
+

{html_module.escape(title)}

+
{overall_verdict}
+
+
{len(reports)}
Screenshots
+
{total_glitches}
Glitches
+
{total_criticals}
Critical
+
{avg_score}
Avg Score
+
+
+""") + + # Score gauge + score_color = "#4caf50" if avg_score >= 80 else "#ff9800" if avg_score >= 60 else "#f44336" + circumference = 2 * 3.14159 * 50 + dash_offset = circumference * (1 - avg_score / 100) + parts.append(f""" +
+ + + + +
{avg_score}
+
+""") + + # Per-screenshot reports + for i, report in enumerate(reports): + status_class = f"status-{report.status.lower()}" + source_name = Path(report.source).name if report.source else f"Screenshot {i+1}" + + # Inline screenshot as base64 + img_tag = "" + if report.source and Path(report.source).exists(): + try: + b64 = base64.b64encode(Path(report.source).read_bytes()).decode() + ext = Path(report.source).suffix.lower() + mime = "image/png" if ext == ".png" else "image/jpeg" if ext in (".jpg", ".jpeg") else "image/webp" + img_tag = f'Screenshot' + except Exception: + img_tag = '
Screenshot unavailable
' + else: + img_tag = '
No screenshot
' + + parts.append(f""" +
+
+ {html_module.escape(source_name)} ({report.width}x{report.height}) + {report.status} — {report.score}/100 +
+
{img_tag}
+""") + + if report.glitches: + parts.append('
') + for g in sorted(report.glitches, key=lambda x: {"critical": 0, "major": 1, "minor": 2, "cosmetic": 3}.get(x.severity.value if hasattr(x.severity, "value") else str(x.severity), 4)): + sev = g.severity.value if hasattr(g.severity, "value") else str(g.severity) + sev_class = f"sev-{sev}" + parts.append(f""" +
+
+
+
{html_module.escape(g.type)} — {sev.upper()}
+
{html_module.escape(g.description)}
+
Region: {html_module.escape(g.region)} | Confidence: {g.confidence:.0%} | Source: {html_module.escape(g.source)}
+
+
""") + parts.append('
') + else: + parts.append('
No glitches detected
') + + parts.append('
') + + # Footer + parts.append(f""" + +
+ +""") + + return "\n".join(parts) + + +# === Output Formatting === + +def format_report(report: GlitchReport, fmt: str = "json") -> str: + if fmt == "json": + data = { + "source": report.source, + "timestamp": report.timestamp, + "status": report.status, + "score": report.score, + "glitches": [asdict(g) for g in report.glitches], + "summary": report.summary, + "model_used": report.model_used, + } + for g in data["glitches"]: + if hasattr(g["severity"], "value"): + g["severity"] = g["severity"].value + return json.dumps(data, indent=2) + + elif fmt == "text": + lines = [ + "=" * 50, + " GLITCH DETECTION REPORT", + "=" * 50, + f" Source: {report.source}", + f" Status: {report.status}", + f" Score: {report.score}/100", + f" Glitches: {len(report.glitches)}", + "", + ] + icons = {"critical": "🔴", "major": "🟡", "minor": "🔵", "cosmetic": "⚪"} + for g in report.glitches: + sev = g.severity.value if hasattr(g.severity, "value") else str(g.severity) + icon = icons.get(sev, "?") + lines.append(f" {icon} [{g.type}] {sev.upper()}: {g.description}") + lines.append(f" Region: {g.region} | Confidence: {g.confidence:.0%}") + lines.append("") + lines.append(f" {report.summary}") + lines.append("=" * 50) + return "\n".join(lines) + + return "" + + +# === CLI === + +def main(): + parser = argparse.ArgumentParser( + description="3D World Glitch Detection — visual artifact scanner for The Matrix" + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--image", help="Screenshot file to analyze") + group.add_argument("--url", help="URL to screenshot and analyze") + group.add_argument("--batch", help="Directory of screenshots to analyze") + + parser.add_argument("--vision", action="store_true", help="Include vision model analysis") + parser.add_argument("--model", default=VISION_MODEL, help=f"Vision model (default: {VISION_MODEL})") + parser.add_argument("--html", help="Generate HTML report at this path") + parser.add_argument("--format", choices=["json", "text"], default="json", help="Output format") + parser.add_argument("--output", "-o", help="Output file (default: stdout)") + + args = parser.parse_args() + + reports = [] + + if args.image: + print(f"Analyzing {args.image}...", file=sys.stderr) + report = detect_glitches(args.image, args.vision, args.model) + reports.append(report) + if not args.html: + print(format_report(report, args.format)) + + elif args.url: + import tempfile + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + screenshot_path = tmp.name + print(f"Capturing screenshot of {args.url}...", file=sys.stderr) + if capture_screenshot(args.url, screenshot_path): + report = detect_glitches(screenshot_path, args.vision, args.model) + report.source = args.url + reports.append(report) + if not args.html: + print(format_report(report, args.format)) + else: + print(f"Failed to capture screenshot", file=sys.stderr) + sys.exit(1) + + elif args.batch: + batch_dir = Path(args.batch) + images = sorted(batch_dir.glob("*.png")) + sorted(batch_dir.glob("*.jpg")) + for img in images: + print(f"Analyzing {img.name}...", file=sys.stderr) + report = detect_glitches(str(img), args.vision, args.model) + reports.append(report) + + # HTML report + if args.html: + html = generate_html_report(reports, title="The Matrix — Glitch Detection Report") + Path(args.html).write_text(html) + print(f"HTML report written to {args.html}", file=sys.stderr) + elif args.batch and not args.html: + # Print JSON array for batch + print(json.dumps([json.loads(format_report(r, "json")) for r in reports], indent=2)) + + # Exit code + if any(r.status == "FAIL" for r in reports): + sys.exit(1) + + +if __name__ == "__main__": + import subprocess + main() diff --git a/tests/test_matrix_glitch_detect.py b/tests/test_matrix_glitch_detect.py new file mode 100644 index 00000000..c1b87c60 --- /dev/null +++ b/tests/test_matrix_glitch_detect.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Tests for matrix_glitch_detect.py — verifies detection and HTML report logic.""" + +import json +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from matrix_glitch_detect import ( + Severity, Glitch, GlitchReport, + format_report, generate_html_report, _parse_json_response, +) + + +def test_parse_json_clean(): + result = _parse_json_response('{"glitches": [], "overall_quality": 95}') + assert result["overall_quality"] == 95 + print(" PASS: test_parse_json_clean") + + +def test_parse_json_fenced(): + result = _parse_json_response('```json\n{"overall_quality": 80}\n```') + assert result["overall_quality"] == 80 + print(" PASS: test_parse_json_fenced") + + +def test_parse_json_garbage(): + assert _parse_json_response("no json") == {} + print(" PASS: test_parse_json_garbage") + + +def test_glitch_dataclass(): + g = Glitch(type="z_fighting", severity=Severity.MAJOR, region="center", description="Shimmer", confidence=0.8) + assert g.type == "z_fighting" + assert g.confidence == 0.8 + print(" PASS: test_glitch_dataclass") + + +def test_report_dataclass(): + r = GlitchReport(source="test.png", status="WARN", score=75) + r.glitches.append(Glitch(type="float", severity=Severity.MINOR)) + assert len(r.glitches) == 1 + assert r.score == 75 + print(" PASS: test_report_dataclass") + + +def test_format_json(): + r = GlitchReport(source="test.png", status="PASS", score=90, summary="Clean") + r.glitches.append(Glitch(type="cosmetic", severity=Severity.COSMETIC, description="Minor")) + output = format_report(r, "json") + parsed = json.loads(output) + assert parsed["status"] == "PASS" + assert len(parsed["glitches"]) == 1 + print(" PASS: test_format_json") + + +def test_format_text(): + r = GlitchReport(source="test.png", status="FAIL", score=30, summary="Critical glitch") + r.glitches.append(Glitch(type="render_failure", severity=Severity.CRITICAL, description="Black screen")) + output = format_report(r, "text") + assert "FAIL" in output + assert "render_failure" in output + print(" PASS: test_format_text") + + +def test_html_report_basic(): + r = GlitchReport(source="test.png", status="PASS", score=100) + html = generate_html_report([r], title="Test Report") + assert "" in html + assert "Test Report" in html + assert "PASS" in html + assert "100" in html + print(" PASS: test_html_report_basic") + + +def test_html_report_with_glitches(): + r = GlitchReport(source="test.png", status="FAIL", score=40) + r.glitches.append(Glitch(type="z_fighting", severity=Severity.CRITICAL, region="center", description="Heavy flicker", confidence=0.9)) + r.glitches.append(Glitch(type="clipping", severity=Severity.MINOR, region="bottom", description="Object through floor", confidence=0.6)) + html = generate_html_report([r], title="Glitch Report") + assert "z_fighting" in html + assert "CRITICAL" in html + assert "clipping" in html + assert "Heavy flicker" in html + print(" PASS: test_html_report_with_glitches") + + +def test_html_report_multi(): + r1 = GlitchReport(source="a.png", status="PASS", score=95) + r2 = GlitchReport(source="b.png", status="WARN", score=70) + r2.glitches.append(Glitch(type="texture_pop", severity=Severity.MAJOR)) + html = generate_html_report([r1, r2]) + assert "a.png" in html + assert "b.png" in html + assert "2" in html # 2 screenshots + print(" PASS: test_html_report_multi") + + +def test_html_self_contained(): + r = GlitchReport(source="test.png", status="PASS", score=100) + html = generate_html_report([r]) + assert "external" not in html.lower() or "no external dependencies" in html.lower() + assert "