Tools/visual_loop.py:
- --verify gates every capture against its expectation in Tools/visual_targets.json
(missing, blank, tone-less or wrong-size shots fail), so 'each screenshot shows
what was expected' is checked rather than assumed.
- --compare measures luminance, RMS contrast, edge density, palette distance and a
3x3 luma grid against the reference photograph, with a 0-1 similarity score.
- --iteration N writes Art/reference/iterations/N/{metrics.json,feedback.md}; the
feedback names the skill to consult (blender-modeler, archviz, materials, lookdev,
camera-cinematography), so the next modelling pass starts from a measurement.
- Iterations are gitignored until the owner confirms a shot; only confirmed shots
are promoted into Screenshots/.
Screenshots/ holds demo-map captures only now: render_screenshots.sh renders just
DemoMap, collects the shots and runs the verification gate (--verify); the review
staging map is no longer rendered for review and its PNGs are removed. The README
is rewritten around the four demo shots with per-shot expected content and a
verified column, and no longer claims the review staging set.
Tests: 67 pass (13 new) covering metrics on synthetic images, comparison scoring,
the feedback rules and the target spec (including that every target is a demo shot
and that iterations are gitignored).
140 lines
5.7 KiB
Python
140 lines
5.7 KiB
Python
"""Tests for Tools/visual_loop.py — metrics, comparison and feedback rules.
|
|
|
|
Synthetic 8-bit images are written to a temp directory and measured, so the
|
|
numbers can be reasoned about directly (a mid-grey flat frame has near-zero edge
|
|
density and contrast; a checkerboard has a lot of both).
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
from PIL import Image
|
|
|
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
|
sys.path.insert(0, os.path.join(ROOT, "Tools"))
|
|
|
|
import visual_loop as vl # noqa: E402
|
|
|
|
|
|
def write_image(path, kind, size=(64, 64)):
|
|
img = Image.new("RGB", size)
|
|
if kind == "flat":
|
|
img.paste((128, 128, 128), (0, 0, size[0], size[1]))
|
|
elif kind == "dark":
|
|
img.paste((10, 10, 10), (0, 0, size[0], size[1]))
|
|
elif kind == "checker":
|
|
px = img.load()
|
|
for y in range(size[1]):
|
|
for x in range(size[0]):
|
|
px[x, y] = (240, 240, 240) if (x // 4 + y // 4) % 2 else (20, 20, 20)
|
|
elif kind == "sky":
|
|
# bright top third, mid stone bottom: composition like an elevation
|
|
px = img.load()
|
|
for y in range(size[1]):
|
|
for x in range(size[0]):
|
|
px[x, y] = (150, 190, 230) if y < size[1] // 3 else (170, 165, 155)
|
|
img.save(path)
|
|
return path
|
|
|
|
|
|
class MetricsTest(unittest.TestCase):
|
|
def test_flat_frame_has_no_edges_and_no_contrast(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
m = vl.metrics(write_image(os.path.join(tmp, "a.png"), "flat"))
|
|
self.assertEqual(m["size"], [64, 64])
|
|
self.assertAlmostEqual(m["mean_luma"], 128.0, delta=1.0)
|
|
self.assertLess(m["rms_contrast"], 1.0)
|
|
self.assertLess(m["edge_density"], vl.MIN_EDGE_DENSITY)
|
|
|
|
def test_checker_frame_has_edges_and_contrast(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
m = vl.metrics(write_image(os.path.join(tmp, "b.png"), "checker"))
|
|
self.assertGreater(m["edge_density"], 0.01)
|
|
self.assertGreater(m["rms_contrast"], 50.0)
|
|
|
|
def test_composition_grid_is_nine_cells(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
m = vl.metrics(write_image(os.path.join(tmp, "c.png"), "sky"))
|
|
self.assertEqual(len(m["grid_luma"]), 9)
|
|
# sky row should be brighter than the stone rows
|
|
self.assertGreater(m["grid_luma"][0], m["grid_luma"][8])
|
|
|
|
def test_dominant_palette_is_reported(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
m = vl.metrics(write_image(os.path.join(tmp, "d.png"), "flat"))
|
|
self.assertTrue(m["dominant"])
|
|
self.assertAlmostEqual(m["dominant"][0]["share"], 1.0, delta=0.05)
|
|
|
|
|
|
class CompareTest(unittest.TestCase):
|
|
def _metrics(self, kind):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
return vl.metrics(write_image(os.path.join(tmp, "x.png"), kind))
|
|
|
|
def test_identical_images_score_high(self):
|
|
m = self._metrics("checker")
|
|
delta = vl.compare(m, m)
|
|
self.assertGreater(delta["score"], 0.9)
|
|
self.assertEqual(delta["delta_luma"], 0.0)
|
|
|
|
def test_flat_against_checker_scores_low_on_edges(self):
|
|
flat, checker = self._metrics("flat"), self._metrics("checker")
|
|
delta = vl.compare(flat, checker)
|
|
self.assertLess(delta["edge_ratio"], 0.5)
|
|
self.assertLess(delta["score"], 0.9)
|
|
|
|
def test_dark_against_bright_reports_a_luma_delta(self):
|
|
dark, bright = self._metrics("dark"), self._metrics("checker")
|
|
delta = vl.compare(dark, bright)
|
|
self.assertLess(delta["delta_luma"], -50)
|
|
|
|
|
|
class FeedbackTest(unittest.TestCase):
|
|
def test_low_edge_ratio_names_the_modelling_skills(self):
|
|
notes = vl.feedback("x", {}, {}, {
|
|
"delta_luma": 0, "contrast_ratio": 1.0, "edge_ratio": 0.3,
|
|
"palette_distance": 10, "composition_correlation": 0.8, "score": 0.5})
|
|
joined = " ".join(notes)
|
|
self.assertIn("Relief", joined)
|
|
self.assertIn("blender-modeler", joined)
|
|
|
|
def test_bright_and_compressed_flags_lighting_and_lookdev(self):
|
|
notes = vl.feedback("x", {}, {}, {
|
|
"delta_luma": 40, "contrast_ratio": 0.5, "edge_ratio": 1.0,
|
|
"palette_distance": 10, "composition_correlation": 0.8, "score": 0.5})
|
|
joined = " ".join(notes)
|
|
self.assertIn("Too bright", joined)
|
|
self.assertIn("lookdev", joined)
|
|
|
|
def test_framing_mismatch_names_the_camera_skill(self):
|
|
notes = vl.feedback("x", {}, {}, {
|
|
"delta_luma": 0, "contrast_ratio": 1.0, "edge_ratio": 1.0,
|
|
"palette_distance": 10, "composition_correlation": 0.1, "score": 0.5})
|
|
self.assertIn("camera-cinematography", " ".join(notes))
|
|
|
|
def test_no_delta_reports_no_action(self):
|
|
notes = vl.feedback("x", {}, {}, {
|
|
"delta_luma": 0, "contrast_ratio": 1.0, "edge_ratio": 1.0,
|
|
"palette_distance": 5, "composition_correlation": 0.9, "score": 0.95})
|
|
self.assertIn("No metric is outside its tolerance", notes[0])
|
|
|
|
|
|
class TargetSpecTest(unittest.TestCase):
|
|
def test_every_target_has_expectation_and_reference(self):
|
|
for target in vl.load_targets():
|
|
self.assertTrue(target["expect"].strip(), target["name"])
|
|
self.assertTrue(target["references"], target["name"])
|
|
self.assertTrue(target["shot"].startswith("Screenshots/demo/"),
|
|
f"{target['name']} must be a demo-map shot")
|
|
|
|
def test_iteration_output_is_gitignored(self):
|
|
# Iterations are deliberately uncommitted until the owner confirms a shot.
|
|
ignore = open(os.path.join(ROOT, ".gitignore"), encoding="utf-8").read()
|
|
self.assertIn("/Art/reference/iterations/", ignore)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|