Some checks failed
Architecture Lint / Lint Repository (push) Has been cancelled
Architecture Lint / Linter Tests (push) Has been cancelled
Smoke Test / smoke (push) Has been cancelled
Validate Config / JSON Validate (push) Has been cancelled
Validate Config / Python Syntax & Import Check (push) Has been cancelled
Validate Config / Python Test Suite (push) Has been cancelled
Validate Config / Shell Script Lint (push) Has been cancelled
Validate Config / Cron Syntax Check (push) Has been cancelled
Validate Config / Deploy Script Dry Run (push) Has been cancelled
Validate Config / Playbook Schema Validation (push) Has been cancelled
Validate Config / YAML Lint (push) Has been cancelled
Merge PR #567
600 lines
22 KiB
Python
600 lines
22 KiB
Python
#!/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
|
|
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
|
|
|
|
|
|
# === 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"
|
|
)
|
|
|
|
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"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{html_module.escape(title)}</title>
|
|
<style>
|
|
*{{margin:0;padding:0;box-sizing:border-box}}
|
|
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,monospace;background:#0a0a14;color:#c0c0d0;font-size:13px;line-height:1.5}}
|
|
.container{{max-width:1000px;margin:0 auto;padding:20px}}
|
|
header{{text-align:center;padding:24px 0;border-bottom:1px solid #1a1a2e;margin-bottom:24px}}
|
|
header h1{{font-size:20px;font-weight:300;letter-spacing:3px;color:#4a9eff;margin-bottom:8px}}
|
|
.verdict{{display:inline-block;padding:6px 20px;border-radius:4px;font-size:14px;font-weight:700;letter-spacing:2px;color:#fff;background:{verdict_color}}}
|
|
.stats{{display:flex;gap:16px;justify-content:center;margin:16px 0;flex-wrap:wrap}}
|
|
.stat{{background:#0e0e1a;border:1px solid #1a1a2e;border-radius:4px;padding:8px 16px;text-align:center}}
|
|
.stat .val{{font-size:20px;font-weight:700;color:#4a9eff}}
|
|
.stat .lbl{{font-size:9px;color:#666;text-transform:uppercase;letter-spacing:1px}}
|
|
.score-gauge{{width:120px;height:120px;margin:0 auto 16px;position:relative}}
|
|
.score-gauge svg{{transform:rotate(-90deg)}}
|
|
.score-gauge .score-text{{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:28px;font-weight:700}}
|
|
.report-card{{background:#0e0e1a;border:1px solid #1a1a2e;border-radius:6px;margin-bottom:16px;overflow:hidden}}
|
|
.report-header{{padding:12px 16px;border-bottom:1px solid #1a1a2e;display:flex;justify-content:space-between;align-items:center}}
|
|
.report-header .source{{color:#4a9eff;font-weight:600;word-break:break-all}}
|
|
.report-header .status-badge{{padding:2px 10px;border-radius:3px;font-size:11px;font-weight:700;color:#fff}}
|
|
.status-pass{{background:#4caf50}}
|
|
.status-warn{{background:#ff9800}}
|
|
.status-fail{{background:#f44336}}
|
|
.screenshot{{text-align:center;padding:12px;background:#080810}}
|
|
.screenshot img{{max-width:100%;max-height:400px;border:1px solid #1a1a2e;border-radius:4px}}
|
|
.glitch-list{{padding:12px 16px}}
|
|
.glitch-item{{padding:8px 0;border-bottom:1px solid #111;display:flex;gap:12px;align-items:flex-start}}
|
|
.glitch-item:last-child{{border-bottom:none}}
|
|
.severity-dot{{width:8px;height:8px;border-radius:50%;margin-top:5px;flex-shrink:0}}
|
|
.sev-critical{{background:#f44336}}
|
|
.sev-major{{background:#ff9800}}
|
|
.sev-minor{{background:#2196f3}}
|
|
.sev-cosmetic{{background:#666}}
|
|
.glitch-detail{{flex:1}}
|
|
.glitch-type{{color:#ffd700;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:1px}}
|
|
.glitch-desc{{color:#aaa;font-size:12px;margin-top:2px}}
|
|
.glitch-meta{{color:#555;font-size:10px;margin-top:2px}}
|
|
.no-glitches{{color:#4caf50;text-align:center;padding:20px;font-style:italic}}
|
|
footer{{text-align:center;padding:16px;color:#444;font-size:10px;border-top:1px solid #1a1a2e;margin-top:24px}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1>{html_module.escape(title)}</h1>
|
|
<div class="verdict">{overall_verdict}</div>
|
|
<div class="stats">
|
|
<div class="stat"><div class="val">{len(reports)}</div><div class="lbl">Screenshots</div></div>
|
|
<div class="stat"><div class="val">{total_glitches}</div><div class="lbl">Glitches</div></div>
|
|
<div class="stat"><div class="val">{total_criticals}</div><div class="lbl">Critical</div></div>
|
|
<div class="stat"><div class="val">{avg_score}</div><div class="lbl">Avg Score</div></div>
|
|
</div>
|
|
</header>
|
|
""")
|
|
|
|
# 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"""
|
|
<div class="score-gauge">
|
|
<svg width="120" height="120" viewBox="0 0 120 120">
|
|
<circle cx="60" cy="60" r="50" fill="none" stroke="#1a1a2e" stroke-width="8"/>
|
|
<circle cx="60" cy="60" r="50" fill="none" stroke="{score_color}" stroke-width="8"
|
|
stroke-dasharray="{circumference}" stroke-dashoffset="{dash_offset}" stroke-linecap="round"/>
|
|
</svg>
|
|
<div class="score-text" style="color:{score_color}">{avg_score}</div>
|
|
</div>
|
|
""")
|
|
|
|
# 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'<img src="data:{mime};base64,{b64}" alt="Screenshot">'
|
|
except Exception:
|
|
img_tag = '<div style="color:#666;padding:40px">Screenshot unavailable</div>'
|
|
else:
|
|
img_tag = '<div style="color:#666;padding:40px">No screenshot</div>'
|
|
|
|
parts.append(f"""
|
|
<div class="report-card">
|
|
<div class="report-header">
|
|
<span class="source">{html_module.escape(source_name)} ({report.width}x{report.height})</span>
|
|
<span class="status-badge {status_class}">{report.status} — {report.score}/100</span>
|
|
</div>
|
|
<div class="screenshot">{img_tag}</div>
|
|
""")
|
|
|
|
if report.glitches:
|
|
parts.append('<div class="glitch-list">')
|
|
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"""
|
|
<div class="glitch-item">
|
|
<div class="severity-dot {sev_class}"></div>
|
|
<div class="glitch-detail">
|
|
<div class="glitch-type">{html_module.escape(g.type)} — {sev.upper()}</div>
|
|
<div class="glitch-desc">{html_module.escape(g.description)}</div>
|
|
<div class="glitch-meta">Region: {html_module.escape(g.region)} | Confidence: {g.confidence:.0%} | Source: {html_module.escape(g.source)}</div>
|
|
</div>
|
|
</div>""")
|
|
parts.append('</div>')
|
|
else:
|
|
parts.append('<div class="no-glitches">No glitches detected</div>')
|
|
|
|
parts.append('</div><!-- /report-card -->')
|
|
|
|
# Footer
|
|
parts.append(f"""
|
|
<footer>
|
|
Generated {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | matrix_glitch_detect.py | timmy-config#544
|
|
</footer>
|
|
</div>
|
|
</body>
|
|
</html>""")
|
|
|
|
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()
|