unique_hue.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. #!/user/bin/env python
  2. # coding=utf-8
  3. """
  4. @author: yannansu
  5. at: 17.05.22 11:43
  6. Experiment for determining individual's four unique hues.
  7. Each run repeat the measurements #n_rep times, e,g, n_rep = 10
  8. Quick start from bash:
  9. python3 unique_hue.py --s sub --c config/exp_config.yaml --p config/unique_hues.yaml --r data/unique_hue
  10. """
  11. import numpy as np
  12. import os
  13. import time
  14. from bisect import bisect_left
  15. from psychopy import monitors, visual, core, event, data, misc
  16. import argparse
  17. from pyiris.colorspace import ColorSpace
  18. from write_data import WriteData, WriteActivity
  19. from yml2dict import yml2dict
  20. class Exp:
  21. def __init__(self, subject, cfg_file, par_file, res_dir):
  22. """
  23. :param subject: subject, e.g. s00 - make sure you have colorspace files for s00
  24. :param cfg_file: config file path, can share the configs of the main experiment 'config/exp_configs.yaml'
  25. :param par_file: parameter file path, 'config/unique_hues.yaml'
  26. :param res_dir: results directory, 'data/unique_hue'
  27. """
  28. self.subject = subject
  29. if not res_dir:
  30. res_dir = 'data/'
  31. self.res_dir = res_dir
  32. self.cfg_file = cfg_file
  33. self.par_file = par_file
  34. self.cfg = yml2dict(self.cfg_file)
  35. self.par = yml2dict(self.par_file)
  36. mon_settings = yml2dict(self.cfg['monitor_settings_file'])
  37. self.monitor = monitors.Monitor(name='ViewPixx Lite 2000A', distance=mon_settings['preferred_mode']['distance'])
  38. # Only need to run and save the monitor setting once!!!
  39. # The saved setting json is stored in ~/.psychopy3/monitors and should have a backup in config/resources/
  40. self.monitor = monitors.Monitor(name=mon_settings['name'],
  41. distance=mon_settings['preferred_mode']['distance'])
  42. self.monitor.setSizePix((mon_settings['preferred_mode']['width'],
  43. mon_settings['preferred_mode']['height']))
  44. self.monitor.setWidth(mon_settings['size']['width'] / 10.)
  45. self.monitor.saveMon()
  46. subject_path = self.cfg['subject_isolum_directory'] + '/' + 'colorspace_' + subject + '.json'
  47. self.bit_depth = self.cfg['depthBits']
  48. self.colorspace = ColorSpace(bit_depth=self.bit_depth,
  49. chromaticity=self.cfg['chromaticity'],
  50. calibration_path=self.cfg['calibration_file'],
  51. subject_path=subject_path)
  52. # self.gray = self.colorspace.lms_center # or use lms center
  53. self.gray = np.array([self.colorspace.gray_level, self.colorspace.gray_level, self.colorspace.gray_level])
  54. self.colorspace.create_color_list(hue_res=0.05,
  55. gray_level=self.colorspace.gray_level) # Make sure the reslution is fine enough - 0.05 should be good
  56. self.colorlist = self.colorspace.color_list[0.05]
  57. self.gray_pp = self.colorspace.color2pp(self.gray)[0]
  58. self.win = visual.window.Window(size=[mon_settings['preferred_mode']['width'],
  59. mon_settings['preferred_mode']['height']],
  60. monitor=self.monitor,
  61. units=self.cfg['window_units'],
  62. fullscr=True,
  63. colorSpace='rgb',
  64. color=self.gray_pp,
  65. mouseVisible=False)
  66. self.idx = time.strftime("%Y%m%dT%H%M", time.localtime()) # index as current date and time
  67. self.text_height = self.cfg['text_height']
  68. def run_exp(self):
  69. """
  70. Main function for starting experiments.
  71. :return:
  72. """
  73. unique_hues = list(self.par.keys())
  74. start_val_range = 20
  75. start_val_num = 40
  76. n_rep = 5
  77. n_trial = len(unique_hues) * n_rep
  78. texts = self.make_texts()
  79. # welcome
  80. texts['welcome'].draw()
  81. self.win.flip()
  82. event.waitKeys()
  83. # init data files
  84. InitActivity = WriteActivity(self.subject, self.idx, dir_path=self.res_dir)
  85. idx = time.strftime("%Y%m%dT%H%M", time.localtime()) # index as current date and time
  86. InitData = WriteData(self.subject, idx, self.res_dir)
  87. data_file = InitData.head(self.cfg_file, self.par_file)
  88. trial_count = 0
  89. # iterate trials
  90. for i_rep in np.arange(n_rep):
  91. np.random.shuffle(unique_hues)
  92. for hue in unique_hues:
  93. trial_count += 1
  94. start_vals = np.linspace(self.par[hue][0]['guess_val'] - start_val_range,
  95. self.par[hue][0]['guess_val'] + start_val_range,
  96. num=start_val_num,
  97. endpoint=True)
  98. theta = np.random.choice(start_vals, 1)
  99. dat = {'sub': self.subject,
  100. 'i_rep': float(i_rep),
  101. 'unique_hue': hue,
  102. 'random_start': float(theta),
  103. 'estimate': None,
  104. 'RT': None
  105. }
  106. text = visual.TextStim(self.win,
  107. text=self.par[hue][1]['text'],
  108. pos=(0, 10),
  109. color='black',
  110. height=self.text_height)
  111. mouse = event.Mouse(win=self.win, visible=False)
  112. mouse.setPos(0.)
  113. _, y_pos = mouse.getPos()
  114. mouse_lim = 4 # fine movement can return 1 deg resolution with this parameter
  115. finish_current_trial = False
  116. reactClock = core.Clock()
  117. while finish_current_trial is False:
  118. color_patch = self.make_color_patch(theta)
  119. color_patch.draw()
  120. text.draw()
  121. self.win.flip()
  122. # change theta by moving mouse cursor vertically
  123. _, y = mouse.getPos() # update y-position
  124. # change theta based on position change - the range depends on mouse_lim
  125. d_theta = mouse_lim * (y - y_pos)
  126. # Update pos and theta
  127. y_pos = y
  128. theta += d_theta
  129. # print(theta)
  130. if event.getKeys('space', timeStamped=reactClock):
  131. finish_current_trial = True
  132. test_theta, _ = self.closest_hue(theta)
  133. dat['estimate'] = float(test_theta)
  134. dat['RT'] = float(np.round(reactClock.getTime(), 2))
  135. InitData.write(trial_count, 'trial', dat)
  136. if event.getKeys('escape', timeStamped=reactClock):
  137. texts['confirm_escape'].draw()
  138. self.win.flip()
  139. for key_press in event.waitKeys():
  140. if key_press == 'y':
  141. # Save escape info
  142. InitActivity.write(self.cfg_file, self.par_file, data_file, status='escape')
  143. core.quit()
  144. mask = self.make_checkerboard_mask()
  145. mask.draw()
  146. self.win.flip()
  147. core.wait(0.5)
  148. InitActivity.write(self.cfg_file, self.par_file, data_file, status='completed')
  149. texts['goodbye'].draw()
  150. self.win.flip()
  151. event.waitKeys()
  152. def make_color_patch(self, theta):
  153. """
  154. Create a color patch for adjustment.
  155. :param theta: hue angle
  156. :return: a colorpatch as a psychopy Rect stimulus
  157. """
  158. closest_theta, closest_rgb = self.closest_hue(theta)
  159. color_patch = visual.Rect(win=self.win,
  160. width=10.,
  161. height=10.,
  162. pos=(0, 0),
  163. lineWidth=0,
  164. fillColor=closest_rgb)
  165. return color_patch
  166. def make_checkerboard_mask(self):
  167. """
  168. Create a color checkerboard mask.
  169. :return:
  170. """
  171. horiz_n = 35
  172. vertic_n = 25
  173. rect = visual.ElementArrayStim(self.win,
  174. units='norm',
  175. nElements=horiz_n * vertic_n,
  176. elementMask=None,
  177. elementTex=None,
  178. sizes=(2 / horiz_n, 2 / vertic_n))
  179. rect.xys = [(x, y)
  180. for x in np.linspace(-1, 1, horiz_n, endpoint=False) + 1 / horiz_n
  181. for y in np.linspace(-1, 1, vertic_n, endpoint=False) + 1 / vertic_n]
  182. rect.colors = [self.closest_hue(theta=x)[1]
  183. for x in
  184. np.random.randint(0, high=360, size=horiz_n * vertic_n)]
  185. return rect
  186. def closest_hue(self, theta):
  187. """
  188. Tool function:
  189. Given a desired hue angle, to find the closest hue angle and the corresponding rgb value.
  190. :param theta: desired hue angle (in degree)
  191. :return: closest hue angle, closest rgb values
  192. """
  193. hue_angles = np.array(self.colorlist['hue_angles'])
  194. if theta < 0:
  195. theta += 360
  196. if theta >= 360:
  197. theta -= 360
  198. closest_theta, pos = np.array(self.take_closest(hue_angles, theta))
  199. closest_rgb = self.colorlist['rgb'][pos.astype(int)]
  200. closest_rgb = self.colorspace.color2pp(closest_rgb)[0]
  201. return np.round(closest_theta, 2), closest_rgb
  202. def take_closest(self, arr, val):
  203. """
  204. Tool function:
  205. Assumes arr is sorted. Returns closest value to val (could be itself).
  206. If two numbers are equally close, return the smallest number.
  207. :param arr: sorted array
  208. :param val: desired value
  209. :return: [closest_val, closest_idx]
  210. """
  211. pos = bisect_left(arr, val)
  212. if pos == 0:
  213. return [arr[0], pos]
  214. if pos == len(arr):
  215. return [arr[-1], pos - 1]
  216. before = arr[pos - 1]
  217. after = arr[pos]
  218. if after - val < val - before:
  219. return [after, pos]
  220. else:
  221. return [before, pos - 1]
  222. def make_texts(self):
  223. """
  224. Create texts.
  225. :return:
  226. """
  227. texts = {}
  228. # Define welcome message
  229. texts["welcome"] = visual.TextStim(self.win,
  230. text=f"Welcome! \n \n"
  231. f"Please follow the instruction to adjust the color by mouse, "
  232. f"until a unique color appears. \n"
  233. f"Then press space key to confirm. \n"
  234. f"Ready? \n"
  235. f"Press any key to start this session :)",
  236. pos=(0, 0),
  237. color='black',
  238. height=self.text_height)
  239. # Define goodbye message
  240. texts["goodbye"] = visual.TextStim(self.win,
  241. text=f"Well done! \n \n"
  242. f"You have completed this session. \n"
  243. f"Press any key to quit:)",
  244. pos=(0, 0),
  245. color='black',
  246. height=self.text_height)
  247. # Define escape confirm
  248. texts["confirm_escape"] = visual.TextStim(self.win,
  249. text=f"Are you sure to quit (Y/N)? \n",
  250. pos=(0, 0),
  251. color='black',
  252. height=self.text_height)
  253. texts['trial_count'] = visual.TextStim(self.win,
  254. pos=(0, -10),
  255. color='black',
  256. height=self.text_height * .5)
  257. return texts
  258. # """
  259. # Run from bash
  260. if __name__ == '__main__':
  261. parser = argparse.ArgumentParser()
  262. parser.add_argument('-s', help='Subject name')
  263. parser.add_argument('-c', help='Configuration file')
  264. parser.add_argument('-p', help='Parameter file')
  265. parser.add_argument('-r', help='Results folder')
  266. args = parser.parse_args()
  267. Exp(subject=args.s, cfg_file=args.c, par_file=args.p, res_dir=args.r).run_exp()
  268. # """
  269. """
  270. # Test
  271. Exp(subject='s01',
  272. cfg_file='config/exp_config.yaml',
  273. par_file='config/unique_hues.yaml',
  274. res_dir='data/unique_hue').run_exp()
  275. """