IPL — Interpretable Pose Latent

Open specification for a rig-agnostic, human-interpretable body-pose latent.

StandardIPL — Interpretable Pose Latent
Version1.1.2
Released2026-10-15
StatusStable baseline for the current axis set. The parameter registry (§4) MAY be extended by a future major change (Y, §8).
SymbolP (the IPL vector)
Namespacesignthai.com/ipl
Media typeapplication/vnd.ipl+json (provisional)
LicenseOpen specification; document © 2026 AAT research team, DHCB, National Science and Technology Development Agency (NSTDA), Thailand, all rights reserved (see License)
Supersedes1.1.1

Cite as: IPL 1.1.2 (2026).

1.Scope

IPL defines a flat, named, normalized, rig-agnostic parameter vector (P) that represents an upper-body signing posture — hands, arms, head, body, face, and mouth — at a single instant (one keyframe). Every dimension is explicitly defined and human-interpretable: a person can read a dimension's definition, understand what it controls, and edit it by hand. The same vector can be produced by an encoder (sign-language pose → P) or authored by a human, and decoded to a rendered avatar (and, in future revisions, to other modalities).

IPL is a representation standard, not an algorithm. It specifies what the vector means, not how any particular encoder or renderer is implemented.

1.1.Design intent (non-normative)

2.Terminology

TermMeaning
IPL vector (P)The complete ordered parameter vector defined by this standard.
Dimension / parameterOne named scalar of P.
GroupA named partition of dimensions (§3.1).
DOFDegrees of freedom contributed by a parameter or group.
Pose (articulation)The base configuration: what the body is doing.
Expression (affect)An additive layer on affect groups: how it is coloured (§3.3).
Hyper conditionA named discrete/conditional channel carried alongside P that conditions the decode — NOT a latent dimension (§3.4). Currently: eye look-at, viseme, contact, anchor.
Body siteA named reference point on the decoded body (per-rig calibrated bone + offset → world point) — the domain of the contact and anchor hyper conditions (§3.4).
Post-processingThe final decode stage: applied on the fully decoded pose (pose ⊕ expression → rig), after all latent decoding (§3.4).
ProducerAny implementation that emits P (encoder or human tool).
ConsumerAny implementation that reads P (renderer, exporter, analyser).

The key words MUST, MUST NOT, SHOULD, MAY are to be interpreted as in RFC 2119.

3.Representation model

3.1.Groups

P is partitioned into seven continuous groups (71 dimensions in all):

CodeGroupRole
aarmarticulation
wpalm facing (wrist orientation)articulation
gfingersarticulation
hheadarticulation + affect
bbodyarticulation + affect
ffacearticulation + affect
mmoutharticulation + affect

Plus hyper conditions (§3.4): eye look-at, viseme, contact, and anchor — conditional channels riding alongside the vector, not latent dimensions.

Articulation-only groups (a w g) MUST NOT carry expression deltas — they encode pure form. Affect-capable groups are h b f m only (§3.3).

3.2.Value convention

3.3.Pose ⊕ Expression

IPL carries two stacked layers:

effective = clamp( pose ⊕ α · expression )

A consumer that ignores expression MUST still render pose correctly. Expression adds no new dimensions to the registry ordering; it is a parallel delta over the affect groups.

3.4.Hyper conditions & post-processing

Alongside the continuous vector, IPL carries hyper conditions — named discrete/conditional channels that condition how P is decoded without being latent dimensions. They are designed (not learned), do not occupy registry positions, never interpolate as scalars, and removing them MUST restore the pure decode of P. All other values remain the latent, unchanged.

The full decode order is:

P(pose) ⊕ α·P(expression)  ──decode──▶  rig pose  ──POST-PROCESSING──▶  rendered body
        (latent, §3.3)      (eye/viseme applied)        (contact/anchor solved)

Four hyper conditions are defined (registry §4.8):

Body sites. The contact domain is a fixed vocabulary of 32 named reference points on the decoded body: neck, navel, forehead, mouth, and per side (_l/_r) shoulder, elbow, wrist, palm, plus {thumb,index,middle,ring,pinky}_{base,tip}. A site resolves to a world point as primary bone + bone-local offset (+ optional secondary bones); the offsets are calibrated per rig and are renderer state, not part of P. Constraints: the mover MUST be a hand site; the target MUST NOT be on the mover's hand nor be the mover's same-side elbow. At most one contact may be active per keyframe.

4.Parameter registry (normative)

The registry is the authoritative list of every dimension. Each row is one dimension (sided dimensions exist as an _l/_r pair sharing the listed definition). Defaults are the neutral, normalized value.

ColumnMeaning
idStable machine identifier (e.g. a_hand_x). Sided ids append _l/_r. MUST NOT be reused or redefined within the same paradigm version (X, §8).
sideL/R (paired) or none (centerline).
definitionOne-sentence human-readable meaning.
−1 … +1Semantics of the two poles (real-unit max in parentheses).
dofDegrees of freedom.
affectWhether the dimension accepts an expression delta (h b f m only).
defaultNeutral normalized value.

4.1.arm

Hand position in the neck-centric frame, with a separate calibrated length scale for each axis, plus elbow spread. All bipolar except a_spread (unipolar).

AxisCalibrated reference distance d
Xwrist_r to middle_tip_r: wrist to middle fingertip, hand in its open bind pose
Yneck to navel: neck to navel, body in its bind pose
Zshoulder_r to wrist_r: shoulder to wrist with the arm fully extended, preserving its native wrist orientation

Distances MUST be measured between calibrated Body sites, including their bone-local offsets, in fixed reference poses. They MUST NOT be remeasured from the animated posture: bending the elbow, curling a finger or turning the body must not change the units. The right-side reference defines the common units for both arms; left/right position signs still follow §3.2. A change to calibrated sites or effective hand geometry requires recomputing the affected reference distances.

For each axis, the physical unit is L = d / r, where r is a fixed per-axis reference ratio defined by the official reference avatar (based): the corresponding distance divided by shoulder width on that avatar. It retains the established numeric scale. A normalized arm coordinate n produces a displacement n × max × L along its body-frame axis, before anchor/contact corrections. Equivalently, a consumer using shoulder-width units multiplies that axis by (d / shoulderWidth) / r. Thus shoulder width alone no longer defines arm placement across avatars. The registry ids, maxima and defaults below remain unchanged.

A consumer lacking a valid reference measurement SHOULD use its legacy shoulder-width unit for that axis and report the missing calibration. Such a fallback does not establish calibrated-axis conformance for that avatar.

idsidedefinition−1 … +1dofdefault
a_hand_xL/RHand sideways position, scaled by calibrated wrist-to-middle-tip length−1 = across to far side · +1 = out past own shoulder (max 1.5)10.333
a_hand_yL/RHand height, scaled by calibrated neck-to-navel length−1 = low/by hip · +1 = high overhead (max 1.5)1−0.933
a_hand_zL/RHand forward reach, scaled by calibrated extended shoulder-to-wrist length−1 = tucked to body · +1 = fully forward (max 1.5)10.35
a_spreadL/RElbow spread0 = tucked to ribs · +1 = elbow out (unipolar, max 1)10.4

4.2.palm facing

Wrist orientation as the vector part of a body-frame global unit quaternion (w reconstructed on decode); gimbal-free. Identity (0,0,0) = palm to front, fingers up. The right hand mirrors across the sagittal plane. Defaults encode "hand hangs following the forearm, palm toward body".

A consumer MUST reconstruct the scalar part from the unit-length constraint:

w = sqrt(1 - (q_x² + q_y² + q_z²))

If the squared norm of the vector part exceeds 1, the consumer MUST rescale the vector part to length at most 1 before reconstructing, and SHOULD clamp it slightly below 1 (e.g. 0.98) to avoid the singularity near w = 0. Taking w ≥ 0 always also makes the representation unambiguous (q and −q are the same rotation).

idsidedefinition−1 … +1dofdefault
w_qxL/RQuaternion x (pitch axis)−1 … +1 (max 1)10.885
w_qyL/RQuaternion y (pronate axis)−1 … +1 (max 1)1−0.093
w_qzL/RQuaternion z (roll axis)−1 … +1 (max 1)1−0.429

4.3.fingers

Per finger curl / fold / side on both hands; the thumb adds a 4th DOF (roll). The thumb is bound ~90° rotated, so its fold = opposition (across the palm) and side = abduction (in/out of the palm plane). g_thumb_roll is frequently unobservable from 2D pose; a producer that cannot estimate it MUST emit the default (0). All bipolar, max 1, default 0.

idsidedefinition−1 … +1dofdefault
g_{thumb,index,middle,ring,pinky}_curlL/RFinger curl (PIP/DIP toward palm)−1 = bent back (hyperextended) · 0 = straight · +1 = fully curled10
g_{index,middle,ring,pinky}_foldL/RMCP base-knuckle fold−1 = folded backward · 0 = straight · +1 = folded toward palm10
g_thumb_foldL/RThumb opposition (sweep across palm to pinky base)−1 = away · +1 = across palm10
g_{index,middle,ring,pinky}_sideL/RFinger spread−1 = toward thumb · +1 = toward pinky10
g_thumb_sideL/RThumb abduction (in/out of palm plane)−1 = in · +1 = out10
g_thumb_rollL/RThumb roll/pronation−1 = pad out of palm facing · +1 = pad in10

4.4.head (affect-capable)

Head Euler angles (stored bipolar, degrees). Affect-capable.

idsidedefinition−1 … +1dofaffectdefault
h_yawnoneTurn left/right±80° (max 80)1yes0
h_pitchnoneNod up/down±60° (max 60)1yes0
h_rollnoneTilt head±50° (max 50)1yes0

4.5.body (affect-capable)

Torso motion distributed along the spine (degrees). Affect-capable.

idsidedefinition−1 … +1dofaffectdefault
b_twistnoneTwist torso left/right±60° (max 60)1yes0
b_bendnoneBend forward/back±50° (max 50)1yes0
b_leannoneLean sideways±40° (max 40)1yes0

4.6.face (affect-capable)

Bipolar expression dims; each drives one or more ARKit-named morph channels per direction. All max 1, default 0. Affect-capable.

idsidedefinition−1 … +1dofaffectdefault
f_brow_innernoneInner brow−1 = furrow (angry) · +1 = raise1yes0
f_browL/ROuter brow−1 = down · +1 = up1yes0
f_eyeL/REyelid−1 = blink-shut · +1 = wide1yes0
f_squintL/REye squint0 = neutral · +1 = squeezed (plus-only)1yes0
f_nosenoneNose wrinkle / sneer0 = neutral · +1 = wrinkled (plus-only)1yes0

4.7.mouth (affect-capable)

Bipolar mouth/oral shapes; each recruits several ARKit morphs per direction so the extremes reach strong shapes (yawn, kiss, bared teeth). Cheeks, tongue, and teeth are oral articulators grouped here. All max 1, default 0. Affect-capable.

idsidedefinition−1 … +1dofaffectdefault
m_mouthL/RMouth corner−1 = frown · +1 = smile1yes0
m_jawnoneJaw−1 = clench · +1 = open (to yawn)1yes0
m_lipsnoneLip rounding−1 = funnel · +1 = pucker1yes0
m_widenoneMouth width−1 = narrow (/u/) · +1 = wide (signed)1yes0
m_rollnoneLip roll−1 = lower over upper · +1 = upper over lower1yes0
m_pressnoneLip press−1 = relaxed-parted · +1 = pressed/tensed (signed)1yes0
m_fwdnoneMouth/jaw thrust0 = neutral · +1 = forward (plus-only)1yes0
m_cheeknoneCheek−1 = suck in · +1 = puff out (signed)1yes0
m_tonguenoneTongue out0 = in · +1 = out/lick (plus-only, best-effort)1yes0
m_teethnoneTeeth bared (lips retracted)0 = neutral · +1 = bared (plus-only)1yes0

When mouthing is active, the artist viseme_* morph remains the primary shape and m_* is a bounded delta from that shape. The normalized viseme mix supplies a share-weighted implied baseline b; decode sends only clamp(b + authored, −1, 1) − b through the ARKit mouth channels. Therefore an authored zero leaves the artist viseme untouched, a same-direction adjustment uses only the remaining range, and an opposing adjustment can cancel it. The conservative seed table is viseme_aa → m_jaw +0.7 and viseme_PP → m_jaw −0.7, m_press +0.7; other pairs imply zero until tuned.

4.8.hyper conditions

Discrete/conditional channels carried alongside the continuous vector — see §3.4. (Section renamed from "4.cat categorical channels" in pre-release drafts; ids unchanged.)

iddomainowner groupstagedefinition
eye_lookatenumfacedecodeGaze target (front / left hand / right hand / glance left, right, up, down).
visemesparse map viseme_* → 0…1mouthdecodeMouthing: a sparse map of viseme_* shape ids to their share, e.g. {viseme_PP: 0.7, viseme_aa: 0.3} — an absent id is 0, {} is no mouthing, and the shares sum to at most 1. SEVERAL shapes may be active at once (each id is its own morph channel, so decode assigns each share directly), and because the value is continuous two mouth keys crossfade on interpolation instead of snapping. A map that arrives over budget is clamped per entry then scaled back onto 1.
contactmover site → target sitearm (mover side)post-processingThe mover body site connects to the target site on the fully decoded body; target never moves (§3.4).
anchorbody site per arm sidearm (per side)post-processingAn anchored arm's position is measured from its site instead of the neck; an arm with no anchored keys never moves; contact wins on its mover side (§3.4).

5.Encoding conventions

6.Conformance

An implementation is a conforming producer if it emits only registered ids, in range, with correct handedness and group assignment.

An implementation is a conforming consumer if it: clamps to [−1, 1]; applies registry defaults for absent dimensions; renders pose correctly with expression ignored; and, if it supports expression, applies clamp(pose ⊕ α·expression) over affect groups only. For calibrated arm placement, it MUST apply the fixed-reference axis definitions and normalization in §4.1.

A consumer MAY ignore hyper conditions (the pure decode of P MUST still be correct). A consumer that supports contact MUST solve it as post-processing on the fully decoded body, MUST NOT move the target side or torso, and MUST NOT alter P. A consumer that supports anchor MUST resolve it as post-processing per arm side (only sides carrying the annotation move; torso untouched), MUST give contact priority on the contact's mover side, and MUST NOT alter P; an unresolvable site falls back to the neck origin.

Conformance is stated against a specific version, e.g. "conforms to IPL 1.1.2".

7.Composition grammar

A composition is a one-line text script that places stored keyframe runs of P — glosses (named lexicon signs) and expressions (named affect deltas, §3.3) — on one timeline. This section defines that script language.

Each construct carries a since tag naming the tier in which it entered the grammar: Since 1.1.0 (basic), Since 1.1.1 (special functions and operators), and Since 1.1.2 (user functions). All three tiers are part of this IPL 1.1.2 document; the tags are not separately published versions.

7.1.Principle

Terms used in this section:

TermMeaning
LexiconThe producer's store of named glosses, expressions, and user functions.
GlossA named run of P keyframes (one sign). Its native length is its last keyframe index (1 if that is 0).
SubgroupA group split by side where the group is sided: a_l a_r w_l w_r g_l g_r h b f m. A gloss drives a subgroup when it has at least one keyframe on it.
Gloss flagsLexicon flags read by the grammar: draft (unfinished), spelling (a fingerspelling letter), fixed_position (body-anchored; never shifted or anchored), left_dominant (the left hand leads).
Neutral poseThe rest pose configured for the composition. Where none is configured, the registry defaults (§4).
FrameOne step of the composition timeline. All pause, gap and ramp values are in frames.

7.2.Lexical conventions (Since 1.1.0)

7.3.Grammar summary

Informative EBNF. Whitespace between tokens is free; the semantic rules in §7.4–§7.8 are normative.

script      = [ item ] , { ( "|" | "||" ) , [ item ] } ;
item        = operand , [ ";" , operand ]                     (* 1.1.1 *)
            | pause | shift | reset | expr | "@none" | "@break" ;
operand     = gloss | group | spell | anchor | mirror | buoy | compound | viseme ;
gloss       = name , [ "(" , [ number ] , [ "," , label ] , ")" ] , [ "!" ] ;   (* "!" 1.1.1 *)
name        = word , [ "#" , word ] ;
group       = "[" , member , { "|" , member } , "]" ,
              [ "(" , [ number ] , [ "," , [ number ] , [ "," , label ] ] , ")" ] ;
member      = gloss | spell ;
run         = unit , { "|" , unit } ;
unit        = gloss | group ;
spell       = "$spell" , [ "(" , [ number ] , [ "," , [ number ] , [ "," , [ number ] ,
              [ "," , [ number ] , [ "," , label ] ] ] ] , ")" ] , "{" , run , "}" ;
num4        = [ number ] , [ "," , [ number ] , [ "," , [ number ] , [ "," , [ number ] ] ] ] ;
pause       = "$pause" , [ "(" , [ number ] , ")" ] ;
shift       = "$shift" , [ "(" , num4 , ")" ] ;
reset       = "$reset" ;
expr        = "@" , word , [ "(" , [ number ] , [ "," , [ number ] , [ "," , [ number ] ] ] , ")" ] ;
anchor      = "$anchor" , "(" , site , ")" , "{" , run , "}" ;                (* 1.1.1 *)
mirror      = "$mirror" , [ args ] , "{" , run , "}" ;                        (* 1.1.1 *)
buoy        = "$buoy" , [ args ] , "{" , run , "}" , "{" , run , "}" ;        (* 1.1.1 *)
compound    = "$compound" , [ args ] , "{" , cop , "}" , "{" , cop , "}" ;   (* 1.1.1 *)
cop         = operand , [ ";" , operand ] ;
viseme      = "$viseme" , "(" , text , [ "," , number ] , ")" ;               (* 1.1.1 *)
args        = "(" , [ number ] , [ "," , label ] , ")" ;
site        = "neck" | "forehead" | "mouth" | "navel" | "shoulder_l" | "shoulder_r" ;
usercall    = "%" , ident , "(" , { char - ( "(" | ")" ) } , ")" ;           (* 1.1.2, pre-pass *)
ident       = ( letter | "_" ) , { letter | digit | "_" } ;                   (* ASCII *)

usercall is not part of the token grammar: calls are expanded to text before tokenizing (§7.8), so after expansion a script contains no % call.

7.4.Name resolution (Since 1.1.0)

A gloss token's name (after removing a trailing ! and any (args)) resolves against the lexicon, case-insensitively:

  1. Exact match. If exactly one entry has that name, it is used. A draft entry resolves when it is the only match. If more than one entry has that name (a concrete entry and a draft included), the script is in error (ambiguous gloss name).
  2. Explicit variation NAME#var. If no entry is named NAME#var exactly, the token falls back to the entry named NAME (the same exact-match rule applies), never to a sibling variation. If neither exists, the name is unknown.
  3. Bare variation family. A name without # that matches no entry exactly, but heads entries named NAME#…, picks one member uniformly at random, choosing among concrete (non-draft) members when any exist, otherwise among all members. The pick is re-rolled on every compose, and a producer serializing the script MUST keep the bare NAME.
  4. Otherwise the name is unknown (unknown gloss), which is an error.

Expression names (@name) resolve the same way, without variations: one match is used; none is unknown expression; more than one is ambiguous expression name.

Aliases and composed glosses are lexicon-level entries, defined here only for resolution:

7.5.Timeline and automatic gaps (Since 1.1.0)

Items are placed in order. A timed item (a gloss, a block, $viseme, or $pause) occupies frames; markers ($shift, $reset, expression markers) occupy none and are transparent to gap sizing.

7.6.Basic constructs (Since 1.1.0)

7.6.1.Sequence A | B

Items placed in order on one timeline, separated by automatic gaps (§7.5).

7.6.2.Gloss NAME, NAME(speed), NAME(speed,label)

Places the resolved gloss (§7.4). speed MUST be a number > 0 (default 1); speed 2 plays in half the native length. label is an optional display caption for the placed item; it does not affect P and MUST NOT contain ; (an error) or any other reserved separator.

7.6.3.Pause $pause, $pause(N)

Holds the neutral pose for N frames, rounded. N MUST be ≥ 1; a bare $pause holds 15. A pause is a timed item: it has automatic gaps on both sides and carries no keyframes.

7.6.4.Shift $shift(x,y,z,twist), $reset

A stateful marker that offsets every following gloss until the next $shift or $reset (or the end of a $spell block, §7.6.6). Every axis is optional and defaults to 0; a bare $shift (all zero) is the same as $reset. For each keyframe the gloss itself authored:

each result clamped to [−1, 1]. Dimensions a gloss does not author are not created. A fixed_position gloss is not shifted. The offset is applied to the placed keyframes; P's definitions are unchanged.

7.6.5.Group [A | B](speed,gap,label)

Members placed as one unit, connected at a fixed gap instead of the automatic gap. speed MUST be > 0 (default 1) and applies to members whose own speed is 1; a member's own speed wins. gap MUST be ≥ 0 (default 3) and is rounded; the next member starts max(1, gap) frames after the previous member's last frame. Members are glosses or $spell runs; a $spell member is expanded into its letters with the shift ladder of §7.6.6 baked into them, without changing the shift state outside the group. Groups MUST NOT nest. An empty group, or any other member kind, is an error. The group is one unit for captions (label, or the member names).

7.6.6.Spelling run $spell(x,y,z,twist,label){A | B | …}

A fingerspelling run with its own shift ladder. The shift defaults to (0.1, 0, 0, 0). Members are glosses or [ ] groups; a group counts as one letter. For letter i of N (0-based), the active shift is the start shift scaled by (N − 1 − i)/(N − 1): the first letter is at the full shift and the last at zero. A single letter keeps the full shift. After the block, the shift state is zero (an earlier $shift is not restored). With a label, the run is one unit for captions. An empty block is an error.

7.6.7.Expressions @name(alpha,rampIn,rampOut), @none, @break

@name opens an expression range on the first free of 3 reference lanes, starting at the start of the next timed item. alpha is the peak gain and MUST satisfy 0 < α ≤ 1 (default 1). rampIn/rampOut are frames ≥ 0, rounded; when omitted, the consumer's expression ramp applies (6 frames unless configured). A fourth concurrent expression is an error. @break closes the most recently opened range and @none closes all open ranges, at the start of the next timed item. A range left open ends at the end of the last item it covers (not at the tail-out). A range that covers no frames is dropped. A lane freed and reopened at the same frame starts one frame later. In a sequence the ranges are references: the expression's deltas are applied at decode as clamp(pose ⊕ Σ αᵢ·expressionᵢ) over h b f m (§3.3) and are never written into the pose keyframes. (A composed gloss bakes them instead, §7.4.)

7.7.Special functions and operators (Since 1.1.1)

The constructs below take a block body. Unless stated otherwise, a block body is a run: glosses and [ ] groups only. A marker ($…, @…) inside a body, an empty body, or a || inside a body is an error (a block sets its own member gaps).

7.7.1.Anchor block $anchor(site){A | B}

Measures the arm position of the covered glosses from a body site instead of the neck, by stamping the anchor hyper condition (§3.4, §4.8) on each arm keyframe the gloss authors: an a_l keyframe gets {l: {site}}, an a_r keyframe {r: {site}}. An arm the gloss does not drive gets nothing. site MUST be one of neck, forehead, mouth, navel, shoulder_l, shoulder_r (case-insensitive); any other value is an error. neck stamps nothing (the default origin). A keyframe that already carries an authored anchor for that side keeps it. fixed_position glosses and subgroups filled by ! (§7.7.7) are not stamped. The block is additive with contact, which keeps its priority (§3.4). The re-origin itself happens at decode post-processing, per rig.

7.7.2.Mirror block $mirror(speed,label){A | B}

Plays the run side-swapped: _l/_r subgroup tracks and dimensions exchange sides; h_yaw, h_roll, b_twist, b_lean are negated; eye look-at, contact and anchor sites swap sides. Mirroring twice is the identity. speed MUST be > 0 (default 1) and applies to members whose own speed is 1. With a label, the run is one unit for captions. The block takes exactly one body; a second {…} is an error.

7.7.3.Buoy $buoy(speed,label){primary…}{secondary…}

The primary run plays as written, and every primary member composes as if marked ! (§7.7.7). The secondary run then plays fully mirrored (as $mirror), and on every subgroup a secondary member does not drive, the primary run's final keyframe value is held across that member. A secondary member marked ! takes the neutral fill instead of the hold. Both bodies are runs and both MUST be non-empty. speed (> 0, default 1) applies to members whose own speed is 1. The whole buoy is one unit for captions.

7.7.4.Compound $compound(speed,label){P}{S} and P ; S

Two-handed simultaneous composition: one placed item built from a primary P and a secondary S. Each operand MUST parse to exactly one sign-producing item (a gloss, group, $spell, $anchor, $mirror, $buoy, $compound, or $viseme); otherwise it is an error. The shorthand P ; S has no argument slots and takes exactly two operands: a second top-level ; or an empty side is an error. Operands may nest; nesting deeper than 32 levels is an error (compound-limit). speed MUST be > 0 (default 1).

7.7.5.Viseme $viseme(word), $viseme(word,speed)

A mouth-only item generated from Thai script or romanized Thai: keyframes on the mouth subgroup carrying only the viseme hyper condition (§4.8). The word is split into chunks on whitespace and -; a chunk with a Thai code point is read as Thai script, otherwise as romanization. Tone marks are ignored; ๆ repeats the whole preceding chunk. The input MUST be at most 512 code points and expand to at most 128 syllables (after ๆ repetition); exceeding either is an error (viseme-limit). A word with no readable syllable is an error. Unreadable syllables are dropped with the diagnostic viseme-incomplete; otherwise heuristic readings give viseme-guessed. The two are mutually exclusive: when both apply, only viseme-incomplete is raised. speed MUST be > 0 (default 1). More than two arguments is an error. The per-syllable timing is consumer configuration and is not part of this grammar.

7.7.6.Minimum-gap join A || B

B joins A at the composition's minimum gap m (§7.5), skipping distance scaling and the spelling rule; B remains its own item. B MUST be a gloss or a block construct (group, $spell, $anchor, $mirror, $buoy, $compound, $viseme); for a block, the join applies to its first placed gloss. A || before a pause or marker, a leading or trailing ||, or a || inside a block body is an error. An empty token between does not cancel the join (A || | B joins B).

7.7.7.Neutral fill NAME!

Every subgroup the gloss does not drive is keyed with the neutral pose at the gloss's first and last frame (one key if they coincide). In addition, one neutral key is placed rampIn frames before the first frame and one rampOut frames after the last frame, using the composition's rest ramps (default 6 each, the outer frame rounded and not below 0), but only when that frame lies within the same captioned unit, no key already sits on that frame, and no other key lies strictly between it and the gloss. $shift and $anchor skip the filled subgroups. ! is the last character of a gloss token, after any #variation and (args); it is a gloss suffix only (not valid after ] or }).

7.8.User functions %name(args) (Since 1.1.2)

A user function is a lexicon entry {name, arity, rules} that rewrites a call into script text. Expansion is a text pre-pass that runs before tokenizing (§7.2), so the token grammar never contains %.

7.9.Errors and diagnostics

A blocking error stops composition: the script produces no keyframes until it is fixed. A diagnostic is non-blocking: composition proceeds, and the producer SHOULD show it to the author.

classconditionraised when
blockingunknown gloss, ambiguous gloss namea gloss name does not resolve, or matches more than one entry (§7.4)
blockingunknown expression, ambiguous expression namethe same for @name
blockingbad gloss speed, bad group args, bad group gap, bad spell args, bad pause, bad shift, bad mirror speed, bad buoy speed, bad compound speed, bad viseme args, bad viseme speed, bad expression alpha, bad expression rampan argument is non-numeric or out of its range
blockingreserved labela label contains ;
blockingbad group, empty group, no nested groups, group membera malformed group, or a member other than a gloss or $spell
blockingempty block, block member, block arityan empty body, a marker in a body, or the wrong number of {…} bodies
blockingunknown anchor sitea site outside §7.7.1
blockingexpression lanesa fourth concurrent expression
blockingcompound operand, compound shorthandan operand that is not one sign-producing item; ; without exactly two operands
blockingcompound-limitcompound nesting deeper than 32
blockingviseme-limit, viseme unreadablethe 512 / 128 limits; no readable syllable
blockingviseme-too-shorta sign too short for its compound mouth run
blockingmisplaced joina misplaced || (§7.7.6)
blockingunknown functionan unknown native $ function
blockingunknown user function, no rule matches, bad pattern, expansion too deep, expanded too many itemsuser-function expansion (§7.8)
blockingnothing to place, variation family in composed gloss, composed gloss chaincomposed-gloss rules (§7.4)
diagnosticviseme-guesseda heuristic syllable reading
diagnosticviseme-incompletesome syllables unreadable and dropped
diagnosticcompound-noopthe secondary contributes nothing

Hyphenated names (compound-limit, viseme-limit, viseme-too-short, viseme-guessed, viseme-incomplete, compound-noop) are machine-readable diagnostic kinds; the other entries name the condition. Message wording is not normative.

7.10.Construct index and examples

constructsyntaxsince
sequenceA | B1.1.0
glossNAME, NAME(speed), NAME(speed,label)1.1.0
variationNAME#var, bare NAME family1.1.0
alias-composedalias and composed-gloss resolution1.1.0
auto-gapimplicit between items, lead-in, tail-out1.1.0
pause$pause, $pause(N)1.1.0
shift$shift(x,y,z,twist), $reset1.1.0
group[A | B](speed,gap,label)1.1.0
spell$spell(x,y,z,twist,label){A | B}1.1.0
expression@name(alpha,rampIn,rampOut), @none, @break1.1.0
anchor$anchor(site){A | B}1.1.1
mirror$mirror(speed,label){A | B}1.1.1
buoy$buoy(speed,label){P}{S}1.1.1
compound$compound(speed,label){P}{S}, P ; S1.1.1
viseme$viseme(word,speed)1.1.1
min-gap-joinA || B1.1.1
neutral-fillNAME!1.1.1
user-function%name(args)1.1.2

Examples (gloss and expression names are illustrative):

1.1.0  $shift(0.3,0,0,0.2) | HELLO | MY NAME(0.75) | @smile(0.5) | $spell{ก | ข} | @none | $pause(15) | $reset
1.1.1  $anchor(mouth){EAT} || WATER! | $compound(1,caption){A}{B} | SIGN ; $viseme(sa-wat-dee)
1.1.2  %num(123) | YEAR

8.Versioning & compatibility

An IPL version is written X.Y.Z. Each digit answers one question about what a release changed; the digits to its right reset to 0 when it increments.

Rules that hold across releases:

Release history: 1.0.0 (baseline) → 1.0.1 (minor: anchor) → 1.1.0 (major: composition grammar introduced) → 1.1.1 (minor) → 1.1.2 (minor, current). The full record is the change log page, ipl.signthai.com/changelog/.

9.Identifiers

KindValue
Prose termIPL — Interpretable Pose Latent
Math symbolP
Namespace / URI basesignthai.com/ipl
Media typeapplication/vnd.ipl+json (provisional)
Version stringIPL 1.1.2

The prose term is IPL; the acronym expands on first use in any document to avoid collision with unrelated uses of "IPL".

10.Change log

The version history (1.0.0 → 1.0.1 → 1.1.0 → 1.1.1 → 1.1.2) is maintained on the change log page, ipl.signthai.com/changelog/.

License

The IPL specification is open: anyone may implement, encode, decode, or exchange IPL data without permission or fee. The text of this document, the website ipl.signthai.com, and the .docx editions are © 2026 AAT research team, DHCB, National Science and Technology Development Agency (NSTDA), Thailand. All rights reserved. Reproduction or redistribution of the document requires written permission.