Scaffolds frontend/ (shadow-cljs, reagent 1.2.0, re-frame 1.4.3) and ports everything below the data model, with the JS kept as a numeric oracle. domain/landmarks index tables, verbatim domain/ring subsample, offset, simplicity domain/geom similarity fit, procrustes, moving average domain/raster indexed scanline fill, stencil, disc, rect domain/palette the ramp, and the no-sampled-RGB rule 58 tests, 166 assertions. Parity with js/ on the identical 72-frame synthetic track: fit-similarity, procrustes-mean, fit-residual, moving-average, smooth-transforms, offset-ring and subsample-slots to 1e-9; the raster pixel-for-pixel over the whole buffer. Three deviations from the JS, each for a reason: - synth.cljs jitters from a SEEDED generator, not Math.random. Parity is only checkable if both sides can be handed the same track, and a failing assertion has to be reproducible. `:rand-fn` takes the generator over, so oracle.mjs stubs js/Math.random and js/ itself stays untouched. - raster/->rgba replaces toImageData. ImageData is a DOM type and domain/ may not touch the DOM; returning plain bytes also lets the no-intermediate-colours assertion run in node. ui/canvas wraps it later. - offset-ring lives in domain/ring, not domain/geom, per architecture.md: it is an operation on an ordered traversal, not on a transform. Step 1's "done" also names the swapped-iris vote, but pairIrises is in pipeline.js and belongs to step 7. The precondition is asserted instead -- `:swap-iris` really does move both blocks -- so the vote will have a track that disagrees with it when it arrives. One finding, recorded in full in the test that measures it: smooth-transforms buys nothing on the synthetic track. Against jitter-free ground truth, radius 1 helps by 17% on one noise realisation and hurts by 0.5% on another, so its benefit is within noise; from radius 2 up the cost is unambiguous, and by radius 5 the filter is below the true motion's own high-frequency energy, i.e. smoothing away performance. The test pins the shape of the knob rather than a preferred value. This may say more about the synth's jitter being unrealistically small (+/-0.001 normalised) than about the knob; step 6 settles it on real footage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
107 lines
5.2 KiB
Clojure
107 lines
5.2 KiB
Clojure
(ns arthur.domain.ring-test
|
|
"The ring-simplicity check exists because \"fixed topology\" is load-bearing in
|
|
docs/design.md: because hold parts CUT between poses rather than
|
|
interpolating, a ring whose vertex order is wrong self-intersects and renders
|
|
as blocks meeting at corners. It is invisible at some vertex counts and obvious
|
|
at others, so it needs an assertion rather than an eyeball."
|
|
(:require [cljs.test :refer [deftest is testing]]
|
|
[arthur.domain.landmarks :as lm]
|
|
[arthur.domain.ring :as ring]
|
|
[arthur.synth :as synth]))
|
|
|
|
(def dense (delay (synth/synth-dense 72)))
|
|
|
|
(defn- first-bad-budget
|
|
"The first [budget frame edge] at which `table`'s subsampled ring
|
|
self-intersects on the synthetic track, or nil. Reported rather than asserted
|
|
per-frame so a failure names the case instead of only the count."
|
|
[table budgets]
|
|
(first
|
|
(for [n budgets
|
|
:let [slots (ring/subsample-slots (count table) n)]
|
|
[f fr] (map-indexed vector @dense)
|
|
:let [pts (mapv #(nth fr (nth table %)) slots)
|
|
hits (ring/self-intersections pts)]
|
|
:when (seq hits)]
|
|
{:verts n :frame f :edges (first hits)})))
|
|
|
|
(deftest subsample-preserves-order-and-count
|
|
(doseq [n (range 4 17 2)]
|
|
(let [s (ring/subsample-slots 20 n)]
|
|
(is (= n (count s)) (str "subsample-slots(20," n ") returns n slots"))
|
|
(is (apply < s) (str "subsample-slots(20," n ") is strictly increasing: " (pr-str s))))))
|
|
|
|
(deftest subsample-ring-agrees-with-subsample-slots
|
|
(is (= (ring/subsample-ring lm/LIPS-OUTER 8)
|
|
(mapv #(nth lm/LIPS-OUTER %) (ring/subsample-slots 20 8)))))
|
|
|
|
(deftest lid-ring-carries-its-own-corners
|
|
;; Subsampling a 16-slot ring to any even budget must keep the two corners at
|
|
;; output indices 0 and n/2. That is what lets the socket be read back off the
|
|
;; drawn polygon instead of measured separately, which is what stops the iris
|
|
;; drifting relative to the eye it sits in.
|
|
(doseq [n (range 4 13 2)]
|
|
(let [sl (ring/subsample-slots 16 n)]
|
|
(is (and (= 0 (nth sl 0)) (= 8 (nth sl (quot n 2))))
|
|
(str "n=" n " -> " (pr-str sl))))))
|
|
|
|
(deftest lip-rings-are-simple-at-every-vertex-budget
|
|
(doseq [[label table] [["outer" lm/LIPS-OUTER] ["inner" lm/LIPS-INNER]]]
|
|
(is (nil? (first-bad-budget table (range 4 17 2)))
|
|
(str label " ring self-intersects: "
|
|
(pr-str (first-bad-budget table (range 4 17 2)))))))
|
|
|
|
(deftest face-oval-is-a-simple-ring-on-every-frame
|
|
;; A wrong ordering here shows up as a lumpy plate rather than an obvious
|
|
;; bowtie, so it needs asserting.
|
|
(is (nil? (first-bad-budget lm/FACE-OVAL [(count lm/FACE-OVAL)]))))
|
|
|
|
(deftest eye-rings-are-simple-at-every-vertex-budget
|
|
;; Checked on blink frames too, where the ring is nearly degenerate.
|
|
(doseq [[label table] [["right" lm/EYE-R-RING] ["left" lm/EYE-L-RING]]]
|
|
(is (nil? (first-bad-budget table (range 4 13 2)))
|
|
(str label " eye ring self-intersects: "
|
|
(pr-str (first-bad-budget table (range 4 13 2)))))))
|
|
|
|
(deftest brow-rings-are-simple-at-every-vertex-budget
|
|
(doseq [[label table] [["A" lm/BROW-A-RING] ["B" lm/BROW-B-RING]]]
|
|
(is (nil? (first-bad-budget table (range 4 11 2)))
|
|
(str "brow " label " ring self-intersects: "
|
|
(pr-str (first-bad-budget table (range 4 11 2)))))))
|
|
|
|
(deftest offset-ring-grows-by-a-fixed-amount
|
|
;; offset-ring must grow by a FIXED amount and survive a degenerate ring - the
|
|
;; shut eyelid is exactly the degenerate case, and it is the frame where the
|
|
;; lash line is the entire drawing.
|
|
(let [sq [{:x -1 :y 0} {:x 0 :y -1} {:x 1 :y 0} {:x 0 :y 1}]
|
|
g (ring/offset-ring sq 2)]
|
|
(is (every? true?
|
|
(map (fn [p q] (< (abs (- (js/Math.hypot (:x p) (:y p))
|
|
(+ (js/Math.hypot (:x q) (:y q)) 2)))
|
|
1e-9))
|
|
g sq))
|
|
"offset-ring pushes every vertex out by exactly d")
|
|
(is (identical? sq (ring/offset-ring sq 0)) "offset-ring(0) is identity")))
|
|
|
|
(deftest offset-ring-survives-a-shut-lid
|
|
(let [shut-lid [{:x -10 :y 0} {:x 0 :y -0.02} {:x 10 :y 0} {:x 0 :y 0.02}]
|
|
band (ring/offset-ring shut-lid 1.5)
|
|
h (- (apply max (map :y band)) (apply min (map :y band)))]
|
|
(is (> h 2.9) (str "shut lid gets a visible lash band, height " h))
|
|
(is (ring/simple? band) "offset-ring keeps the shut lid simple")))
|
|
|
|
(deftest offset-ring-tolerates-a-vertex-on-the-centroid
|
|
;; A vertex sitting exactly on the centroid has no outward direction. Leave it
|
|
;; where it is rather than emitting NaN and poisoning the whole ring.
|
|
(let [degenerate [{:x 0 :y 0} {:x 0 :y 0} {:x 0 :y 0}]]
|
|
(is (every? #(and (not (js/isNaN (:x %))) (not (js/isNaN (:y %))))
|
|
(ring/offset-ring degenerate 3)))))
|
|
|
|
(deftest a-bowtie-is-detected
|
|
;; The detector has to actually fire, or every simplicity assertion above is
|
|
;; asserting nothing. Swapping two opposite vertices of a square is exactly
|
|
;; the bowtie the lip rings grew.
|
|
(let [square [{:x 0 :y 0} {:x 10 :y 0} {:x 10 :y 10} {:x 0 :y 10}]
|
|
bowtie [{:x 0 :y 0} {:x 10 :y 0} {:x 0 :y 10} {:x 10 :y 10}]]
|
|
(is (ring/simple? square))
|
|
(is (not (ring/simple? bowtie)) "a swapped pair of opposite vertices is caught")))
|