Port step 5: freeze measured mouth into playable channels
This commit is contained in:
parent
942e2f38ab
commit
8a06835895
24 changed files with 1625 additions and 506 deletions
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
463
frontend/test/arthur/flow/freeze_test.cljs
Normal file
463
frontend/test/arthur/flow/freeze_test.cljs
Normal 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")))
|
||||
|
|
@ -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))))
|
||||
|
|
@ -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)))))))
|
||||
300
frontend/test/browser/take.mjs
Normal file
300
frontend/test/browser/take.mjs
Normal 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); });
|
||||
4
frontend/test/parity/.gitignore
vendored
4
frontend/test/parity/.gitignore
vendored
|
|
@ -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
|
||||
|
|
@ -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}`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue