Skip to content

Vero Signal Protocol Specification

Version: 1.0.0 Status: Normative Scope: All platforms (iOS, Android, Web)

This document defines the exact encoding, color mapping, timing, and framing rules for converting a session key into a visual light signal projected onto the presenter's face. Two independent implementations following this specification MUST produce identical color sequences for the same input key and parameters.


1. Overview

The signal protocol converts a hexadecimal session key into a binary string, maps each bit (or bit group) to a display color, and projects the resulting sequence as timed full-screen color pulses on the presenter's device screen. The sequence is framed with terminator signals and separated by black delimiter pulses.


2. Color Palettes

V1 is 1-bit only (normative). Conforming V1 implementations MUST support the 1-bit palette (§2.1) and nothing more. The 2-bit and 3-bit palettes (§2.2, §2.3) are non-normative research profiles: field testing found 2-bit unreliable and 3-bit not separable through skin (a printed/reflected 8-colour palette scores ~0 offline), so they are NOT conformance requirements and MUST NOT be relied upon for interoperability. They are retained only to document the design space.

2.1 1-Bit Palette (3 colors: 2 data + 1 terminator)

Signal Binary Value Color Name Hex Code RGB
1 1 Green #00FF00 (0, 255, 0)
0 0 Blue #0000FF (0, 0, 255)
T Terminator Red #FF0000 (255, 0, 0)

Delimiter (inter-signal): Black #000000 RGB (0, 0, 0)

Implementation note: Production display colors MAY be adjusted for brightness/detectability while maintaining the same dominant channel. The reference adjusted values used in the current mobile implementation are:

Signal Adjusted Hex Adjusted RGB Rationale
1 #00FF00 (0, 255, 0) Pure green, unchanged
0 #0070FF (0, 112, 255) Blue with added white for brightness on face
T #FF2000 (255, 32, 0) Red with slight warmth for visibility

Implementations MUST document any adjusted display values. The detection algorithm operates on the dominant RGB channel (Section 7), so adjusted values MUST preserve the same dominant channel as the canonical palette.

2.2 2-Bit Palette (5 colors: 4 data + 1 terminator) — NON-NORMATIVE (research)

Research profile only. Not a V1 conformance requirement (found unreliable in the field). See §2.

Signal Binary Value Color Name Hex Code RGB
00 00 Red #FF0000 (255, 0, 0)
01 01 Green #00FF00 (0, 255, 0)
10 10 Blue #0000FF (0, 0, 255)
11 11 Yellow #FFFF00 (255, 255, 0)
T Terminator White #FFFFFF (255, 255, 255)

Delimiter: Black #000000

2.3 3-Bit Palette (9 colors: 8 data + 1 terminator) — NON-NORMATIVE (research)

Research profile only. Not a V1 conformance requirement (the 8-colour palette is not separable through skin). See §2.

Signal Binary Value Color Name Hex Code RGB
000 000 Red #FF0000 (255, 0, 0)
001 001 Orange #FFA500 (255, 165, 0)
010 010 Yellow #FFFF00 (255, 255, 0)
011 011 Green #00FF00 (0, 255, 0)
100 100 Cyan #00FFFF (0, 255, 255)
101 101 Blue #0000FF (0, 0, 255)
110 110 Magenta #FF00FF (255, 0, 255)
111 111 Purple #A020F0 (160, 32, 240)
T Terminator White #FFFFFF (255, 255, 255)

Delimiter: Black #000000


3. Key-to-Sequence Encoding

3.1 Hex to Binary Conversion

The session key is a hexadecimal string. Each hex character MUST be converted to exactly 4 binary digits, zero-padded on the left.

Algorithm:

function hexToBinary(hex: string) -> string:
    result = ""
    for each character c in hex:
        decimal = parseHexDigit(c)        // 0-15
        bits = toBinaryString(decimal)     // "0" to "1111"
        result += leftPad(bits, 4, '0')   // always 4 chars
    return result

Example: Session key "a3" (2 hex chars, sessionKeyLength = 2) - 'a' = 10 decimal = "1010" - '3' = 3 decimal = "0011" - Binary result: "10100011" (8 bits)

BigInt alternative (for leading-zero preservation):

function hexToBinary(hex: string) -> string:
    prefixed = "0x" + hex
    binary = BigInt(prefixed).toString(2)
    expectedLength = len(hex) * 4
    return leftPad(binary, expectedLength, '0')

Both methods MUST produce identical output. Leading zeros in the binary representation MUST be preserved.

3.2 Binary to Color Sequence (1-Bit Mode)

Given the binary string, produce a display sequence as follows:

  1. Start with a Terminator pulse (T)
  2. For each bit in the binary string, left to right:
  3. Emit a delimiter (_ = black)
  4. Emit the data color: 1 = Green, 0 = Blue
  5. Optionally end with a Terminator (T) if single-pass mode

Sequence string format: Characters represent pulses separated by _ for delimiters.

function binaryToBlinkSequence(binary: string) -> string:
    sequence = "R_"                          // Start terminator + delimiter
    for each bit in binary:
        if bit == '1': sequence += "G_"      // Green + delimiter
        else:          sequence += "B_"      // Blue + delimiter
    return sequence                          // No trailing terminator for continuous loop

Example: Binary "10100011" produces: R_G_B_G_B_B_B_G_G_

3.3 Binary to Color Sequence (2-Bit Mode)

Binary string is consumed 2 bits at a time. If the binary length is odd, pad with a trailing 0.

3.4 Binary to Color Sequence (3-Bit Mode)

Binary string is consumed 3 bits at a time. If the binary length is not divisible by 3, pad with trailing 0s to the next multiple of 3.


4. Timing Parameters

The blink rate defines the duration in milliseconds for which each color pulse (including delimiters) is displayed.

Parameter Default Value Bounds Unit Configurable
blinkRate 200 150–600 ms Yes (via calibration)

Each element in the sequence (data color OR delimiter) is displayed for exactly blinkRate milliseconds.

Bounds are authoritative at 150–600 ms — reconciled to the calibration handshake (CALIBRATION_HANDSHAKE.md); the earlier session-schema range of 50–1000 ms is void. The final blinkRate for a session is chosen by calibration to satisfy BOTH decodability (≥4 camera frames/pulse and weakest-channel SNR ≥ 3) AND the protocol Safety bound (photosensitivity — see the Safety section). The floor is a joint decodability-and-safety constraint, not a decodability floor alone; a verifier-supplied blinkRate MUST be clamped to these bounds on the presenter device before any emission.

Total sequence duration for 1-bit mode:

totalPulses = 1 (start terminator) + 1 (delimiter after terminator) + binaryLength * 2 (data + delimiter each)
durationMs = totalPulses * blinkRate

Example: 20-bit key at 200ms blink rate: - Pulses: 1 + 1 + 20*2 = 42 pulses - Duration: 42 * 200ms = 8,400ms = 8.4 seconds per loop

4.2 Sample Rate

The verifier's camera sampling rate determines detection resolution.

Parameter Default Value Unit
sampleRate 50 ms

The sample rate MUST be at most blinkRate / 2 (Nyquist criterion). At sampleRate = 50ms and blinkRate = 200ms, there are 4 samples per pulse, providing adequate detection margin.

4.3 Buffer Size

The wave detection buffer size is derived:

bufferSize = ceil(blinkRate / sampleRate)    // e.g., ceil(200/50) = 4
effectiveBufferSize = max(3, bufferSize)     // minimum 3 for slope detection


5. Sequence Framing

5.1 Start Terminator

Every sequence MUST begin with a single Terminator pulse. This allows the detector to synchronize by recognizing the terminator color before data begins.

5.2 Delimiters

A black (#000000) delimiter pulse MUST separate every signal element (including after the start terminator and after each data pulse). The delimiter serves two purposes: 1. Returns the face illumination to baseline (ambient only) 2. Creates the trough in the wave pattern that the detector uses to distinguish consecutive pulses

5.3 End Terminator (Optional)

For single-pass mode, a Terminator pulse at the end signals sequence completion. For continuous loop mode (default), the sequence wraps and the start terminator of the next loop serves as the end marker for the previous loop.

5.4 Looping

In the default operating mode, the sequence loops continuously until the presenter stops the scan. Each loop iteration is identical. The verifier's detector evaluates each complete loop as an independent detection attempt.


6. Session Key Parameters

The following parameters are transmitted from the verifier to the presenter alongside the session key:

Parameter Type Description Default
sessionKeyLength integer Number of hex characters in the session key 5
blinkRate integer Milliseconds per pulse 200
sessionKeyType char 's' = session key, 'm' = multiplier key 's'
bitMode integer Bits per pulse (1, 2, or 3) 1

Session key length in bits: sessionKeyLength * 4

Example configurations:

sessionKeyLength Bits 1-bit pulses Duration at 200ms
5 20 42 8.4s
8 32 66 13.2s
16 64 130 26.0s

7. Detection Algorithm (Verifier Side)

7.1 Color Analysis Pipeline

For each sampled camera frame from the ROI (region of interest on the presenter's face):

  1. Average RGB: Compute mean R, G, B across all pixels in the ROI
  2. Mean Shift RGB: Apply mean-shift clustering (bandwidth=20, maxIter=10, epsilon=0.5, bins=16) to find the dominant color mode, using the average RGB as initial seed
  3. Total Intensity: Sum t = r + g + b of the mean-shift result

7.2 Wave Detection

The detector identifies peaks (color pulses) and troughs (delimiters/baseline) in the total intensity stream.

State machine parameters:

Parameter Default Description
flatThreshold 1 Maximum delta to classify slope as "steady"
debounceSamples 2 Samples to suppress after reporting an event
minSamplesBetweenEvents 3 Minimum samples between consecutive events

Slope classification:

delta = current_total - previous_total
if abs(delta) <= flatThreshold: slope = "steady"
else if delta > 0:              slope = "rising"
else:                           slope = "falling"

Event detection: - Trough (baseline): previous slope was "falling" or "steady", current slope is "rising" - Peak (color pulse): previous slope was "rising" or "steady", current slope is "falling"

7.3 Spike Color Identification

On each detected peak, identify the dominant color by comparing the peak frame's average RGB to the established baseline RGB:

deltaR = current.r - baseline.r
deltaG = current.g - baseline.g
deltaB = current.b - baseline.b
maxDelta = max(0, deltaR, deltaG, deltaB)

if maxDelta == 0: return null       // no significant spike
if deltaR == maxDelta: return "red"
if deltaG == maxDelta: return "green"
if deltaB == maxDelta: return "blue"

7.4 Signal Mapping

Map detected color to signal using the active palette's colorToSignal table:

1-Bit mode:

green -> "1"
blue  -> "0"
red   -> "T" (terminator)

On terminator detection, the accumulated bit string is evaluated against the expected binary. The accumulator is then cleared for the next loop.


7.5 Implementation Notes (Non-Normative)

These record where the iOS implementation deviates from the pseudocode above, and why. They are non-normative — Section 8 conformance is unaffected — but any platform hitting the same field conditions will likely need the same measures.

7.5.1 Sample-rate enforcement is the detector's responsibility

Sections 4.2 and 4.3 assume the detector is driven at sampleRate, giving ~4 samples per pulse. The original web implementation got this for free from a fixed setInterval. Native camera pipelines instead deliver every frame (30-60 fps), which is 10-20 samples per pulse. At that density the Section 7.2 state machine reports several "peaks" inside a single flash, and the debounceSamples / minSamplesBetweenEvents constants — which were tuned for ~4 samples per pulse — no longer bound the event rate to one per pulse.

Implementations that receive frames at the camera rate MUST decimate to the protocol sample rate before Section 7.2. Field evidence: an undecimated iOS verifier accumulated 94 bits for a 20-bit key.

Implementations SHOULD additionally enforce a real-time refractory window of half a flash+gap period after each emitted signal. Two adjacent flashes are a full period apart, so this cannot suppress a real bit, but it does absorb the extra peaks a long flash produces. The refractory should be armed only when a signal is actually emitted, so that a sub-threshold noise peak on a rising edge cannot mask the real flash behind it.

7.5.2 Loop closure when the terminator is missed

Section 7.4 closes a loop only on terminator detection. The terminator is one pulse in ~42, and at two-device distance a meaningful fraction of reflected flashes fragment or drop entirely. When the terminator is missed, a strictly terminator-driven detector never clears its accumulator and never advances its loop counter, so the two-consecutive-80% rule in comparison-spec.md Section 5.3 can never fire — the detector can sit above the acceptance threshold indefinitely without ever accepting.

Implementations SHOULD also close and evaluate a loop once the accumulator reaches sessionKeyLength * 4 bits. A loop closed this way is evaluated identically. If the phase is wrong because a terminator was missed, the match percentage is simply low, which is the correct outcome — this adds no acceptance path, only a bound on the accumulator and a guarantee that loops keep closing.

7.5.3 Spike ranking for composite-color palettes

Section 7.3's argmax(deltaR, deltaG, deltaB) is exact for the 1-bit palette, whose three colors each dominate a different channel. It mis-ranks the composite colors of the 2- and 3-bit palettes when the signal is reflected off skin rather than read from screen pixels: skin reflects red roughly twice as efficiently as blue, so a Yellow flash arrives as approximately ΔRGB = (84, 54, 0) and scores higher on red than on yellow. White — the 2/3-bit terminator — is scored by its weakest channel and therefore almost never wins.

For palettes containing composite colors, implementations SHOULD gate presence on the magnitude of the rectified delta as specified, but rank candidates by its direction (cosine against each palette color's RGB direction). For the 1-bit palette this is provably identical to Section 7.3, so Section 8 item 6 continues to hold there.

7.5.4 Matching operates on the current loop only

Match percentage MUST be computed per comparison-spec.md Section 5.2 (right-aligned) over the bits of the current loop, not over a lifetime accumulator. Sliding the expected pattern across an ever-growing buffer and keeping the maximum produces a monotonically rising high-water mark: as the buffer grows, a coincidental ≥80% alignment becomes near-certain regardless of the signal. That is a false-accept hazard, and it destroys the score's value as a signal-quality reading.


8. Conformance Requirements

An implementation is conformant with this specification if:

  1. Given the same hex session key and bit mode, it produces the identical binary string
  2. Given the same binary string and palette, it produces the identical ordered color sequence
  3. Pulse timing is within +/-10% of the specified blinkRate
  4. The delimiter color is exactly black (#000000) at full opacity
  5. Data colors preserve the correct dominant RGB channel as specified in the palette tables
  6. The detection algorithm, given the same intensity stream, produces the same peak/trough events and signal assignments

9. Test Vectors

9.1 Hex-to-Binary

Hex Input Binary Output
"0" "0000"
"f" "1111"
"a3" "10100011"
"00ff0" "00000000111111110000"
"1b2e7" "00011011001011100111"

9.2 Binary-to-Sequence (1-Bit)

Binary Sequence String
"10" R_G_B_
"0110" R_B_G_G_B_
"1111" R_G_G_G_G_
"0000" R_B_B_B_B_

9.3 Full Pipeline (1-Bit, sessionKeyLength=2)

Input: Session key hex = "a3" Binary: "10100011" Sequence: R_G_B_G_B_B_B_G_G_ Display colors (in order): 1. Red (#FF0000) - start terminator 2. Black (#000000) - delimiter 3. Green (#00FF00) - bit "1" 4. Black (#000000) - delimiter 5. Blue (#0000FF) - bit "0" 6. Black (#000000) - delimiter 7. Green (#00FF00) - bit "1" 8. Black (#000000) - delimiter 9. Blue (#0000FF) - bit "0" 10. Black (#000000) - delimiter 11. Blue (#0000FF) - bit "0" 12. Black (#000000) - delimiter 13. Blue (#0000FF) - bit "0" 14. Black (#000000) - delimiter 15. Green (#00FF00) - bit "1" 16. Black (#000000) - delimiter 17. Green (#00FF00) - bit "1" 18. Black (#000000) - delimiter (wraps to start)