`stabilize` is three things wearing one name, and it is now three functions in two stages: `flow/measure/anchor` fits the rigid transform, `flow/condition` smooths its parameters, `flow/measure/mouth` measures the lip rings through the result. Parity is on the COMPOSITION and not on the pieces -- a split that agreed function by function and not end to end would be a split rather than a port. The oracle now drives `stabilize` at three configurations and the port agrees to 1e-9 on ref, rigid, transforms, outer, inner and aperture, plus `smoothContours` at three radii. Two of the three configurations are at aspect 0.5625, a 1080x1920 phone clip, because at aspect 1 `pick` is the identity: a port that dropped the anisotropy correction outright would pass every other assertion in the suite. 148 tests, up from 134. Three decisions worth the reading time. `makeXform` is not ported, and its absence takes the face oval with it. It centres on the oval's bounding box and zooms until the face is 80% of the raster height, so every vertex it touched carried a cropping decision made once, at analysis time, from one frame's landmarks. Geometry belongs in the node's own local space with the framing as a transform on a node, so this is a deletion. The oval's only other consumer was the placeholder plate outline, which is painting. The residual is taken against the RAW fit, and the prototype took it against the smoothed one. That is the only deliberate numeric divergence here, and parity is kept by asserting `anchor/residuals` on exactly what the prototype handed it. The number's job is to say whether a section is stabilisable at all; folding the smoothing error into it makes a slider look like a property of the footage, and docs/architecture.md lists the residual under stage 3, which requires it to be knob-free. `condition/anchor` therefore replaces `:transforms` and leaves `:residual` alone. The stage order is not the strict chain the table in docs/architecture.md looks like, and that document now says so. The fit is knob-free, conditioning smooths it, and the rings are measured *through* the conditioned transform -- so `anchor avg` does re-run the ring mapping, which is a few hundred frames of twenty points. The guarantee was only ever about the part that reads a source pixel, and that part never sees a transform. Two things fall out and are asserted rather than assumed. Smoothing and subsampling commute, because both are per-slot, which is what lets `vertices` stay a stage-5 knob downstream of a stage-4 one -- and it is also why the port can smooth the full twenty slots where the prototype smooths eight and still match. And `condition/contours` is `geom/moving-average` per vertex per axis rather than its own clamped window, so "radius 2" cannot come to mean two different things at the two knobs. One dead end recorded so nobody walks it twice: the synth's head is perfectly rigid -- its jitter is a whole-head translation, which a similarity absorbs exactly -- so every frame's rigid configuration is congruent with frame zero's and the Procrustes mean IS frame zero to 1e-15, jitter or none. "The reference is the mean and not frame zero" cannot be asserted on this track and is asserted in geom-test, where the two can differ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
11 KiB
Clojure
245 lines
11 KiB
Clojure
(ns arthur.parity-test
|
|
"Diffs the CLJS port against js/ — the numeric oracle — on the identical
|
|
synthetic track.
|
|
|
|
DELETABLE, and deliberately so. This namespace and test/parity/oracle.mjs go
|
|
together, in one commit, once the CLJS player renders the synthetic take
|
|
correctly (port-plan step 5).
|
|
|
|
Parity proves the port is FAITHFUL, not that the answer is RIGHT. The JS is a
|
|
prototype and several of its conclusions contradict each other; a parity test
|
|
pins behaviour while the code moves, and a correctness test asserts something
|
|
that has been decided and stays. Conflating the two bakes the prototype's
|
|
mistakes into the rewrite and makes them permanent — so nothing in here is
|
|
allowed to outlive the move, and nothing in here is evidence that a number is
|
|
the number we want.
|
|
|
|
Run test/parity/oracle.mjs first; `npm test` does."
|
|
(:require [cljs.test :refer [deftest is testing]]
|
|
[arthur.domain.geom :as geom]
|
|
[arthur.domain.landmarks :as lm]
|
|
[arthur.domain.palette :as pal]
|
|
[arthur.domain.raster :as raster]
|
|
[arthur.domain.ring :as ring]
|
|
[arthur.flow.condition :as condition]
|
|
[arthur.flow.measure.anchor :as anchor]
|
|
[arthur.flow.measure.mouth :as mouth]
|
|
[arthur.synth :as synth]))
|
|
|
|
;; The port plan's number. A larger gap than this is a port bug, not float noise.
|
|
(def TOL 1e-9)
|
|
|
|
(def oracle
|
|
(delay
|
|
(let [fs (js/require "fs")
|
|
path (str js/__dirname "/../test/parity/oracle.json")]
|
|
(when-not (.existsSync fs path)
|
|
(throw (ex-info (str "no oracle at " path
|
|
" — run `node test/parity/oracle.mjs` first")
|
|
{:path path})))
|
|
(js->clj (js/JSON.parse (.readFileSync fs path "utf8")) :keywordize-keys true))))
|
|
|
|
;; Both sides get jitter of exactly zero, which is what makes the tracks
|
|
;; comparable at all: the JS uses Math.random and the CLJS a seeded generator.
|
|
(def track (delay (synth/synth-dense (:frames @oracle) {:rand-fn (constantly 0.5)})))
|
|
|
|
(defn- worst
|
|
"The largest absolute difference between two equally-shaped nested numeric
|
|
structures, and where it was, so a failure names the case."
|
|
[a b]
|
|
(let [seen (atom {:d -1 :at nil})]
|
|
(letfn [(walk [x y path]
|
|
(cond
|
|
(number? x)
|
|
(let [d (abs (- x y))]
|
|
(when (> d (:d @seen)) (reset! seen {:d d :at path :got x :want y})))
|
|
(map? x)
|
|
(doseq [k (keys x)] (walk (get x k) (get y k) (conj path k)))
|
|
(sequential? x)
|
|
(do (when (not= (count x) (count y))
|
|
(throw (ex-info "shape mismatch" {:at path :got (count x) :want (count y)})))
|
|
(dotimes [i (count x)] (walk (nth x i) (nth y i) (conj path i))))
|
|
:else nil))]
|
|
(walk a b []))
|
|
@seen))
|
|
|
|
(defn- agrees?
|
|
"Assert two structures agree to TOL, reporting the worst offender."
|
|
[label a b]
|
|
(let [{:keys [d at got want]} (worst a b)]
|
|
(is (< d TOL)
|
|
(str label ": worst gap " d " at " (pr-str at) " (" got " vs " want ")"))))
|
|
|
|
;; ---- the track itself ----
|
|
;;
|
|
;; Everything below is meaningless if the two synths disagree, so this is
|
|
;; asserted first and separately: a track mismatch would otherwise surface as a
|
|
;; dozen numeric failures pointing nowhere near the cause.
|
|
|
|
(deftest the-two-synths-produce-the-same-track
|
|
(let [js-track (:track @oracle)]
|
|
(is (= (count js-track) (count @track)))
|
|
(is (= (count (first js-track)) (count (first @track))))
|
|
(agrees? "synthetic track" @track js-track)))
|
|
|
|
;; ---- the tables ----
|
|
|
|
(deftest tables-were-transcribed-without-a-typo
|
|
(let [t (:tables @oracle)]
|
|
(is (= lm/RIGID (:RIGID t)))
|
|
(is (= lm/LIPS-OUTER (:LIPS_OUTER t)))
|
|
(is (= lm/EYE-R-RING (:EYE_R_RING t)))
|
|
(is (= lm/BROW-A-RING (:BROW_A_RING t)))
|
|
(is (= lm/FACE-OVAL (:FACE_OVAL t)))))
|
|
|
|
;; ---- domain/ring ----
|
|
|
|
(deftest subsample-slots-agrees
|
|
;; The oracle keys are "len/n", which js->clj reads as a NAMESPACED keyword —
|
|
;; so the length is the namespace, not the first half of the name.
|
|
(doseq [[k want] (:subsampleSlots @oracle)]
|
|
(let [len (js/parseInt (namespace k))
|
|
n (js/parseInt (name k))]
|
|
(is (= want (ring/subsample-slots len n))
|
|
(str "subsample-slots(" len "," n ")")))))
|
|
|
|
(deftest offset-ring-agrees
|
|
(doseq [{:keys [d ring shutLid collapsed]} (:offsetRing @oracle)]
|
|
(let [src (mapv #(nth (first @track) %) lm/LIPS-OUTER)]
|
|
(agrees? (str "offset-ring(lips, " d ")")
|
|
(mapv #(select-keys % [:x :y]) (ring/offset-ring src d))
|
|
(mapv #(select-keys % [:x :y]) ring)))
|
|
(agrees? (str "offset-ring(shut lid, " d ")")
|
|
(mapv #(select-keys % [:x :y])
|
|
(ring/offset-ring [{:x -10 :y 0} {:x 0 :y -0.02}
|
|
{:x 10 :y 0} {:x 0 :y 0.02}] d))
|
|
(mapv #(select-keys % [:x :y]) shutLid))
|
|
;; A vertex on the centroid has no outward direction. Both sides must leave
|
|
;; it alone rather than emit NaN, and NaN != NaN would slip past `worst`.
|
|
(let [got (ring/offset-ring [{:x 0 :y 0} {:x 0 :y 0} {:x 0 :y 0}] d)]
|
|
(is (every? #(and (not (js/isNaN (:x %))) (not (js/isNaN (:y %)))) got)
|
|
(str "offset-ring(collapsed, " d ") is NaN-free"))
|
|
(is (every? #(and (not (js/isNaN (:x %))) (not (js/isNaN (:y %)))) collapsed)
|
|
"...and the oracle's is too, so this is parity and not a shared bug"))))
|
|
|
|
;; ---- domain/geom: the two the port plan names ----
|
|
|
|
(deftest fit-similarity-agrees-on-a-known-transform
|
|
(let [{:keys [src dst fit]} (:known @oracle)]
|
|
(agrees? "fit-similarity (known transform)"
|
|
(geom/fit-similarity src dst)
|
|
fit)))
|
|
|
|
(deftest fit-similarity-agrees-over-the-whole-shot
|
|
(let [rigid (mapv (fn [fr] (mapv #(nth fr %) lm/RIGID)) @track)
|
|
ref (:rigidRef @oracle)]
|
|
;; Fitted against the ORACLE's reference, so this isolates fit-similarity
|
|
;; from procrustes-mean instead of compounding the two.
|
|
(agrees? "fit-similarity over 72 frames"
|
|
(mapv #(geom/fit-similarity % ref) rigid)
|
|
(:transforms @oracle))))
|
|
|
|
(deftest procrustes-mean-agrees
|
|
(let [rigid (mapv (fn [fr] (mapv #(nth fr %) lm/RIGID)) @track)]
|
|
(agrees? "procrustes-mean"
|
|
(mapv #(select-keys % [:x :y]) (geom/procrustes-mean rigid))
|
|
(mapv #(select-keys % [:x :y]) (:rigidRef @oracle)))))
|
|
|
|
(deftest fit-residual-agrees
|
|
(let [rigid (mapv (fn [fr] (mapv #(nth fr %) lm/RIGID)) @track)
|
|
ref (:rigidRef @oracle)]
|
|
(agrees? "fit-residual"
|
|
(mapv (fn [r tf] (geom/fit-residual tf r ref))
|
|
rigid (:transforms @oracle))
|
|
(:residuals @oracle))))
|
|
|
|
;; ---- domain/geom: the smoothing knobs ----
|
|
|
|
(deftest moving-average-agrees-at-every-radius
|
|
(let [tx (mapv :tx (:transforms @oracle))]
|
|
(doseq [{:keys [radius vals]} (:movingAverage @oracle)]
|
|
(agrees? (str "moving-average radius " radius)
|
|
(geom/moving-average tx radius)
|
|
vals))))
|
|
|
|
(deftest smooth-transforms-agrees-at-every-radius
|
|
(doseq [{:keys [radius tfs]} (:smoothed @oracle)]
|
|
(agrees? (str "smooth-transforms radius " radius)
|
|
(geom/smooth-transforms (:transforms @oracle) radius)
|
|
tfs)))
|
|
|
|
;; ---- domain/raster ----
|
|
|
|
(deftest raster-agrees-pixel-for-pixel
|
|
;; Integer output, so this is EXACT equality and not TOL. The whole buffer is
|
|
;; diffed rather than a pixel count: one scanline a pixel wide of the JS would
|
|
;; read as a seam between two parts, not as an error, and a count would miss it.
|
|
(let [{:keys [w h buf]} (:raster @oracle)
|
|
fr (first @track)
|
|
ras (-> (raster/make w h) (raster/clear! 0))]
|
|
(raster/fill-poly! ras (mapv (fn [i] {:x (- (* (:x (nth fr i)) 320) 100)
|
|
:y (- (* (:y (nth fr i)) 200) 40)})
|
|
lm/LIPS-OUTER) 2)
|
|
(raster/fill-poly! ras [{:x 8.5 :y 8.5} {:x 56.25 :y 8.5}
|
|
{:x 56.25 :y 40.75} {:x 8.5 :y 40.75}] 1)
|
|
(raster/fill-disc! ras 30.4 24.6 9.2 3 1)
|
|
(raster/fill-disc! ras 5.5 44.5 4 4)
|
|
(raster/fill-rect! ras 30.49 24.51 3 5 3)
|
|
(raster/fill-rect! ras 1 1 0 6)
|
|
(let [got (vec (array-seq (:buf ras)))
|
|
diff (keep-indexed (fn [i v] (when (not= v (nth buf i))
|
|
{:at [(mod i w) (quot i w)]
|
|
:got v :want (nth buf i)}))
|
|
got)]
|
|
(is (empty? diff)
|
|
(str (count diff) " of " (* w h) " pixels differ, first few: "
|
|
(pr-str (vec (take 5 diff)))))
|
|
;; A buffer that agreed because both sides drew nothing would pass the
|
|
;; above, so check the drawing actually happened.
|
|
(is (> (count (distinct got)) 3)
|
|
(str "only " (pr-str (distinct got)) " indices present")))))
|
|
|
|
(deftest hex-to-rgb-agrees
|
|
(agrees? "palette rgb" pal/rgb (:paletteRgb @oracle)))
|
|
|
|
;; ---- flow: stage 3 measure + stage 4 condition ----
|
|
;;
|
|
;; The prototype's `stabilize` is three things: the anchor fit, the smoothing of
|
|
;; its parameters, and the mouth measured through the result. Here they are three
|
|
;; functions in two stages, so what is diffed is the COMPOSITION — a split that
|
|
;; agreed on every piece and not on the whole would be a split and not a port.
|
|
|
|
(deftest stabilize-agrees-across-the-split-stages
|
|
(doseq [{:keys [aspect radius ref rigid transforms residual outer inner aperture]}
|
|
(:stabilize @oracle)]
|
|
(let [label (str "stabilize(aspect " aspect ", radius " radius ")")
|
|
fitted (anchor/fit {:aspect aspect} {:dense @track})
|
|
anchd (condition/anchor {:anchor-avg radius} fitted)
|
|
got (mouth/measure {:aspect aspect}
|
|
{:dense @track :transforms (:transforms anchd)})]
|
|
(agrees? (str label " ref") (:ref fitted) ref)
|
|
(agrees? (str label " rigid") (:rigid fitted) rigid)
|
|
(agrees? (str label " transforms") (:transforms anchd) transforms)
|
|
(agrees? (str label " outer") (:outer got) outer)
|
|
(agrees? (str label " inner") (:inner got) inner)
|
|
(agrees? (str label " aperture") (:aperture got) aperture)
|
|
;; The prototype takes the residual against the SMOOTHED transforms, because
|
|
;; those were the ones in scope. `anchor/fit` takes it against the raw fit,
|
|
;; deliberately: the number's job is to say whether the footage is
|
|
;; stabilisable, and folding the smoothing error into it makes a setting look
|
|
;; like a property of the shot. Parity is on the function, handed what the
|
|
;; prototype handed it — so the divergence is a decision and not a drift.
|
|
(agrees? (str label " residual")
|
|
(anchor/residuals (:ref fitted) (:rigid fitted) (:transforms anchd))
|
|
residual)
|
|
(when (zero? radius)
|
|
(agrees? (str label " residual, as fit reports it") (:residual fitted) residual)))))
|
|
|
|
(deftest smooth-contours-agrees-at-every-radius
|
|
(let [fitted (anchor/fit {:aspect 0.5625} {:dense @track})
|
|
rings (:outer (mouth/measure {:aspect 0.5625}
|
|
{:dense @track :transforms (:transforms fitted)}))]
|
|
(doseq [{:keys [radius outer]} (:smoothContours @oracle)]
|
|
(agrees? (str "condition/contours radius " radius)
|
|
(condition/contours {:contour-avg radius} rings)
|
|
outer))))
|