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)

Shared Types

All moving generators reuse these types from MotionParams.hpp:

// Boundary
enum class BoundaryMode { clamp, bounce, wrap };

struct BoundaryParams {
    BoundaryMode boundary_mode = BoundaryMode::clamp;
    float bounds_min_x = 0.0f;
    float bounds_max_x = 640.0f;
    float bounds_min_y = 0.0f;
    float bounds_max_y = 480.0f;
};

// Per-model params
struct LinearMotionParams {
    float velocity_x = 1.0f;
    float velocity_y = 0.0f;
};

struct SinusoidalMotionParams {
    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;
};

struct BrownianMotionParams {
    float diffusion = 1.0f;
    uint64_t seed = 42;
};

// Discriminated union
using MotionModelVariant = rfl::TaggedUnion<
    "model",
    LinearMotionParams,
    SinusoidalMotionParams,
    BrownianMotionParams>;

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

  1. The line shape is defined by (start_x, start_y) to (end_x, end_y) with num_points_per_line evenly spaced sample points.
  2. The centroid of the line definition is computed: centroid = ((start_x + end_x) / 2, (start_y + end_y) / 2).
  3. Each vertex is stored as an offset from this centroid.
  4. The trajectory is computed starting from (trajectory_start_x, trajectory_start_y).
  5. 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:

  1. start_x, start_y, num_frames — always visible, non-optional fields.
  2. motion — rendered as a combo box with three choices (LinearMotionParams / SinusoidalMotionParams / BrownianMotionParams) and a QStackedWidget sub-form underneath. Selecting a model shows only that model’s relevant fields.
  3. boundary — rendered as a group with boundary_mode as 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 boundary object can be omitted entirely to use all defaults
  • Individual fields within boundary or the motion sub-struct can be omitted