OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / test_points_emit.py

295 lines · python ref:
"""
Proves the emitted point cloud IS the fractal — same ground truth as the bake.

`test_bake_orientation` established a document whose attractor is known
analytically rather than measured: four Affine iterators `p := 0.5p + 0.5F_i`, so
the attractor is the Sierpinski tetrahedron with vertices `F_i`, its convex hull is
exactly that tetrahedron and its centroid is exactly `mean(F)`. This reuses it, so
the volume path and the points path are judged against one reference instead of
each against its own.

The tetrahedron pays a second dividend here. Four maps at ratio 0.5 give it a
Hausdorff dimension of exactly `ln 4 / ln 2 = 2`, which is a real prediction for
`estimate_spacing` — the radius derivation measures the attractor's dimension on
the way to a spacing, so there is a known number for it to hit rather than a
plausible-looking one to accept.

Three failure modes this is built to catch, none of which look like a failure:

* **A truncated cloud.** The array fills and the counter keeps going; a cloud of the
  first million of twelve million samples is still a perfectly convincing fractal.
* **Uninitialised texels.** Reading past the counter yields (0, 0, 0), and the origin
  sits INSIDE this attractor's hull, so a hull test alone would not notice. The
  kernel treats `dot(pos, pos) == 0` as an invalid point and never emits it, so an
  exact zero in the output can only be a slot that was never written.
* **A cloud that is not what the renderer draws.** The emitted points are projected
  through the engine's own camera and compared to the actual still render.

Needs the GPU, so it runs inside Blender:
    blender -b --factory-startup -noaudio --python blender/test_points_emit.py
"""

import json
import os
import sys
import tempfile
import unittest

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from test_bake_orientation import (
    FIXED_POINTS,
    STILL_SIZE,
    _cpu_attractor,
    _document_dict,
    _mean,
)

CAPACITY = 200_000
SMALL_CAPACITY = 8192          # exactly two rows of the points image

# The single white key in the orientation document would make every point the same
# colour, which cannot tell "colour is carried" from "colour is a constant".
PALETTE = [
    {"X": 1.0, "Y": 0.2, "Z": 0.1, "W": 1.0},
    {"X": 0.2, "Y": 1.0, "Z": 0.3, "W": 1.0},
    {"X": 0.1, "Y": 0.3, "Z": 1.0, "W": 1.0},
]


def _outside_hull(points, vertices, tolerance):
    """How many points fall outside the tetrahedron, and by how far at worst."""
    import numpy as np
    verts = np.array(vertices, dtype=np.float64)
    outside, worst = 0, 0.0
    for corner in range(4):
        face = np.array([verts[j] for j in range(4) if j != corner])
        normal = np.cross(face[1] - face[0], face[2] - face[0])
        normal = normal / np.linalg.norm(normal)
        offset = float(normal @ face[0])
        # Orient the plane so "inside" is the side the fourth vertex is on.
        inside = 1.0 if (normal @ verts[corner] - offset) > 0 else -1.0
        signed = inside * ((points @ normal) - offset)
        outside += int((signed < -tolerance).sum())
        worst = min(worst, float(signed.min()))
    return outside, worst


class Emit(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        try:
            import numpy  # noqa: F401
            import ifs_points  # noqa: F401
        except ImportError as ex:
            raise unittest.SkipTest(f"needs Blender's gpu module ({ex})")

        import ifs_document
        import ifs_points
        import ifs_render

        document = _document_dict()
        document["Palette"] = {"Colors": PALETTE}
        cls.tmp = tempfile.mkdtemp()
        cls.doc_path = os.path.join(cls.tmp, "points.ifsjson")
        with open(cls.doc_path, "w", encoding="utf-8") as handle:
            json.dump(document, handle)

        library = ifs_render.load_library(
            os.path.join(ifs_render.REPO, "Transforms"),
            os.path.join(ifs_render.REPO, "WpfDisplay", "Library", "Includes"))
        cls.doc = ifs_document.load(cls.doc_path, library)
        cls.positions, cls.colors, cls.stats = ifs_points.emit_document(
            cls.doc, capacity=CAPACITY)
        print(f"[points] {cls.stats}")

    # ------------------------------------------------- is it the right fractal

    def test_the_points_land_on_the_attractor(self):
        import numpy as np
        expected = np.array([_mean(FIXED_POINTS, a) for a in range(3)])
        measured = self.positions.mean(axis=0).astype(np.float64)
        print(f"[points] centroid {np.round(measured, 5)} expected "
              f"{np.round(expected, 5)}")
        for axis in range(3):
            self.assertAlmostEqual(
                expected[axis], measured[axis], delta=0.01,
                msg=f"axis {axis}: emitted centroid {measured[axis]:.5f} against "
                    f"the analytic {expected[axis]:.5f}")

    def test_no_point_is_outside_the_convex_hull(self):
        """
        The attractor is contained in the tetrahedron by construction, so anything
        outside it is not a sample of this fractal. The tolerance is float slop, not
        latitude — the maps are exact contractions toward the vertices.
        """
        import numpy as np
        outside, worst = _outside_hull(
            self.positions.astype(np.float64), FIXED_POINTS, 1e-4)
        print(f"[points] outside hull: {outside} of {len(self.positions)}, "
              f"worst {worst:.2e}")
        self.assertEqual(0, outside,
                         f"{outside} points lie outside the attractor's hull, "
                         f"the worst by {abs(worst):.4f}")

    def test_no_point_sits_at_the_origin(self):
        """
        `ivec3` of an unwritten texel is (0, 0, 0), and the origin is INSIDE this
        hull, so a slot never written would pass every other check here. The kernel
        rejects `dot(pos, pos) == 0` as an invalid point, so a zero can only mean the
        readback ran past the counter.
        """
        import numpy as np
        zeros = int((np.abs(self.positions).sum(axis=1) == 0.0).sum())
        self.assertEqual(0, zeros,
                         f"{zeros} points are exactly at the origin — the readback "
                         "is including slots the kernel never wrote")

    def test_the_cloud_reproduces_the_still_render(self):
        """
        The checkpoint the plan asked for: does the emitted cloud have the same shape,
        in the same place, as what the renderer draws? Projected through the engine's
        own camera and compared to an actual GPU still render.
        """
        import numpy as np
        from test_bake_orientation import projected_density, still_density

        rendered = still_density(self.doc)
        # A subsample keeps the pure-Python projection loop honest about runtime;
        # 60k points over a 192x192 frame still covers every lit pixel many times.
        index = np.random.default_rng(7).choice(
            len(self.positions), size=min(60_000, len(self.positions)),
            replace=False)
        predicted = projected_density(
            self.doc.camera.view_proj, self.positions[index])

        def centroid(image):
            ys, xs = np.nonzero(image)
            weights = image[ys, xs]
            total = weights.sum()
            return np.array([(xs * weights).sum() / total,
                             (ys * weights).sum() / total])

        got, want = centroid(predicted), centroid(rendered)
        overlap = np.logical_and(rendered > 0, predicted > 0).sum()
        union = np.logical_or(rendered > 0, predicted > 0).sum()
        iou = overlap / float(union)
        print(f"[points] projected centroid {np.round(got, 2)} vs still render "
              f"{np.round(want, 2)} px, IoU {iou:.3f}")

        for axis, name in enumerate(("x", "y")):
            self.assertLess(
                abs(got[axis] - want[axis]), 4.0,
                f"screen {name}: the cloud projects to {got[axis]:.1f} px and the "
                f"still render sits at {want[axis]:.1f} px")
        self.assertGreater(
            iou, 0.5,
            f"only {iou:.1%} of lit pixels agree — the cloud is not the shape the "
            "renderer draws")

    # --------------------------------------------------- is it carried correctly

    def test_colour_is_carried_per_point(self):
        import numpy as np
        rgb = self.colors[:, :3]
        self.assertTrue(np.isfinite(self.colors).all(), "colour has NaN or inf")
        self.assertGreaterEqual(float(rgb.min()), 0.0)
        self.assertLessEqual(float(rgb.max()), 1.0)
        spread = float(rgb.std(axis=0).max())
        print(f"[points] colour spread {spread:.4f}, mean {np.round(rgb.mean(0), 3)}")
        self.assertGreater(
            spread, 0.01,
            "every point has the same colour — the palette is not reaching the emit")
        # The w channel carries the iterator's opacity, which is 1.0 throughout here.
        self.assertAlmostEqual(1.0, float(self.colors[:, 3].min()), places=5)

    def test_the_dimension_estimate_recovers_the_known_value(self):
        """
        Four maps at ratio 0.5 put the Sierpinski tetrahedron at ln4/ln2 = 2 exactly.
        The radius derivation measures dimension on its way to a spacing, so there is
        a right answer for it to land on — which is the difference between a derived
        number and a plausible one.
        """
        import ifs_points
        spacing, dimension = ifs_points.estimate_spacing(self.positions)
        print(f"[points] spacing {spacing:.6f}, dimension {dimension:.3f} "
              f"(exact 2.0)")
        self.assertAlmostEqual(
            2.0, dimension, delta=0.3,
            msg=f"measured dimension {dimension:.3f} against the tetrahedron's "
                "exact 2.0")
        self.assertGreater(spacing, 0.0)
        # A spacing wider than the fractal itself would mean one blob per point.
        self.assertLess(spacing, 0.1)

    # ------------------------------------------------------ capacity and honesty

    def test_capacity_is_respected_and_overflow_reported(self):
        """
        A truncated cloud looks exactly like a complete one, so the host has to say
        so. The counter runs past the end on purpose to make that possible.
        """
        import ifs_points
        _positions, _colors, stats = ifs_points.emit_document(
            self.doc, capacity=SMALL_CAPACITY)
        print(f"[points] small emit {stats}")
        self.assertEqual(SMALL_CAPACITY, stats["capacity"])
        self.assertEqual(SMALL_CAPACITY, stats["points"])
        self.assertEqual(SMALL_CAPACITY, len(_positions))
        self.assertTrue(stats["filled"])
        self.assertGreaterEqual(stats["requested"], stats["capacity"])

    def test_the_arrays_agree_with_the_reported_count(self):
        self.assertEqual(self.stats["points"], len(self.positions))
        self.assertEqual(self.stats["points"], len(self.colors))
        self.assertEqual(3, self.positions.shape[1])
        self.assertEqual(4, self.colors.shape[1])

    def test_the_emitted_cloud_matches_a_cpu_chaos_game(self):
        """Per-axis mass distribution against an independent CPU run of the same IFS."""
        import numpy as np
        reference = _cpu_attractor()
        for axis in range(3):
            for q in (5, 50, 95):
                got = float(np.percentile(self.positions[:, axis], q))
                want = float(np.percentile(reference[:, axis], q))
                self.assertAlmostEqual(
                    want, got, delta=0.02,
                    msg=f"axis {axis} p{q:02d}: emitted {got:+.4f} against the CPU "
                        f"attractor's {want:+.4f}")

    # ------------------------------------------------------------------ ingest

    def test_the_pointcloud_ingests_into_blender(self):
        import numpy as np
        import ifs_points
        take = 5000
        positions = self.positions[:take]
        colors = self.colors[:take]
        cloud = ifs_points.to_pointcloud(positions, colors, radius=0.001,
                                         name="IFS Points Test")
        self.assertEqual(take, len(cloud.points))

        out = np.zeros(take * 3, dtype=np.float32)
        cloud.attributes['position'].data.foreach_get('vector', out)
        self.assertLess(float(np.abs(out.reshape(take, 3) - positions).max()), 1e-6)

        self.assertIn(ifs_points.COLOR_ATTRIBUTE, cloud.attributes)
        # `color` is now written too, carrying the raw degree-0 SH coefficient for
        # Blender's splat path. Measured: its presence does not change the points
        # render at all. What must stay true is that the MATERIAL reads ifs_color —
        # pinned by test_points_operator, not here.
        got = np.zeros(take * 4, dtype=np.float32)
        cloud.attributes[ifs_points.COLOR_ATTRIBUTE].data.foreach_get('color', got)
        self.assertLess(
            float(np.abs(got.reshape(take, 4)[:, :3] - colors[:, :3]).max()), 1e-6)

        radii = np.zeros(take, dtype=np.float32)
        cloud.attributes['radius'].data.foreach_get('value', radii)
        self.assertAlmostEqual(0.001, float(radii.min()), places=6)


if __name__ == "__main__":
    result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result
    sys.exit(0 if result.wasSuccessful() else 1)