__init__.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. #
  2. # MIT License
  3. #
  4. # Copyright (c) 2020 Keisuke Sehara
  5. #
  6. # Permission is hereby granted, free of charge, to any person obtaining a copy
  7. # of this software and associated documentation files (the "Software"), to deal
  8. # in the Software without restriction, including without limitation the rights
  9. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  10. # copies of the Software, and to permit persons to whom the Software is
  11. # furnished to do so, subject to the following conditions:
  12. #
  13. # The above copyright notice and this permission notice shall be included in all
  14. # copies or substantial portions of the Software.
  15. #
  16. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  17. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  20. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  21. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  22. # SOFTWARE.
  23. #
  24. from collections import namedtuple as _namedtuple
  25. from pathlib import Path as _Path
  26. import re as _re
  27. import numpy as _np
  28. import pandas as _pd
  29. DEBUG = False
  30. def debug(msg, end="\n"):
  31. if DEBUG == True:
  32. print(msg, flush=True)
  33. class Specification(_namedtuple("_Specification",
  34. ("dataset", "subject", "session", "domain", "trial", "suffix"))):
  35. DOMAIN_TO_SEARCH = "states"
  36. TRIAL_CAPTURE = _re.compile(r"_run(\d+)")
  37. def __new__(cls, dataset=None, subject=None, session=None, domain=None, trial=None, suffix=None):
  38. if dataset is not None:
  39. dataset = _Path(dataset).resolve()
  40. if subject is not None:
  41. subject = str(subject)
  42. if session is not None:
  43. session = str(session)
  44. if trial is not None:
  45. trial = int(trial)
  46. if domain is not None:
  47. domain = str(domain)
  48. if suffix is not None:
  49. suffix = str(suffix)
  50. if not suffix.startswith("."):
  51. suffix = "." + suffix
  52. return super(cls, Specification).__new__(cls, dataset, subject, session, domain, trial, suffix)
  53. def with_values(self, **kwargs):
  54. out = {}
  55. for field, defaultvalue in zip(self._fields, self):
  56. out[field] = kwargs.get(field, defaultvalue)
  57. return self.__class__(**out)
  58. @property
  59. def dataset_directory(self):
  60. if self.dataset is None:
  61. raise ValueError("dataset is not specified")
  62. elif not self.dataset.exists():
  63. raise FileNotFoundError(f"dataset directory not found: {self.dataset}")
  64. return self.dataset
  65. @property
  66. def subject_directory(self):
  67. dsdir = self.dataset_directory
  68. if self.subject is None:
  69. raise ValueError("subject is not specified")
  70. subdir = dsdir / self.subject
  71. if not subdir.exists():
  72. raise FileNotFoundError(f"subject directory not found: {subdir}")
  73. return subdir
  74. @property
  75. def session_directory(self):
  76. subdir = self.subject_directory
  77. if self.session is None:
  78. raise ValueError("session is not specified")
  79. sessdir = subdir / self.session
  80. if not sessdir.exists():
  81. raise FileNotFoundError(f"session directory not found: {sessdir}")
  82. return sessdir
  83. @property
  84. def domain_directory(self):
  85. sessdir = self.session_directory
  86. if self.domain is None:
  87. raise ValueError("domain is not specified")
  88. domdir = sessdir / self.domain
  89. if not domdir.exists():
  90. raise FileNotFoundError(f"domain directory not found: {domdir}")
  91. return domdir
  92. @property
  93. def filepath(self):
  94. domdir = self.domain_directory
  95. if self.trial is None:
  96. raise ValueError("trial index is not specified")
  97. ret = domdir / f"{self.subject}_{self.session}_{self.domain}_run{self.trial:05d}"
  98. if self.suffix is not None:
  99. ret = ret.with_suffix(self.suffix)
  100. if not ret.exists():
  101. raise FileNotFoundError(f"file not found: {ret.name}")
  102. return ret
  103. @property
  104. def subjects(self):
  105. if self.subject is not None:
  106. debug(f"found subject: {self.subject}")
  107. return (self,)
  108. else:
  109. ret = []
  110. for sub in sorted(self.dataset_directory.glob("MLA*")):
  111. debug(f"found subject: {sub.name}")
  112. ret.append(self.with_values(subject=sub.name))
  113. return tuple(ret)
  114. @property
  115. def sessions(self):
  116. ret = []
  117. for sub in self.subjects:
  118. for path in sorted(sub.subject_directory.glob("session*")):
  119. if (self.session is None) or (path.name == self.session):
  120. debug(f"found session: {sub.subject}/{path.name}")
  121. ret.append(sub.with_values(session=path.name))
  122. return tuple(ret)
  123. @property
  124. def trials(self):
  125. ret = []
  126. for session in self.sessions:
  127. if self.domain is None:
  128. domdir = session.with_values(domain=self.DOMAIN_TO_SEARCH).domain_directory
  129. else:
  130. domdir = session.domain_directory
  131. for path in sorted(domdir.glob("*run*")):
  132. captured = self.TRIAL_CAPTURE.search(path.stem)
  133. if not captured:
  134. continue
  135. try:
  136. idx = int(captured.group(1))
  137. if (self.trial is None) or (idx == self.trial):
  138. debug(f"found trial: {session.subject}/{session.session}/trial{idx:05d}")
  139. ret.append(session.with_values(trial=idx))
  140. except ValueError:
  141. pass
  142. return tuple(ret)
  143. def load_trials(dataset, subject=None, session=None, trial=None):
  144. basespecs = Specification(dataset=dataset,
  145. subject=subject,
  146. session=session,
  147. trial=trial)
  148. return tuple(Trial.load(trial) for trial in basespecs.trials)
  149. class Trial:
  150. """
  151. [usage]
  152. ```
  153. trial = Trial.load("../datasets/merged-output", <subject>, <session>, <trial-index>)
  154. ```
  155. or
  156. ```
  157. trial = Trial(<'states' DataFrame>, <'tracking' DataFrame>)
  158. ```
  159. - the 'states' data-frame can be accessed by `trial.states`.
  160. - the 'tracking' data-frame can be accessed by `trial.tracking`.
  161. """
  162. @classmethod
  163. def load(cls, dataset, subject=None, session=None, trial=None):
  164. if not isinstance(dataset, str):
  165. # assumed to be a Specification
  166. basespecs = Specification(*dataset)
  167. else:
  168. basespecs = Specification(dataset=dataset,
  169. subject=subject,
  170. session=session,
  171. trial=trial)
  172. states_path = basespecs.with_values(domain="states", suffix=".csv").filepath
  173. track_path = basespecs.with_values(domain="tracking", suffix=".csv").filepath
  174. return cls(_pd.read_csv(str(states_path)),
  175. _pd.read_csv(str(track_path)),
  176. specification=basespecs)
  177. def __init__(self, states_dataframe, tracking_dataframe, specification=None):
  178. self.states = states_dataframe
  179. self.tracking = tracking_dataframe
  180. self._specs = specification
  181. @property
  182. def subject(self):
  183. return self._specs.subject
  184. @property
  185. def session(self):
  186. return self._specs.session
  187. @property
  188. def index(self):
  189. return self._specs.trial
  190. def has_eyedata(self):
  191. """returns if eye data is there in this trial."""
  192. return not _np.all(_np.isnan(self.tracking.left_pupil_normalized_position))
  193. def get_timeranges(self, pattern):
  194. """returns a list of (from-rowindex, to-rowindex) objects
  195. in the tracking table, given a pattern of states."""
  196. if isinstance(pattern, str):
  197. pattern = StateCapture(pattern)
  198. return pattern.parse(self.states)
  199. @property
  200. def specification(self):
  201. return _specs
  202. class Capture:
  203. CAPTURE_PATTERN = _re.compile(r"<([a-zA-Z]+)>$")
  204. def __init__(self, pattern):
  205. self._seq = [item.strip() for item in pattern.split()]
  206. if len(self._seq) == 0:
  207. raise ValueError("add at least one state in the pattern")
  208. self._captured = -1
  209. self._offset = 0
  210. for i, item in enumerate(self._seq):
  211. if self.CAPTURE_PATTERN.match(item):
  212. if self._captured >= 0:
  213. raise ValueError(f"cannot take two or more captured states in one pattern: {pattern}")
  214. self._captured = i
  215. if self._captured == -1:
  216. raise ValueError(f"bracket the state to capture by '<' and '>': {pattern}")
  217. self._seq[self._captured] = self._seq[self._captured][1:-1]
  218. debug(f"capture: {self._seq} (captured: '{self._seq[self._captured]}')")
  219. def push(self, item):
  220. debug(f"pushed: {item}")
  221. if item == self._seq[self._offset]:
  222. if self._captured == self._offset:
  223. debug("[capture]", end=" ")
  224. self.capture()
  225. self._offset += 1
  226. if self._offset == len(self._seq):
  227. debug("[match]", end=" ")
  228. self.matched()
  229. self._offset = 0
  230. else:
  231. debug("[clear]", end=" ")
  232. self.clear()
  233. self._offset = 1 if item == self._seq[0] else 0
  234. debug(f"offset={self._offset}")
  235. def clear(self):
  236. pass
  237. def matched(self):
  238. pass
  239. def capture(self):
  240. pass
  241. class StateCapture(Capture):
  242. def __init__(self, pattern):
  243. super().__init__(pattern)
  244. def initialize(self, states):
  245. self.__states = states
  246. self.__started = 0
  247. self.__current = 0
  248. self.__size = self.__states.shape[0]
  249. self.__captured = None
  250. self.__periods = []
  251. def parse(self, states):
  252. self.initialize(states)
  253. while self.__current < self.__size:
  254. self.push(self.__states.iloc[self.__current].State)
  255. self.__current += 1
  256. debug(f"return: {self.__periods}")
  257. return self.__periods
  258. def clear(self):
  259. self.__started = self.__current + 1
  260. def matched(self):
  261. if self.__captured is None:
  262. raise RuntimeError("matched() called without any capture")
  263. period = (self.__captured.FromFrame, self.__captured.ToFrame)
  264. if period not in self.__periods:
  265. debug(f"adding: {period}")
  266. self.__periods.append(period)
  267. self.__captured = None
  268. self.__current = self.__started
  269. self.__started += 1
  270. def capture(self):
  271. self.__captured = self.__states.iloc[self.__current]