tb_quat_color_rotate — 2D typed op

Data kinds: qimageqimage

Call: fullseye.apply(img, "tb_quat_color_rotate", a=0.5, b=0.5) (the 2-D model is one image plus two scalar knobs a,b∈[0,1])

tb_quat_color_rotate: input → output

*The figure is the actual output on a synthetic 128×128 input. Left: input, right: output. Point clouds are drawn as a top-down scatter (brightness = z), 1-D series as a line plot, volumes as the maximum-intensity projection along z, videos as the middle frame, complex images as magnitude; return values that are not pictures are shown as the values themselves.*

*Knob a does not change the output (measured: identical at 0.1 / 0.5 / 0.9).*

*Knob b does not change the output (measured: identical at 0.1 / 0.5 / 0.9).*

Stages (the ops that come before → this op, left to right):

tb_quat_color_rotate: stages (docs site)

On other images (synthetic scene / photo / coins. Top row: inputs, bottom row: their outputs. Knobs at default):

tb_quat_color_rotate: other inputs (docs site)

Usage

Rotate every pixel's colour about an RGB axis: `q x conj(q)`. → (H, W, 4).

The operation a complex pixel cannot express. `axis_rgb` is a direction in

RGB space and `angle_rad` the rotation about it; the rotor

`q = cos(a/2) + sin(a/2) * axis` is built with

`pose_quat.axis_angle_to_quat` and the conjugation is applied to the vector

part of every pixel, leaving the scalar part untouched (a conjugation cannot

move it).

Exactness and what it is worth

------------------------------

The conjugation is applied through the `3x3` matrix from

`pose_quat.quat_to_hom_mat3d` rather than by two per-pixel Hamilton

products, because for a `(512, 512)` image that is 500k quaternion

multiplications versus one `einsum`. The two are the *same map*, measured:

against per-pixel `pose_quat.quat_rotate_point_3d` the agreement is

`4.44e-16, the round trip rotate(rotate(q, ax, a), ax, -a)` returns

`q to 2.22e-16, and the colour magnitude is preserved to 2.22e-16`.

The matrix identity is also the honest limit of the *capability* claim.

`SO(3)` and the unit quaternions are isomorphic, so **a 3x3 orthogonal

colour matrix does exactly this and nothing is lost by using one** — measured

against an explicit `Rz(30 deg), the agreement is 2.22e-16`. What a

quaternion buys is 4 numbers instead of 9, exact closure under composition,

and `slerp`. Measured over 100,000 random small rotations composed in

sequence, the quaternion (renormalised each step, 4 divisions) drifts from

unit norm by 0.0 while the matrix (composed by multiplication, not

re-orthonormalised) drifts to `|R^T R - I| = 4.33e-14`.

**That advantage is real but it is nearly nothing, and an earlier revision of

this file oversold it by four orders of magnitude.** The same measurement

then read `4.4e-10` for the matrix, which looked like a decisive argument

for quaternions. It was not an argument about quaternions at all: it was the

`pose_quat` defect described below, feeding a slightly non-orthogonal

matrix into every one of the 100,000 steps. With that fixed the honest figure

is `4.33e-14`, i.e. ordinary rounding over 100k products. The lesson is the

repository's own: a number that flatters the thing you are building is the

one to re-measure first.

What a *channelwise* pipeline — three independent scalar filters, which is

what running the complex ops on R, G and B separately means — cannot do is

this operation at all: it never mixes channels, so it cannot turn red towards

green. Pure red rotated 90 degrees about the blue axis comes back as

`(-2.2e-16, 1.0, 0.0)`; no per-channel gain can put anything in the green

channel, because it starts at zero. That is the comparison in

`tests/test_quatimage.py`, and it is the one that is decisive.

Two traps in the rotor, both refused here regardless of the dependency

---------------------------------------------------------------------

`pose_quat used to normalise as n / (norm + 1e-12)`. A zero axis

then returned `[cos(a/2), 0, 0, 0], which quat_to_hom_mat3d`

re-normalised to the identity: a rotation request silently became a no-op.

Worse, at `angle_rad = pi that same path produced [0, 0, 0, 0]`, whose

normalisation was `0/(0+1e-12) = 0` and whose matrix was again the identity

— a *180-degree* colour rotation silently becoming a copy. Both were reported

and have since been fixed in `pose_quat` itself (zero length now

raises; `axis_angle_to_quat(0, 0, 1, pi)` now returns exactly

`[0, 0, 0, 1]` with norm 1, where it used to return norm 0.9999999999990).

This operator nevertheless keeps both of its own guards —

:func:_require_direction on the axis, and an explicit unit-norm assertion

on the finished rotor (tolerance :data:_UNIT_TOL). A check that only holds

while a dependency behaves is not a check, and the caller of this module

should get this module's error message. A genuine pi rotation still works:

pure red about the blue axis gives `(-1.0, 1.2e-16, 0.0)`.

Raises `ValueError: *qimage* is not a valid (H, W, 4)` field;

*axis_rgb* is not a finite non-zero 3-vector; *angle_rad* is not a finite

real scalar; the constructed rotor is not unit norm.

Typed bridge of the quat op `quat_color_rotate into the 2-D evolution registry: the same implementation, called under the op(v, a, b) convention. This op has no tunable parameter; a and b` are unused.

References (sample data, literature)

• Sample-data catalog (download URLs / licences) — 2-D uses skimage.data (BSD/public domain) plus synthetic images; 3-D lists download URLs for real data sources (Stanford, PDS, …).

• Operator provenance and references — the sources of the research/methods this op family came from.

Try it in Studio

The program below has been verified to run (same input as the figure). In Studio's help this block becomes buttons that load and run it on the spot.

img_to_rgb 0.50 0.50
tb_rgb_to_quaternion 0.50 0.50
tb_quat_color_rotate 0.50 0.50

▸ Load this pipeline  ·  Load & run

Runnable examples (verified samples that actually call this op)

The examples below call the underlying ledger op quat_color_rotate. This bridge op is the same implementation adapted to the fn(v, a, b) convention, so the behaviour carries over unchanged (only the call form differs).

quaternion_monogenicpy -3.11 examples/quaternion_monogenic.py

Ops the type connects to (they accept qimage as input)

identity · tb_quaternion_to_rgb · tb_quat_norm · tb_quat_conjugate_image · tb_quat_normalize_image · tb_monogenic_amplitude · tb_monogenic_phase · tb_monogenic_orientation

Same category (typed)

tb_points_to_voxel · tb_estimate_point_normals · tb_iss_keypoints · tb_project_points · tb_render_point_depth · tb_statistical_outlier_removal · tb_radius_outlier_removal · tb_voxel_grid_downsample


*Provenance: ops.py — 2D operator registry. This per-op note is generated by tools/opdocs.py md (do not hand-edit).*

© 2026 Kazufumi Furuse — Fullseye operator documentation. Licensed under Apache-2.0.