Markers

Initialize NeuroAnalyzer
using NeuroAnalyzer
using DataFrames

Markers (events, triggers, annotations) mark moments or periods of the recording: stimuli, responses, condition changes, artifacts or notes of the technician. They are used to create event-related epochs (e.g. for ERPs), to select or reject segments of the signal and to document the recording.

Marker Table

Markers are stored in obj.markers, a DataFrame with one row per marker:

Column Type Contents
:id String marker identifier, e.g. "Stimulus"
:start Float64 start of the marker in seconds from the start of the recording
:length Float64 duration in seconds (0 or one sample for instantaneous events)
:value String marker value or description, e.g. "s71" or "eyes open"
:channel Int64 channel the marker refers to; 0 = all channels

A face perception experiment recorded in the BrainVision format:

faces = import_recording("files/eeg/s1_faces.vhdr")
first(faces.markers, 6)
6×5 DataFrame
Row id start length value channel
String Float64 Float64 String Int64
1 New Segment 0.0 0.00195312 marker 0
2 Marker 0.0 0.00195312 Impedance 0
3 Stimulus 20.5605 0.00195312 s111 0
4 Stimulus 20.9785 0.00195312 s130 0
5 Stimulus 21.8242 0.00195312 s71 0
6 Stimulus 25.0098 0.00195312 s130 0

Number of markers of each value:

combine(groupby(faces.markers, :value), nrow => :n)
9×2 DataFrame
Row value n
String Int64
1 marker 1
2 Impedance 1
3 s111 1
4 s130 200
5 s71 100
6 s70 100
7 s121 1
8 s122 1
9 s102 1

Most formats store events in the file and they are imported automatically, e.g. EDF+ annotations, BrainVision .vmrk files, EEGLAB events and events of MEGIN (FIFF) recordings. EDF+ annotations are often free-text notes of the technician:

eeg = import_edf("files/eeg/eeg_edfplus.edf")
first(eeg.markers, 8)
8×5 DataFrame
Row id start length value channel
String Float64 Float64 String Int64
1 1 0.0 0.0 REC START BioCal CAL 0
2 2 1.14 0.0 A1+A2 OFF 0
3 3 4.22 0.0 IMP CHECK ON 0
4 4 8.24 0.0 IMP CHECK OFF 0
5 5 10.24 0.0 PAT BioCal EEG 0
6 6 17.07 0.0 PAT 1-Banana EEG 0
7 7 21.64 0.0 Head Straight 0
8 8 23.38 0.0 Awake 0

view_marker() prints the marker table, and markers are drawn by default in signal plots (vertical lines labelled with the marker ID and value); the signal is high-pass filtered here to remove the DC offsets of the raw recording:

faces_hp = NeuroAnalyzer.filter(faces;
                                ch = "eeg",
                                fprototype = :butterworth,
                                ftype = :hp,
                                cutoff = 0.5,
                                order = 4)
NeuroAnalyzer.plot(faces_hp; ch = "eeg", seg = (20, 30), gui = false)

Editing Markers

Markers are added, edited and deleted with add_marker!(), edit_marker!() and delete_marker!() (or their non-mutating versions). Markers are referred to by their row number:

e = crop(eeg; seg = (60, 120))
e.markers = e.markers[1:0, :]
add_marker!(e; id = "event", start = 10, len = 0.5, value = "eyes open")
add_marker!(e; id = "event", start = 40, len = 0.5, value = "eyes closed")
edit_marker!(e; n = 2, id = "event", start = 45, len = 0.5,
             value = "eyes closed")
view_marker(e)
n    ID                      start [s]   length [s]  value                   channel
1    'event'                 10.0        0.5         'eyes open'             0
2    'event'                 45.0        0.5         'eyes closed'           0
delete_marker!(e; n = 1)
println("markers: ", nrow(e.markers))
markers: 1

add_markers() replaces the whole marker table with a DataFrame of the same columns:

m = DataFrame(:id => ["stim", "stim"],
              :start => [5.0, 15.0],
              :length => [0.0, 0.0],
              :value => ["A", "B"],
              :channel => [0, 0])
add_markers!(e; markers = m)
e.markers
2×5 DataFrame
Row id start length value channel
String Float64 Float64 String Int64
1 stim 5.0 0.0 A 0
2 stim 15.0 0.0 B 0

Markers stored in a separate EDF+ annotations file are imported with import_edf_annotations(), which returns such a table. A marker table with times in samples (e.g. from another program) is converted to seconds with markers_s2t():

m_samples = DataFrame(:id => ["stim"],
                      :start => [2561],
                      :length => [256],
                      :value => ["A"],
                      :channel => [0])
markers_s2t(m_samples; fs = 256)
1×5 DataFrame
Row id start length value channel
String Float64 Float64 String Int64
1 stim 10.0 1.0 A 0

Markers are exported to a CSV file with export_markers().

Markers from an Event Channel

Some systems record events as pulses (trigger codes) in a dedicated channel, e.g. the stimulus channels (STI …) of MEGIN MEG systems. A trigger channel of a MEG recording contains several event codes:

meg = import_fiff("files/meg/test_raw.fif")
sti = extract_channel(meg; ch = "STI 014")
println("event codes: ", Int.(sort(unique(sti))))
event codes: [0, 1, 2, 3, 4, 5, 32]

channel2marker() converts the pulses of one code (v) into markers; the marker start is the first sample of the pulse and its length the duration of the pulse:

m = channel2marker(meg; ch = "STI 014", v = 1, value = "STI 014: 1")
println("events with code 1 in STI 014:       ",
        sum(m.markers.value .== "STI 014: 1"))
println("imported markers with value 1 (FIFF): ",
        sum(meg.markers.value .== "1"))
events with code 1 in STI 014:       7
imported markers with value 1 (FIFF): 7

For FIFF recordings the events are also imported automatically from the event table of the file; both give the same events.

Using Markers

Event-related epochs

epoch() creates epochs time-locked to the markers of a given value: offset is the time before the marker and ep_len the epoch length (seconds):

ep71 = epoch(faces; marker = "s71", offset = 0.2, ep_len = 1.0)
println("epochs: ", nepochs(ep71), ", epoch time: ", ep71.epoch_time[1],
        " … ", round(ep71.epoch_time[end]; digits = 3), " s")
epochs: 100, epoch time: -0.2 … 0.798 s

Markers of the epochs whose window would extend beyond the signal are skipped. All markers that fall inside the epochs (of any value) are kept and moved to the timeline of the epoched object. Averaging these epochs gives the event-related potential; see ERP.

Selecting segments

Markers can be used to select parts of the recording, e.g. the signal between two markers:

t1 = faces.markers[findfirst(==("s111"), faces.markers.value), :start]
t2 = faces.markers[findlast(==("s70"), faces.markers.value), :start]
task = crop(faces; seg = (t1, t2))
println("task: ", round(t1; digits = 2), " … ", round(t2; digits = 2),
        " s, duration ", round(signal_duration(task); digits = 2), " s")
task: 20.56 … 792.07 s, duration 771.51 s

Marker Timing

Marker times are stored in seconds, not in samples. Their precision is therefore not tied to the sampling rate, and they keep their values when the sampling rate changes. Whenever an operation must place a marker at a sample, the time is rounded to the nearest sample (never rounded down):

\[ \text{sample} = \text{round}(t \cdot f_s) + 1 \]

(sample 1 is at \(t = 0\)). Rounding to the nearest sample, rather than truncating, also prevents floating-point errors (e.g. \(0.3 \times 10 = 3.0000000000000004\)) from shifting a marker by one sample.

t2s() and s2t() convert between time and sample number, t2n() and n2t() between a duration and a number of samples:

fs = sr(faces)
t = faces.markers[3, :start]
s = t2s(t, fs)
println("marker at ", t, " s → sample ", s, " → ", s2t(s, fs), " s")
println("1 s = ", t2n(1, fs), " samples, 512 samples = ", n2t(512, fs), " s")
marker at 20.560546875 s → sample 10528 → 20.560546875 s
1 s = 512 samples, 512 samples = 1.0 s

What happens to the markers in different operations

Operation Markers
import times converted to seconds as stored in the file
resample(), upsample(), downsample() unchanged (times in seconds); a marker may fall between two samples of the new sampling rate
epoch() (by markers), trim(), crop() moved: converted to the nearest sample, shifted and converted back to seconds (snapped to the sample grid)
filtering, referencing and other processing unchanged

Resampling a 512 Hz recording to 128 Hz, then cropping it:

faces128 = resample(faces; new_sr = 128)
c = crop(faces128; seg = (10, 60))
idx = findfirst(==("s111"), faces.markers.value)
t_orig = faces.markers[idx, :start]
t_rs = faces128.markers[idx, :start]
t_crop = c.markers[findfirst(==("s111"), c.markers.value), :start]
println("original (512 Hz):   ", t_orig, " s")
println("resampled (128 Hz):  ", t_rs, " s = sample ",
        t_rs * 128 + 1, " (between samples)")
println("cropped from 10 s:   ", t_crop, " s (exact: ", t_orig - 10, " s)")
println("difference:          ",
        round(1000 * (t_crop - (t_orig - 10)); digits = 2), " ms")
original (512 Hz):   20.560546875 s
resampled (128 Hz):  20.560546875 s = sample 2632.75 (between samples)
cropped from 10 s:   10.5625 s (exact: 10.560546875 s)
difference:          1.95 ms

Resampling keeps the marker time; cropping places it at the nearest sample of the 128 Hz signal.

A marker therefore loses at most half a sampling interval of precision, and only when it is moved:

Sampling rate Sampling interval Maximum marker shift
128 Hz 7.8 ms 3.9 ms
250 Hz 4 ms 2 ms
512 Hz 1.95 ms 0.98 ms
1000 Hz 1 ms 0.5 ms

Tip: Resampling to a lower sampling rate before epoching or trimming coarsens the timing of events to the new sampling interval. When the timing of events matters (e.g. ERP latencies), epoch the signal at the original sampling rate, or keep a sampling rate high enough for the required precision.