Port step 5: freeze measured mouth into playable channels

This commit is contained in:
Olive Vaughn 2026-09-27 19:01:39 -04:00
parent 942e2f38ab
commit 8a06835895
24 changed files with 1625 additions and 506 deletions

View file

@ -95,6 +95,27 @@
(is (not (ch/nothing? 0)) "zero is a value, not an absence")
(is (not (ch/nothing? false)) "and so is false")))
(deftest the-mask-belongs-to-the-block-s-slice-and-not-to-frame-zero
;; A block is NODE-MAJOR, so one block holds several nodes' tracks and therefore
;; several mask regions. Indexing the mask by the frame alone reads the FIRST
;; track's absence for every track in the block — which is not a subtly wrong
;; pose, it is every part in the block vanishing on the frames where one of them
;; was occluded, and `demo/swarm` drew nothing at all for its first thirty-four
;; frames on account of it.
(let [nf 3
;; Track 0 absent on frame 1, track 1 absent on frame 2.
state (js/Uint8Array. #js [ch/present ch/absent-bit ch/present
ch/present ch/present ch/absent-bit])
store {"blk" {:data (js/Int16Array. #js [10 11, 20 21, 30 31,
40 41, 50 51, 60 61])
:state state}}
track (fn [i] {:animated? true
:dense {:store "blk" :offset (* i nf 2) :stride 2 :frames nf}})
read (fn [i f] (let [v (ch/value-at (track i) f store)]
(if (ch/nothing? v) :absent [(ch/component v 0) (ch/component v 1)])))]
(is (= [[10 11] :absent [30 31]] (mapv #(read 0 %) (range nf))))
(is (= [[40 41] [50 51] :absent] (mapv #(read 1 %) (range nf))))))
(deftest a-block-with-no-mask-is-present-throughout
;; The mask is optional: a generator that cannot fail to detect has nothing to
;; say, and allocating a zeroed byte per frame to say it would be noise.
@ -129,6 +150,43 @@
(is (= (mapv spec fs) (via-cursor c fs))
(str label " / " order-name)))))))
(deftest a-fixed-point-block-decodes-through-its-own-scale
;; `:scale` is in the block HEADER and not agreed by convention, because a block
;; in image-height units and a block in stage pixels need different ones to fill
;; an Int16 usefully. It is what lets the block in memory be byte for byte the
;; block on the wire, which a handle naming a sha256 requires.
(let [store {"blk" {:data (js/Int16Array. #js [16384 -8192, 4096 32767]) :state nil}}
wide {:animated? true :dense {:store "blk" :offset 0 :stride 2 :frames 2
:scale 16384}}
thin {:animated? true :dense {:store "blk" :offset 0 :stride 1 :frames 4
:scale 16384}}]
(is (= [[1.0 -0.5] [0.25 (/ 32767 16384)]]
(mapv (fn [f] (let [v (ch/value-at wide f store)]
[(ch/component v 0) (ch/component v 1)]))
(range 2))))
(is (= [1.0 -0.5 0.25] (mapv #(ch/value-at thin % store) (range 3)))
"a stride-1 block decodes to a number and needs no buffer")
(is (seq (ch/problems (assoc-in wide [:dense :scale] 0)))
"a scale of zero divides every value in the block by zero")
(is (seq (ch/problems (assoc-in wide [:dense :scale] -16384))))))
(deftest the-cursor-decodes-into-a-buffer-it-owns-and-agrees-with-the-spec
;; Decoding costs the subarray view, so the reader owns one destination per
;; channel — the same bargain the resolver makes with its point buffers, and it
;; carries the same contract: a value has to be CONSUMED before the next frame
;; is asked for, because the next read overwrites it.
(let [store {"blk" {:data (js/Int16Array. #js [16384 0, 0 16384, -16384 0]) :state nil}}
c {:animated? true :dense {:store "blk" :offset 0 :stride 2 :frames 3
:scale 16384}}
cur (ch/cursor c store)
pair (fn [v] [(ch/component v 0) (ch/component v 1)])]
(is (= (mapv #(pair (ch/value-at c % store)) (range 3))
(mapv #(pair (ch/sample! cur %)) (range 3))))
(let [held (ch/sample! cur 0)]
(ch/sample! cur 2)
(is (= [-1.0 0.0] (pair held))
"the buffer is reused, which is the contract and not a bug"))))
(deftest the-cursor-reads-a-dense-block-too
(let [store {"blk" {:data (js/Float32Array. #js [1 2 3 4 5]) :state nil}}
c {:animated? true :dense {:store "blk" :offset 0 :stride 1 :frames 5}}

View file

@ -301,7 +301,7 @@
;; same on every frame.
(let [res (scene/resolver demo/scene)
render (fn [f]
(let [r (raster/make demo/width demo/height)]
(let [r (raster/make (:width demo/scene) (:height demo/scene))]
(raster/clear! r (:bg pal/index-of))
(raster/draw-ops! r (res f))
r))
@ -319,7 +319,7 @@
;; other than the frame the channels are sampled at.
(let [res (scene/resolver demo/scene)
render (fn [f]
(let [r (raster/make demo/width demo/height)]
(let [r (raster/make (:width demo/scene) (:height demo/scene))]
(raster/clear! r (:bg pal/index-of))
(raster/draw-ops! r (res f))
(vec (array-seq (:buf r)))))]
@ -336,8 +336,8 @@
;; pupil by the iris, and neither is expressed anywhere as a chain.
(let [res (scene/resolver demo/scene)]
(doseq [f (range 0 (:frames demo/scene) 4)]
(let [before (raster/make demo/width demo/height)
after (raster/make demo/width demo/height)
(let [before (raster/make (:width demo/scene) (:height demo/scene))
after (raster/make (:width demo/scene) (:height demo/scene))
ops (res f)
card? (fn [op] (= :card (:node op)))]
(raster/clear! before (:bg pal/index-of))

View file

@ -0,0 +1,463 @@
(ns arthur.flow.freeze-test
"The freeze is where the model's central claim is either true or false:
measurement and a hand produce THE SAME DATA, and the only difference is a flag
nothing in the renderer reads. Most of what is asserted here is that claim,
taken apart into the pieces that could quietly stop holding.
It is also the first step whose done-criterion is a PICTURE, so nothing in here
is the proof that step 5 is done — a take that resolves to the right numbers and
draws nothing would pass every assertion below. See test/browser/take.mjs, which
drives a real Chrome."
(:require [cljs.test :refer [deftest is testing]]
[arthur.demo.take :as take]
[arthur.domain.channel :as ch]
[arthur.domain.geom :as geom]
[arthur.domain.node :as node]
[arthur.domain.palette :as pal]
[arthur.domain.raster :as raster]
[arthur.domain.ring :as ring]
[arthur.domain.scene :as scene]
[arthur.flow.freeze :as freeze]))
(def ^:private W 320)
(def ^:private H 200)
;; The whole vertical slice, exactly as the page builds it. Asserting against the
;; page's own clip rather than against a fixture built here is deliberate: a
;; fixture is a second scene nobody looks at, and the one that renders is the one
;; that has to be right.
(def clip (delay @take/frozen))
(def scene* (delay (:scene @clip)))
(def store (delay (:store @clip)))
(defn- node [id] (get-in @scene* [:nodes id]))
(defn- chan [id path] (get-in (node id) [:channels path]))
(defn- pts-at
"The mouth's [:geom :pts] at frame f, as a flat CLJS vector."
[id f]
(let [v (ch/value-at (chan id [:geom :pts]) f @store)]
(mapv #(ch/component v %) (range (.-length v)))))
(defn- ops-at [sc f]
((scene/resolver sc @store pal/index-of) f))
(defn- render
"One frame of a scene into a byte buffer. The stage's size comes off the clip,
because project dimensions are the project's and not the footage's."
[sc f]
(let [r (raster/make (:width sc) (:height sc))]
(raster/clear! r (get pal/index-of :bg))
(raster/draw-ops! r (ops-at sc f))
(vec (array-seq (:buf r)))))
(defn- drawn
"How many pixels are not background."
[buf]
(count (remove #(= % (get pal/index-of :bg)) buf)))
;; ---------------------------------------------------------------------------
;; the shape of what came out
(deftest the-frozen-take-is-a-valid-scene-in-every-head-mode
;; `scene/problems` is total by construction, so this is safe to run over data
;; before the data is trusted — which is what it is for.
(doseq [mode [:as-filmed :locked]]
(let [sc (freeze/head-mode {:mode mode} @clip)]
(is (empty? (scene/problems sc)) (str mode ": " (pr-str (scene/problems sc))))))
(let [sc (freeze/head-mode {:mode :per-plate :kept #{0 12 40 88 150}} @clip)]
(is (empty? (scene/problems sc)) (pr-str (scene/problems sc)))))
(deftest the-tree-is-the-one-the-model-specifies
;; :face is AUTHORED and :head is MEASURED, and they are two nodes because two
;; different things want that transform. A group node is free; keeping the
;; hand-placed and the measured transform apart is the whole reason the
;; transform is decomposed in the first place.
(is (= [:face :root] (scene/lineage (:nodes @scene*) :face)))
(is (= [:head :face :root] (scene/lineage (:nodes @scene*) :head)))
(is (= [:mouth :head :face :root] (scene/lineage (:nodes @scene*) :mouth)))
(is (= [:mouth-in :mouth :head :face :root]
(scene/lineage (:nodes @scene*) :mouth-in)))
;; Exposure lives on the clip root and inherits strictly.
(is (= {:mode :map :expose 2} (:time (node :root))))
(is (every? #(nil? (:time (node %))) [:face :head :mouth :mouth-in])))
(deftest geometry-is-flat-and-dense-and-the-interior-shares-the-outline-s-block
(doseq [id [:mouth :mouth-in]]
(is (= :dense (ch/describe (chan id [:geom :pts])))))
;; ONE block, two tracks, node-major. offset(track 1) = frames · stride, which
;; is the layout demo/swarm holds and the reason a frame is a rectangular slice
;; at an arithmetic offset rather than a lookup into a table.
(let [a (:dense (chan :mouth [:geom :pts]))
b (:dense (chan :mouth-in [:geom :pts]))]
(is (= (:store a) (:store b)))
(is (zero? (:offset a)))
(is (= (* (:frames a) (:stride a)) (:offset b))))
;; Flat: [x0 y0 x1 y1 …], so stride is twice the vertex budget and a value reads
;; the same way an authored vector does.
(is (= (* 2 8) (:stride (:dense (chan :mouth [:geom :pts])))))
(is (= (* 2 8) (count (pts-at :mouth 0)))))
(deftest the-fixed-point-block-round-trips-to-within-its-own-quantum
;; The one thing fixed point can cost is precision, so it is measured rather
;; than assumed. `rings->flat` is re-run here against the same measured rings,
;; which is what the block was written from.
(let [want (freeze/rings->flat (:outer @take/measured) 8)
q (/ 1.0 freeze/geom-scale)
gap (reduce max (for [f (range 0 take/frames 7)
[a b] (map vector (pts-at :mouth f) (nth want f))]
(abs (- a b))))]
(is (<= gap (/ q 2))
(str "worst quantisation error " gap " against a quantum of " q))
;; And in stage pixels, which is the unit anyone can judge. `:face`'s scale is
;; stage px per image height, so the whole quantum is q·k — a twentieth of a
;; pixel at this placement, two orders below anything the rasteriser can
;; express, which is the argument for Int16 geometry stated as a measurement.
(let [k (first (:value (chan :face [:xform :scale])))]
(is (< (* q k) 0.1)
(str "the quantum is " (* q k) " stage pixels at " k " px per image height"))
(is (< (* gap k) (* q k))))))
(deftest the-scale-is-in-the-header-and-not-agreed-by-convention
;; A block in image-height units and a block in stage pixels want different
;; scales, which is why it is a field. Transform blocks carry none at all: they
;; are Float32, because an angle and a scale factor have no natural fixed point
;; and there are four numbers a frame of them rather than forty.
(is (= freeze/geom-scale (:scale (:dense (chan :mouth [:geom :pts])))))
(doseq [path [[:xform :pos] [:xform :rot] [:xform :scale]]]
(is (nil? (:scale (:dense (get-in (node :head) [:measured path]))))
(str path " should be plain Float32")))
(is (instance? js/Int16Array (:data (get @store "take/geom"))))
(is (instance? js/Float32Array (:data (get @store "take/head-pos")))))
(deftest a-value-past-the-block-s-range-is-refused-rather-than-saturated
;; Saturating reads as articulation flattening off at the extremes — a bad
;; detection, not a bad scale — so it has to be loud. A ring three image heights
;; wide cannot be real, and that is the point: if it happens, the block's space
;; is wrong and there is nothing to be gained by drawing something.
(is (thrown-with-msg?
ExceptionInfo #"does not fit the block's fixed point"
(freeze/clip (assoc take/params :name "huge")
(update @take/measured :outer
(fn [rings] (mapv (fn [r] (mapv #(update % :x + 3) r)) rings)))))))
;; ---------------------------------------------------------------------------
;; the anchor: three channels, and the inverse
(deftest the-similarity-inverse-undoes-the-fit-exactly
;; The fit takes the head's motion OUT and geometry is stored in the space it
;; produces, so putting the motion back — "as filmed" — is the fit's inverse.
;; Still factored, because the three components land on three independently
;; keyframable channels.
(let [gap (reduce max
(for [tf (:transforms @take/measured)
p [{:x 0.5 :y 0.6} {:x 0.0 :y 0.0} {:x -0.3 :y 1.2}]]
(let [q (geom/apply-sim (freeze/invert tf) (geom/apply-sim tf p))]
(js/Math.hypot (- (:x q) (:x p)) (- (:y q) (:y p))))))]
(is (< gap 1e-12) (str "invert ∘ fit is off by " gap))))
(deftest the-head-carries-the-inverse-fit-split-into-its-three-components
;; Float32 storage, so this is a tolerance and not equality — four bytes a
;; number is the spec's choice for transform blocks and it costs about seven
;; decimal digits, which at 850 stage pixels per image height is far below a
;; pixel.
(let [measured (get-in (node :head) [:measured])
at (fn [path f] (ch/value-at (get measured path) f @store))]
(doseq [f (range 0 take/frames 11)]
(let [want (freeze/invert (nth (:transforms @take/measured) f))
pos (at [:xform :pos] f)]
(is (< (abs (- (ch/component pos 0) (:tx want))) 1e-5))
(is (< (abs (- (ch/component pos 1) (:ty want))) 1e-5))
(is (< (abs (- (at [:xform :rot] f) (:theta want))) 1e-6))
(is (< (abs (- (ch/component (at [:xform :scale] f) 0) (:s want))) 1e-6))))))
(deftest head-local-geometry-composed-through-head-and-face-lands-on-the-stage
;; The end-to-end claim of the split, asserted against the OPS the resolver
;; actually emits rather than against an intermediate: stored head-local, put
;; back through `:head`, placed by `:face`, the mouth is where the composition
;; of the two says it is. A test that recomputed the chain would only be
;; checking arithmetic against itself; this checks `node/local!`, `node/world!`
;; and `emit` as well.
(let [sc (freeze/head-mode {:mode :as-filmed} @clip)
res (scene/resolver sc @store pal/index-of)
k (first (:value (chan :face [:xform :scale])))
anc (:value (chan :face [:xform :anchor]))
pos (:value (chan :face [:xform :pos]))
tfs (:transforms @take/measured)]
(doseq [f (range 0 take/frames 13)]
(let [ops (res f)
op (first (filter #(= :mouth (:node %)) ops))
;; EXPOSURE FIRST. The clip root is on 2s and exposure inherits
;; strictly, so frame 13 shows frame 12's pose — which is also the
;; cheapest place to assert that the grid is actually being applied,
;; since reading the unexposed frame here misses by half a pixel and
;; looks like a rounding problem.
ef (node/expose f 2)
;; The frozen, quantised vertex — so the fixed point is not part of
;; what is being asserted here; it has its own test.
flat (pts-at :mouth ef)
g {:x (nth flat 0) :y (nth flat 1)}
;; Where the anchor fit says that head-local point was in the image:
;; the fit removed the head's motion, so putting it back is the fit's
;; inverse. Node :head carries exactly this.
im (geom/apply-sim (freeze/invert (nth tfs ef)) g)
;; And where :face puts it: scaled about the anchor, then translated,
;; which is p ↦ k(p - anchor) + anchor + pos.
wx (+ (* k (- (:x im) (nth anc 0))) (nth anc 0) (nth pos 0))
wy (+ (* k (- (:y im) (nth anc 1))) (nth anc 1) (nth pos 1))]
(is (some? op) (str "frame " f " emitted no mouth op"))
;; Tolerance is the Float32 transform block's, scaled to stage pixels, and
;; it is three orders below a pixel.
(is (< (js/Math.hypot (- (aget (:pts op) 0) wx)
(- (aget (:pts op) 1) wy))
0.01)
(str "frame " f ": op has ["
(aget (:pts op) 0) " " (aget (:pts op) 1)
"], the composition says [" wx " " wy "]"))))))
;; ---------------------------------------------------------------------------
;; the three modes are the three channel shapes
(deftest the-three-head-modes-are-the-three-channel-shapes
(let [kept #{0 12 40 88 150}
of (fn [sc path] (get-in sc [:nodes :head :channels path]))]
(testing "locked is framed identity"
(let [sc (freeze/head-mode {:mode :locked} @clip)]
(is (= [:framed :framed :framed]
(mapv #(ch/describe (of sc %))
[[:xform :pos] [:xform :rot] [:xform :scale]])))
(is (= [0.0 0.0] (:value (of sc [:xform :pos]))))
(is (= 0.0 (:value (of sc [:xform :rot]))))
(is (= [1.0 1.0] (:value (of sc [:xform :scale]))))))
(testing "as filmed is dense"
(let [sc (freeze/head-mode {:mode :as-filmed} @clip)]
(is (= [:dense :dense :dense]
(mapv #(ch/describe (of sc %))
[[:xform :pos] [:xform :rot] [:xform :scale]])))))
(testing "per plate is keyed at exactly the kept frames"
(let [sc (freeze/head-mode {:mode :per-plate :kept kept} @clip)]
(doseq [path [[:xform :pos] [:xform :rot] [:xform :scale]]]
(is (= :keyed (ch/describe (of sc path))))
(is (= (sort kept) (sort (keys (:keys (of sc path))))))
;; A dense read is a VIEW into tier 2. Storing one in the document would
;; be storing a value that changes when a re-freeze rewrites the array
;; under it, so the keys hold plain data.
(doseq [[_ v] (:keys (of sc path))]
(is (or (number? v) (vector? v)) (str path " key is " (pr-str v)))))
;; And the keys are the dense track sampled at those frames, which is the
;; whole of what "per plate" means.
(is (= (mapv #(ch/value-at (get-in (node :head) [:measured [:xform :rot]]) % @store)
(sort kept))
(mapv (:keys (of sc [:xform :rot])) (sort kept))))))))
(deftest switching-modes-rewrites-the-head-and-nothing-else
;; It has to be impossible for the toggle to move something a hand placed, and
;; it has to be a DOCUMENT edit: tier 1, undoable, syncable, instant, and not a
;; reason to re-analyse.
(let [a (freeze/head-mode {:mode :as-filmed} @clip)
b (freeze/head-mode {:mode :locked} @clip)
c (freeze/head-mode {:mode :per-plate :kept #{0 40}} @clip)]
(doseq [sc [b c]]
(is (= (get-in a [:nodes :face]) (get-in sc [:nodes :face]))
":face moved")
(is (= (dissoc (:nodes a) :head) (dissoc (:nodes sc) :head))
"a node other than :head changed")
;; The measurement does not go away when the head is locked: always measure,
;; always store factored, toggle the parent.
(is (= (get-in a [:nodes :head :measured]) (get-in sc [:nodes :head :measured]))))))
(deftest a-head-mode-that-is-not-one-of-the-three-is-refused
(is (thrown-with-msg? ExceptionInfo #"not one of the three channel shapes"
(freeze/head-mode {:mode :stabilised} @clip)))
;; The kept-frame set belongs to the plate strip, not to measurement, so freeze
;; cannot invent one.
(is (thrown-with-msg? ExceptionInfo #"kept-frame set"
(freeze/head-mode {:mode :per-plate} @clip))))
;; ---------------------------------------------------------------------------
;; the face: authored, and what makes makeXform deletable
(deftest the-face-is-authored-and-carries-no-provenance
;; The difference from `makeXform` in one assertion: the placement is a FRAMED
;; transform on a node, which a hand can revise, and it claims no generator that
;; would offer to overwrite it.
(doseq [path [[:xform :pos] [:xform :rot] [:xform :scale] [:xform :anchor]]]
(let [c (get (node/channels (get-in @scene* [:nodes :face])) path)]
(is (= :framed (ch/describe c)) (str path " is not framed"))
(is (nil? (:generated c)) (str path " claims provenance")))))
(deftest the-face-puts-the-head-s-centre-where-it-says-it-does
;; anchor + pos is where the anchor lands in the parent, which is what makes
;; `:anchor` the registration point: scale and rotation happen about the head's
;; centre rather than about the corner of the footage, where MediaPipe's
;; normalised space has its origin.
(let [anc (:value (chan :face [:xform :anchor]))
pos (:value (chan :face [:xform :pos]))
c (geom/centroid (:ref @take/measured))]
(is (< (abs (- (nth anc 0) (:x c))) 1e-12))
(is (< (abs (- (nth anc 1) (:y c))) 1e-12))
(is (< (abs (- (+ (nth anc 0) (nth pos 0)) (/ W 2))) 1e-9))
(is (< (abs (- (+ (nth anc 1) (nth pos 1)) (* 0.25 H))) 1e-9))))
(deftest the-stage-is-the-clip-s-and-not-the-footage-s
;; Project dimensions are independent of the footage, which is precisely what
;; dropping makeXform buys. Nothing below the freeze knows the frame size, so
;; asking for a different stage moves and rescales the same geometry rather than
;; re-measuring anything.
(let [big (freeze/clip (assoc take/params :stage [640 480] :name "big")
@take/measured)]
(is (= [640 480] [(:width (:scene big)) (:height (:scene big))]))
(is (= (vec (array-seq (:data (get @store "take/geom"))))
(vec (array-seq (:data (get (:store big) "big/geom")))))
"the geometry is the same numbers at either stage size")
(is (not= (:value (get-in (:scene big) [:nodes :face :channels [:xform :scale]]))
(:value (chan :face [:xform :scale]))))))
;; ---------------------------------------------------------------------------
;; the aperture, as [:vis]
(deftest the-mouth-interior-is-hidden-below-the-aperture-cut
(let [c (chan :mouth-in [:vis])
ap (:aperture @take/measured)
peak (reduce max ap)
want (mapv #(>= (/ % peak) 0.12) ap)]
(is (= :keyed (ch/describe c)))
(is (= want (mapv #(ch/value-at c %) (range take/frames)))
"the held keys do not reproduce the threshold")
;; The reason it is keyed: a threshold crossing is a handful of transitions,
;; hold is the default, and keys are the shape a human can correct. A dense
;; block would be 229 bytes in tier 2 to say the same thing, un-editable.
(is (< (count (:keys c)) 40)
(str (count (:keys c)) " keys for " take/frames " frames"))
(is (contains? (:keys c) 0) "the first key is the pose the part starts in")
;; It really does both, or the assertion above is vacuous.
(is (some true? want))
(is (some false? want))))
(deftest the-mouth-outline-is-never-hidden
;; The dark band OUTSIDE the interior is what makes a flat shape read as an
;; opening rather than a blob, so the outline keeps every frame; only the
;; interior comes and goes.
(is (nil? (chan :mouth [:vis])))
(doseq [f (range 0 take/frames 9)]
(is (some #(= :mouth (:node %)) (ops-at @scene* f))
(str "frame " f " drew no mouth outline"))))
;; ---------------------------------------------------------------------------
;; provenance
(deftest every-generated-channel-says-who-generated-it-and-under-which-knobs
;; `:generated` is what lets the UI offer a parameter panel and a re-freeze
;; instead of raw keys, and which knobs it names is the invalidation table
;; written where a re-freeze can read it.
(is (= :roto/lips-outer (:by (:generated (chan :mouth [:geom :pts])))))
(is (= :roto/lips-inner (:by (:generated (chan :mouth-in [:geom :pts])))))
(is (= :roto/mouth-aperture (:by (:generated (chan :mouth-in [:vis])))))
(is (= :anchor/similarity
(:by (:generated (get-in (node :head) [:measured [:xform :pos]])))))
(is (= {:anchor-avg 2 :contour-avg 1 :verts 8}
(:params (:generated (chan :mouth [:geom :pts])))))
;; The aperture does NOT depend on `contour avg`: measure reports the inner
;; ring's own height and nothing smooths it.
(is (= {:anchor-avg 2 :aperture-cut 0.12}
(:params (:generated (chan :mouth-in [:vis])))))
(is (every? #(some? (:analysis (:generated %)))
[(chan :mouth [:geom :pts]) (chan :mouth-in [:vis])])))
(deftest the-renderer-never-reads-provenance
;; The load-bearing claim, asserted rather than trusted: strip every
;; `:generated` out of the document and the frame is the same bytes. If this
;; ever fails, a rotoscoped part and a hand-drawn one have stopped being the
;; same data.
(let [stripped (update @scene* :nodes
(fn [ns] (into {} (map (fn [[id n]]
[id (update n :channels
#(into {} (map (fn [[p c]] [p (dissoc c :generated)])) %))]))
ns)))]
(doseq [f (range 0 take/frames 17)]
(is (= (render @scene* f) (render stripped f))
(str "frame " f " differs with provenance removed")))))
;; ---------------------------------------------------------------------------
;; presence is not visibility
(deftest an-undetected-frame-has-no-pose-at-all
;; A subject that was not on the frame has NO VALUE, which is different from a
;; part being switched off. The mask lands on every block of the freeze, so an
;; absent frame takes the head's transform with it — and a node with no
;; transform gives its children nowhere to be, so the whole face goes.
(let [gap (set (range 40 60))
det (mapv #(not (contains? gap %)) (range take/frames))
c (freeze/clip (assoc take/params :name "gappy")
(assoc @take/measured :detected det))
sc (:scene c)
res (scene/resolver sc (:store c) pal/index-of)]
(doseq [f [39 40 50 59 60]]
(let [ops (res f)]
(if (contains? gap f)
(is (empty? ops) (str "frame " f " is absent and drew " (count ops) " ops"))
(is (seq ops) (str "frame " f " is present and drew nothing")))))
;; And it is the MASK doing it, not a hidden flag: `[:vis]` on :mouth-in is
;; unchanged across the gap, because hiding and absence are different
;; questions with different answers.
(is (= (mapv #(ch/value-at (get-in sc [:nodes :mouth-in :channels [:vis]]) %)
(range take/frames))
(mapv #(ch/value-at (chan :mouth-in [:vis]) %) (range take/frames))))))
;; ---------------------------------------------------------------------------
;; the rings are still rings
(deftest a-frozen-ring-is-simple-at-every-vertex-budget
;; Because hold parts CUT between poses rather than interpolating, a ring whose
;; vertex order is wrong renders as blocks meeting at corners rather than as an
;; error. It is invisible at odd vertex counts and obvious at even ones, so it
;; needs an assertion rather than an eyeball — and the subsample is the one
;; operation in the freeze that could reorder a traversal.
(doseq [verts [4 6 8 10 16 20]
which [:outer :inner]]
(let [flat (freeze/rings->flat (get @take/measured which) verts)
bad (first (for [f (range 0 take/frames 3)
:let [r (mapv (fn [k] {:x (nth (nth flat f) (* 2 k))
:y (nth (nth flat f) (inc (* 2 k)))})
(range verts))
hits (ring/self-intersections r)]
:when (seq hits)]
{:verts verts :ring which :frame f :edges (first hits)}))]
(is (nil? bad) (str "self-intersection: " (pr-str bad))))))
(deftest an-odd-vertex-budget-is-refused
;; It lands off the cardinal slots, and it is the one setting at which the
;; simplicity assertion above stops protecting anything.
(doseq [bad [3 5 7 2 22 8.5]]
(is (thrown-with-msg? ExceptionInfo #"vertex budget"
(freeze/rings->flat (:outer @take/measured) bad))
(str bad " was accepted"))))
;; ---------------------------------------------------------------------------
;; it draws, and it moves
(deftest the-take-draws-something-on-every-frame
(doseq [f (range 0 take/frames 5)]
(let [n (drawn (render @scene* f))]
(is (> n 200) (str "frame " f " drew only " n " pixels")))))
(deftest the-mouth-moves
;; The synth holds each pose for nine frames in a four-beat cycle, so frames
;; from different beats are genuinely different mouths and frames inside one
;; beat are not. This is the numeric half of step 5's done-criterion; the other
;; half is a picture and lives in test/browser/take.mjs.
(let [locked (freeze/head-mode {:mode :locked} @clip)
shot (fn [f] (render locked f))
differ (fn [a b] (count (remove true? (map = a b))))]
;; Beat 1 is wide open and beat 3 is shut. Rendered with the head LOCKED, so
;; what differs is articulation and not the head wandering across the stage.
(is (> (differ (shot 10) (shot 28)) 300)
"the open and the shut mouth rasterise the same")
;; And within a beat, on the exposure grid, it holds.
(is (= (shot 10) (shot 10)))
(is (< (differ (shot 10) (shot 12)) 200)
"a held pose is moving more than the detector noise it should have lost"))
;; As filmed, the head carries it around the stage as well.
(let [filmed (freeze/head-mode {:mode :as-filmed} @clip)]
(is (> (count (remove true? (map = (render filmed 10) (render filmed 120)))) 300)
"the head does not move across the take")))

View file

@ -1,245 +0,0 @@
(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))))

View file

@ -1,173 +0,0 @@
(ns arthur.synth
"Synthetic landmark frames, shaped exactly like FaceLandmarker output.
Exists so the whole chain downstream of detection - Procrustes, smoothing,
stabilisation, key selection, rasterising, take writing - can be exercised and
verified without a video file. A synthetic face is also the only way to test
stabilisation against a KNOWN head motion, since real footage gives no ground
truth to compare against.
Differs from js/synth.js in exactly one way, deliberately: the jitter comes
from a SEEDED generator rather than Math.random. Two reasons. A failing
assertion has to be reproducible to be worth anything, and the JS is the
numeric oracle - parity is only checkable if both sides can be handed the same
track. `:rand-fn` takes the generator over, so stubbing js/Math.random in a
node harness makes the two implementations agree exactly."
(:require [arthur.domain.landmarks :as lm]))
;; mulberry32. Chosen for being four lines of int32 arithmetic that port
;; unambiguously between JS and CLJS, not for its statistics: this is jitter for
;; a smoother to remove, not a source of entropy.
(defn mulberry32 [seed]
(let [a (atom (bit-or seed 0))]
(fn []
(let [x (swap! a (fn [v] (bit-or (+ v 0x6D2B79F5) 0)))
t (js/Math.imul (bit-xor x (unsigned-bit-shift-right x 15)) (bit-or 1 x))
t (bit-xor (+ t (js/Math.imul (bit-xor t (unsigned-bit-shift-right t 7))
(bit-or 61 t)))
t)]
(/ (unsigned-bit-shift-right (bit-xor t (unsigned-bit-shift-right t 14)) 0)
4294967296)))))
;; Half the corner separation, and the lid half-height at full open.
(def ^:private EYE-RX 0.0235)
(def ^:private EYE-RY 0.011)
(def ^:private EYE-Y -0.044)
(defn synth-dense
"`n-frames` of dense landmarks.
`:swap-iris` places the two iris blocks on the opposite eyes. It exists so the
pairing resolver can be tested against a track it actually disagrees with:
a resolver checked only against the convention it was written for is checking
nothing at all."
([] (synth-dense 72 {}))
([n-frames] (synth-dense n-frames {}))
([n-frames {:keys [swap-iris rand-fn seed]
:or {swap-iris false, seed 1}}]
(let [rnd (or rand-fn (mulberry32 seed))]
(vec
(for [t (range n-frames)]
(let [pts (make-array lm/NUM-LANDMARKS)
_ (dotimes [i lm/NUM-LANDMARKS] (aset pts i {:x 0.5 :y 0.5 :z 0}))
;; Known head motion: drift, sway, roll and a slow scale change, plus a
;; little per-frame jitter so transform smoothing has something to remove.
ph (/ t n-frames)
hx (+ 0.5 (* 0.045 (js/Math.sin (* ph js/Math.PI 2))) (* (- (rnd) 0.5) 0.002))
hy (+ 0.5 (* 0.02 (js/Math.cos (* ph js/Math.PI 3))) (* (- (rnd) 0.5) 0.002))
roll (* 0.18 (js/Math.sin (* ph js/Math.PI 2.5)))
scale (+ 1 (* 0.06 (js/Math.sin (* ph js/Math.PI 1.5))))
cr (js/Math.cos roll)
sr (js/Math.sin roll)
place (fn [i lx ly]
(let [sx (* lx scale) sy (* ly scale)]
(aset pts i {:x (- (+ hx (* cr sx)) (* sr sy))
:y (+ hy (* sr sx) (* cr sy))
:z 0})))
;; Mouth opens in four sustained beats with holds between, so key selection
;; has genuine extremes and genuine plateaux to find.
beat (mod (js/Math.floor (/ t 9)) 4)
open-amt (nth [0.004 0.05 0.022 0.0] beat)
wide (+ 0.10 (case beat 1 0.012, 3 -0.008, 0))
;; A blink is ONE frame, which is the honest hard case: at 12fps that is
;; what a real blink costs, and it is exactly the length that reads as a
;; dropped frame rather than as a blink unless `hold` extends it.
blink (and (> t 5) (zero? (mod t 19)))
openness (if blink 0.05 1)
;; Gaze holds and then jumps, the way gaze actually behaves, with a little
;; jitter on top so quantisation has noise to remove and the dwell has
;; something to suppress.
[gx gy] (nth [[0 0] [0.16 0.0] [-0.16 0.05] [0.0 -0.09]]
(mod (js/Math.floor (/ t 11)) 4))
jit (fn [] (* (- (rnd) 0.5) 0.012))
;; Eyes. The corners (RIGID[0..3]) are placed BY the lid rings rather than
;; separately, because they are slots 0 and 8 of those rings: writing them
;; twice is how the mouth grew a bowtie, and a corner that disagrees with
;; its own ring would make the eye self-intersect at some vertex budgets
;; and not others.
eye (fn [ring cx dir iris]
(let [n (count ring)]
(dotimes [k n]
;; dir flips the traversal so each ring runs the direction its real
;; table does: slot 0 outer corner, 4 upper lid, 8 inner, 12 lower.
(let [a (if (pos? dir)
(+ js/Math.PI (* (/ k n) js/Math.PI 2))
(- (* (/ k n) js/Math.PI 2)))]
(place (nth ring k)
(+ cx (* EYE-RX (js/Math.cos a)))
(+ EYE-Y (* EYE-RY openness (js/Math.sin a))))))
;; Iris: centre first, then four ring points, as the refined mesh emits.
(let [ix (+ cx (* (+ gx (jit)) EYE-RX 2))
iy (+ EYE-Y (* (+ gy (jit)) EYE-RX 2))
m (count iris)]
(place (nth iris 0) ix iy)
(doseq [k (range 1 m)]
(let [a (* (/ (dec k) (dec m)) js/Math.PI 2)]
(place (nth iris k)
(+ ix (* 0.008 (js/Math.cos a)))
(+ iy (* 0.008 (js/Math.sin a)))))))))
;; Brows, held in four sustained poses so raise quantisation has genuine
;; plateaux to find: rest, surprise (both ends up), worry (inner up only),
;; anger (inner down). Commanded in eye widths above the eye centre so the
;; measurement can be checked against a number rather than an eyeball.
[b-out b-in] (nth [[0.30 0.30] [0.46 0.46] [0.30 0.44] [0.30 0.18]]
(mod (js/Math.floor (/ t 13)) 4))
EYE-W (* EYE-RX 2)
HALF 0.006 ; ring half-thickness
brow (fn [ring cx outer-sign]
;; Slots 0-4 are one edge outer->inner, 5-9 the other inner->outer, so the
;; ends land on {0,9} and {4,5} exactly as the table promises.
(let [n (count ring) half (/ n 2)]
(dotimes [k n]
(let [along (if (< k half)
(/ k (dec half))
(/ (- n 1 k) (dec half)))
rise (+ b-out (* (- b-in b-out) along))]
(place (nth ring k)
(+ cx (* outer-sign (- EYE-RX (* along EYE-W)) 1.05))
(+ (- EYE-Y (* rise EYE-W))
(if (< k half) (- HALF) HALF)))))))
;; Lip rings as ellipse arcs, traversed so ring ORDER matches the tables:
;; slot 0 = right corner, 5 = top centre, 10 = left corner, 15 = bottom
;; centre, with y growing downward. Getting this convention wrong swaps two
;; opposite vertices and the ring self-intersects into a bowtie - see the
;; ring-simplicity assertion in domain/ring's tests.
ring (fn [table rx ry cy]
(let [n (count table)]
(dotimes [k n]
(let [a (- (* (/ k n) js/Math.PI 2))]
(place (nth table k)
(* rx (js/Math.cos a))
(+ cy (* ry (js/Math.sin a))))))))]
(place (nth lm/RIGID 4) 0.000 -0.050)
(place (nth lm/RIGID 5) 0.000 -0.020)
(place (nth lm/RIGID 6) 0.000 0.012)
(eye lm/EYE-R-RING -0.0515 1 (if swap-iris lm/IRIS-B lm/IRIS-A))
(eye lm/EYE-L-RING 0.0515 -1 (if swap-iris lm/IRIS-A lm/IRIS-B))
(brow lm/BROW-A-RING -0.0515 -1)
(brow lm/BROW-B-RING 0.0515 1)
(ring lm/LIPS-OUTER (/ wide 2) (+ 0.012 (* open-amt 0.6)) 0.075)
;; APERTURE (13, 14) are slots 5 and 15 of the inner ring, so the ring itself
;; places them at the vertical extremes. Writing them again afterwards is what
;; produced the bowtie; the aperture is simply the inner ring's height.
(ring lm/LIPS-INNER (/ wide 2.6) (+ 0.001 open-amt) 0.075)
(let [n (count lm/FACE-OVAL)]
(dotimes [k n]
(let [a (+ (- (/ js/Math.PI 2)) (* (/ k n) js/Math.PI 2))]
(place (nth lm/FACE-OVAL k)
(* 0.105 (js/Math.cos a))
(+ (* 0.145 (js/Math.sin a)) 0.01)))))
(vec pts)))))))

View file

@ -0,0 +1,300 @@
// Drives a real Chrome at the running dev server and checks that the frozen
// take is a MOVING MOUTH on a canvas.
//
// This exists because port-plan step 5 is the first step whose done-criterion is
// a picture, and a picture cannot be asserted from cljs.test. A take that
// resolves to the right numbers and draws nothing would pass every assertion in
// arthur.flow.freeze-test: a blank canvas under a perfectly correct transport is
// the bug class unit tests miss, and it has already happened once here.
//
// No dependencies, deliberately. Playwright is not installed and CDP needs
// nothing: `node --experimental-websocket` has a global WebSocket and
// `--headless=new --remote-debugging-port=N` is the whole of the other side.
//
// cd frontend
// mise exec -- npx shadow-cljs compile app
// mise exec -- npx shadow-cljs watch app # or `server`, for :dev-http
// mise exec -- node --experimental-websocket test/browser/take.mjs
//
// Writes a PNG per sampled frame into test/browser/out/ so that "it drew
// something" can be checked by eye as well as by pixel count.
import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const OUT = join(HERE, 'out');
const URL_ = process.env.ARTHUR_URL ?? 'http://localhost:8778/index.html';
const PORT = 9333;
const CHROME = process.env.CHROME ??
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
// The palette's background, from domain/palette. A pixel of this colour is
// nothing drawn, and every check below is a count of pixels that are not it.
const BG = [0x12, 0x14, 0x1c];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let failures = 0;
function check(ok, label, detail = '') {
console.log(`${ok ? ' ok ' : ' FAIL'} ${label}${detail ? ` — ${detail}` : ''}`);
if (!ok) failures++;
}
// ---------------------------------------------------------------------------
// the CDP connection
async function connect() {
const profile = mkdtempSync(join(tmpdir(), 'arthur-chrome-'));
const chrome = spawn(CHROME, [
'--headless=new',
`--remote-debugging-port=${PORT}`,
`--user-data-dir=${profile}`,
// The take plays against audio.wav, and the clock IS the audio element, so
// without these the frame never advances and "plays back" cannot be checked
// at all — the failure would look like a broken rAF loop.
'--autoplay-policy=no-user-gesture-required',
'--mute-audio',
'--no-first-run',
'--no-default-browser-check',
'--disable-gpu',
'--window-size=1200,900',
URL_,
], { stdio: ['ignore', 'ignore', 'pipe'] });
chrome.stderr.on('data', () => {});
let wsUrl = null;
for (let i = 0; i < 100 && !wsUrl; i++) {
await sleep(100);
try {
const targets = await fetch(`http://127.0.0.1:${PORT}/json/list`).then((r) => r.json());
wsUrl = targets.find((t) => t.type === 'page' && t.url.includes('index.html'))
?.webSocketDebuggerUrl;
} catch { /* not up yet */ }
}
if (!wsUrl) throw new Error(`Chrome never offered a page target on ${PORT}`);
const ws = new WebSocket(wsUrl);
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
let id = 0;
const pending = new Map();
const logs = [];
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && pending.has(msg.id)) {
const { res, rej } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result);
} else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') {
logs.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
} else if (msg.method === 'Runtime.exceptionThrown') {
logs.push(msg.params.exceptionDetails.text + ' ' +
(msg.params.exceptionDetails.exception?.description ?? ''));
}
};
const send = (method, params = {}) =>
new Promise((res, rej) => {
const n = ++id;
pending.set(n, { res, rej });
ws.send(JSON.stringify({ id: n, method, params }));
});
await send('Runtime.enable');
await send('Page.enable');
return {
send, logs,
async eval(expr) {
const r = await this.send('Runtime.evaluate', {
expression: expr, awaitPromise: true, returnByValue: true,
});
if (r.exceptionDetails) {
throw new Error(r.exceptionDetails.exception?.description ??
r.exceptionDetails.text);
}
return r.result.value;
},
async shot(name) {
const r = await this.send('Page.captureScreenshot', { format: 'png' });
writeFileSync(join(OUT, `${name}.png`), Buffer.from(r.data, 'base64'));
},
close() { ws.close(); chrome.kill(); rmSync(profile, { recursive: true, force: true }); },
};
}
// ---------------------------------------------------------------------------
// what we ask the page
//
// Every probe reads the canvas's own pixels rather than a screenshot: the CSS
// scales the stage up by 2 with image-rendering:pixelated, so a screenshot is
// four pixels per raster pixel and is for looking at, not for counting.
const PROBE = `(() => {
const c = document.querySelector('canvas.stage');
if (!c) return null;
const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
const bg = [${BG.join(',')}];
let drawn = 0;
const tones = new Set();
let cx = 0, cy = 0;
for (let i = 0; i < d.length; i += 4) {
if (d[i] === bg[0] && d[i+1] === bg[1] && d[i+2] === bg[2]) continue;
drawn++;
tones.add((d[i] << 16) | (d[i+1] << 8) | d[i+2]);
const p = i / 4;
cx += p % c.width; cy += Math.floor(p / c.width);
}
// A cheap 32-bit hash of the whole buffer: two frames with the same drawn
// count can still be different pictures, and "the mouth moved" is a question
// about the picture.
let h = 2166136261;
for (let i = 0; i < d.length; i += 4) { h ^= d[i] + d[i+1] * 31 + d[i+2] * 131; h = Math.imul(h, 16777619); }
return {
w: c.width, h: c.height, drawn, tones: [...tones].length,
cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null,
hash: h >>> 0,
frame: document.querySelector('.readout span')?.textContent ?? '',
scene: [...document.querySelectorAll('.transport .row button')]
.filter((b) => b.classList.contains('on')).map((b) => b.textContent),
};
})()`;
const SEEK = (f) => `(() => {
const el = document.querySelector('input.scrub');
// React installs its own value setter on the element, so assigning .value
// directly updates the DOM and not React's idea of it, and onChange never
// fires. The prototype-level setter plus a bubbling 'input' event is what
// React's synthetic onChange actually listens for.
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
set.call(el, '${f}');
el.dispatchEvent(new Event('input', { bubbles: true }));
return el.value;
})()`;
const CLICK = (label) => `(() => {
const b = [...document.querySelectorAll('.transport button')]
.find((b) => b.textContent.trim() === ${JSON.stringify(label)});
if (!b) return false;
b.click();
return true;
})()`;
// ---------------------------------------------------------------------------
async function main() {
rmSync(OUT, { recursive: true, force: true });
mkdirSync(OUT, { recursive: true });
const page = await connect();
try {
// Mounted, and painting. Polled rather than waited on a fixed delay: the
// canvas :ref fires after the loop starts, so there genuinely is a window in
// which the page is up and the canvas is blank.
let probe = null;
for (let i = 0; i < 100; i++) {
probe = await page.eval(PROBE);
if (probe && probe.drawn > 0) break;
await sleep(100);
}
if (!probe) throw new Error('no canvas.stage on the page — is `shadow-cljs watch app` running?');
console.log(`\ncanvas ${probe.w}x${probe.h}, scene ${JSON.stringify(probe.scene)}`);
check(probe.scene.includes('take'), 'the take is the scene that opens');
check(probe.w === 320 && probe.h === 200, 'the canvas is the stage size',
`${probe.w}x${probe.h}`);
check(probe.drawn > 200, 'the first frame is not blank', `${probe.drawn} px drawn`);
// --- it is a mouth: two tones, one inside the other ---
//
// The three-layer structure is what makes a flat shape read as an opening
// rather than a blob, so the interior being a SECOND tone is the check that
// this is a mouth and not one polygon.
await page.eval(SEEK(10)); // beat 1: wide open
await sleep(120);
const open = await page.eval(PROBE);
await page.shot('take-open');
check(open.tones >= 2, 'an open mouth draws an outline and an interior',
`${open.tones} tones`);
await page.eval(SEEK(28)); // beat 3: shut
await sleep(120);
const shut = await page.eval(PROBE);
await page.shot('take-shut');
check(shut.tones === 1, 'a shut mouth draws the outline alone',
`${shut.tones} tones`);
check(open.hash !== shut.hash, 'the open and the shut mouth are different pictures');
check(open.drawn > shut.drawn, 'the open mouth covers more of the stage',
`${open.drawn} vs ${shut.drawn} px`);
// --- it moves under the head, and the head is a channel ---
const filmed = [];
for (const f of [0, 40, 80, 120, 160, 200]) {
await page.eval(SEEK(f));
await sleep(120);
filmed.push(await page.eval(PROBE));
}
check(new Set(filmed.map((p) => p.hash)).size === filmed.length,
'every sampled frame is a different picture');
const xs = filmed.map((p) => p.cx);
check(Math.max(...xs) - Math.min(...xs) > 8,
'as filmed, the head carries the mouth across the stage',
`centroid x spans ${(Math.max(...xs) - Math.min(...xs)).toFixed(1)} px`);
check(await page.eval(CLICK('locked')), 'the locked take is selectable');
await sleep(200);
const locked = [];
for (const f of [0, 40, 80, 120, 160, 200]) {
await page.eval(SEEK(f));
await sleep(120);
locked.push(await page.eval(PROBE));
}
await page.shot('take-locked');
const lxs = locked.map((p) => p.cx);
check(locked.every((p) => p.drawn > 200), 'the locked take draws too');
// The same blocks with one node's channels written differently: the mouth
// still articulates, and the head no longer wanders. That is the claim
// "stabilisation is a channel, not a mode", by eye.
check(Math.max(...lxs) - Math.min(...lxs) < (Math.max(...xs) - Math.min(...xs)) / 2,
'locked, the head holds still while the mouth still articulates',
`centroid x spans ${(Math.max(...lxs) - Math.min(...lxs)).toFixed(1)} px`);
check(new Set(locked.map((p) => p.hash)).size > 3,
'and it is still a performance, not a still frame');
// --- it PLAYS, against the audio clock ---
check(await page.eval(CLICK('take')), 'back to the take');
await sleep(150);
await page.eval(SEEK(0));
await sleep(150);
check(await page.eval(CLICK('play')), 'play is clickable');
const during = [];
for (let i = 0; i < 8; i++) { await sleep(180); during.push(await page.eval(PROBE)); }
await page.eval(CLICK('pause'));
// The readout is "frame 12 / 229", so the first run of digits is the
// playhead. Parsed rather than reached for in app-db on purpose: what the
// page SHOWS is what a person would check, and the readout agreeing with the
// picture is half of what the transport is for.
const nums = during.map((p) => parseInt((p.frame.match(/\d+/) ?? [NaN])[0], 10));
check(nums[nums.length - 1] > nums[0] + 5, 'the playhead advances under the clock',
`frame ${nums[0]} -> ${nums[nums.length - 1]}`);
check(new Set(during.map((p) => p.hash)).size > 4,
'and the picture changes while it runs',
`${new Set(during.map((p) => p.hash)).size} distinct of ${during.length}`);
await page.shot('take-playing');
check(page.logs.length === 0, 'no errors on the console',
page.logs.slice(0, 3).join(' | '));
} finally {
page.close();
}
console.log(`\n${failures ? `${failures} FAILED` : 'all checks passed'}` +
` — screenshots in test/browser/out/\n`);
process.exit(failures ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(2); });

View file

@ -1,4 +0,0 @@
# Generated by oracle.mjs on every `npm test`. Not committed: it is 1.2MB of
# derived numbers, and a stale copy would make the parity suite pass against
# yesterday's oracle.
oracle.json

View file

@ -1,146 +0,0 @@
// Runs the JS prototype — the numeric oracle — and writes its answers to JSON
// for arthur.parity-test to diff against the CLJS port.
//
// DELETABLE. This file and arthur.parity-test go together, in one commit, once
// the CLJS player renders the synthetic take correctly (port-plan step 5). A
// parity test pins behaviour while the code moves; keeping it afterwards would
// bake the prototype's mistakes into the rewrite and make them permanent.
//
// Math.random is stubbed to a constant so both sides get the IDENTICAL track:
// js/synth.js reads Math.random at call time, not at import time, so assigning
// it here — before synthDense is called below — is enough, and js/ stays
// untouched. 0.5 makes every (Math.random() - 0.5) jitter term exactly zero,
// which is also what `:rand-fn (constantly 0.5)` does on the CLJS side.
Math.random = () => 0.5;
import { writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { RIGID, LIPS_OUTER, EYE_R_RING, BROW_A_RING, FACE_OVAL,
subsampleSlots } from '../../../js/landmarks.js';
import { fitSimilarity, applySim, fitResidual, procrustesMean,
movingAverage, smoothTransforms, offsetRing } from '../../../js/mathutil.js';
import { stabilize, smoothContours } from '../../../js/pipeline.js';
import { synthDense } from '../../../js/synth.js';
import { IndexedRaster, hexToRgb } from '../../../js/raster.js';
const FRAMES = 72;
const track = synthDense(FRAMES);
const rigid = track.map((f) => RIGID.map((i) => f[i]));
// The two the port plan names explicitly, plus everything else in mathutil.js:
// a function nobody diffed is a function nobody ported.
const ref = procrustesMean(rigid);
const tfs = rigid.map((r) => fitSimilarity(r, ref));
const strip = (p) => ({ x: p.x, y: p.y, z: p.z ?? 0 });
const xy = (p) => ({ x: p.x, y: p.y });
const ring = (r) => r.map(xy);
const stripTf = (t) => ({ s: t.s, theta: t.theta, tx: t.tx, ty: t.ty });
// A known transform recovered exactly, which is the same case the CLJS unit test
// asserts — here so a disagreement can be localised to the fit rather than to
// the track.
const knownSrc = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: 2, y: 3 }];
const knownTruth = { s: 1.7, theta: 0.6, tx: 4, ty: -2 };
const knownDst = knownSrc.map((p) => applySim(knownTruth, p));
const out = {
frames: FRAMES,
track: track.map((f) => f.map(strip)),
rigidRef: ref.map(strip),
transforms: tfs.map(stripTf),
residuals: rigid.map((r, i) => fitResidual(tfs[i], r, ref)),
smoothed: [0, 1, 2, 5].map((radius) => ({
radius, tfs: smoothTransforms(tfs, radius).map(stripTf),
})),
known: { src: knownSrc, truth: knownTruth, dst: knownDst,
fit: stripTf(fitSimilarity(knownSrc, knownDst)) },
// tx over the shot is the sway; it is the one-dimensional series the smoothing
// knob actually acts on, so it is what movingAverage gets diffed on.
movingAverage: [0, 1, 2, 3, 7].map((radius) => ({
radius, vals: movingAverage(tfs.map((t) => t.tx), radius),
})),
// stabilize(), which the CLJS side reaches as three stages: the anchor fit,
// the conditioning of its parameters, and the mouth measured through the
// result. Diffing the composition is the point — a split that agreed on each
// piece and not on the whole would be a split, not a port.
//
// aspect 1 is in here to isolate the rest, and 0.5625 (a 1080x1920 phone clip)
// because it is the only value that exercises the anisotropy correction at all:
// at aspect 1 `pick` is the identity and a port that dropped it entirely would
// pass. radius 0 and 2 because the split moved the smoothing OUT of the middle
// of this function, so agreeing only at radius 0 would prove nothing about it.
stabilize: [{ aspect: 1, radius: 0 },
{ aspect: 0.5625, radius: 0 },
{ aspect: 0.5625, radius: 2 }].map(({ aspect, radius }) => {
const st = stabilize(track, radius, aspect);
return {
aspect, radius,
ref: st.ref.map(xy),
rigid: st.rigid.map(ring),
transforms: st.transforms.map(stripTf),
residual: st.residual,
outer: st.outer.map(ring),
inner: st.inner.map(ring),
aperture: st.aperture,
};
}),
// The contour knob, on the ring it is actually dragged for. The rings are the
// full 20 slots and not a subsample, which is where the CLJS side differs in
// arrangement and must not differ in numbers: the prototype subsamples before
// smoothing, the port smooths before subsampling, and the two commute because
// both operations are per-slot.
smoothContours: (() => {
const st = stabilize(track, 0, 0.5625);
return [0, 1, 3].map((radius) => ({
radius, outer: smoothContours(st.outer, radius).map(ring),
}));
})(),
offsetRing: [0, 0.5, 2, -1].map((d) => ({
d,
ring: offsetRing(LIPS_OUTER.map((i) => track[0][i]), d).map(strip),
// The degenerate case: a shut lid is a flat sliver and must still open into
// a band, and a ring collapsed onto its own centroid must not emit NaN.
shutLid: offsetRing([{ x: -10, y: 0 }, { x: 0, y: -0.02 },
{ x: 10, y: 0 }, { x: 0, y: 0.02 }], d).map(strip),
collapsed: offsetRing([{ x: 0, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }], d).map(strip),
})),
subsampleSlots: Object.fromEntries(
[[20, 4], [20, 6], [20, 8], [20, 10], [20, 16], [16, 4], [16, 6], [16, 12],
[10, 4], [10, 6], [10, 10], [36, 8]]
.map(([len, n]) => [`${len}/${n}`, subsampleSlots(len, n)])),
tables: { RIGID, LIPS_OUTER, EYE_R_RING, BROW_A_RING, FACE_OVAL },
// The raster is integer output, so parity here is EXACT equality, not 1e-9.
// One scanline drawn one pixel wide of the JS would read as a seam between two
// parts rather than as an error, which is why the whole buffer is diffed and
// not a pixel count.
//
// toImageData is not exercised: it needs an ImageData, the CLJS side returns
// plain bytes on purpose so domain/ stays DOM-free, and the palette expansion
// is asserted directly in arthur.domain.raster-test instead.
raster: (() => {
const r = new IndexedRaster(64, 48);
r.clear(0);
// A real mouth ring at raster scale, so the scanline fill is diffed on a
// shape with fractional coordinates and non-convex spans rather than on an
// axis-aligned box that would agree even if the rounding were wrong.
r.fillPoly(LIPS_OUTER.map((i) => ({ x: track[0][i].x * 320 - 100,
y: track[0][i].y * 200 - 40 })), 2);
r.fillPoly([{ 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);
r.fillDisc(30.4, 24.6, 9.2, 3, 1); // stencilled by the box
r.fillDisc(5.5, 44.5, 4, 4); // unstencilled, clipped by the edge
r.fillRect(30.49, 24.51, 3, 5, 3); // stencilled by the disc
r.fillRect(1, 1, 0, 6); // size 0 draws nothing
return { w: r.w, h: r.h, buf: Array.from(r.buf) };
})(),
paletteRgb: ['#12141c', '#b07a5a', '#7a4f3a', '#24161a', '#d9cfc2',
'#c9c3b4', '#4a5468', '#171a22', '#3a2a22'].map(hexToRgb),
};
const here = dirname(fileURLToPath(import.meta.url));
const path = join(here, 'oracle.json');
writeFileSync(path, JSON.stringify(out));
console.log(`oracle: ${FRAMES} frames -> ${path}`);