Skip to content

Commit 181fea1

Browse files
myd7349larsoner
andauthored
FIX: Read Nihon Kohden annotation file accurately (mne-tools#13251)
Co-authored-by: Eric Larson <[email protected]>
1 parent 52234fe commit 181fea1

File tree

6 files changed

+68
-23
lines changed

6 files changed

+68
-23
lines changed

.github/workflows/tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ jobs:
5959
PYTHON_VERSION: '${{ matrix.python }}'
6060
MKL_NUM_THREADS: '1'
6161
OPENBLAS_NUM_THREADS: '1'
62+
OMP_NUM_THREADS: '1'
6263
PYTHONUNBUFFERED: '1'
6364
MNE_CI_KIND: '${{ matrix.kind }}'
6465
CI_OS_NAME: '${{ matrix.os }}'

azure-pipelines.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ stages:
8888
variables:
8989
DISPLAY: ':99'
9090
OPENBLAS_NUM_THREADS: '1'
91+
OMP_NUM_THREADS: '1'
9192
MNE_TEST_ALLOW_SKIP: '^.*(PySide6 causes segfaults).*$'
9293
MNE_BROWSER_PRECOMPUTE: 'false'
9394
steps:

doc/changes/dev/13251.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Read Nihon Kohden annotation file accurately (using sublog parsing), by `Tom Ma`_.

mne/datasets/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
# update the checksum in the MNE_DATASETS dict below, and change version
8888
# here: ↓↓↓↓↓↓↓↓
8989
RELEASES = dict(
90-
testing="0.161",
90+
testing="0.166",
9191
misc="0.27",
9292
phantom_kit="0.2",
9393
ucl_opm_auditory="0.2",
@@ -115,7 +115,7 @@
115115
# Testing and misc are at the top as they're updated most often
116116
MNE_DATASETS["testing"] = dict(
117117
archive_name=f"{TESTING_VERSIONED}.tar.gz",
118-
hash="md5:a32cfb9e098dec39a5f3ed6c0833580d",
118+
hash="md5:273c5919cf74198a39146e9cbc146ce0",
119119
url=(
120120
"https://codeload.github.com/mne-tools/mne-testing-data/"
121121
f"tar.gz/{RELEASES['testing']}"

mne/io/nihon/nihon.py

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,45 @@ def _read_nihon_header(fname):
276276
return header
277277

278278

279+
def _read_event_log_block(fid, t_block, version):
280+
fid.seek(0x92 + t_block * 20)
281+
data = np.fromfile(fid, np.uint32, 1)
282+
if data.size == 0 or data[0] == 0:
283+
return
284+
t_blk_address = data[0]
285+
286+
fid.seek(t_blk_address + 0x1)
287+
data = np.fromfile(fid, "|S16", 1).astype("U16")
288+
if data.size == 0 or data[0] != version:
289+
return
290+
291+
fid.seek(t_blk_address + 0x12)
292+
data = np.fromfile(fid, np.uint8, 1)
293+
if data.size == 0:
294+
return
295+
n_logs = data[0]
296+
297+
fid.seek(t_blk_address + 0x14)
298+
return np.fromfile(fid, "|S45", n_logs)
299+
300+
301+
def _parse_event_log(event_log):
302+
t_desc = event_log[:20]
303+
hour, minute, second = (
304+
int(event_log[20:22]),
305+
int(event_log[22:24]),
306+
int(event_log[24:26]),
307+
)
308+
t_onset = hour * 3600 + minute * 60 + second
309+
return t_desc, t_onset
310+
311+
312+
def _parse_sub_event_log(sub_event_log):
313+
t_sub_desc = sub_event_log[:20]
314+
t_sub_onset = int(sub_event_log[24:30]) / 1e6
315+
return t_sub_desc, t_sub_onset
316+
317+
279318
def _read_nihon_annotations(fname):
280319
fname = _ensure_path(fname)
281320
log_fname = fname.with_suffix(".LOG")
@@ -292,27 +331,32 @@ def _read_nihon_annotations(fname):
292331
n_logblocks = np.fromfile(fid, np.uint8, 1)[0]
293332
all_onsets = []
294333
all_descriptions = []
334+
may_have_sub_blocks = n_logblocks <= 21
295335
for t_block in range(n_logblocks):
296-
fid.seek(0x92 + t_block * 20)
297-
t_blk_address = np.fromfile(fid, np.uint32, 1)[0]
298-
fid.seek(t_blk_address + 0x12)
299-
n_logs = np.fromfile(fid, np.uint8, 1)[0]
300-
fid.seek(t_blk_address + 0x14)
301-
t_logs = np.fromfile(fid, "|S45", n_logs)
302-
for t_log in t_logs:
336+
t_logs = _read_event_log_block(fid, t_block, version)
337+
t_sub_logs = None
338+
if may_have_sub_blocks:
339+
t_sub_logs = _read_event_log_block(fid, t_block + 22, version)
340+
341+
for li, t_log in enumerate(t_logs):
342+
t_desc, t_onset = _parse_event_log(t_log)
343+
if t_sub_logs is not None and t_sub_logs.size == t_logs.size:
344+
t_sub_desc, t_sub_onset = _parse_sub_event_log(t_sub_logs[li])
345+
t_desc += t_sub_desc
346+
t_onset += t_sub_onset
347+
348+
t_desc = t_desc.rstrip(b"\x00")
303349
for enc in _encodings:
304350
try:
305-
t_log = t_log.decode(enc)
351+
t_desc = t_desc.decode(enc)
306352
except UnicodeDecodeError:
307353
pass
308354
else:
309355
break
310356
else:
311357
warn(f"Could not decode log as one of {_encodings}")
312358
continue
313-
t_desc = t_log[:20].strip("\x00")
314-
t_onset = datetime.strptime(t_log[20:26], "%H%M%S")
315-
t_onset = t_onset.hour * 3600 + t_onset.minute * 60 + t_onset.second
359+
316360
all_onsets.append(t_onset)
317361
all_descriptions.append(t_desc)
318362

mne/io/nihon/tests/test_nihon.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# Copyright the MNE-Python contributors.
44

55
import pytest
6-
from numpy.testing import assert_array_almost_equal
6+
from numpy.testing import assert_allclose
77

88
from mne.datasets import testing
99
from mne.io import read_raw_edf, read_raw_nihon
@@ -27,24 +27,22 @@ def test_nihon_eeg():
2727
_test_raw_reader(read_raw_nihon, fname=fname, test_scaling=False)
2828
fname_edf = data_path / "NihonKohden" / "MB0400FU.EDF"
2929
raw_edf = read_raw_edf(fname_edf, preload=True)
30+
raw_edf.drop_channels(["Events/Markers"])
3031

3132
assert raw._data.shape == raw_edf._data.shape
3233
assert raw.info["sfreq"] == raw.info["sfreq"]
33-
# ch names and order are switched in the EDF
34-
edf_ch_names = {x: x.split(" ")[1].replace("-Ref", "") for x in raw_edf.ch_names}
34+
# a couple of ch names differ in the EDF
35+
edf_ch_names = {"EEG Mark1": "$A2", "EEG Mark2": "$A1"}
3536
raw_edf.rename_channels(edf_ch_names)
3637
assert raw.ch_names == raw_edf.ch_names
3738

38-
for i, an1 in enumerate(raw.annotations):
39-
# EDF has some weird annotations, which are not in the LOG file
40-
an2 = raw_edf.annotations[i * 2 + 1]
39+
assert len(raw.annotations) == len(raw_edf.annotations)
40+
for an1, an2 in zip(raw.annotations, raw_edf.annotations):
4141
assert an1["onset"] == an2["onset"]
4242
assert an1["duration"] == an2["duration"]
43-
# Also, it prepends 'Segment: ' to some annotations
44-
t_desc = an2["description"].replace("Segment: ", "")
45-
assert an1["description"] == t_desc
43+
assert an1["description"] == an2["description"].rstrip()
4644

47-
assert_array_almost_equal(raw._data, raw_edf._data)
45+
assert_allclose(raw.get_data(), raw_edf.get_data())
4846

4947
with pytest.raises(ValueError, match="Not a valid Nihon Kohden EEG file"):
5048
raw = read_raw_nihon(fname_edf, preload=True)

0 commit comments

Comments
 (0)