Motion Models
Overview
Milestone 5b introduces motion models to the DataSynthesizer, enabling spatial generators to produce time-varying trajectories. The design separates three concerns — where something is (trajectory), what it looks like (shape), and how it gets stored (populate) — so that motion logic is shared across all spatial data types.
Architecture: Trajectory → Shape → Populate
1. Trajectory 2. Shape 3. Populate
(physics) (rendering) (data storage)
computeTrajectory() renderShape(pos, t, ...) addAtTime(t, shape)
─────────────────► ─────────────────────► ──────────────────►
produces produces per-frame writes into
vector<Point2D<float>> Mask2D / Line2D / MaskData / LineData /
(one position per Point2D<float> PointData
frame) at that position
The trajectory library is shared by all moving generators. Each generator only needs to implement the shape-rendering step for its output type.
Trajectory Library
Location: src/DataSynthesizer/Trajectory/Trajectory.hpp + Trajectory.cpp
TrajectoryParams
struct TrajectoryParams {
std::string model = "linear"; // "linear", "sinusoidal", "brownian"
// Linear
float velocity_x = 1.0f;
float velocity_y = 0.0f;
// Sinusoidal
float amplitude_x = 0.0f;
float amplitude_y = 0.0f;
float frequency_x = 0.0f;
float frequency_y = 0.0f;
float phase_x = 0.0f;
float phase_y = 0.0f;
// Brownian
float diffusion = 1.0f;
uint64_t seed = 42;
// Boundary
std::string boundary_mode = "clamp"; // "clamp", "bounce", "wrap"
float bounds_min_x = 0.0f;
float bounds_max_x = 640.0f;
float bounds_min_y = 0.0f;
float bounds_max_y = 480.0f;
};TrajectoryParams is used internally by computeTrajectory(). It is not used directly for JSON parsing — generators use typed parameter structs with MotionModelVariant (an rfl::TaggedUnion) and BoundaryParams (with BoundaryMode enum), then convert to TrajectoryParams via toTrajectoryParams().
computeTrajectory()
std::vector<Point2D<float>> computeTrajectory(
float start_x, float start_y,
int num_frames,
TrajectoryParams const & params);Returns num_frames positions starting from (start_x, start_y). Frame 0 is always the start position. Boundary enforcement is applied per-step.
Motion Models
| Model | Formula | Key Parameters |
|---|---|---|
| linear | pos(t) = start + velocity × t |
velocity_x, velocity_y |
| sinusoidal | pos(t) = start + amplitude × sin(2π × frequency × t + phase) |
amplitude_*, frequency_*, phase_* |
| brownian | pos(t) = pos(t-1) + N(0, diffusion) |
diffusion, seed |
Boundary Modes
Boundaries are enforced per-step on each axis independently.
| Mode | Behavior |
|---|---|
| clamp | Position is clamped to [min, max]. Velocity unchanged. |
| bounce | Position reflects off boundaries. Velocity is negated on reflection (linear model). |
| wrap | Position wraps around: if pos > max, pos -= range; if pos < min, pos += range. |
Typed Motion Parameter System (Milestone 5b-ia)
Location: src/DataSynthesizer/Trajectory/MotionParams.hpp
Milestone 5b-ia replaced the flat-optional parameter style with a typed system using enum class for boundary modes and rfl::TaggedUnion for motion model selection. This integrates with ParameterSchema and AutoParamWidget to provide:
- A combo box for selecting motion model (linear / sinusoidal / brownian)
- A swapping sub-form showing only the relevant parameters for the selected model
- A combo box for selecting boundary mode (clamp / bounce / wrap)
toTrajectoryParams()
Converts from the typed variant + boundary params to the internal TrajectoryParams used by computeTrajectory():
TrajectoryParams toTrajectoryParams(
MotionModelVariant const & motion,
BoundaryParams const & boundary);Uses motion.visit() to dispatch on the active variant alternative and populate the corresponding TrajectoryParams fields.
DefaultIfMissing
The RegisterGenerator template uses rfl::DefaultIfMissing as a processor when parsing JSON, so missing fields in the JSON use the struct’s default member initializer values. This means users only need to specify the fields they want to override.
PixelClipping Utility
Location: src/DataSynthesizer/Trajectory/PixelClipping.hpp
A convenience header that re-exports clipPixelsToImage() and related rasterization functions from CoreGeometry/masks.hpp. Used by moving mask generators (Milestone 5b-ii) that need to clip rasterized pixels at each frame.
MovingPoint Generator
Location: src/DataSynthesizer/Generators/Point/MovingPointGenerator.cpp Registration name: "MovingPoint" · Category: Spatial · Output: PointData
Produces a PointData with exactly one point per frame, following a trajectory computed by computeTrajectory(). This is the simplest moving generator and validates the trajectory library.
Parameters
Uses MotionModelVariant and BoundaryParams for typed motion and boundary selection.
| Parameter | Type | Default | Notes |
|---|---|---|---|
start_x |
float |
100.0 | Starting X position |
start_y |
float |
100.0 | Starting Y position |
num_frames |
int |
100 | Number of frames to generate |
motion |
MotionModelVariant |
LinearMotionParams{} |
Tagged union — see sub-fields below |
boundary |
BoundaryParams |
defaults | Boundary configuration |
Motion sub-fields (only the active model’s fields are relevant):
| Linear | Sinusoidal | Brownian |
|---|---|---|
velocity_x (1.0) |
amplitude_x (0.0) |
diffusion (1.0) |
velocity_y (0.0) |
amplitude_y (0.0) |
seed (42) |
frequency_x (0.0) |
||
frequency_y (0.0) |
||
phase_x (0.0) |
||
phase_y (0.0) |
JSON Format
The JSON format uses a nested motion object with a "model" discriminator and a nested boundary object:
Linear motion:
{
"start_x": 0, "start_y": 100, "num_frames": 200,
"motion": {
"model": "LinearMotionParams",
"velocity_x": 2.0, "velocity_y": 0.5
},
"boundary": {
"bounds_max_x": 1000, "bounds_max_y": 1000
}
}Sinusoidal motion:
{
"start_x": 200, "start_y": 200, "num_frames": 100,
"motion": {
"model": "SinusoidalMotionParams",
"amplitude_x": 50, "frequency_x": 0.01
},
"boundary": {
"bounds_min_x": -1000, "bounds_max_x": 1000,
"bounds_min_y": -1000, "bounds_max_y": 1000
}
}Brownian motion with bounce:
{
"start_x": 320, "start_y": 240, "num_frames": 500,
"motion": {
"model": "BrownianMotionParams",
"diffusion": 3, "seed": 12345
},
"boundary": {
"boundary_mode": "bounce",
"bounds_min_x": 0, "bounds_max_x": 640,
"bounds_min_y": 0, "bounds_max_y": 480
}
}MovingMask Generator
Location: src/DataSynthesizer/Generators/Mask/MovingMaskGenerator.cpp Registration name: "MovingMask" · Category: Spatial · Output: MaskData
Produces a MaskData where each frame contains a shape (circle, rectangle, or ellipse) at a position determined by computeTrajectory(). At each frame, the shape is re-rasterized at the trajectory position and pixels are clipped to image bounds via clipPixelsToImage().
Shape Selection
The shape parameter is an enum class MaskShape (circle / rectangle / ellipse), rendered as a combo box in the GUI. Shape-specific size parameters are provided as flat fields; only the fields relevant to the selected shape are used:
| Shape | Size parameters used |
|---|---|
circle |
radius |
rectangle |
width, height |
ellipse |
semi_major, semi_minor, angle |
The MaskShape enum is defined in namespace WhiskerToolbox::DataSynthesizer (not the anonymous namespace) because reflect-cpp requires named-namespace enums for compile-time enum value detection.
Parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
shape |
MaskShape |
circle |
Shape type (circle / rectangle / ellipse) |
image_width |
int |
640 | Output image width |
image_height |
int |
480 | Output image height |
start_x |
float |
320.0 | Starting center X |
start_y |
float |
240.0 | Starting center Y |
radius |
float |
50.0 | Circle radius |
width |
float |
100.0 | Rectangle width |
height |
float |
60.0 | Rectangle height |
semi_major |
float |
80.0 | Ellipse semi-major axis |
semi_minor |
float |
40.0 | Ellipse semi-minor axis |
angle |
float |
0.0 | Ellipse rotation (degrees) |
num_frames |
int |
100 | Number of frames |
motion |
MotionModelVariant |
LinearMotionParams{} |
Motion model |
boundary_mode |
BoundaryMode |
clamp |
Boundary enforcement |
bounds_* |
float |
varies | Boundary box limits |
JSON Format
Circle moving linearly:
{
"shape": "circle", "radius": 20,
"image_width": 640, "image_height": 480,
"start_x": 100, "start_y": 240, "num_frames": 200,
"motion": {"model": "LinearMotionParams", "velocity_x": 2, "velocity_y": 0},
"boundary_mode": "bounce",
"bounds_min_x": 0, "bounds_max_x": 640,
"bounds_min_y": 0, "bounds_max_y": 480
}Rectangle with Brownian motion:
{
"shape": "rectangle", "width": 40, "height": 30,
"image_width": 640, "image_height": 480,
"start_x": 320, "start_y": 240, "num_frames": 500,
"motion": {"model": "BrownianMotionParams", "diffusion": 3, "seed": 42},
"boundary_mode": "clamp",
"bounds_min_x": 50, "bounds_max_x": 590,
"bounds_min_y": 50, "bounds_max_y": 430
}Pixel Clipping Behavior
At every frame, pixels are regenerated at the trajectory position and clipped to [0, image_width) × [0, image_height). A mask fully interior to the image has constant pixel count across frames. A mask near an edge has reduced pixel count due to clipping — only the portion within the image is stored.
MovingLine Generator
Location: src/DataSynthesizer/Generators/Line/MovingLineGenerator.cpp Registration name: "MovingLine" · Category: Spatial · Output: LineData
Produces a LineData where each frame contains a line segment whose centroid follows a trajectory computed by computeTrajectory(). The line shape is defined once by start/end points, decomposed into offsets from the centroid, then rigidly translated at each frame. No clipping is applied — vertices are float-valued and may extend beyond any nominal bounds.
Parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
start_x |
float |
0.0 | Line definition start X |
start_y |
float |
0.0 | Line definition start Y |
end_x |
float |
100.0 | Line definition end X |
end_y |
float |
100.0 | Line definition end Y |
num_points_per_line |
int |
50 | Number of sample points on the line |
num_frames |
int |
100 | Number of frames |
trajectory_start_x |
float |
100.0 | Starting X for trajectory centroid |
trajectory_start_y |
float |
100.0 | Starting Y for trajectory centroid |
motion |
MotionModelVariant |
LinearMotionParams{} |
Motion model |
boundary_mode |
BoundaryMode |
clamp |
Boundary enforcement for centroid |
bounds_* |
float |
varies | Boundary box limits for centroid |
How It Works
- The line shape is defined by
(start_x, start_y)to(end_x, end_y)withnum_points_per_lineevenly spaced sample points. - The centroid of the line definition is computed:
centroid = ((start_x + end_x) / 2, (start_y + end_y) / 2). - Each vertex is stored as an offset from this centroid.
- The trajectory is computed starting from
(trajectory_start_x, trajectory_start_y). - At each frame, all vertex offsets are added to the trajectory position to produce the translated line.
No Clipping
Unlike MovingMask, no pixel clipping is performed. Line vertices are float-valued and the renderer handles visibility. This means vertices may extend beyond the trajectory bounds — only the centroid is constrained by boundary enforcement.
JSON Format
Linear motion:
{
"start_x": -50, "start_y": 0, "end_x": 50, "end_y": 0,
"num_points_per_line": 20, "num_frames": 200,
"trajectory_start_x": 100, "trajectory_start_y": 100,
"motion": {"model": "LinearMotionParams", "velocity_x": 2, "velocity_y": 1},
"bounds_max_x": 640, "bounds_max_y": 480
}Brownian motion:
{
"start_x": -30, "start_y": -10, "end_x": 30, "end_y": 10,
"num_points_per_line": 15, "num_frames": 500,
"trajectory_start_x": 320, "trajectory_start_y": 240,
"motion": {"model": "BrownianMotionParams", "diffusion": 3, "seed": 42},
"boundary_mode": "bounce",
"bounds_min_x": 50, "bounds_max_x": 590,
"bounds_min_y": 50, "bounds_max_y": 430
}GUI Behavior
In the DataSynthesizer widget, selecting MovingPoint as the generator shows:
start_x,start_y,num_frames— always visible, non-optional fields.motion— rendered as a combo box with three choices (LinearMotionParams/SinusoidalMotionParams/BrownianMotionParams) and aQStackedWidgetsub-form underneath. Selecting a model shows only that model’s relevant fields.boundary— rendered as a group withboundary_modeas a combo box (clamp / bounce / wrap) and four float fields for bounds.
Design Notes
Why Typed Variants Instead of Flat Optionals
The original MovingPointParams had 18 optional fields for three mutually-exclusive motion models. Users saw all fields simultaneously with no indication that most were irrelevant. The typed system with rfl::TaggedUnion solves this by:
- Showing only relevant fields for the selected model in the UI
- Providing compile-time type safety for model-specific parameters
- Producing cleaner, more self-documenting JSON
Reuse in Future Generators
MotionModelVariant and BoundaryParams are defined once in MotionParams.hpp and reused by all moving generators. MovingMask and MovingLine use the same types as MovingPoint, getting combo-box-driven sub-forms automatically.
DefaultIfMissing Processor
The RegisterGenerator RAII helper uses rfl::DefaultIfMissing when parsing JSON parameters. This means:
- Fields with default values in the struct don’t need to be in the JSON
- The
boundaryobject can be omitted entirely to use all defaults - Individual fields within
boundaryor the motion sub-struct can be omitted