Initialize NeuroAnalyzer
using NeuroAnalyzer
using DataFramesusing NeuroAnalyzer
using DataFramesMarkers (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.
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)| 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)| 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)| 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)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| 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)| 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().
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.
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 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.