One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:
Tools/unreal/ commandlets and the two map builders, with ue_common.py and ue_placement.py as the
shared imports (they need the engine)
Tools/blender/ builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
(they need the Blender Flatpak)
Tools/review/ the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
visual_targets.json
Tools/gen/ image and texture generation: the ComfyUI driver, deterministic texture writers,
reference fetching
Tools/input/ OS-level input injection
root _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/
Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.
51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.
Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
149 lines
5.9 KiB
Python
149 lines
5.9 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
|
|
|
|
_TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, _TOOLS)
|
|
for _sub in ("review", "gen", "unreal", "blender", "input"):
|
|
sys.path.insert(0, os.path.join(_TOOLS, _sub))
|
|
|
|
|
|
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()
|