How to Load Tabular Data

CoordinateField, ComputedField, and custom loader configuration

How to Load Tabular Data

The fastest way to get music data into TimeToAlign! is through tabular loaders. If your data is in CSV or TSV format, you’re just 3 lines of code away from analysis.

What you’ll learn: - Load music annotations from TSV/CSV files - Access event counts, coordinate ranges, and metadata - Create timelines from loaded data - Create custom loaders with different extra_columns strategies - Use Field for nested JSON column access

Time: 15 minutes

TL;DR

from timetoalign.loader.tabular import Ms3Loader

loader = Ms3Loader()
loader.load("beethoven.notes.tsv")

df = loader.events.to_pandas()       # Get as DataFrame
timeline = loader.create_timeline()  # Create Timeline

Setup

from timetoalign.testdata import ensure_data

BEETHOVEN = ensure_data("score") / "beethoven_woo71"
THORESEN = ensure_data("thoresen")

# Available files
{
    "Beethoven files": [f.name for f in BEETHOVEN.glob("WoO71.*.tsv")],
    "Thoresen files": [f.name for f in THORESEN.glob("*.tsv")],
}
{'Beethoven files': ['WoO71.chords.tsv',
  'WoO71.measures.tsv',
  'WoO71.notes.tsv'],
 'Thoresen files': ['thoresen_test_h.tsv', 'thoresen_test.tsv']}

Loading Notes from TSV

The Ms3Loader handles TSV files exported from the ms3 parser, which processes MuseScore files.

Three lines of code:

from timetoalign.loader.tabular import Ms3Loader  # noqa: E402

loader = Ms3Loader()
loader.load(BEETHOVEN / "WoO71.notes.tsv")

f"{len(loader.events):,} notes loaded"
'4,753 notes loaded'

Converting to pandas

Use to_pandas() to get a DataFrame with clean coordinate values:

loader.events.to_pandas().head()
id name temporal_type event_type start end duration tpc chord_id midi mn staff voice mc octave
0 e000000 A3 interval Note 0 1/4 1/4 3 3 57 0 2 1 1 3
1 e000001 E4 interval Note 0 1/4 1/4 4 2 64 0 1 2 1 4
2 e000002 A4 interval Note 0 1/8 1/8 3 0 69 0 1 1 1 4
3 e000003 C#5 interval Note 0 1/8 1/8 7 0 73 0 1 1 1 5
4 e000004 E5 interval Note 1/2 5/8 1/8 4 1 76 0 1 1 1 5

Quick Statistics

The loader provides immediate access to summary information:

{
    "event_count": len(loader.events),
    "coordinate_range": loader.events.coordinate_range(),
    "unit": str(loader.unit),
    "number_type": str(loader.number_type),
}
{'event_count': 4753,
 'coordinate_range': (0.0, 877.75),
 'unit': 'quarters',
 'number_type': 'fraction'}

Creating Timelines

TimeToAlign! represents temporal data as Timelines:

timeline = loader.create_timeline(uid="beethoven_notes")
timeline
ContinuousLogicalTimeline[beethoven_notes] (4753 events)
                      0 _______________________________ 877.8 quarters

Custom Loaders for Non-Standard Formats

For files that don’t match the ms3 format, create a custom loader by subclassing TsvLoader or CsvLoader.

Let’s load the Thoresen annotations file which has a different column structure:

import pandas as pd  # noqa: E402

pd.read_csv(THORESEN / "thoresen_test.tsv", sep="\t", nrows=3)
event_id alignment_group_id start_time_sec duration_sec event_type graphical_element_id image_filename rect_coords_json text_content text_anchor_xy_json layer_order description
0 annot_cue_001 NaN 0.0 5.0 rectangle rect_a thoresen_2010_form-building-patterns_p90-91_pa... {"x": 10, "y": 90, "width": 148, "height": 55} NaN NaN NaN NaN
1 annot_cue_002 NaN 1.5 4.0 rectangle rect_b thoresen_2010_form-building-patterns_p90-91_pa... {"x": 40, "y": 37, "width": 127, "height": 21} NaN NaN NaN NaN
2 annot_cue_003 NaN 3.5 2.0 rectangle rect_c thoresen_2010_form-building-patterns_p90-91_pa... {"x": 111, "y": 60, "width": 57, "height": 23} NaN NaN NaN NaN

Strategy 1: Simplest Case (No Extra Columns)

The simplest custom loader just maps the core coordinate columns. No extra_columns means only the base event fields are loaded:

from timetoalign.core import NumberType, TimeUnit  # noqa: E402
from timetoalign.loader.tabular import TsvLoader  # noqa: E402


class ThoresenMinimalLoader(TsvLoader):
    """Minimal loader - only core event fields."""

    id_column = "event_id"
    start_column = "start_time_sec"
    duration_column = "duration_sec"
    event_type_column = "event_type"
    name_column = "description"

    _default_unit = TimeUnit.seconds
    coordinate_type = NumberType.float


minimal = ThoresenMinimalLoader()
minimal.load(THORESEN / "thoresen_test.tsv")
minimal.events.to_pandas()
id name temporal_type event_type start end duration
0 annot_cue_001 NaN interval rectangle 0.0 5.00 5.00
1 annot_cue_002 NaN interval rectangle 1.5 5.50 4.00
2 annot_cue_003 NaN interval rectangle 3.5 5.50 2.00
3 annot_cue_004 NaN interval rectangle 34.6 39.80 5.20
4 annot_cue_005 NaN interval rectangle 43.5 48.00 4.50
5 annot_cue_006 NaN interval rectangle 71.0 75.75 4.75
6 annot_cue_007 NaN interval rectangle 76.0 83.50 7.50
7 annot_cue_008 NaN interval rectangle 90.5 94.50 4.00
8 annot_cue_009 NaN interval rectangle 113.4 116.40 3.00
9 annot_cue_010 NaN interval rectangle 121.0 128.50 7.50
10 annot_cue_011 NaN interval rectangle 141.0 142.50 1.50

Strategy 2: Auto-Infer All Columns

Set extra_columns = True to automatically include all remaining columns with inferred types:

class ThoresenAutoLoader(TsvLoader):
    """Auto-infer all remaining columns."""

    id_column = "event_id"
    start_column = "start_time_sec"
    duration_column = "duration_sec"
    event_type_column = "event_type"
    name_column = "description"

    _default_unit = TimeUnit.seconds
    coordinate_type = NumberType.float

    # Include ALL remaining columns with inferred types
    extra_columns = True


auto = ThoresenAutoLoader()
auto.load(THORESEN / "thoresen_test.tsv")
auto.events.to_pandas()
id name temporal_type event_type start end duration text_anchor_xy_json graphical_element_id text_content layer_order alignment_group_id rect_coords_json image_filename
0 annot_cue_001 NaN interval rectangle 0.0 5.00 5.00 NaN rect_a NaN NaN NaN {"x": 10, "y": 90, "width": 148, "height": 55} thoresen_2010_form-building-patterns_p90-91_pa...
1 annot_cue_002 NaN interval rectangle 1.5 5.50 4.00 NaN rect_b NaN NaN NaN {"x": 40, "y": 37, "width": 127, "height": 21} thoresen_2010_form-building-patterns_p90-91_pa...
2 annot_cue_003 NaN interval rectangle 3.5 5.50 2.00 NaN rect_c NaN NaN NaN {"x": 111, "y": 60, "width": 57, "height": 23} thoresen_2010_form-building-patterns_p90-91_pa...
3 annot_cue_004 NaN interval rectangle 34.6 39.80 5.20 NaN rect_a2 NaN NaN NaN {"x": 145, "y": 90, "width": 160, "height": 58} thoresen_2010_form-building-patterns_p90-91_pa...
4 annot_cue_005 NaN interval rectangle 43.5 48.00 4.50 NaN rect_h2 NaN NaN NaN {"x": 385, "y": 46, "width": 139, "height": 20} thoresen_2010_form-building-patterns_p90-91_pa...
5 annot_cue_006 NaN interval rectangle 71.0 75.75 4.75 NaN rect_d3 NaN NaN NaN {"x": 310, "y": 93, "width": 154, "height": 18} thoresen_2010_form-building-patterns_p90-91_pa...
6 annot_cue_007 NaN interval rectangle 76.0 83.50 7.50 NaN rect_b3 NaN NaN NaN {"x": 456, "y": 69, "width": 229, "height": 18} thoresen_2010_form-building-patterns_p90-91_pa...
7 annot_cue_008 NaN interval rectangle 90.5 94.50 4.00 NaN rect_i4 NaN NaN NaN {"x": 14, "y": 115, "width": 127, "height": 31} thoresen_2010_form-building-patterns_p90-91_pa...
8 annot_cue_009 NaN interval rectangle 113.4 116.40 3.00 NaN rect_a4 NaN NaN NaN {"x": 663, "y": 82, "width": 97, "height": 23} thoresen_2010_form-building-patterns_p90-91_pa...
9 annot_cue_010 NaN interval rectangle 121.0 128.50 7.50 NaN rect_i5 NaN NaN NaN {"x": 19, "y": 119, "width": 251, "height": 29} thoresen_2010_form-building-patterns_p90-91_pa...
10 annot_cue_011 NaN interval rectangle 141.0 142.50 1.50 NaN rect_f5 NaN NaN NaN {"x": 595, "y": 45, "width": 64, "height": 21} thoresen_2010_form-building-patterns_p90-91_pa...

Strategy 3: Explicit Columns with Types

Use a dict to specify exactly which columns to include and their types:

class ThoresenTypedLoader(TsvLoader):
    """Explicit columns with types."""

    id_column = "event_id"
    start_column = "start_time_sec"
    duration_column = "duration_sec"
    event_type_column = "event_type"
    name_column = "description"

    _default_unit = TimeUnit.seconds
    coordinate_type = NumberType.float

    # Explicit columns with types
    extra_columns = {
        "image_filename": str,
        "graphical_element_id": int,
    }


typed = ThoresenTypedLoader()
typed.load(THORESEN / "thoresen_test.tsv")
typed.events.to_pandas()
id name temporal_type event_type start end duration image_filename graphical_element_id
0 annot_cue_001 NaN interval rectangle 0.0 5.00 5.00 thoresen_2010_form-building-patterns_p90-91_pa... rect_a
1 annot_cue_002 NaN interval rectangle 1.5 5.50 4.00 thoresen_2010_form-building-patterns_p90-91_pa... rect_b
2 annot_cue_003 NaN interval rectangle 3.5 5.50 2.00 thoresen_2010_form-building-patterns_p90-91_pa... rect_c
3 annot_cue_004 NaN interval rectangle 34.6 39.80 5.20 thoresen_2010_form-building-patterns_p90-91_pa... rect_a2
4 annot_cue_005 NaN interval rectangle 43.5 48.00 4.50 thoresen_2010_form-building-patterns_p90-91_pa... rect_h2
5 annot_cue_006 NaN interval rectangle 71.0 75.75 4.75 thoresen_2010_form-building-patterns_p90-91_pa... rect_d3
6 annot_cue_007 NaN interval rectangle 76.0 83.50 7.50 thoresen_2010_form-building-patterns_p90-91_pa... rect_b3
7 annot_cue_008 NaN interval rectangle 90.5 94.50 4.00 thoresen_2010_form-building-patterns_p90-91_pa... rect_i4
8 annot_cue_009 NaN interval rectangle 113.4 116.40 3.00 thoresen_2010_form-building-patterns_p90-91_pa... rect_a4
9 annot_cue_010 NaN interval rectangle 121.0 128.50 7.50 thoresen_2010_form-building-patterns_p90-91_pa... rect_i5
10 annot_cue_011 NaN interval rectangle 141.0 142.50 1.50 thoresen_2010_form-building-patterns_p90-91_pa... rect_f5

Nested JSON Column Access with Field

The Thoresen data has a rect_coords_json column containing pixel coordinates as JSON:

{"x": 10, "y": 90, "width": 148, "height": 55}

Use Field("column", "nested_field") to access nested fields directly. TimeToAlign! automatically parses JSON when needed:

from timetoalign.loader import ComputedField, Field  # noqa: E402


class ThoresenGraphicalLoader(TsvLoader):
    """Loader using PIXEL coordinates from nested JSON.

    Field automatically parses JSON columns when accessing nested fields.
    """

    # Use nested fields directly - JSON is parsed automatically
    start_column = Field("rect_coords_json", "x")
    end_column = ComputedField(
        "end", formula="rect_coords_json.x + rect_coords_json.width"
    )

    _default_unit = TimeUnit.pixels
    coordinate_type = NumberType.float
    default_event_type = "Rectangle"


graphical = ThoresenGraphicalLoader()
graphical.load(THORESEN / "thoresen_test.tsv")

{
    "unit": str(graphical.unit),
    "coordinate_range": graphical.events.coordinate_range(),
}
{'unit': 'pixels', 'coordinate_range': (10.0, 760.0)}

Two Coordinate Systems from One File

The same TSV file can create timelines in different coordinate systems:

# Physical timeline (seconds)
typed.events.to_pandas()
id name temporal_type event_type start end duration image_filename graphical_element_id
0 annot_cue_001 NaN interval rectangle 0.0 5.00 5.00 thoresen_2010_form-building-patterns_p90-91_pa... rect_a
1 annot_cue_002 NaN interval rectangle 1.5 5.50 4.00 thoresen_2010_form-building-patterns_p90-91_pa... rect_b
2 annot_cue_003 NaN interval rectangle 3.5 5.50 2.00 thoresen_2010_form-building-patterns_p90-91_pa... rect_c
3 annot_cue_004 NaN interval rectangle 34.6 39.80 5.20 thoresen_2010_form-building-patterns_p90-91_pa... rect_a2
4 annot_cue_005 NaN interval rectangle 43.5 48.00 4.50 thoresen_2010_form-building-patterns_p90-91_pa... rect_h2
5 annot_cue_006 NaN interval rectangle 71.0 75.75 4.75 thoresen_2010_form-building-patterns_p90-91_pa... rect_d3
6 annot_cue_007 NaN interval rectangle 76.0 83.50 7.50 thoresen_2010_form-building-patterns_p90-91_pa... rect_b3
7 annot_cue_008 NaN interval rectangle 90.5 94.50 4.00 thoresen_2010_form-building-patterns_p90-91_pa... rect_i4
8 annot_cue_009 NaN interval rectangle 113.4 116.40 3.00 thoresen_2010_form-building-patterns_p90-91_pa... rect_a4
9 annot_cue_010 NaN interval rectangle 121.0 128.50 7.50 thoresen_2010_form-building-patterns_p90-91_pa... rect_i5
10 annot_cue_011 NaN interval rectangle 141.0 142.50 1.50 thoresen_2010_form-building-patterns_p90-91_pa... rect_f5
# Graphical timeline (pixels)
graphical.events.to_pandas()
id name temporal_type event_type start end duration
0 e000000 NaN interval rectangle 10.0 158.0 148.0
1 e000001 NaN interval rectangle 40.0 167.0 127.0
2 e000002 NaN interval rectangle 111.0 168.0 57.0
3 e000003 NaN interval rectangle 145.0 305.0 160.0
4 e000004 NaN interval rectangle 385.0 524.0 139.0
5 e000005 NaN interval rectangle 310.0 464.0 154.0
6 e000006 NaN interval rectangle 456.0 685.0 229.0
7 e000007 NaN interval rectangle 14.0 141.0 127.0
8 e000008 NaN interval rectangle 663.0 760.0 97.0
9 e000009 NaN interval rectangle 19.0 270.0 251.0
10 e000010 NaN interval rectangle 595.0 659.0 64.0

Creating Timelines

Use create_timeline() to convert loaded events into a Timeline object. The diagram() method shows an ASCII visualization:

# Create Physical Timeline (seconds)
physical_tl = typed.create_timeline(uid="thoresen_physical")
physical_tl
ContinuousPhysicalTimeline[thoresen_physical] (11 events)
                      0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 142.5 seconds
physical_tl.get_timestamp_table()
pyarrow.Table
axis: double
thoresen_physical: double
----
axis: [[0,1.5,3.5,5,5.5,...,116.4,121,128.5,141,142.5]]
thoresen_physical: [[0,1.5,3.5,5,5.5,...,116.4,121,128.5,141,142.5]]
# Create Graphical Timeline (pixels)
graphical_tl = graphical.create_timeline(uid="thoresen_graphical")
graphical_tl
DiscreteGraphicalTimeline[thoresen_graphical] (11 events)
                      0 ∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶∶ 760 pixels

Note: Both timelines represent the same 11 events, but in different coordinate systems: - Physical: 0 - 142.5 seconds (audio time) - Graphical: 10 - 760 pixels (image coordinates)

TimeToAlign! uses these dual representations to align graphical annotations with audio.


Advanced Features

The following sections cover advanced features for complex data loading scenarios.

CoordinateField: Loading Multiple Coordinate Columns

Sometimes your data contains coordinates in multiple systems (e.g., seconds AND pixels). Use CoordinateField to parse any column as a proper coordinate struct with unit metadata, enabling:

  • Multiple coordinate columns in one EventData
  • C-Map creation from loaded coordinate pairs
  • Full precision preservation with Fraction number type
  • Proper unit tracking per column (not just the primary unit)

The Thoresen data has both time coordinates (seconds) and pixel coordinates (in JSON):

from timetoalign.core import NumberType, TimeUnit  # noqa: E402, F811
from timetoalign.loader import CoordinateField, Field  # noqa: E402, F811
from timetoalign.loader.tabular import TsvLoader  # noqa: E402, F811


class MultiCoordinateLoader(TsvLoader):
    """Loader that extracts multiple coordinate columns.
    \n    Primary coordinates in seconds, with additional x_pixels column.
    This enables creating C-Maps between coordinate systems.
    """

    # Primary coordinates: seconds
    id_column = "event_id"
    start_column = "start_time_sec"
    duration_column = "duration_sec"
    event_type_column = "event_type"

    _default_unit = TimeUnit.seconds
    coordinate_type = NumberType.float

    # Extra columns - mix of regular and coordinate columns
    extra_columns = [
        "image_filename",  # Regular string column
        # CoordinateField extracts x as a proper coordinate struct
        CoordinateField(
            "x_pixels",
            source=Field("rect_coords_json", "x"),  # Nested JSON access
            unit=TimeUnit.pixels,
        ),
    ]


multi = MultiCoordinateLoader()
multi.load(THORESEN / "thoresen_test.tsv")

# The x_pixels column is now a proper coordinate with unit metadata
multi.events.to_pandas()[["id", "start", "end", "x_pixels", "image_filename"]]
id start end x_pixels image_filename
0 annot_cue_001 0.0 5.00 10.0 thoresen_2010_form-building-patterns_p90-91_pa...
1 annot_cue_002 1.5 5.50 40.0 thoresen_2010_form-building-patterns_p90-91_pa...
2 annot_cue_003 3.5 5.50 111.0 thoresen_2010_form-building-patterns_p90-91_pa...
3 annot_cue_004 34.6 39.80 145.0 thoresen_2010_form-building-patterns_p90-91_pa...
4 annot_cue_005 43.5 48.00 385.0 thoresen_2010_form-building-patterns_p90-91_pa...
5 annot_cue_006 71.0 75.75 310.0 thoresen_2010_form-building-patterns_p90-91_pa...
6 annot_cue_007 76.0 83.50 456.0 thoresen_2010_form-building-patterns_p90-91_pa...
7 annot_cue_008 90.5 94.50 14.0 thoresen_2010_form-building-patterns_p90-91_pa...
8 annot_cue_009 113.4 116.40 663.0 thoresen_2010_form-building-patterns_p90-91_pa...
9 annot_cue_010 121.0 128.50 19.0 thoresen_2010_form-building-patterns_p90-91_pa...
10 annot_cue_011 141.0 142.50 595.0 thoresen_2010_form-building-patterns_p90-91_pa...
multi.events.table.schema
id: string not null
name: string
temporal_type: string not null
event_type: string not null
start: struct<value: double, numerator: int64, denominator: int64>
  child 0, value: double
  child 1, numerator: int64
  child 2, denominator: int64
  -- field metadata --
  unit: 'seconds'
end: struct<value: double, numerator: int64, denominator: int64>
  child 0, value: double
  child 1, numerator: int64
  child 2, denominator: int64
  -- field metadata --
  unit: 'seconds'
duration: struct<value: double, numerator: int64, denominator: int64>
  child 0, value: double
  child 1, numerator: int64
  child 2, denominator: int64
  -- field metadata --
  unit: 'seconds'
x_pixels: struct<value: double, numerator: int64, denominator: int64>
  child 0, value: double
  child 1, numerator: int64
  child 2, denominator: int64
  -- field metadata --
  unit: 'pixels'
  number_type: 'float'
image_filename: string
-- schema metadata --
timetoalign: '{"loader_class": "EventData", "number_type": "float", "sour' + 60

create_cmap(): Building Conversion Maps

With dual coordinates loaded, you can create Conversion Maps (C-Maps) to convert between coordinate systems. The loader’s create_cmap() method supports:

  • TableMap (default): Point-to-point mapping with interpolation
  • LinearMap: Fits a linear function y = ax + b
  • ScalarMap: Fits a pure scaling y = ax
from timetoalign.maps import LinearMap  # noqa: E402

# Create a TableMap from start (seconds) -> x_pixels
table_cmap = multi.create_cmap("start", "x_pixels")

# Create a LinearMap (fits y = ax + b)
linear_cmap = multi.create_cmap("start", "x_pixels", map_type=LinearMap)

# Compare the two map types
{
    "TableMap": str(table_cmap),
    "LinearMap": str(linear_cmap),
    "5.0 seconds (TableMap)": f"{table_cmap(5.0):.1f} pixels",
    "5.0 seconds (LinearMap)": f"{linear_cmap(5.0):.1f} pixels",
}
{'TableMap': 'TableMap(seconds -> pixels)',
 'LinearMap': 'LinearMap(seconds -> pixels)',
 '5.0 seconds (TableMap)': '112.6 pixels',
 '5.0 seconds (LinearMap)': '94.9 pixels'}

group_by: Creating Child Timelines from Column Values

When your data contains events from multiple sources (e.g., multiple images, pages, or tracks), use group_by to automatically create child timelines for each unique value.

The Thoresen data has events from 5 different image files:

# Using the earlier 'auto' loader which has image_filename
from timetoalign.timelines import create_timeline  # noqa: E402

# Create timeline grouped by image filename
grouped_tl = create_timeline(auto, group_by="image_filename")
grouped_tl
ContinuousPhysicalTimeline[tl:1] (11 events, 5 children)
                      0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 142.5 seconds
  ├─ thoresen_...     0 ~                                5.5 (3 events)
  ├─ thoresen_...     0 ~~~~~~~~~~                       48 (2 events)
  ├─ thoresen_...     0 ~~~~~~~~~~~~~~~~~~               83.5 (2 events)
  ├─ thoresen_...     0 ~~~~~~~~~~~~~~~~~~~~~~~~~~       116.4 (2 events)
  └─ thoresen_...     0 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 142.5 (2 events)
# Each child timeline represents events from one image
{
    "parent_id": grouped_tl.id,
    "n_children": grouped_tl.n_children,
    "children": {
        child.id: len(child._events) if child._events else 0
        for _, child in grouped_tl.iter_children()
    },
}
{'parent_id': 'tl:1',
 'n_children': 5,
 'children': {'thoresen_2010_form-building-patterns_p90-91_page1_1.jpeg': 3,
  'thoresen_2010_form-building-patterns_p90-91_page1_2.jpeg': 2,
  'thoresen_2010_form-building-patterns_p90-91_page1_3.jpeg': 2,
  'thoresen_2010_form-building-patterns_p90-91_page1_4.jpeg': 2,
  'thoresen_2010_form-building-patterns_p90-91_page2_1.jpeg': 2}}

Summary

Extra Columns Strategies

Strategy Syntax Use Case
None extra_columns not set Only core event fields
Auto-infer extra_columns = True Include all columns, infer types
Explicit dict extra_columns = {"col": type} Specific columns with types
With CoordinateField extra_columns = [CoordinateField(...)] C-Maps

Key API

# Load
loader = Ms3Loader()
loader.load("file.tsv")

# Access
df = loader.events.to_pandas()
timeline = loader.create_timeline()

# Custom loader with explicit columns
class MyLoader(TsvLoader):
    start_column = "onset"
    duration_column = "dur"
    extra_columns = {"pitch": int, "velocity": int}

# Nested JSON field access (auto-parses JSON)
from timetoalign.loader import Field, ComputedField

class GraphicalLoader(TsvLoader):
    start_column = Field("rect_json", "x")  # JSON parsed automatically
    end_column = ComputedField("end", formula="rect_json.x + rect_json.width")

# Multiple coordinate columns with CoordinateField
from timetoalign.loader import CoordinateField

class MyCoordinateLoader(TsvLoader):
    start_column = "time_sec"
    extra_columns = [
        CoordinateField("x_pixels", source="x_px", unit=TimeUnit.pixels),
    ]

# Create C-Maps from loaded coordinates
cmap = loader.create_cmap("start", "x_pixels")  # TableMap (default)
cmap = loader.create_cmap("start", "x_pixels", map_type=LinearMap)

# Group events by column value into child timelines
timeline = create_timeline(loader, group_by="image_filename")

Key Takeaway: Tabular loaders provide a declarative way to map CSV/TSV columns to TimeToAlign! events. Use Field for nested JSON access, CoordinateField for additional coordinate columns with unit tracking, and group_by for multi-source timelines.