Give features stable identity, eye pairs and per-feature presence

Step 8's data model, ahead of its controls. Nothing here is a UI.

domain/params holds every knob's definition once — default, applicable area,
value constraints and the areas a change would force to regenerate. flow/take's
literal knob map becomes a view of it, so the take's defaults and the future
parameter panel cannot drift apart.

domain/feature adds subjects, features and groups as document data the renderer
never reads. A feature ID is stable for the whole clip, across occlusion: a run
of visible frames is not a new identity. An eye pair is an explicit group of one
or two eyes of the same subject, so a profile view with one identified eye needs
no invented partner. Settings resolve area -> subject -> group -> feature, and
dropping an eye from a pair materialises its effective values first so playback
does not jump. scene/problems now validates all of it.

Presence becomes per-feature rather than per-subject. freeze's :absent predicate
takes a track as well as a frame, so one occluded eye can be absent while its
partner still has a value; a full-face miss still marks everything absent. A
manifest may annotate known gaps as one-based inclusive intervals, which ingest
expands into observation tracks before measurement. An unobserved eye then gets
no vote in the iris pairing and cannot steer the shared gaze — gaze falls back to
whichever eye is visible. Temporal filters still see a sample on every frame,
held from the last observed one, because the numbers are a rectangular buffer;
the state mask, not the buffer, is what says the frame has no value.

js/app.js gets the same occlusion lesson: leading nulls from a face that starts
occluded used to throw away the whole take, and the neutral frame could be chosen
from a held duplicate pose.

Parameter editing, scoped regeneration and a feature-level detector remain. Until
one exists, footage without annotations falls back to the full-face mask rather
than claiming occlusions it cannot see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B87NVmiU36qQmN9gmFYnJ9
This commit is contained in:
Olive Vaughn 2026-09-27 22:44:36 -04:00
parent ccca93e233
commit 35ef150b48
18 changed files with 631 additions and 86 deletions

View file

@ -18,7 +18,8 @@ modern conveniences belong in the workflow, not the output. See
The active port has reached [step 7 of the port plan](docs/port-plan.md): it plays The active port has reached [step 7 of the port plan](docs/port-plan.md): it plays
the synthetic take and can load extracted real footage with mouth, eyes, brows, the synthetic take and can load extracted real footage with mouth, eyes, brows,
and pixel-derived teeth. and pixel-derived teeth. The step 8 data model now represents persistent feature
IDs, eye pairs and feature-level observation gaps; its controls are still pending.
See [frontend/README.md](frontend/README.md) for setup and the **load frames** See [frontend/README.md](frontend/README.md) for setup and the **load frames**
workflow. The rest of this README describes the older JS prototype, which still workflow. The rest of this README describes the older JS prototype, which still
runs separately on port 8777. runs separately on port 8777.

View file

@ -79,6 +79,59 @@ this way.
over which the node exists at all. Distinct from a `[:vis]` channel, which over which the node exists at all. Distinct from a `[:vis]` channel, which
blinks an existing node on and off. blinks an existing node on and off.
### Subjects and tracked features
Scene nodes describe drawings, not tracking identity. A scene may also carry a
flat `:features` map. A feature ID stays stable for the whole clip, including
frames where that feature is occluded and later reappears:
```clojure
:subjects {:face-1 {:id :face-1}}
:features
{:eye-r {:id :eye-r :subject :face-1 :area :eye
:nodes [:eye-r :eye-r-in :iris-r :pupil-r] :params {}}
:eye-l {:id :eye-l :subject :face-1 :area :eye
:nodes [:eye-l :eye-l-in :iris-l :pupil-l] :params {}}
:mouth {:id :mouth :subject :face-1 :area :mouth
:nodes [:mouth :mouth-in :teeth] :params {}}}
:groups
{:eyes-1 {:id :eyes-1 :kind :eye-pair :subject :face-1
:members [:eye-r :eye-l] :params {}}}
```
An eye pair is an explicit relationship between one or two eyes of the **same
subject**. It may have one member when only one eye has been identified; it does
not invent a second eye. Five subjects with nine identified eyes can have four
two-member pairs and one one-member pair. Each eye still has its own feature ID
and presence track. A group is a settings association, not a scene parent or a
tracking ID. Membership lives only on the group, avoiding a second pointer on
the feature that could disagree with it.
Each feature resolves settings from its area's definitions, then its group,
then its own `:params`. An eye can therefore inherit a pair setting or override
it without changing its partner. Removing it from a pair copies its effective
values into the feature first, so the result does not jump. Feature identity
and pair membership are clip-wide; a future parameter track can vary values
over time without splitting a feature at an observation gap.
Parameter definitions live in one registry: key, default, applicable area,
value constraints and affected areas. The registry supplies the take's defaults
today. The parameter UI and regeneration from edited values are later work.
Dense channel state records whether a measurement exists **for that feature on
that frame**. Occlusion means absent data on that frame, not a false `[:vis]`
value and not the end of the feature's identity. A full-face detection failure
makes all its features absent. A single occluded eye need only make that eye's
channels absent. Footage can carry explicit feature absence intervals in its
manifest, with one-based inclusive source frame numbers, for example
`"feature-absence": {"eye-r": [[10, 14]]}`. The loader expands these into
per-frame observation tracks before measurement. Unobserved landmarks may fill
rectangular numeric buffers, but they cannot contribute to an eye's contour,
blink or shared gaze. When one eye is absent, gaze uses the observed eye.
Until a detector supplies feature-level confidence, footage without annotations
uses the full-face detection mask as the fallback; it must not claim to detect
individual occlusions that it cannot see.
## Channel ## Channel
Every animatable property is a channel, and channels are addressed **by path**: Every animatable property is a channel, and channels are addressed **by path**:

View file

@ -7,8 +7,11 @@ Step 6 reads extracted footage from the manifest, detects landmarks with local
MediaPipe assets at full source cadence, and runs the same freeze path as the MediaPipe assets at full source cadence, and runs the same freeze path as the
synthetic take. The scene time map can sample the frozen roto at a lower picture synthetic take. The scene time map can sample the frozen roto at a lower picture
fps without changing source analysis, duration or audio. Step 7 adds dense fps without changing source analysis, duration or audio. Step 7 adds dense
eyelids, shared gaze, brows and pixel-derived teeth. Step 8 is next: authored eyelids, shared gaze, brows and pixel-derived teeth. Step 8's data model now
parameter controls and their scoped recomputation. has stable feature identity, feature-level presence, explicit eye pairs and
shared parameter definitions. A manifest can now supply known feature absence
intervals through measurement and freeze. Automatic per-feature detection,
parameter editing and scoped regeneration remain.
## What arthur is ## What arthur is
@ -326,9 +329,28 @@ minus paint. The fixed pixel thresholds remain provisional; step 8 exposes their
parameters for tuning without changing the source track or picture timing. parameters for tuning without changing the source track or picture timing.
### 8 — knobs ### 8 — knobs
The parameter UI, as leaf-addressed params in app-db Build the parameter model before its UI. Define each parameter once with its
(`clip/:cid/params/:subject/:area` — see below), so the sync layer added later has default, validation, applicable area and regeneration dependencies. Store values
nothing to retrofit. by stable subject and feature ID. Represent an eye pair as one group with one or
two eye member IDs from the same subject; a profile view with one identified eye
needs no invented partner. Each eye may override a pair value. Removing an eye
from a pair materialises its effective values so playback does not change. Keep the
existing frozen channels as renderer input; settings and provenance do not enter
the render path.
Carry feature-level presence through freeze and dense channel state. The same
feature ID covers every observed run across occlusion; a missing measurement
has no channel value on that frame. Full-face detection is the fallback mask
until there is a feature-level detector or authored presence data. A shared gaze
measurement may still feed two independently identified eyes. A small manifest
annotation can supply feature absence intervals now: the loader expands them
before measurement, so invalid eye landmarks are ignored and gaze uses the
visible eye. This is an input format, not a control UI or an automatic detector.
Use leaf-addressable settings under the clip, subject, feature and optional
group. Retain source measurements so a setting change can regenerate affected
channels without re-detecting footage. Time-varying parameter values and all
parameter controls are deferred to the UI pass.
### 9 — backend ### 9 — backend
Django project, the `clips` app, models for Project/Clip/Footage/Analysis/Leaf, Django project, the `clips` app, models for Project/Clip/Footage/Analysis/Leaf,
@ -342,16 +364,14 @@ nobody should pre-empt by porting the old one.
## Two things to not foreclose ## Two things to not foreclose
The feature controls will later be rethought to handle more than one face, Feature controls will later handle more than one face and editing presence.
periodic occlusion, stable identity across frames, and feature groups with their The underlying identity, occlusion and group association model begins in step 8:
own parameters. That design can wait; two decisions here are free now and
annoying to reverse:
- **Presence is not visibility.** An occluded subject has *no value* on a frame, - **Presence is not visibility.** An occluded feature has *no value* on a frame,
which is different from a part being hidden. Give every dense block a which is different from a part being hidden. Dense blocks carry a per-track,
`Uint8Array` state mask per frame and let it mean *absent* as well as hidden. per-frame absence mask; `[:vis]` remains the sole hiding mechanism.
- **Params carry a subject segment.** `clip/:cid/params/:subject/:area`, with one - **Params carry stable identity.** A subject and its features keep their IDs
subject today. Adding a path segment later touches every read and write. across observation gaps. A run of visible frames is not a new identity.
The identity tracker, when it comes, should use the same pattern the iris and brow The identity tracker, when it comes, should use the same pattern the iris and brow
correspondences already use: vote across every frame rather than trusting one. correspondences already use: vote across every frame rather than trusting one.

View file

@ -110,11 +110,20 @@ landmarks and the teeth from source pixels, then freezes them into channels,
and adds a button for the footage clip. Detection happens once when you load; and adds a button for the footage clip. Detection happens once when you load;
playback only resolves channels and paints. Frames without a detection remain playback only resolves channels and paints. Frames without a detection remain
marked absent even though their neighbouring poses are used to condition the marked absent even though their neighbouring poses are used to condition the
track. The stage stays 320×200 regardless of the footage dimensions. Real track. The scene now records stable subject and feature IDs and explicit eye
pairs; dense channels can mark one feature absent while another is observed.
Current MediaPipe loading supplies only the full-face detection mask. The stage
stays 320×200 regardless of the footage dimensions. Real
footage starts at the source picture rate. The **picture fps** buttons sample the footage starts at the source picture rate. The **picture fps** buttons sample the
frozen roto at lower rates while the source track, duration and audio clock stay frozen roto at lower rates while the source track, duration and audio clock stay
unchanged. Picking frames to trace into cels is a separate future editing step. unchanged. Picking frames to trace into cels is a separate future editing step.
For known occlusion intervals, an extracted manifest may add
`"feature-absence": {"eye-r": [[10, 14]]}`. Frame numbers are one-based and
inclusive, matching PNG filenames. The eye remains the same feature when it
reappears; the other eye and the mouth continue through the gap. This is an
input annotation, with no UI for editing it yet.
MediaPipe's JS, wasm and model are under `public/mediapipe/` and served locally. MediaPipe's JS, wasm and model are under `public/mediapipe/` and served locally.
No CDN is used by this app. See that directory's README for provenance. No CDN is used by this app. See that directory's README for provenance.

View file

@ -0,0 +1,85 @@
(ns arthur.domain.feature
"Stable tracked identities and explicit eye-pair settings associations.
These maps are document data. Rendering only reads the nodes and channels."
(:require [arthur.domain.params :as params]))
(defn group-for [scene feature-id]
(first (filter (fn [[_ group]] (some #{feature-id} (:members group)))
(:groups scene))))
(defn effective-params
"Resolve static settings for one feature. A future parameter channel can
replace a scalar at this boundary without changing feature or pair identity."
[scene feature-id]
(let [{:keys [subject area params] :as feature} (get-in scene [:features feature-id])
[_ group] (group-for scene feature-id)]
(when-not feature
(throw (ex-info "unknown feature" {:feature feature-id})))
(merge (params/for-area :subject)
(get-in scene [:subjects subject :params])
(params/for-area area)
(:params group)
params)))
(defn remove-from-pair
"Keep the eye's current settings when its association is removed. Empty pairs
are removed; a one-eye pair remains valid and can acquire a partner later."
[scene feature-id]
(if-let [[group-id group] (group-for scene feature-id)]
(let [area (get-in scene [:features feature-id :area])
values (select-keys (effective-params scene feature-id)
(keys (params/for-area area)))
members (vec (remove #{feature-id} (:members group)))]
(-> scene
(assoc-in [:features feature-id :params] values)
(update :groups (fn [groups]
(if (seq members)
(assoc-in groups [group-id :members] members)
(dissoc groups group-id))))))
scene))
(defn problems
"Check identity references and pair membership before storing a scene."
[scene]
(let [subjects (:subjects scene)
features (:features scene)
groups (:groups scene)
memberships (mapcat (comp :members val) groups)
node-owners (mapcat (comp :nodes val) features)]
(vec
(concat
(for [[id s] subjects :when (not= id (:id s))]
(str "subject " (pr-str id) " has a different :id"))
(for [[id s] subjects
:when (not (params/valid-settings? :subject (or (:params s) {})))]
(str "subject " (pr-str id) " has invalid settings"))
(for [[id f] features :when (not= id (:id f))]
(str "feature " (pr-str id) " has a different :id"))
(for [[id f] features :when (not (contains? subjects (:subject f)))]
(str "feature " (pr-str id) " has no subject"))
(for [[id f] features :when (not (contains? (disj params/areas :subject) (:area f)))]
(str "feature " (pr-str id) " has an unknown area"))
(for [[id f] features
:when (not (params/valid-settings? (:area f) (or (:params f) {})))]
(str "feature " (pr-str id) " has invalid settings for " (pr-str (:area f))))
(for [[id f] features node-id (:nodes f)
:when (not (contains? (:nodes scene) node-id))]
(str "feature " (pr-str id) " refers to missing node " (pr-str node-id)))
(for [[id n] (frequencies node-owners) :when (> n 1)]
(str "node " (pr-str id) " belongs to more than one feature"))
(for [[id g] groups :when (not= id (:id g))]
(str "group " (pr-str id) " has a different :id"))
(for [[id g] groups
:when (not (and (= :eye-pair (:kind g))
(<= 1 (count (:members g)) 2)
(= (count (:members g)) (count (distinct (:members g))))))]
(str "group " (pr-str id) " must be an eye pair of one or two distinct eyes"))
(for [[id g] groups
:when (not (params/valid-settings? :eye (or (:params g) {})))]
(str "group " (pr-str id) " has invalid eye settings"))
(for [[id g] groups member (:members g)
:let [f (get features member)]
:when (not (and f (= :eye (:area f)) (= (:subject g) (:subject f))))]
(str "group " (pr-str id) " has an eye from another subject or an unknown feature"))
(for [[id n] (frequencies memberships) :when (> n 1)]
(str "feature " (pr-str id) " belongs to more than one group"))))))

View file

@ -0,0 +1,61 @@
(ns arthur.domain.params
"Definitions for generated settings. Defaults, scope and value constraints live
here once; UI metadata and regeneration rules can be added to each entry.")
(def definitions
{:anchor-avg {:area :subject :default 2 :type :integer :min 0
:affects #{:head :mouth :eye :brow :teeth}}
:contour-avg {:area :subject :default 1 :type :integer :min 0
:affects #{:mouth :eye :brow}}
:verts {:area :mouth :default 8 :type :integer :min 4 :even? true}
:aperture-cut {:area :mouth :default 0.12 :type :number :min 0 :max 1}
:eye-verts {:area :eye :default 8 :type :integer :min 4 :even? true}
:blink-cut {:area :eye :default 0.13 :type :number :min 0}
:gaze-gain {:area :eye :default 1 :type :number :min 0}
:gaze-step {:area :eye :default 0.08 :type :number :min 0}
:iris-size {:area :eye :default 0.42 :type :number :min 0}
:lash-weight {:area :eye :default 0.06 :type :number :min 0}
:pupil-size {:area :eye :default 0.15 :type :number :min 0}
:brow-verts {:area :brow :default 6 :type :integer :min 4 :even? true}
:brow-gain {:area :brow :default 1 :type :number :min 0}
:brow-step {:area :brow :default 0.08 :type :number :min 0}
:brow-weight {:area :brow :default 0.05 :type :number :min 0}
:cavity-erode {:area :teeth :default 0.18 :type :number :min 0}
:tongue-reject {:area :teeth :default 0.18 :type :number :min 0}
:blob-grow {:area :teeth :default 0 :type :integer}
:top-bias {:area :teeth :default 0.6 :type :number}
:teeth-verts {:area :teeth :default 10 :type :integer :min 4}
:min-area {:area :teeth :default 12 :type :integer :min 0}
:teeth-on {:area :teeth :default 0.12 :type :number :min 0}
:teeth-smooth {:area :teeth :default 1 :type :integer :min 0}})
(def defaults
(into {} (map (fn [[id spec]] [id (:default spec)])) definitions))
(def areas #{:subject :mouth :eye :brow :teeth})
(defn affected-areas [id]
(when-let [spec (get definitions id)]
(or (:affects spec) #{(:area spec)})))
(defn for-area [wanted-area]
(into {} (keep (fn [[id {:keys [area default]}]]
(when (= wanted-area area) [id default])))
definitions))
(defn valid-value? [id value]
(when-let [{:keys [type min max] must-even? :even?} (get definitions id)]
(and (case type
:integer (integer? value)
:number (number? value)
false)
(or (nil? min) (<= min value))
(or (nil? max) (<= value max))
(or (not must-even?) (and (integer? value) (even? value))))))
(defn valid-settings? [area settings]
(and (map? settings)
(every? (fn [[id value]]
(and (= area (get-in definitions [id :area]))
(valid-value? id value)))
settings)))

View file

@ -28,6 +28,7 @@
vector of numbers, and they read the same way, which is what makes freezing vector of numbers, and they read the same way, which is what makes freezing
fill in the same channel rather than convert into a second format." fill in the same channel rather than convert into a second format."
(:require [arthur.domain.channel :as ch] (:require [arthur.domain.channel :as ch]
[arthur.domain.feature :as feature]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.palette :as pal])) [arthur.domain.palette :as pal]))
@ -451,8 +452,8 @@
(into (for [[id n] nodes (into (for [[id n] nodes
p (node/problems n)] p (node/problems n)]
(str "node " (pr-str id) ": " p))) (str "node " (pr-str id) ": " p)))
(into (feature/problems scene))
(into (try (into (try
(doall (map #(depth nodes %) (keys nodes))) (doall (map #(depth nodes %) (keys nodes)))
nil nil
(catch :default e [(ex-message e)]))))))) (catch :default e [(ex-message e)])))))))

View file

@ -58,7 +58,8 @@
(defn- build-clip [manifest {:keys [dense detected dimensions interior missing first-real]}] (defn- build-clip [manifest {:keys [dense detected dimensions interior missing first-real]}]
(let [[w h] dimensions (let [[w h] dimensions
frozen (take/footage manifest {:dense dense :detected detected frozen (take/footage manifest {:dense dense :detected detected
:dimensions dimensions :interior interior}) :dimensions dimensions :interior interior
:presence (:presence manifest)})
scene (:scene frozen)] scene (:scene frozen)]
(assoc (select-keys scene [:fps :frames :width :height]) (assoc (select-keys scene [:fps :frames :width :height])
:display-fps (:fps scene) :display-fps (:fps scene)

View file

@ -13,20 +13,47 @@
:y (/ (+ (:y a) (:y b)) 2) :y (/ (+ (:y a) (:y b)) 2)
:w (distance a b)})) :w (distance a b)}))
(defn- mean-width [lids] (defn- hold-observed
(/ (reduce + (map (comp :w socket) lids)) (count lids))) "Give temporal filters real samples at every index. The freeze mask still
marks the gap absent; these held values are only numeric placeholders."
[values observed fallback]
(let [values (vec values)
seed (or (first (keep-indexed (fn [i v]
(when (and (nth observed i) (some? v)) v))
values))
fallback (first values))]
(loop [f 0 last-value seed out []]
(if (= f (count values))
out
(let [v (nth values f)
next-value (if (and (nth observed f) (some? v)) v last-value)]
(recur (inc f) next-value (conj out next-value)))))))
(defn- mean-width [lids observed]
(let [valid (seq (keep-indexed (fn [i lid] (when (nth observed i) lid)) lids))
rings (or valid lids)]
(/ (reduce + (map (comp :w socket) rings)) (count rings))))
(defn apply-defaults (defn apply-defaults
[{:keys [fps contour-avg blink-cut gaze-gain gaze-step iris-size [{:keys [fps contour-avg blink-cut gaze-gain gaze-step iris-size
lash-weight pupil-size]} {:keys [lid-r lid-l open-r open-l gaze] lash-weight pupil-size]} {:keys [lid-r lid-l open-r open-l gaze
observed-r observed-l gaze-observed]
:as measured}] :as measured}]
(let [fps (or fps 30) (let [fps (or fps 30)
right (condition/contours {:contour-avg contour-avg} lid-r) observed-r (or observed-r (vec (repeat (count lid-r) true)))
left (condition/contours {:contour-avg contour-avg} lid-l) observed-l (or observed-l (vec (repeat (count lid-l) true)))
w-r (mean-width right) gaze-observed (or gaze-observed (vec (repeat (count gaze) true)))
w-l (mean-width left) right (condition/contours {:contour-avg contour-avg}
(hold-observed lid-r observed-r nil))
left (condition/contours {:contour-avg contour-avg}
(hold-observed lid-l observed-l nil))
w-r (mean-width right observed-r)
w-l (mean-width left observed-l)
w (/ (+ w-r w-l) 2) w (/ (+ w-r w-l) 2)
rest (condition/median-point gaze) valid-gaze (seq (keep-indexed (fn [i point]
(when (nth gaze-observed i) point)) gaze))
gaze (hold-observed gaze gaze-observed {:x 0 :y 0})
rest (if valid-gaze (condition/median-point valid-gaze) {:x 0 :y 0})
px (mapv (fn [{:keys [x y]}] px (mapv (fn [{:keys [x y]}]
{:x (* (- x (:x rest)) gaze-gain w) {:x (* (- x (:x rest)) gaze-gain w)
:y (* (- y (:y rest)) gaze-gain w)}) gaze) :y (* (- y (:y rest)) gaze-gain w)}) gaze)
@ -42,8 +69,10 @@
:lid-r right :lid-l left :lid-r right :lid-l left
:lash-r (mapv #(ring/offset-ring % (* lash-weight w-r)) right) :lash-r (mapv #(ring/offset-ring % (* lash-weight w-r)) right)
:lash-l (mapv #(ring/offset-ring % (* lash-weight w-l)) left) :lash-l (mapv #(ring/offset-ring % (* lash-weight w-l)) left)
:shut-r (condition/resolve-blink open-r blink) :shut-r (condition/resolve-blink
:shut-l (condition/resolve-blink open-l blink) (hold-observed open-r observed-r nil) blink)
:shut-l (condition/resolve-blink
(hold-observed open-l observed-l nil) blink)
:iris-r (centres right) :iris-l (centres left) :iris-r (centres right) :iris-l (centres left)
:radius-r (* 0.5 iris-size w-r) :radius-r (* 0.5 iris-size w-r)
:radius-l (* 0.5 iris-size w-l) :radius-l (* 0.5 iris-size w-l)

View file

@ -76,10 +76,9 @@
`:ctor` makes the array — a function and not a type, because `new` is not `:ctor` makes the array — a function and not a type, because `new` is not
something a value can carry — `:scale` is the fixed-point scale or nil, and something a value can carry — `:scale` is the fixed-point scale or nil, and
`:absent` an optional per-frame predicate. Absence is the SUBJECT's, not the `:absent` an optional (track, frame) predicate. The state is per track, so one
part's, so it is written into every track of the block: the mouth outline and occluded eye can be absent while its partner still has a value. A full-face
the mouth interior are one face, and a frame that face was not on has no pose miss marks every track absent.
for either.
Written with `dotimes` and `aset` rather than as a fold, and that is the Written with `dotimes` and `aset` rather than as a fold, and that is the
exception rather than the rule in this codebase: the destination is a typed exception rather than the rule in this codebase: the destination is a typed
@ -110,7 +109,7 @@
:track i :frame f :component k}))) :track i :frame f :component k})))
q) q)
v)))) v))))
(when (and state (absent f)) (when (and state (absent i f))
(aset state (+ (* i nf) f) ch/absent-bit)))))) (aset state (+ (* i nf) f) ch/absent-bit))))))
{:data data :state state :stride stride :frames nf :scale scale {:data data :state state :stride stride :frames nf :scale scale
:offsets (mapv #(* % nf stride) (range n))})) :offsets (mapv #(* % nf stride) (range n))}))
@ -372,16 +371,24 @@
{:by by :analysis analysis {:by by :analysis analysis
:params (merge {:anchor-avg anchor-avg :contour-avg contour-avg} :params (merge {:anchor-avg anchor-avg :contour-avg contour-avg}
extra)}) extra)})
eye-block (pack {:ctor #(js/Int16Array. %) :scale geom-scale :absent absent} eye-block (pack {:ctor #(js/Int16Array. %) :scale geom-scale
:absent (when absent (fn [i f]
(absent (if (< i 2) :eye-r :eye-l) f)))}
(mapv #(rings->flat % eye-verts) (mapv #(rings->flat % eye-verts)
[(:lash-r eyes) (:lid-r eyes) [(:lash-r eyes) (:lid-r eyes)
(:lash-l eyes) (:lid-l eyes)])) (:lash-l eyes) (:lid-l eyes)]))
iris-block (pack {:ctor #(js/Float32Array. %) :absent absent} iris-block (pack {:ctor #(js/Float32Array. %)
:absent (when absent (fn [i f]
(absent (if (zero? i) :eye-r :eye-l) f)))}
[(:iris-r eyes) (:iris-l eyes)]) [(:iris-r eyes) (:iris-l eyes)])
brow-block (pack {:ctor #(js/Int16Array. %) :scale geom-scale :absent absent} brow-block (pack {:ctor #(js/Int16Array. %) :scale geom-scale
:absent (when absent (fn [i f]
(absent (if (zero? i) :brow-r :brow-l) f)))}
(mapv #(rings->flat % brow-verts) (mapv #(rings->flat % brow-verts)
[(:ring-r brows) (:ring-l brows)])) [(:ring-r brows) (:ring-l brows)]))
brow-pos-block (pack {:ctor #(js/Float32Array. %) :absent absent} brow-pos-block (pack {:ctor #(js/Float32Array. %)
:absent (when absent (fn [i f]
(absent (if (zero? i) :brow-r :brow-l) f)))}
[(:pos-r brows) (:pos-l brows)]) [(:pos-r brows) (:pos-l brows)])
eye-node (fn [id z track] eye-node (fn [id z track]
{:id id :name (clojure.core/name id) :kind :poly :parent :head :z z {:id id :name (clojure.core/name id) :kind :poly :parent :head :z z
@ -434,11 +441,11 @@
"Freeze the pixel-derived radial contour under the mouth cavity. Missing "Freeze the pixel-derived radial contour under the mouth cavity. Missing
contours use the dense block's absence bit; contrast decides editable :vis." contours use the dense block's absence bit; contrast decides editable :vis."
[name {:keys [analysis teeth-verts cavity-erode tongue-reject blob-grow [name {:keys [analysis teeth-verts cavity-erode tongue-reject blob-grow
top-bias teeth-on teeth-smooth]} detected top-bias teeth-on teeth-smooth]} absent-feature
{:keys [contours shown]}] {:keys [contours shown]}]
(let [key (str name "/teeth") (let [key (str name "/teeth")
absent #(or (nil? (nth contours %)) absent (fn [_ f] (or (nil? (nth contours f))
(and detected (not (nth detected %)))) (and absent-feature (absent-feature :teeth f))))
empty-points (vec (repeat (* 2 teeth-verts) 0)) empty-points (vec (repeat (* 2 teeth-verts) 0))
values (mapv (fn [ring] values (mapv (fn [ring]
(if ring (if ring
@ -495,7 +502,9 @@
:eyes :brows the CONDITIONED head-local feature measurements :eyes :brows the CONDITIONED head-local feature measurements
:teeth optional pixel-derived, conditioned radial contour :teeth optional pixel-derived, conditioned radial contour
:aperture head-local aperture per frame :aperture head-local aperture per frame
:detected optional per-frame booleans; absent frames get the state mask :detected optional per-frame face booleans
:presence optional feature-id -> per-frame booleans; false means an
unobserved feature, even if the rest of the face was found
The node tree is the one docs/animation-model.md specifies, and the two groups The node tree is the one docs/animation-model.md specifies, and the two groups
are two different things wanting the same transform: are two different things wanting the same transform:
@ -522,21 +531,32 @@
provenance having to be rebuilt." provenance having to be rebuilt."
[{:keys [name fps stage expose verts aperture-cut head kept [{:keys [name fps stage expose verts aperture-cut head kept
analysis anchor-avg contour-avg] :as params} analysis anchor-avg contour-avg] :as params}
{:keys [transforms outer inner detected eyes brows teeth] :as inputs}] {:keys [transforms outer inner detected presence eyes brows teeth] :as inputs}]
(let [nf (count outer) (let [nf (count outer)
absent (when detected #(not (nth detected % true))) _ (doseq [[id track] presence]
(when (not= nf (count track))
(throw (ex-info "feature presence track must match the clip"
{:feature id :frames nf :actual (count track)}))))
absent (when (or detected presence)
(fn [id f]
(or (and detected (not (nth detected f true)))
(and (contains? presence id)
(not (nth (get presence id) f))))))
head-absent (when detected (fn [_ f] (not (nth detected f true))))
geom-k (str name "/geom") geom-k (str name "/geom")
head-k #(str name "/head-" %) head-k #(str name "/head-" %)
prov (fn [by extra] prov (fn [by extra]
{:by by :analysis analysis {:by by :analysis analysis
:params (merge {:anchor-avg anchor-avg} extra)}) :params (merge {:anchor-avg anchor-avg} extra)})
rings (pack {:ctor #(js/Int16Array. %) :scale geom-scale :absent absent} rings (pack {:ctor #(js/Int16Array. %) :scale geom-scale
:absent (when absent (fn [_ f] (absent :mouth f)))}
[(rings->flat outer verts) (rings->flat inner verts)]) [(rings->flat outer verts) (rings->flat inner verts)])
;; The anchor, inverted and split into its three components. Three blocks ;; The anchor, inverted and split into its three components. Three blocks
;; and not one: they are three channels, they have three strides, and a ;; and not one: they are three channels, they have three strides, and a
;; single block would need a per-component offset table to say so. ;; single block would need a per-component offset table to say so.
inv (mapv invert transforms) inv (mapv invert transforms)
xf (fn [f] (pack {:ctor #(js/Float32Array. %) :absent absent} [(mapv f inv)])) xf (fn [f] (pack {:ctor #(js/Float32Array. %) :absent head-absent}
[(mapv f inv)]))
pos (xf (fn [t] [(:tx t) (:ty t)])) pos (xf (fn [t] [(:tx t) (:ty t)]))
rot (xf (fn [t] [(:theta t)])) rot (xf (fn [t] [(:theta t)]))
scale (xf (fn [t] [(:s t) (:s t)])) scale (xf (fn [t] [(:s t) (:s t)]))
@ -549,10 +569,31 @@
anchor-prov (prov :anchor/similarity nil) anchor-prov (prov :anchor/similarity nil)
roto (fn [by] (prov by {:verts verts :contour-avg contour-avg})) roto (fn [by] (prov by {:verts verts :contour-avg contour-avg}))
features (when (and eyes brows) (feature-parts name absent params inputs)) features (when (and eyes brows) (feature-parts name absent params inputs))
interior (when teeth (interior-part name params detected teeth)) interior (when teeth (interior-part name params absent teeth))
scene {:name name scene {:name name
:frames nf :frames nf
:fps fps :fps fps
;; A gap changes channel state, never these IDs or pair links.
:subjects {:face-1 {:id :face-1 :params {}}}
:features (merge
{:mouth {:id :mouth :subject :face-1 :area :mouth
:nodes [:mouth :mouth-in] :params {}}}
(when features
{:eye-r {:id :eye-r :subject :face-1 :area :eye
:nodes [:eye-r :eye-r-in :iris-r :pupil-r] :params {}}
:eye-l {:id :eye-l :subject :face-1 :area :eye
:nodes [:eye-l :eye-l-in :iris-l :pupil-l] :params {}}
:brow-r {:id :brow-r :subject :face-1 :area :brow
:nodes [:brow-r] :params {}}
:brow-l {:id :brow-l :subject :face-1 :area :brow
:nodes [:brow-l] :params {}}})
(when teeth
{:teeth {:id :teeth :subject :face-1 :area :teeth
:nodes [:teeth] :params {}}}))
:groups (if features
{:eyes-1 {:id :eyes-1 :kind :eye-pair :subject :face-1
:members [:eye-r :eye-l] :params {}}}
{})
;; Stage dimensions, on the clip and not on the footage. See ;; Stage dimensions, on the clip and not on the footage. See
;; face-placement: nothing below here knows the frame size. ;; face-placement: nothing below here knows the frame size.
:width (first stage) :width (first stage)
@ -591,6 +632,10 @@
(prov :roto/mouth-aperture (prov :roto/mouth-aperture
{:aperture-cut aperture-cut}))}}} {:aperture-cut aperture-cut}))}}}
(:nodes features) (:nodes interior))} (:nodes features) (:nodes interior))}
presence-check (doseq [id (keys presence)]
(when-not (contains? (:features scene) id)
(throw (ex-info "presence track names no feature in this scene"
{:feature id :features (keys (:features scene))}))))
;; Tier 2, behind a handle. The keys are descriptive because there is no ;; Tier 2, behind a handle. The keys are descriptive because there is no
;; hashing yet; they become the blocks' sha256 when the backend arrives ;; hashing yet; they become the blocks' sha256 when the backend arrives
;; and nothing above here changes, which is the point of a handle. ;; and nothing above here changes, which is the point of a handle.

View file

@ -2,6 +2,32 @@
"Read a pre-extracted take. The manifest owns timing and the exact frame count." "Read a pre-extracted take. The manifest owns timing and the exact frame count."
(:require [clojure.string :as str])) (:require [clojure.string :as str]))
(defn feature-presence
"Expand one-based, inclusive absence intervals from a manifest into boolean
observation tracks. Unknown features are rejected by freeze, where the scene
knows its feature IDs."
[frames absence]
(when (some? absence)
(when-not (map? absence)
(throw (ex-info "feature-absence must be a map of feature IDs to intervals"
{:feature-absence absence})))
(into {}
(map (fn [[id intervals]]
(when-not (and (keyword? id) (sequential? intervals)
(every? (fn [span]
(and (vector? span) (= 2 (count span))
(every? integer? span)
(<= 1 (first span) (second span) frames)))
intervals))
(throw (ex-info "feature-absence intervals must be [first last] source frames"
{:feature id :intervals intervals :frames frames})))
[id (mapv (fn [frame]
(not-any? (fn [[first-frame last-frame]]
(<= first-frame frame last-frame))
intervals))
(range 1 (inc frames)))]))
absence)))
(defn- valid-manifest [m] (defn- valid-manifest [m]
(let [fps (js/Number (:fps m)) (let [fps (js/Number (:fps m))
frames (js/Number (:frames m))] frames (js/Number (:frames m))]
@ -10,7 +36,8 @@
(string? (:dir m)) (seq (:dir m)) (string? (:dir m)) (seq (:dir m))
(string? (:audio m)) (seq (:audio m))) (string? (:audio m)) (seq (:audio m)))
(throw (ex-info "manifest.json needs fps, frames (1–900), dir and audio" {:manifest m}))) (throw (ex-info "manifest.json needs fps, frames (1–900), dir and audio" {:manifest m})))
(assoc m :fps fps :frames frames))) (assoc m :fps fps :frames frames
:presence (feature-presence frames (:feature-absence m)))))
(defn manifest! (defn manifest!
[path] [path]

View file

@ -16,22 +16,38 @@
(geom/apply-sim-all tf (anchor/pick frame table aspect))) (geom/apply-sim-all tf (anchor/pick frame table aspect)))
dense transforms)) dense transforms))
(defn- iris-pair [iris-a iris-b corners-r] (defn- observed-track [frames detected presence feature-id]
(let [part (get presence feature-id)]
(mapv (fn [f]
(and (or (nil? detected) (nth detected f))
(or (nil? part) (nth part f))))
(range frames))))
(defn- iris-pair [iris-a iris-b corners-r corners-l observed-r observed-l]
(when iris-a (when iris-a
;; An occluded eye may still get a plausible iris from MediaPipe. It gets no
;; vote. Either eye can establish the pairing when its partner is absent.
(let [votes (reduce + (let [votes (reduce +
(map (fn [a b [outer inner]] (for [f (range (count iris-a))
(if (< (distance (first a) (midpoint outer inner)) [seen corners sign] [[(nth observed-r f) (nth corners-r f) 1]
(distance (first b) (midpoint outer inner))) [(nth observed-l f) (nth corners-l f) -1]]
1 -1)) :when seen]
iris-a iris-b corners-r))] (let [a (first (nth iris-a f))
b (first (nth iris-b f))
[outer inner] corners]
(if (< (distance a (midpoint outer inner))
(distance b (midpoint outer inner)))
sign (- sign)))))]
(if (pos? votes) {:right :a :left :b} {:right :b :left :a})))) (if (pos? votes) {:right :a :left :b} {:right :b :left :a}))))
(defn measure (defn measure
"All rings and iris coordinates use the anchor's head-local image-height unit. "All rings and iris coordinates use the anchor's head-local image-height unit.
Eye openness and gaze use each eye's rigid corner width as their unit. Iris Eye openness and gaze use each eye's rigid corner width as their unit. Iris
block identity is voted from proximity over the whole take." block identity is voted from observed eyes over the whole take."
[{:keys [aspect debug?]} {:keys [dense transforms]}] [{:keys [aspect debug?]} {:keys [dense transforms detected presence]}]
(let [track (partial local-track dense transforms aspect) (let [track (partial local-track dense transforms aspect)
observed-r (observed-track (count dense) detected presence :eye-r)
observed-l (observed-track (count dense) detected presence :eye-l)
lid-r (track lm/EYE-R-RING) lid-r (track lm/EYE-R-RING)
lid-l (track lm/EYE-L-RING) lid-l (track lm/EYE-L-RING)
corners-r (track lm/EYE-R-CORNERS) corners-r (track lm/EYE-R-CORNERS)
@ -41,7 +57,7 @@
has-iris? (every? #(> (count %) (last lm/IRIS-B)) dense) has-iris? (every? #(> (count %) (last lm/IRIS-B)) dense)
iris-a (when has-iris? (track lm/IRIS-A)) iris-a (when has-iris? (track lm/IRIS-A))
iris-b (when has-iris? (track lm/IRIS-B)) iris-b (when has-iris? (track lm/IRIS-B))
pairing (iris-pair iris-a iris-b corners-r) pairing (iris-pair iris-a iris-b corners-r corners-l observed-r observed-l)
iris-for (fn [side f] iris-for (fn [side f]
(first (nth (if (= (get pairing side) :a) iris-a iris-b) f))) (first (nth (if (= (get pairing side) :a) iris-a iris-b) f)))
openness (fn [corners lids] openness (fn [corners lids]
@ -57,17 +73,25 @@
iris (iris-for side f)] iris (iris-for side f)]
{:x (/ (- (:x iris) (:x c)) w) {:x (/ (- (:x iris) (:x c)) w)
:y (/ (- (:y iris) (:y c)) w)})) :y (/ (- (:y iris) (:y c)) w)}))
r (one :right corners-r) r (when (nth observed-r f) (one :right corners-r))
l (one :left corners-l)] l (when (nth observed-l f) (one :left corners-l))]
(midpoint r l))) (cond
(and r l) (midpoint r l)
r r
l l
:else nil)))
(range (count dense))) (range (count dense)))
(vec (repeat (count dense) {:x 0 :y 0})))] (vec (repeat (count dense) {:x 0 :y 0})))]
{:lid-r lid-r :lid-l lid-l {:lid-r lid-r :lid-l lid-l
:corners-r corners-r :corners-l corners-l :corners-r corners-r :corners-l corners-l
:open-r (openness corners-r lids-r) :open-r (openness corners-r lids-r)
:open-l (openness corners-l lids-l) :open-l (openness corners-l lids-l)
:observed-r observed-r :observed-l observed-l
:gaze-observed (if pairing (mapv #(or %1 %2) observed-r observed-l)
(vec (repeat (count dense) true)))
:gaze gaze :has-iris? (boolean pairing) :gaze gaze :has-iris? (boolean pairing)
:iris-pair pairing :iris-pair pairing
:debug (when debug? {:open-r (openness corners-r lids-r) :debug (when debug? {:open-r (openness corners-r lids-r)
:open-l (openness corners-l lids-l) :open-l (openness corners-l lids-l)
:observed-r observed-r :observed-l observed-l
:gaze gaze :iris-pair pairing})})) :gaze gaze :iris-pair pairing})}))

View file

@ -9,24 +9,25 @@
[arthur.flow.measure.brows :as brows] [arthur.flow.measure.brows :as brows]
[arthur.flow.measure.eyes :as eyes] [arthur.flow.measure.eyes :as eyes]
[arthur.flow.measure.interior :as interior] [arthur.flow.measure.interior :as interior]
[arthur.flow.measure.mouth :as mouth])) [arthur.flow.measure.mouth :as mouth]
[arthur.domain.params :as params]))
(def knobs (def knobs params/defaults)
{:anchor-avg 2 :contour-avg 1 :verts 8 :aperture-cut 0.12
:eye-verts 8 :blink-cut 0.13 :gaze-gain 1 :gaze-step 0.08
:iris-size 0.42 :lash-weight 0.06 :pupil-size 0.15
:brow-verts 6 :brow-gain 1 :brow-step 0.08 :brow-weight 0.05
:cavity-erode 0.18 :tongue-reject 0.18 :blob-grow 0 :top-bias 0.6
:teeth-verts 10 :min-area 12 :teeth-on 0.12 :teeth-smooth 1})
(defn measure (defn measure
"Condition the anchor before measuring rings through it." "Condition the anchor before measuring rings through it."
[{:keys [aspect] :as params} {:keys [dense detected interior]}] [{:keys [aspect] :as params} {:keys [dense detected interior presence]}]
(let [fitted (anchor/fit {:aspect aspect} {:dense dense}) (let [_ (doseq [[id track] presence]
(when (not= (count dense) (count track))
(throw (ex-info "feature presence track must match the source"
{:feature id :frames (count dense)
:actual (count track)}))))
fitted (anchor/fit {:aspect aspect} {:dense dense})
anchored (condition/anchor params fitted) anchored (condition/anchor params fitted)
rings (mouth/measure {:aspect aspect} rings (mouth/measure {:aspect aspect}
{:dense dense :transforms (:transforms anchored)}) {:dense dense :transforms (:transforms anchored)})
eye-data (eyes/measure params {:dense dense :transforms (:transforms anchored)}) eye-data (eyes/measure params {:dense dense :transforms (:transforms anchored)
:detected detected :presence presence})
brow-data (brows/measure params {:dense dense :transforms (:transforms anchored)}) brow-data (brows/measure params {:dense dense :transforms (:transforms anchored)})
inner-data (when interior inner-data (when interior
(condition-interior/apply-defaults (condition-interior/apply-defaults
@ -41,7 +42,8 @@
:eyes (condition-eyes/apply-defaults params eye-data) :eyes (condition-eyes/apply-defaults params eye-data)
:brows (condition-brows/apply-defaults params brow-data eye-data) :brows (condition-brows/apply-defaults params brow-data eye-data)
:teeth inner-data :teeth inner-data
:detected detected))) :detected detected
:presence presence)))
(defn build (defn build
[params inputs] [params inputs]
@ -51,11 +53,12 @@
"A real manifest and its detected landmarks through the same measurement and "A real manifest and its detected landmarks through the same measurement and
freeze path as the synthetic take. The source cadence stays in :fps; picture freeze path as the synthetic take. The source cadence stays in :fps; picture
sampling is a root time map applied only after this artifact exists." sampling is a root time map applied only after this artifact exists."
[manifest {:keys [dense detected dimensions interior]}] [manifest {:keys [dense detected dimensions interior presence]}]
(let [[w h] dimensions (let [[w h] dimensions
params (merge knobs params (merge knobs
{:name "footage" :fps (:fps manifest) :aspect (/ w h) {:name "footage" :fps (:fps manifest) :aspect (/ w h)
:stage [320 200] :fit-motion? true :stage [320 200] :fit-motion? true
:expose 1 :head :as-filmed :expose 1 :head :as-filmed
:analysis (str "mediapipe:1.0.1/" (:source manifest))})] :analysis (str "mediapipe:1.0.1/" (:source manifest))})]
(build params {:dense dense :detected detected :interior interior}))) (build params {:dense dense :detected detected :interior interior
:presence presence})))

View file

@ -0,0 +1,54 @@
(ns arthur.domain.feature-test
(:require [cljs.test :refer [deftest is]]
[arthur.domain.feature :as feature]
[arthur.domain.params :as params]))
(defn- nine-eyes []
(let [people (mapv #(keyword (str "person-" %)) (range 5))
eyes (mapv #(keyword (str "eye-" %)) (range 9))
members [[:eye-0 :eye-1] [:eye-2 :eye-3] [:eye-4 :eye-5]
[:eye-6 :eye-7] [:eye-8]]]
{:subjects (into {} (map (fn [id] [id {:id id :params {}}]) people))
:features (into {} (map-indexed (fn [i id]
[id {:id id :subject (nth people (quot i 2))
:area :eye :nodes [] :params {}}]) eyes))
:groups (into {} (map-indexed (fn [i ids]
(let [id (keyword (str "pair-" i))]
[id {:id id :kind :eye-pair
:subject (nth people i)
:members ids :params {}}])) members))
:nodes {}}))
(deftest five-people-can-have-nine-identified-eyes
(let [scene (nine-eyes)]
(is (empty? (feature/problems scene)))
(is (= [:eye-8] (get-in scene [:groups :pair-4 :members])))
(is (= :person-4 (get-in scene [:features :eye-8 :subject])))))
(deftest parameter-definitions-drive-the-current-take-defaults
(is (= #{:eye} (params/affected-areas :blink-cut)))
(is (= #{:head :mouth :eye :brow :teeth}
(params/affected-areas :anchor-avg)))
(is (params/valid-settings? :eye {:blink-cut 0.2}))
(is (not (params/valid-settings? :eye {:verts 8}))))
(deftest pair-values-are-shared-but-eye-overrides-are-independent
(let [scene (-> (nine-eyes)
(assoc-in [:groups :pair-0 :params :blink-cut] 0.2)
(assoc-in [:features :eye-1 :params :blink-cut] 0.3))]
(is (= 0.2 (:blink-cut (feature/effective-params scene :eye-0))))
(is (= 0.3 (:blink-cut (feature/effective-params scene :eye-1))))
(is (= (:eye-verts params/defaults)
(:eye-verts (feature/effective-params scene :eye-0))))
(let [before (feature/effective-params scene :eye-0)
unpaired (feature/remove-from-pair scene :eye-0)]
(is (= before (feature/effective-params unpaired :eye-0)))
(is (= [:eye-1] (get-in unpaired [:groups :pair-0 :members])))
(is (empty? (feature/problems unpaired))))))
(deftest a-pair-cannot-cross-subjects-or-own-an-eye-twice
(let [scene (nine-eyes)]
(is (seq (feature/problems
(assoc-in scene [:groups :pair-0 :members] [:eye-0 :eye-2]))))
(is (seq (feature/problems
(assoc-in scene [:groups :pair-1 :members] [:eye-0 :eye-3]))))))

View file

@ -0,0 +1,67 @@
(ns arthur.flow.eye-occlusion-test
(:require [cljs.test :refer [deftest is]]
[arthur.demo.take :as take]
[arthur.domain.channel :as ch]
[arthur.domain.landmarks :as lm]
[arthur.domain.palette :as pal]
[arthur.domain.scene :as scene]
[arthur.flow.condition.eyes :as condition-eyes]
[arthur.flow.ingest :as ingest]
[arthur.flow.measure.eyes :as eyes]
[arthur.flow.take :as flow-take]))
(deftest an-unobserved-eye-cannot-steer-the-shared-gaze
(let [frame 11
params (assoc take/params :aspect 1)
presence (ingest/feature-presence take/frames {:eye-r [[10 14]]})
inputs {:dense @take/analysis
:transforms (:transforms @take/measured)
:presence presence}
clean (eyes/measure params inputs)
iris-ids (if (= :a (:right (:iris-pair clean))) lm/IRIS-A lm/IRIS-B)
bad-ids (distinct (concat lm/EYE-R-RING iris-ids))
corrupted (mapv (fn [i source]
(if (<= 9 i 13)
(reduce (fn [points id]
(update-in points [id :x] + 0.25))
source bad-ids)
source))
(range take/frames) @take/analysis)
masked (eyes/measure params (assoc inputs :dense corrupted))
unmasked (eyes/measure params
(dissoc (assoc inputs :dense corrupted) :presence))
drawn-clean (condition-eyes/apply-defaults params clean)
drawn-masked (condition-eyes/apply-defaults params masked)]
(is (= lm/NUM-LANDMARKS (count (nth corrupted frame))))
(is (:has-iris? masked) (str "pair " (:iris-pair masked)))
(is (false? (nth (:observed-r masked) frame)))
(is (true? (nth (:observed-l masked) frame)))
(is (= (nth (:gaze clean) frame) (nth (:gaze masked) frame)))
(is (not= (nth (:gaze clean) frame) (nth (:gaze unmasked) frame)))
(is (= (nth (:lid-r drawn-clean) frame)
(nth (:lid-r drawn-masked) frame)))
(is (= (nth (:iris-l drawn-clean) frame)
(nth (:iris-l drawn-masked) frame)))))
(deftest the-full-take-hides-only-the-annotated-eye-and-returns-to-the-same-id
(let [presence (ingest/feature-presence take/frames {:eye-r [[10 14]]})
{:keys [scene store]} (flow-take/build
(assoc take/params :aspect 1 :name "observed-gap")
{:dense @take/analysis :presence presence})
sample (fn [id frame]
(ch/value-at (get-in scene [:nodes id :channels [:geom :pts]])
frame store))]
(is (empty? (scene/problems scene)))
(is (= [:eye-r :eye-l] (get-in scene [:groups :eyes-1 :members])))
(doseq [f (range 9 14)]
(is (ch/nothing? (sample :eye-r f)))
(is (not (ch/nothing? (sample :eye-l f))))
(is (not (ch/nothing? (sample :mouth f)))))
(let [drawn (into #{} (map :node)
((scene/resolver scene store pal/index-of) 11))]
(is (not (contains? drawn :eye-r)))
(is (not (contains? drawn :iris-r)))
(is (contains? drawn :eye-l))
(is (contains? drawn :mouth)))
(is (not (ch/nothing? (sample :eye-r 8))))
(is (not (ch/nothing? (sample :eye-r 14))))))

View file

@ -381,6 +381,27 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; presence is not visibility ;; presence is not visibility
(deftest an-eye-can-disappear-and-return-under-the-same-identity
(let [gap (set (range 40 60))
presence {:eye-r (mapv #(not (contains? gap %)) (range take/frames))}
c (freeze/clip (assoc take/params :name "one-eye-gappy")
(assoc @take/measured :presence presence))
sc (:scene c)
at (fn [id f]
(ch/value-at (get-in sc [:nodes id :channels [:geom :pts]]) f (:store c)))]
(is (= [:eye-r :eye-l] (get-in sc [:groups :eyes-1 :members])))
(is (= :eye-r (get-in sc [:features :eye-r :id])))
(is (not (ch/nothing? (at :eye-r 39))))
(is (ch/nothing? (at :eye-r 50)))
(is (not (ch/nothing? (at :eye-r 60))))
(is (not (ch/nothing? (at :eye-l 50))))
(is (not (ch/nothing? (at :mouth 50))))
(let [drawn-nodes (into #{} (map :node) ((scene/resolver sc (:store c) pal/index-of) 50))]
(is (not (contains? drawn-nodes :eye-r)))
(is (contains? drawn-nodes :eye-l))
(is (contains? drawn-nodes :mouth)))
(is (empty? (scene/problems sc)))))
(deftest an-undetected-frame-has-no-pose-at-all (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 ;; 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 ;; part being switched off. The mask lands on every block of the freeze, so an
@ -447,10 +468,15 @@
;; beat are not. This is the numeric half of step 5's done-criterion; the other ;; 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. ;; half is a picture and lives in test/browser/take.mjs.
(let [locked (freeze/head-mode {:mode :locked} @clip) (let [locked (freeze/head-mode {:mode :locked} @clip)
shot (fn [f] (render locked f)) shot (fn [f]
(let [r (raster/make W H)
mouth (filter #(= :mouth (:node %)) (ops-at locked f))]
(raster/clear! r (get pal/index-of :bg))
(raster/draw-ops! r mouth)
(vec (array-seq (:buf r)))))
differ (fn [a b] (count (remove true? (map = a b))))] 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 ;; Beat 1 is wide open and beat 3 is shut. Compare only the mouth outline:
;; what differs is articulation and not the head wandering across the stage. ;; eyes and brows added in step 7 have their own motion within the beat.
(is (> (differ (shot 10) (shot 28)) 300) (is (> (differ (shot 10) (shot 28)) 300)
"the open and the shut mouth rasterise the same") "the open and the shut mouth rasterise the same")
;; And within a beat, on the exposure grid, it holds. ;; And within a beat, on the exposure grid, it holds.

View file

@ -0,0 +1,15 @@
(ns arthur.flow.ingest-test
(:require [cljs.test :refer [deftest is]]
[arthur.flow.ingest :as ingest]))
(deftest feature-absence-uses-the-source-frame-numbers
(let [tracks (ingest/feature-presence
8 {:eye-r [[3 5]] :eye-l [[1 1] [8 8]]})]
(is (= [true true false false false true true true] (:eye-r tracks)))
(is (= [false true true true true true true false] (:eye-l tracks)))))
(deftest malformed-feature-absence-fails-before-analysis
(doseq [bad [{:eye-r [[0 2]]}
{:eye-r [[6 9]]}
{:eye-r [[5 3]]}]]
(is (thrown? ExceptionInfo (ingest/feature-presence 8 bad)))))

View file

@ -51,6 +51,7 @@ const state = {
lead: 0, // performance-track offset in frames lead: 0, // performance-track offset in frames
exposure: 1, // 1 = on 1s, 2 = on 2s. Picture holds; audio does not. exposure: 1, // 1 = on 1s, 2 = on 2s. Picture holds; audio does not.
interior: null, // per-frame teeth measurement from image content interior: null, // per-frame teeth measurement from image content
detected: null, // per-frame: did MediaPipe really see a face here?
teeth: null, // resolved per-frame {show, t} after knobs teeth: null, // resolved per-frame {show, t} after knobs
eyes: null, // resolved per-frame lid rings, shut flags, iris discs eyes: null, // resolved per-frame lid rings, shut flags, iris discs
brows: null, // resolved per-frame brow rings after quantised raise brows: null, // resolved per-frame brow rings after quantised raise
@ -167,18 +168,29 @@ async function initLandmarker() {
async function detectAll(images) { async function detectAll(images) {
const lm = await initLandmarker(); const lm = await initLandmarker();
const cv = document.createElement('canvas'); const cv = document.createElement('canvas');
const dense = [], missing = []; const dense = [], missing = [], detected = [];
for (let i = 0; i < images.length; i++) { for (let i = 0; i < images.length; i++) {
const im = images[i]; const im = images[i];
cv.width = im.naturalWidth; cv.height = im.naturalHeight; cv.width = im.naturalWidth; cv.height = im.naturalHeight;
cv.getContext('2d').drawImage(im, 0, 0); cv.getContext('2d').drawImage(im, 0, 0);
const out = lm.detect(cv); const out = lm.detect(cv);
if (out.faceLandmarks && out.faceLandmarks.length) dense.push(out.faceLandmarks[0]); if (out.faceLandmarks && out.faceLandmarks.length) {
else { missing.push(i); dense.push(dense.length ? dense[dense.length - 1] : null); } dense.push(out.faceLandmarks[0]); detected.push(true);
} else {
missing.push(i); detected.push(false);
dense.push(dense.length ? dense[dense.length - 1] : null);
}
if (i % 4 === 0) status(`detecting… ${i + 1}/${images.length}`); if (i % 4 === 0) status(`detecting… ${i + 1}/${images.length}`);
} }
if (dense[0] === null) throw new Error('no face found in the first frame'); // A gap mid-take holds the previous frame, but a gap at the TOP has no
return { dense, missing }; // previous to hold - a face that starts occluded or walks in late left
// leading nulls, and this used to throw and discard the whole take. Back-fill
// from the first real detection: the mirror of hold-previous, and the only
// fill that is a real pose from this take rather than an invention.
const firstReal = dense.findIndex((d) => d !== null);
if (firstReal < 0) throw new Error('no face found in any frame — check framing and light');
for (let i = 0; i < firstReal; i++) dense[i] = dense[firstReal];
return { dense, missing, detected, firstReal };
} }
// Interior measurement is a function of pixels alone, so it runs once with // Interior measurement is a function of pixels alone, so it runs once with
@ -217,10 +229,19 @@ function rebuild(resetKeep) {
state.stab = stabilize(state.dense, o.smoothWin, state.aspect); state.stab = stabilize(state.dense, o.smoothWin, state.aspect);
// The neutral drives calibration and the placeholder plate, so it has to be a
// frame the camera actually saw: a back-filled or held frame is a duplicate
// pose, and letting one win this contest would calibrate the whole take
// against a landmark set that belongs to some other moment.
const ap = state.stab.aperture; const ap = state.stab.aperture;
const real = state.detected;
const shut = (a, b) => (b < 0 || ap[a] < ap[b] ? a : b);
const head = Math.max(1, Math.floor(N / 4)); const head = Math.max(1, Math.floor(N / 4));
let neutral = 0; let neutral = -1;
for (let i = 0; i < head; i++) if (ap[i] < ap[neutral]) neutral = i; for (let i = 0; i < head; i++) if (!real || real[i]) neutral = shut(i, neutral);
// Whole head of the take occluded: widen to any real frame rather than give up.
if (neutral < 0) for (let i = 0; i < N; i++) if (!real || real[i]) neutral = shut(i, neutral);
if (neutral < 0) neutral = 0;
state.neutral = neutral; state.neutral = neutral;
state.xform = makeXform(state.stab, neutral); state.xform = makeXform(state.stab, neutral);
@ -1072,8 +1093,8 @@ async function runFrames() {
status(`no frames in ${el('framedir').value}/ — run extract.sh first`, 'err'); status(`no frames in ${el('framedir').value}/ — run extract.sh first`, 'err');
return; return;
} }
const { dense, missing } = await detectAll(images); const { dense, missing, detected, firstReal } = await detectAll(images);
state.images = images; state.dense = dense; state.images = images; state.dense = dense; state.detected = detected;
state.aspect = images[0].naturalWidth / images[0].naturalHeight; state.aspect = images[0].naturalWidth / images[0].naturalHeight;
status('measuring mouth interiors…'); status('measuring mouth interiors…');
state.interior = measureAll(images, dense, opts()); state.interior = measureAll(images, dense, opts());
@ -1086,7 +1107,9 @@ async function runFrames() {
status(`${images.length} frames · ${images[0].naturalWidth}x${images[0].naturalHeight} · ` + status(`${images.length} frames · ${images[0].naturalWidth}x${images[0].naturalHeight} · ` +
`${state.fps}fps · ${dur}s` + `${state.fps}fps · ${dur}s` +
(state.audio ? ' · audio loaded' : ' · no audio') + (state.audio ? ' · audio loaded' : ' · no audio') +
(missing.length ? ` · no face on ${missing.length} (held previous)` : ''), (missing.length ? ` · no face on ${missing.length}` +
(firstReal ? ` (${firstReal} at the top back-filled from ${firstReal + 1}, rest held)`
: ' (held previous)') : ''),
missing.length ? 'warn' : 'ok'); missing.length ? 'warn' : 'ok');
} catch (e) { status(e.message, 'err'); console.error(e); } } catch (e) { status(e.message, 'err'); console.error(e); }
} }
@ -1096,6 +1119,7 @@ function runSynthetic() {
attachAudio(null); attachAudio(null);
state.fps = 12; state.fps = 12;
state.aspect = 1; // synthetic landmarks are generated square state.aspect = 1; // synthetic landmarks are generated square
state.detected = null; // no detection ran, so every frame counts as real
state.lead = 0; state.lead = 0;
state.interior = null; // no pixels, so no teeth state.interior = null; // no pixels, so no teeth