-
Notifications
You must be signed in to change notification settings - Fork 3
/
MOTcircular.py
1634 lines (1495 loc) · 99.8 KB
/
MOTcircular.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
############################################################
### Hm, haven't tried waitBlank = True for a while
###For set-up on a new machine, some variables to consider
###
### useClock
### For setup of new experiment variant, variables to consider:
### trialDurMin, trackVariableIntervMax
##############
from psychopy import sound, monitors, logging, visual, data, core
import psychopy.gui, psychopy.event, psychopy.info
import numpy as np, pandas as pd
import itertools #to calculate all subsets
from copy import deepcopy
from math import atan, atan2, pi, cos, sin, sqrt, ceil, floor
import time, random, sys, platform, os, gc, io, warnings
import matplotlib.pyplot as plt
import helpersAOH
from helpersAOH import openMyStimWindow
try:
import pylink #to turn off eyetracker graphics environment after eyetracker calibration. pylink comes from Eyelink Developers Kit download
except Exception as e:
print("When trying to import Eyelink's pylink library, an exception occurred:",str(e))
print('pylink is not included in PsychoPy download, you have to download and install the Eyelink Developers Kit from the SR Research Forum website.')
try:
from analysisPython import logisticRegression as logisticR
except Exception as e:
print("An exception occurred:",str(e))
print('Could not import logisticRegression.py (you need that file in the analysisPython directory, which needs an __init__.py file in its directory too)')
try:
from staircasing import staircaseAndNoiseHelpers
except Exception as e:
print("An exception occurred in staircase_tester_.py:",str(e))
print('Could not import staircaseAndNoiseHelpers.py (you need that file to be in the staircasing subdirectory, which needs an __init__.py file in it too)')
try:
from eyetrackingCode import EyelinkHolcombeLabHelpers #imports from eyetrackingCode subfolder.
#EyeLinkTrack_Holcombe class originally created by Chris Fajou to combine lots of eyelink commands to create simpler functions
except Exception as e:
print("An exception occurred:",str(e))
print('Could not import EyelinkHolcombeLabHelpers.py (you need that file to be in the eyetrackingCode subdirectory, which needs an __init__.py file in it too)')
try:
from theory import publishedEmpiricalThreshes #imports from theory subfolder.
except Exception as e:
print("An exception occurred:",str(e))
print('Could not import publishedEmpiricalThreshes.py (you need that file to be in the theory subdirectory, which needs an __init__.py file in it too)')
eyetracking = False; eyetrackFileGetFromEyelinkMachine = False #very timeconsuming to get the file from the eyetracking machine over the ethernet cable,
#sometimes better to get the EDF file from the Eyelink machine by hand by rebooting into Windows and going to
useSound=True
quitFinder = True
if quitFinder and ('Darwin' in platform.system()): #turn Finder off. Only know the command for MacOS (Darwin)
applescript="\'tell application \"Finder\" to quit\'" #quit Finder.
shellCmd = 'osascript -e '+applescript
os.system(shellCmd)
process_priority = 'realtime' # 'normal' 'high' or 'realtime', but don't know if this works
disable_gc = True
subject='temp'#'test'
autoLogging = False
quickMeasurement = False #If true, use method of gradually speeding up and participant says when it is too fast to track
demo = False
autopilot= False; simulateObserver=True; showOnlyOneFrameOfStimuli = False
if autopilot: subject='auto'
feedback=True
exportImages= False #quits after one trial / output image
screenshot= False; screenshotDone = False;allowGUI = False; waitBlank = False
trackAllIdenticalColors = True#with tracking, can either use same colors as other task (e.g. 6 blobs but only 3 colors so have to track one of 2) or set all blobs identical color
timeAndDateStr = time.strftime("%d%b%Y_%H-%M", time.localtime())
respTypes=['order']; respType=respTypes[0]
rng_seed = int(time.time())
np.random.seed(seed=rng_seed); random.seed(rng_seed)
drawingAsGrating = True; debugDrawBothAsGratingAndAsBlobs = False
antialiasGrating = False; #True makes the mask not work perfectly at the center, so have to draw fixation over the center
gratingTexPix=1024 #If go to 128, cue doesn't overlap well with grating #numpy textures must be a power of 2. So, if numColorsRoundTheRing not divide without remainder into textPix, there will be some rounding so patches will not all be same size
numRings=3
radii=np.array([2.5,7,15]) #[2.5,9.5,15] #Need to encode as array for those experiments where more than one ring presented
respRadius=radii[0] #deg
refreshRate= 100.0 #160 #set to the framerate of the monitor
useClock = True #as opposed to using frame count, which assumes no frames are ever missed
fullscr=1; scrn=0
#Find out if screen may be Retina because of bug in psychopy for mouse coordinates (https://discourse.psychopy.org/t/mouse-coordinates-doubled-when-using-deg-units/11188/5)
has_retina_scrn = False
import subprocess
if 'Darwin' in platform.system(): #Because want to run Unix commands, which won't work on Windows - only do it if Mac
resolutionOfScreens = subprocess.check_output("system_profiler SPDisplaysDataType | grep -i 'Resolution'",shell=True)
print("resolution of screens reported by system_profiler = ",resolutionOfScreens)
if subprocess.call("system_profiler SPDisplaysDataType | grep -i 'retina'", shell=True) == 0:
has_retina_scrn = True #https://stackoverflow.com/questions/58349657/how-to-check-is-it-a-retina-display-in-python-or-terminal
dlgBoxTitle = 'MOT, and no Mac Retina screen detected'
if has_retina_scrn:
dlgBoxTitle = 'MOT. At least one screen is apparently a Retina screen'
# create a dialog box from dictionary
infoFirst = { 'Autopilot':autopilot, 'Screen to use':scrn, 'Fullscreen (timing errors if not)': fullscr, 'Screen refresh rate': refreshRate }
OK = psychopy.gui.DlgFromDict(dictionary=infoFirst,
title=dlgBoxTitle,
order=['Autopilot','Screen to use', 'Screen refresh rate', 'Fullscreen (timing errors if not)'],
tip={'Check refresh etc': 'To confirm refresh rate and that can keep up, at least when drawing a grating',
'Screen to use': '0 means primary screen, 1 means second screen'},
)
if not OK.OK:
print('User cancelled from dialog box'); core.quit()
autopilot = infoFirst['Autopilot']
checkRefreshEtc = True
scrn = infoFirst['Screen to use']
#print('scrn = ',scrn, ' from dialog box')
fullscr = infoFirst['Fullscreen (timing errors if not)']
refreshRate = infoFirst['Screen refresh rate']
#trialDurMin does not include trackVariableIntervMax or trackingExtraTime, during which the cue is on.
trialDurMin = 2 #1
trackingExtraTime= 1.2 #giving the person time to attend to the cue (secs). This gets added to trialDurMin
trackVariableIntervMax = 2.5 #Random interval that gets added to trackingExtraTime and trialDurMin
if demo:
trialDurMin = 5; refreshRate = 60.;
tokenChosenEachRing= [-999]*numRings
cueRampUpDur=0; #duration of contrast ramp from stationary, during cue
cueRampDownDur=0 #duration of contrast ramp down to the end of the trial
def maxTrialDur():
return( trialDurMin+trackingExtraTime+trackVariableIntervMax )
badTimingCushion = 0.3 #Creating more of reversals than should need. Because if miss frames and using clock time instead of frames, might go longer
def maxPossibleReversals(): #need answer to know how many blank fields to print to file
return int( ceil( (maxTrialDur() - trackingExtraTime) / timeTillReversalMin ) )
def getReversalTimes():
reversalTimesEachRing = [ [] for i in range(numRings) ]
for r in range(numRings): # set random reversal times
thisReversalDur = trackingExtraTime
while thisReversalDur< trialDurTotal+badTimingCushion:
thisReversalDur += np.random.uniform(timeTillReversalMin,timeTillReversalMax) #10000; print('WARNING thisReversalDur off')
reversalTimesEachRing[r].append(thisReversalDur)
return reversalTimesEachRing
cueDur = cueRampUpDur+cueRampDownDur+trackingExtraTime #giving the person time to attend to the cue (secs)
rampUpFrames = refreshRate*cueRampUpDur; rampDownFrames = refreshRate*cueRampDownDur;
cueFrames = int( refreshRate*cueDur )
ballStdDev = 1.8 * 3
#mouseChoiceArea = ballStdDev * 0.2 #debugAH #*0.8 # origin =1.3 #Now use a function for this,
units='deg' #'cm'
timeTillReversalMin = 0.5 #0.5;
timeTillReversalMax = 2.0# 1.3 #2.9
colors_all = np.array([[1,-1,-1]] * 20) #colors of the blobs (typically all identical) in a ring. Need as many as max num objects in a ring
cueColor = np.array([1,1,1])
#monitor parameters
widthPixRequested = 800 #1440 #monitor width in pixels
heightPixRequested = 600 #900 #monitor height in pixels
monitorwidth = 38; #30 38.5 #monitor width in centimeters
viewdist = 50.; #57 cm
bgColor = [-1,-1,-1] #black background
monitorname = 'testMonitor' # 'mitsubishi' #in psychopy Monitors Center
if exportImages:
fullscr=0; scrn=0
widthPixRequested = 600; heightPixRequested = 450
monitorwidth = 25.0
if demo:
scrn=0; fullscr=0
widthPixRequested = 800; heightPixRequested = 600
monitorname='testMonitor'
allowGUI = True
monitorwidth = 23#18.0
mon = monitors.Monitor(monitorname,width=monitorwidth, distance=viewdist)#fetch the most recent calib for this monitor
mon.setSizePix( (widthPixRequested,heightPixRequested) )
myWin = openMyStimWindow(mon,widthPixRequested,heightPixRequested,bgColor,allowGUI,units,fullscr,scrn,waitBlank,autoLogging)
myWin.setRecordFrameIntervals(False)
trialsPerCondition = 3
refreshMsg2 = ''
if not checkRefreshEtc:
refreshMsg1 = 'REFRESH RATE WAS NOT CHECKED'
refreshRateWrong = False
else: #checkRefreshEtc
runInfo = psychopy.info.RunTimeInfo(
# if you specify author and version here, it overrides the automatic detection of __author__ and __version__ in your script
#author='<your name goes here, plus whatever you like, e.g., your lab or contact info>',
#version="<your experiment version info>",
win=myWin, ## a psychopy window instance; None = default temp window used; False = no win, no win.flips()
refreshTest='grating', ## None, True, or 'grating' (eye-candy to avoid a blank screen)
verbose=True, ## True means report on everything
userProcsDetailed=True ## if verbose and userProcsDetailed, return (command, process-ID) of the user's processes
)
print('Finished runInfo- which assesses the refresh and processes of this computer')
refreshMsg1 = 'Median frames per second ='+ str( np.round(1000./runInfo["windowRefreshTimeMedian_ms"],1) )
refreshRateTolerancePct = 3
pctOff = abs( (1000./runInfo["windowRefreshTimeMedian_ms"]-refreshRate) / refreshRate)
refreshRateWrong = pctOff > (refreshRateTolerancePct/100.)
if refreshRateWrong:
refreshMsg1 += ' BUT'
refreshMsg1 += ' program assumes ' + str(refreshRate)
refreshMsg2 = 'which is off by more than ' + str(round(refreshRateTolerancePct,0)) + '%'
else:
refreshMsg1 += ', which is close enough to desired val of ' + str( round(refreshRate,1) )
myWinRes = myWin.size
myWin.allowGUI =True
myWin.close() #have to close window to show dialog box
dlgLabelsOrdered = list() #new dialog box
session='a'
myDlg = psychopy.gui.Dlg(title="object tracking experiment", pos=(200,400))
if not autopilot:
myDlg.addField('Subject name or ID:', subject, tip='')
dlgLabelsOrdered.append('subject')
myDlg.addField('session:',session, tip='a,b,c,')
dlgLabelsOrdered.append('session')
myDlg.addField('Trials per condition (default=' + str(trialsPerCondition) + '):', trialsPerCondition, tip=str(trialsPerCondition))
dlgLabelsOrdered.append('trialsPerCondition')
pctCompletedBreaks = np.array([])
myDlg.addText(refreshMsg1, color='Black')
if refreshRateWrong:
myDlg.addText(refreshMsg2, color='Red')
msgWrongResolution = ''
if checkRefreshEtc and (not demo) and (myWinRes != [widthPixRequested,heightPixRequested]).any():
msgWrongResolution = 'Instead of desired resolution of '+ str(widthPixRequested)+'x'+str(heightPixRequested)+ ' pixels, screen apparently '+ str(myWinRes[0])+ 'x'+ str(myWinRes[1])
myDlg.addText(msgWrongResolution, color='GoldenRod')
print(msgWrongResolution)
myDlg.addText('To abort, press ESC at a trial response screen', color='DimGrey') #color names stopped working along the way, for unknown reason
myDlg.show()
if myDlg.OK: #unpack information from dialogue box
thisInfo = myDlg.data #this will be a list of data returned from each field added in order
if not autopilot:
name=thisInfo[dlgLabelsOrdered.index('subject')]
if len(name) > 0: #if entered something
subject = name #change subject default name to what user entered
sessionEntered =thisInfo[dlgLabelsOrdered.index('session')]
session = str(sessionEntered) #cast as str in case person entered a number
trialsPerCondition = int( thisInfo[ dlgLabelsOrdered.index('trialsPerCondition') ] ) #convert string to integer
else:
print('User cancelled from dialog box.')
logging.flush()
core.quit()
if os.path.isdir('.'+os.sep+'dataRaw'):
dataDir='dataRaw'
else:
print('"dataRaw" directory does not exist, so saving data in present working directory')
dataDir='.'
expname = ''
datafileName = dataDir+'/'+subject+ '_' + str(session) + '_' + expname+timeAndDateStr
if not demo and not exportImages:
dataFile = open(datafileName+'.tsv', 'w') # sys.stdout
import shutil
#Create a copy of this actual code so we know what exact version of the code was used for each participant
ok = shutil.copy2(sys.argv[0], datafileName+'.py') # complete target filename given
#print("Result of attempt to copy = ", ok)
logF = logging.LogFile(datafileName+'.log',
filemode='w',#if you set this to 'a' it will append instead of overwriting
level=logging.INFO)#errors, data and warnings will be sent to this logfile
if demo or exportImages:
dataFile = sys.stdout
logging.console.setLevel(logging.ERROR) #only show this level messages and higher
logging.console.setLevel(logging.WARNING) #DEBUG means set the console to receive nearly all messges, INFO is for everything else, INFO, EXP, DATA, WARNING and ERROR
if refreshRateWrong:
logging.error(refreshMsg1+refreshMsg2)
else: logging.info(refreshMsg1+refreshMsg2)
longerThanRefreshTolerance = 0.27
longFrameLimit = round(1000./refreshRate*(1.0+longerThanRefreshTolerance),3) # round(1000/refreshRate*1.5,2)
msg = 'longFrameLimit=' + str(longFrameLimit) + ' Recording trials where one or more interframe interval exceeded this figure '
logging.info(msg)
print(msg)
if msgWrongResolution != '':
logging.error(msgWrongResolution)
logging.info("computer platform="+sys.platform)
#save a copy of the code as it was when that subject was run
logging.info('File that generated this = sys.argv[0]= '+sys.argv[0])
logging.info("has_retina_scrn="+str(has_retina_scrn))
logging.info('trialsPerCondition =' + str(trialsPerCondition))
logging.info('random number seed =' + str(rng_seed))
#Not a test - the final window opening
myWin = openMyStimWindow(mon,widthPixRequested,heightPixRequested,bgColor,allowGUI,units,fullscr,scrn,waitBlank,autoLogging)
myWin.setRecordFrameIntervals(False)
#Just roll with whatever wrong resolution the screen is set to
if (not demo) and (myWinRes != [widthPixRequested,heightPixRequested]).any():
msgWrongResolutionFinal = ('Instead of desired resolution of '+ str(widthPixRequested)+'x'+str(heightPixRequested)
+' pixels, screen is apparently '+ str(myWinRes[0])+ 'x'+ str(myWinRes[1]) + ' will base calculations off that.')
logging.warn(msgWrongResolutionFinal)
widthPix = myWin.size[0]
heightPix = myWin.size[1]
logging.info( 'Screen resolution, which is also being used for calculations, is ' + str(widthPix) + ' by ' + str(heightPix) )
pixelperdegree = widthPix / (atan(monitorwidth/viewdist) /np.pi*180)
myMouse = psychopy.event.Mouse(visible = 'true',win=myWin)
runInfo = psychopy.info.RunTimeInfo(
win=myWin, ## a psychopy window instance; None = default temp window used; False = no win, no win.flips()
refreshTest='grating', ## None, True, or 'grating' (eye-candy to avoid a blank screen)
verbose=True, ## True means report on everything
userProcsDetailed=True ## if verbose and userProcsDetailed, return (command, process-ID) of the user's processes
)
logging.info('second window opening runInfo mean ms='+str(runInfo["windowRefreshTimeAvg_ms"]))
logging.info(runInfo)
logging.info('gammaGrid='+str(mon.getGammaGrid()))
logging.info('linearizeMethod='+str(mon.getLinearizeMethod()))
#Create Gaussian blob
blob = visual.PatchStim(myWin, tex='none',mask='gauss',colorSpace='rgb',size=ballStdDev,autoLog=autoLogging)
labelBlobs = False #Draw the number of each Gaussian blob over it, to visualize the drawing algorithm better
if labelBlobs:
blobLabels = list()
for i in range(20): #assume no more than 20 objects
label = str(i)
blobText = visual.TextStim(myWin,text=label,colorSpace='rgb',color = (-1,-.2,-1),autoLog=False)
blobLabels.append(blobText)
optionChosenCircle = visual.Circle(myWin, edges=32, colorSpace='rgb',fillColor = (1,0,1),autoLog=autoLogging) #to outline chosen options
#Optionally show zones around objects that will count as a click for that object
clickableRegion = visual.Circle(myWin, edges=32, colorSpace='rgb',fillColor=(-1,-.7,-1),autoLog=autoLogging) #to show clickable zones
#Optionally show location of most recent click
clickedRegion = visual.Circle(myWin, edges=32, colorSpace='rgb',lineColor=None,fillColor=(-.5,-.1,-1),autoLog=autoLogging) #to show clickable zones
clickedRegion.setColor((-.1,.8,-1)) #show in yellow
circlePostCue = visual.Circle(myWin, radius=2*radii[0], edges=96, colorSpace='rgb',lineColor=(.5,.5,-.6),lineWidth=8,fillColor=None,autoLog=autoLogging) #visual postcue
#referenceCircle allows optional visualisation of trajectory
referenceCircle = visual.Circle(myWin, radius=radii[0], edges=32, colorSpace='rgb',lineColor=(-1,-1,1),autoLog=autoLogging) #visual postcue
blindspotFill = 0 #a way for people to know if they move their eyes
if blindspotFill:
blindspotStim = visual.PatchStim(myWin, tex='none',mask='circle',size=4.8,colorSpace='rgb',color = (-1,1,-1),autoLog=autoLogging) #to outline chosen options
blindspotStim.setPos([13.1,-2.7]) #AOH, size=4.8; pos=[13.1,-2.7] #DL: [13.3,-0.8]
fixatnNoise = True
fixSizePix = 6 #20 make fixation big so flicker more conspicuous
if fixatnNoise:
checkSizeOfFixatnTexture = fixSizePix/4
nearestPowerOfTwo = round( sqrt(checkSizeOfFixatnTexture) )**2 #Because textures (created on next line) must be a power of 2
fixatnNoiseTexture = np.round( np.random.rand(nearestPowerOfTwo,nearestPowerOfTwo) ,0 ) *2.0-1 #Can counterphase flicker noise texture to create salient flicker if you break fixation
fixation= visual.PatchStim(myWin,pos=(0,0), tex=fixatnNoiseTexture, size=(fixSizePix,fixSizePix), units='pix', mask='circle', interpolate=False, autoLog=autoLogging)
fixationBlank= visual.PatchStim(myWin,pos=(0,0), tex=-1*fixatnNoiseTexture, colorSpace='rgb',mask='circle',size=fixSizePix,units='pix',autoLog=autoLogging)
else:
fixation = visual.PatchStim(myWin,tex='none',colorSpace='rgb',color=(.9,.9,.9),mask='circle',units='pix',size=fixSizePix,autoLog=autoLogging)
fixationBlank= visual.PatchStim(myWin,tex='none',colorSpace='rgb',color=(-1,-1,-1),mask='circle',units='pix',size=fixSizePix,autoLog=autoLogging)
fixationPoint = visual.PatchStim(myWin,colorSpace='rgb',color=(1,1,1),mask='circle',units='pix',size=2,autoLog=autoLogging) #put a point in the center
#respText = visual.TextStim(myWin,pos=(0, -.5),colorSpace='rgb',color = (1,1,1),anchorHoriz='center', anchorVert='center', units='norm',autoLog=autoLogging)
NextText = visual.TextStim(myWin,pos=(0, 0),colorSpace='rgb',color = (1,1,1),anchorHoriz='center', anchorVert='center', units='norm',autoLog=autoLogging)
NextRemindPctDoneText = visual.TextStim(myWin,pos=(-.1, -.4),colorSpace='rgb',color= (1,1,1),anchorHoriz='center', anchorVert='center', units='norm',autoLog=autoLogging)
NextRemindCountText = visual.TextStim(myWin,pos=(.1, -.5),colorSpace='rgb',color = (1,1,1),anchorHoriz='center', anchorVert='center', units='norm',autoLog=autoLogging)
speedText = visual.TextStim(myWin,pos=(-0.5, 0.5),colorSpace='rgb',color = (1,1,1),anchorHoriz='center', anchorVert='center', units='norm',text="0.00rps",autoLog=False)
if useSound:
ringQuerySoundFileNames = [ 'innerring.wav', 'middlering.wav', 'outerring.wav' ]
soundDir = 'sounds'
lowSound = sound.Sound('E',octave=4, stereo = False, sampleRate = 44100, secs=.8, volume=1.0, autoLog=autoLogging)
respPromptSounds = [-99] * len(ringQuerySoundFileNames)
for i in range(len(ringQuerySoundFileNames)):
soundFileName = ringQuerySoundFileNames[i]
soundFileNameAndPath = os.path.join(soundDir, ringQuerySoundFileNames[ i ])
respPromptSounds[i] = sound.Sound(soundFileNameAndPath, secs=.2, autoLog=autoLogging)
corrSoundPathAndFile= os.path.join(soundDir, 'Ding44100Mono.wav')
corrSound = sound.Sound(corrSoundPathAndFile, volume=0.3, autoLog=autoLogging)
stimList = []
doStaircase = True
# temporalfrequency limit test
numTargets = [2, 3] #[2]
numObjsInRing = [4, 8] #[4] #Limitation: gratings don't align with blobs with odd number of objects
# Get all combinations of those two main factors
#mainCondsInfo = {
# 'numTargets': [2, 2, 3, 3],
# 'numObjects': [4, 8, 4, 8],
#}
combinations = list(itertools.product(numTargets, numObjsInRing))
# Create the DataFrame with all combinations
mainCondsDf = pd.DataFrame(combinations, columns=['numTargets', 'numObjects'])
mainCondsInfo = mainCondsDf.to_dict('list') #change into a dictionary, in list format
publishedThreshes = publishedEmpiricalThreshes.getAvgMidpointThreshes()
publishedThreshes = publishedThreshes[['numTargets', 'HzAvgPreviousLit']] #only want average of previous literature
mainCondsDf = pd.DataFrame( mainCondsInfo )
mainCondsDf = pd.merge(mainCondsDf, publishedThreshes, on='numTargets', how='left')
mainCondsDf['midpointThreshPrevLit'] = mainCondsDf['HzAvgPreviousLit'] / mainCondsDf['numObjects']
mainCondsDf = mainCondsDf.drop('HzAvgPreviousLit', axis=1) #Use this Dataframe to choose the starting speed for the staircase and the behavior of the autopilot observer
#Old way of setting all speeds manually:
#speedsEachNumTargetsNumObjects = [ [ [0.5,1.0,1.4,1.7], [0.5,1.0,1.4,1.7] ], #For the first numTargets condition
# [ [0.2,0.5,0.7,1.0], [0.5,1.0,1.4,1.7] ] ] #For the second numTargets condition
#don't go faster than 2 rps at 120 Hz because of temporal blur/aliasing
maxTrialsPerStaircase = 500 #Just an unreasonably large number so that the experiment won't stop before the number of trials set by the trialHandler is finished
staircases = []
#Need to create a different staircase for each condition because chanceRate will be different and want to estimate midpoint threshold to match previous work
if doStaircase: #create the staircases
for stairI in range(len(mainCondsDf)): #one staircase for each main condition
descendingPsychometricCurve = True
#the average threshold speed across conditions found by previous literature for young people
avgAcrossCondsFromPrevLit = mainCondsDf['midpointThreshPrevLit'].mean()
#Assume that first session is 'a', second session is 'b', etc.
sessionNum = ord(session) - ord('a') + 1
if sessionNum <= 1: #give all the staircases the same starting value
startVal = 0.6 * avgAcrossCondsFromPrevLit #Don't go higher because this was the average for the young people only
elif sessionNum == 2:
startVal = avgAcrossCondsFromPrevLit
elif sessionNum >= 3:
startVal = 0.75 * avgAcrossCondsFromPrevLit
startValInternal = staircaseAndNoiseHelpers.toStaircase(startVal, descendingPsychometricCurve)
print('staircase startVal=',startVal,' startValInternal=',startValInternal)
this_row = mainCondsDf.iloc[stairI]
condition = this_row.to_dict() # {'numTargets': 2, 'numObjects': 4}
nUp = 1; nDown=3 #1-up 3-down homes in on the 79.4% threshold. Make it easier if get one wrong. Make it harder when get 3 right in a row
if nUp==1 and nDown==3:
staircaseConvergePct = 0.794
else:
print('WARNING: dont know what staircaseConvergePct is')
minSpeed = .03# -999 #0.05
maxSpeed= 1.8 #1.8 #1.8
minSpeedForStaircase = staircaseAndNoiseHelpers.toStaircase(minSpeed, descendingPsychometricCurve)
maxSpeedForStaircase = staircaseAndNoiseHelpers.toStaircase(maxSpeed, descendingPsychometricCurve)
#if descendingPsychometricCurve
if minSpeedForStaircase > maxSpeedForStaircase:
#Swap values of the two variables
minSpeedForStaircase, maxSpeedForStaircase = maxSpeedForStaircase, minSpeedForStaircase
#print('for internals, minSpeedForStaircase=',minSpeedForStaircase, 'maxSpeedForStaircase=',maxSpeedForStaircase)
staircase = data.StairHandler(
extraInfo = condition,
startVal=startValInternal,
stepType='lin',
stepSizes= [.3,.3,.2,.1,.1,.05],
minVal = minSpeedForStaircase,
maxVal= maxSpeedForStaircase,
nUp=nUp, nDown=nDown,
nTrials = maxTrialsPerStaircase)
numPreStaircaseTrials = 0
#staircaseAndNoiseHelpers.printStaircase(staircase, descendingPsycho, briefTrialUpdate=True, printInternalVal=True, alsoLog=False)
print('Adding this staircase to list')
staircases.append(staircase)
#phasesMsg = ('Doing '+str(numPreStaircaseTrials)+'trials with speeds= TO BE DETERMINED'+' then doing a max '+ \
# str(maxTrialsPerStaircase)+'-trial staircase for each condition:')
queryEachRingEquallyOften = False
#To query each ring equally often, the combinatorics are complicated because have different numbers of target conditions.
if queryEachRingEquallyOften:
leastCommonMultipleSubsets = int( helpersAOH.calcCondsPerNumTargets(numRings,numTargets) )
leastCommonMultipleTargetNums = int( helpersAOH.LCM( numTargets ) ) #have to use this to choose ringToQuery.
#for each subset, need to counterbalance which target is queried. Because each element occurs equally often, which one queried can be an independent factor. But need as many repetitions as largest number of target numbers.
# 3 targets . 3 subsets maximum. Least common multiple is 3. 3 rings that could be post-cued. That means counterbalancing requires 3 x 3 x 3 = 27 trials. NO! doesn't work
# But what do you do when 2 targets, which one do you pick in the 3 different situations? Can't counterbalance it evenly, because when draw 3rd situation, half of time should pick one and half the time the other. Therefore have to use least common multiple of all the possible set sizes. Unless you just want to throw away that possibility. But then have different number of trials in 2 targets than in 3 targets.
# - Is that last sentence true? Because always seem to be running leastCommonMultipleSubsets/numSubsetsThis for each numTargets
# Check counterbalancing of numObjectsInRing*speed*numTargets*ringToQuery. Leaving out whichIsTargetEachRing which is a list of which of those numTargets is the target.
print('leastCommonMultipleSubsets=',leastCommonMultipleSubsets, ' leastCommonMultipleTargetNums= ', leastCommonMultipleTargetNums)
for numObjs in numObjsInRing: #set up experiment design
for nt in numTargets: #for each num targets condition,
numObjectsIdx = numObjsInRing.index(numObjs)
numTargetsIdx = numTargets.index(nt)
if doStaircase: #Speeds will be determined trial-by-trial by the staircases. However, to estimate lapse rate,
#we need occasional trials with a slow speed.
speeds = [[.02,.1],'staircase','staircase','staircase','staircase'] #speeds = [0.02, 0.1, -99, -99, -99]
else:
speeds= speedsEachNumTargetsNumObjects[ numTargetsIdx ][ numObjectsIdx ]
for speed in speeds:
ringNums = np.arange(numRings)
if queryEachRingEquallyOften:
#In case of 3 rings and 2 targets, 3 choose 2 = 3 possible ring combinations
#If 3 concentric rings involved, have to consider 3 choose 2 targets, 3 choose 1 targets, have to have as many conditions as the maximum
subsetsThis = list(itertools.combinations(ringNums,nt)) #all subsets of length nt from the rings. E.g. if 3 rings and nt=2 targets
numSubsetsThis = len( subsetsThis ); print('numSubsetsThis=',numSubsetsThis, ' subsetsThis = ',subsetsThis)
repsNeeded = leastCommonMultipleSubsets / numSubsetsThis #that's the number of repetitions needed to make up for number of subsets of rings
for r in range( int(repsNeeded) ): #Balance different nt conditions. For nt with largest number of subsets, need no repetitions
for s in subsetsThis: #to equate ring usage, balance by going through all subsets. E.g. 3 rings with 2 targets is 1,2; 1,3; 2,3
whichIsTargetEachRing = np.ones(numRings)*-999 #initialize to -999, meaning not a target in that ring.
for ring in s:
whichIsTargetEachRing[ring] = np.random.randint(0,numObjs-1,size=1)
print('numTargets=',nt,' whichIsTargetEachRing=',whichIsTargetEachRing,' and that is one of ',numSubsetsThis,' possibilities and we are doing ',repsNeeded,'repetitions')
for whichToQuery in range( leastCommonMultipleTargetNums ): #for each subset, have to query one. This is dealed out to the current subset by using modulus. It's assumed that this will result in equal total number of queried rings
whichSubsetEntry = whichToQuery % nt #e.g. if nt=2 and whichToQuery can be 0,1,or2 then modulus result is 0,1,0. This implies that whichToQuery won't be totally counterbalanced with which subset, which is bad because
#might give more resources to one that's queried more often. Therefore for whichToQuery need to use least common multiple.
ringToQuery = s[whichSubsetEntry]; #print('ringToQuery=',ringToQuery,'subset=',s)
for basicShape in ['circle']: #'diamond'
for initialDirRing0 in [-1,1]:
stimList.append( {'basicShape':basicShape, 'numObjectsInRing':numObjs,'speed':speed,'initialDirRing0':initialDirRing0,
'numTargets':nt,'whichIsTargetEachRing':whichIsTargetEachRing,'ringToQuery':ringToQuery} )
else: # not queryEachRingEquallyOften, because that requires too many trials for a quick session. Instead
#will randomly at time of trial choose which rings have targets and which one querying.
whichIsTargetEachRing = np.ones(numRings)*-999 #initialize to -999, meaning not a target in that ring. '1' will indicate which is the target
ringToQuery = 999 #this is the signal to choose the ring randomly
for basicShape in ['circle']: #'diamond'
for initialDirRing0 in [-1,1]:
stimList.append( {'basicShape':basicShape, 'numObjectsInRing':numObjs,'speed':speed,'initialDirRing0':initialDirRing0,
'numTargets':nt,'whichIsTargetEachRing':whichIsTargetEachRing,'ringToQuery':ringToQuery} )
trials = data.TrialHandler(stimList,trialsPerCondition) #constant stimuli method
print('len(stimList), which is the list of conditions, is =',len(stimList))
#print('stimList = ',stimList)
timeAndDateStr = time.strftime("%d%b%Y_%H-%M", time.localtime())
logging.info( str('starting exp with name: "'+'TemporalFrequencyLimit'+'" at '+timeAndDateStr) )
msg = 'numtrials='+ str(trials.nTotal)+', trialDurMin= '+str(trialDurMin)+ ' trackVariableIntervMax= '+ str(trackVariableIntervMax) + 'refreshRate=' +str(refreshRate)
logging.info( msg )
print(msg)
msg = 'cueRampUpDur=' + str(cueRampUpDur) + ' cueRampDownDur= ' + str(cueRampDownDur) + ' secs'
logging.info(msg);
logging.info('task='+'track'+' respType='+respType)
logging.info('ring radii=' + str(radii))
logging.info('drawingAsGrating=' + str(drawingAsGrating) + ' gratingTexPix='+ str(gratingTexPix) + ' antialiasGrating=' + str(antialiasGrating))
logging.flush()
stimColorIdxsOrder=[[0,0],[0,0],[0,0]]#this was used for drawing blobs during LinaresVaziriPashkam stimulus, now just vestigial for grating
def decimalPart(x):
return ( abs(x-floor(x)) )
def constructRingAsGratingSimplified(radii,numObjects,patchAngle,colors,stimColorIdxsOrder,gratingTexPix,blobToCue):
#Will create the ring of objects (ringRadial) grating and also a ring grating for the cue, for each ring
#patchAngle is the angle an individual object subtends, of the circle
ringRadial=list(); cueRing=list();
#The textures specifying the color at each portion of the ring. The ringRadial will have multiple cycles but only one for the cueRing
myTexEachRing=list();cueTexEachRing=list();
#grating texture is rendered in reverse order than is blobs version, but that won't matter if blobs all the same color
angleSegment = 360./(numObjects*2)
if gratingTexPix % (numObjects*2) >0: #gratingTexPix contains 2 patches, one for object and one for space between.
#numCycles will control how many total objects there are around circle
logging.warn('Culd not exactly render a numObjects*2='+str(numObjects*2)+'-segment pattern radially, will be off by '+str( (gratingTexPix%(numObjects*2))*1.0 /gratingTexPix ) )
if patchAngle > angleSegment:
msg='Error: patchAngle (angle of circle spanned by object) requested ('+str(patchAngle)+') bigger than maximum possible (' + str(angleSegment)
print(msg); logging.error(msg)
#initialize list of textures for objects grating, one for each ring. Initialize with bgColor
for i in range(numRings):
myTexThis = np.zeros([gratingTexPix,3]) + bgColor[0] #start with all of texture = bgColor
myTexEachRing.append( myTexThis )
#initialize cueTex list with bgColor like myTexThis
cueTexEachRing = deepcopy(myTexEachRing)
#for i in range(numRings): cueTexEachRing[i][:] = [-1,-1,0.5] #initialized with dark blue for visualization
#Entire cycle of grating is just one object and one blank space
halfCyclePixTexture = gratingTexPix/2
#Calculate pix of patch. gratingTexPix is entire cycle, so patchAngle is proportion of angleSegment*2
patchPixTexture = patchAngle/(angleSegment*2)* gratingTexPix
patchPixTexture = round(patchPixTexture) #best is odd number, even space on either size
patchFlankPix = round( (halfCyclePixTexture-patchPixTexture)/2. )
patchAngleActual = patchPixTexture / gratingTexPix * (360./numObjects)
if abs(patchAngleActual - patchAngle) > .04:
msg = 'Desired patchAngle = '+str(patchAngle)+' but closest can get with '+str(gratingTexPix)+' gratingTexPix is '+str(patchAngleActual);
logging.warn(msg)
#print('halfCyclePixTexture=',halfCyclePixTexture,' patchPixTexture=',patchPixTexture, ' patchFlankPix=',patchFlankPix)
#patchFlankPix at 199 is way too big, because patchPixTexture = 114
#set half of texture to color of objects
start = 0
end = start + halfCyclePixTexture #patchPixTexture
start = round(start); end = round(end) #don't round until now after did addition, otherwise can fall short if multiplication involved
ringColor=list();
for i in range(numRings):
ringColor.append(colors[ stimColorIdxsOrder[i][0] ]) #assuming only one object color for each ring (or all the same)
for i in range(numRings):
myTexEachRing[i][start:end, :] = ringColor[i];
#Color flanks (bgColor)
# so object to subtend less than half-cycle, as indicated by patchAngle) by overwriting first and last entries of segment
myTexEachRing[i][start:start+patchFlankPix, :] = bgColor[0]; #one flank
myTexEachRing[i][end-1-patchFlankPix:end, :] = bgColor[0]; #other flank
#Do cueTex ####################################################################################
#Assign cueTex with object color (or yellow if debug)
segmentTexCuePix = gratingTexPix* 1.0/numObjects #number of texture pix of one object (not counting spaces in between)
for ringI in range(numRings):
for objectI in range(numObjects):
#Fill in the cueTex object locations initially with red, so that it can be constantly shown on top of the objects ring
#It's only one cycle for the entire ring, unlike the objects ring, so that can highlight just a part of it as the white cue.
#First color in the entire segment
start = objectI * (segmentTexCuePix)
end = start + segmentTexCuePix/2.0
start = round(start); end = round(end) #don't round until now after did addition, otherwise can fall short
debugCue = False
objectColor = ringColor[0] #conventionally, red
if debugCue:
objectColor = [1,1,0] #make cuing ring obvious by having all its objects be yellow
cueTexEachRing[ringI][start:end, :] = objectColor
#print('cueTex ringI=', ringI, ' objectI=',objectI,' start=',start,'end=',end, '[start,:] = ', cueTexEachRing[ringI][start, :])
#Erase flanks (color with bgColor)
patchCueProportnOfCircle = patchAngle / 360
patchCueProportnOfCircle = patchCueProportnOfCircle*.98 #I can't explain why, but it's too big otherwise so spills into bg area
#Calculate number of texture elements taken up by the object
patchPixCueTex = patchCueProportnOfCircle * gratingTexPix
#Calculate number of texture elements taken up by the entire area available to an object and its flanks
objectAreaPixCueTex = (gratingTexPix / numObjects) / 2.0
#Calculate number of texture elements taken up by the flankers. That's the area available - those taken up by the object,
# divide by 2 because there's a flank on either side.
patchFlankCuePix = (objectAreaPixCueTex - patchPixCueTex) / 2
patchFlankCuePix = round(patchFlankCuePix)
#print('patchAngle=',patchAngle,'patchPixCueTex=',patchPixCueTex, 'patchFlankCuePix=',patchFlankCuePix, 'segmentTexCuePix=',segmentTexCuePix) #debugAH
firstFlankStart = start
firstFlankEnd = start+patchFlankCuePix
#print('firstFlankStart=',firstFlankStart, ' firstFlankEnd=',firstFlankEnd)
cueTexEachRing[ringI][ firstFlankStart:firstFlankEnd, :] = bgColor[0]
secondFlankStart = end-1-patchFlankCuePix
secondFlankEnd = end
cueTexEachRing[ringI][ secondFlankStart:secondFlankEnd, :] = bgColor[0]
#Color the cueTex segment to be cued white
#only a portion of that segment should be colored, the amount corresponding to angular patch
if blobToCue[ringI] >=0: #-999 means dont cue anything
blobToCue_alignWithBlobs = -1 * blobToCue[ringI] #orientation for gratings is opposite direction than Descartes
blobToCue_relativeToGaussianBlobsCorrect = (blobToCue_alignWithBlobs) % numObjects
cueStart = blobToCue_relativeToGaussianBlobsCorrect * (gratingTexPix/numObjects)
cueEnd = cueStart + (gratingTexPix/numObjects)/2.
#print("blobToCue =",blobToCue_relativeToGaussianBlobsCorrect, " cueStart=",cueStart, " cueEnd=",cueEnd)
#the critical line that colors the actual cue
cueTexEachRing[ringI][round(cueStart):round(cueEnd), :] = -1 * bgColor[0]
#fill flankers with bgColor
firstFlankStart = cueStart
firstFlankEnd = cueStart + patchFlankCuePix
cueTexEachRing[ringI][round(firstFlankStart):round(firstFlankEnd), :] = bgColor[0] # [.8,-1,.5] #opposite of bgColor (usually black), thus usually white
secondFlankStart = cueEnd-1-patchFlankCuePix
secondFlankEnd = cueEnd
cueTexEachRing[ringI][round(secondFlankStart):round(secondFlankEnd), :] = bgColor[0] # [.8,-1,.5] #opposite of bgColor (usually black), thus usually white
angRes = 100 #100 is default. I have not seen any effect. This is currently not printed to log file.
ringRadialMask=[[0,0,0,1,1],[0,0,0,0,0,0,1,1],[0,0,0,0,0,0,0,0,0,0,1,1]] #Masks to turn each grating into an annulus (a ring)
for i in range(numRings): #Create the actual ring graphics objects, both the objects ring and the cue rings
#Need to shift texture by halfCyclePixTexture/2 to center it on how Gabor blobs are drawn. Because Gabor blobs are centered on angle=0, whereas
# grating starts drawing at angle=0 rather than being centered on it, and extends from there
shiftToAlignWithGaussianBlobs = -1 * round(halfCyclePixTexture/2.)
myTexEachRing[i] = np.roll( myTexEachRing[i], shiftToAlignWithGaussianBlobs, axis=0 )
#Make myTexEachRing into a two-dimensional texture. Presently it's only one dimension. Actually it's possible psychopy automatically cycles it
arr_ex = np.expand_dims(myTexEachRing[i], axis=0)
# Duplicate along the new first dimension to make that the same length so we have a square matrix of RGB triplets
repeatsWanted = len(myTexEachRing[i])
myTex2dThisRing = np.repeat(arr_ex, repeatsWanted, axis=0)
#print(myTex2dThisRing.shape)
#Draw annular stimulus (ring) using radialGrating function. Colors specified by myTexEachRing.
thisRing = visual.RadialStim(myWin, tex=myTex2dThisRing, color=[1,1,1],size=radii[i],
mask=ringRadialMask[i], # this is a 1-D mask dictating the behaviour from the centre of the stimulus to the surround.
radialCycles=0, #the ringRadialMask is radial and indicates that should show only .3-.4 as one moves radially, creating an annulus
angularCycles= numObjects,
angularRes=angRes, interpolate=antialiasGrating, autoLog=autoLogging)
ringRadial.append(thisRing)
#Create cueRing
#Need to shift texture by object/2 to center it on how Gabor blobs are drawn. Because Gabor blobs are centered on angle=0, whereas
# grating starts drawing at angle=0 rather than being centered on it, and extends from there
shiftToAlignWithGaussBlobs = -1 * round( (gratingTexPix/numObjects) / 4 )
cueTexThisRing = np.roll( cueTexEachRing[i], shiftToAlignWithGaussBlobs, axis=0 )
#print("Did np.roll successfully change cueTex, before vs after EQUALS = ", np.array_equal(cueTexThisRing,cueTexEachRing[i])) # test if same shape, same elements values
#Make cueTexEachRing into a two-dimensional texture. Presently it's only one dimension. Actually it's possible psychopy automatically cycles it
arr_ex = np.expand_dims(cueTexThisRing, axis=0)
# Duplicate along the new first dimension to make that the same length so we have a square matrix of RGB triplets
repeatsWanted = len(cueTexThisRing)
cueTex2dThisRing = np.repeat(arr_ex, repeatsWanted, axis=0)
#print(cueTex2dThisRing.shape)
#draw cue grating for tracking task. Entire grating will be empty except for one white sector
cueRing.append(visual.RadialStim(myWin, tex=cueTex2dThisRing, color=[1,1,1], size=radii[i], #cueTexInner is white. Only one sector of it shown by mask
mask=ringRadialMask[i], radialCycles=0,
angularCycles=1, #only one cycle because no pattern actually repeats- trying to highlight only one sector
angularRes=angRes, interpolate=antialiasGrating, autoLog=autoLogging) )#depth doesn't work, I think was abandoned by Psychopy
currentlyCuedBlob = blobToCue #this will mean that don't have to redraw
return ringRadial,cueRing,currentlyCuedBlob
######### End constructRingAsGratingSimplified ###########################################################
RFcontourAmp= 0.0
RFcontourFreq = 2.0
RFcontourPhase = 0
def RFcontourCalcModulation(angle,freq,phase):
modulation = sin(angle*freq + phase) #radial frequency contour equation, e.g. http://www.journalofvision.org/content/14/11/12.full from Wilkinson et al. 1998
return modulation
def diamondShape(constSpeedOrConstRps,angle):
def triangleWave(period, phase):
#triangle wave is in sine phase (starts at 0)
y = -abs(phase % (2*period) - period) # http://stackoverflow.com/questions/1073606/is-there-a-one-line-function-that-generates-a-triangle-wave
#y goes from -period to 0. Need to rescale to -1 to 1 to match sine wave etc.
y = y/period*2 + 1
#Now goes from -1 to 1
return y
if constSpeedOrConstRps: #maintain constant rps. So, draw the blob at the prescribed theta. But change the radius to correspond to a square.
#As a consequence, will travel faster the more the direction differs from the circle, like around the corners
#Theta varies from 0 to 2pi. Taking its cosine, gives x coordinate on circle.
#Instead of taking cosine, I should just make it a linear ramp of x back and forth. That is, turn it into a triangle wave
#Want 0 to pi to be -1 to 1
x = triangleWave(pi,angle)
y = triangleWave(pi, (angle-pi/2)%(2*pi ))
#This will always describe a diamond. To change the shape would have to use vector rotation formula
else: #constant speed, so
#take theta not as the angle wanted, but what proportion (after being divided by 2pi) along the trajectory you want to go
angle = angle % (2*pi) #modulus
proportnTraj = angle/(2*pi)
if (proportnTraj < 0) or (proportnTraj>1):
print("Unexpected angle below 0!"); logging.error("Unexpected angle below 0!")
#how do I go from proportnTraj either to x,y or to theta?
#Analytic method is that as increase theta deviates from 4 points that touches circle, theta change is smaller for equal change in proportnTraj
#Brute force method is to divide into 4 segments, below.
zeroToFour = proportnTraj*4
if zeroToFour < 1: #headed NW up the first quadrant
x = 1 - (zeroToFour-0)
y = (zeroToFour-0)
elif zeroToFour < 2: #SW
x = - (zeroToFour - 1)
y = 1- (zeroToFour - 1)
elif zeroToFour < 3: #SE
x = -1+(zeroToFour - 2)
y = - (zeroToFour - 2)
elif zeroToFour < 4: #NE
x = (zeroToFour-3)
y = -1+(zeroToFour-3)
else: logging.error("Unexpected zeroToFour="+ str(zeroToFour))
#Max x is 1, meaning that it will be the diamond that circumscribes the unit circle.
#Otherwise need to adjust by calculating the average eccentricity of such a diamond and compensating, which I never did.
return x,y
ampTemporalRadiusModulation = 0.0 # 1.0/3.0
ampModulatnEachRingTemporalPhase = np.random.rand(numRings) * 2*np.pi
def xyThisFrameThisAngle(basicShape, radiiThisTrial, numRing, angle, thisFrameN, speed):
#period of oscillation should be in sec
r = radiiThisTrial[numRing]
timeSeconds = thisFrameN / refreshRate
def waveForm(type,speed,timeSeconds,numRing):
if speed==0 and ampTemporalRadiusModulation==0:
return 0 #this way don't get division by zero error when speed=0
else:
periodOfRadiusModulation = 1.0/speed#so if speed=2 rps, radius modulation period = 0.5 s
modulatnPhaseRadians = timeSeconds/periodOfRadiusModulation * 2*pi + ampModulatnEachRingTemporalPhase[numRing]
if type=='sin':
return sin(modulatnPhaseRadians)
elif type == 'sqrWave':
ans = np.sign( sin(modulatnPhaseRadians) ) #-1 or 1. That's great because that's also sin min and max
if ans==0: ans = -1+ 2*round( np.random.rand(1)[0] ) #exception case is when 0, gives 0, so randomly change that to -1 or 1
return ans
else: print('Error! unexpected type in radiusThisFrameThisAngle')
if basicShape == 'circle':
rThis = r + waveForm('sin',speed,timeSeconds,numRing) * r * ampTemporalRadiusModulation
rThis += r * RFcontourAmp * RFcontourCalcModulation(angle,RFcontourFreq,RFcontourPhase)
x = rThis*cos(angle)
y = rThis*sin(angle)
elif basicShape == 'diamond': #actual square-shaped trajectory. Could also add all the modulations to this, later
x,y = diamondShape(constSpeedOrConstRps = False, angle=angle)
x*=r
y*=r
else:
print('Unexpected basicShape: ',basicShape)
return x,y
def angleChangeThisFrame(speed,initialDirectionEachRing, numRing, thisFrameN, lastFrameN):
angleMoveRad = initialDirectionEachRing[numRing] * speed*2*pi*(thisFrameN-lastFrameN) / refreshRate
return angleMoveRad
def alignAngleWithBlobs(angleOrigRad):
centerInMiddleOfSegment = 0 #360./numObjects/2.0 #if don't add this factor, won't center segment on angle and so won't match up with blobs of response screen
angleDeg = angleOrigRad/pi*180
angleCentered = angleDeg + centerInMiddleOfSegment
angleCentered = -1*angleCentered #multiply by -1 because with gratings, orientations is clockwise from east, contrary to Cartesian coordinates
angleCentered = angleCentered + 90 #To align with individual blob drawing method, and hence response screen, as revealed by debugDrawBothAsGratingAndAsBlobs = True
return angleCentered
def oneFrameOfStim(thisTrial,speed,currFrame,clock,useClock,offsetXYeachRing,initialDirectionEachRing,currAngleRad,blobToCueEachRing,isReversed,reversalNumEachRing,cueFrames):
#defining a function to draw each frame of stim. So can call second time for tracking task response phase
global cueRing,ringRadial, currentlyCuedBlob #makes explicit that will be working with the global vars, not creating a local variable
global angleIniEachRing, correctAnswers
angleIniEachRingRad = angleIniEachRing
#Determine what frame we are on
if useClock: #Don't count on not missing frames. Use actual time.
t = clock.getTime()
n = round(t*refreshRate)
else:
n = currFrame
if n<rampUpFrames:
contrast = cos( -pi+ pi* n/rampUpFrames ) /2. +.5 #starting from -pi trough of cos, and scale into 0->1 range
elif rampDownFrames>0 and n > rampDownStart:
contrast = cos(pi* (n-rampDownStart)/rampDownFrames ) /2.+.5 #starting from peak of cos, and scale into 0->1 range
else: contrast = 1
for numRing in range(numRings):
angleMoveRad = angleChangeThisFrame(speed,initialDirectionEachRing, numRing, n, n-1)
currAngleRad[numRing] = currAngleRad[numRing]+angleMoveRad*(isReversed[numRing])
angleObject0Rad = angleIniEachRingRad[numRing] + currAngleRad[numRing]
#Handle reversal if time for reversal
if reversalNumEachRing[numRing] <= len(reversalTimesEachRing[numRing]): #haven't exceeded reversals assigned
reversalNum = int(reversalNumEachRing[numRing])
if len( reversalTimesEachRing[numRing] ) <= reversalNum:
msg = 'Not enough reversal times allocated, reached ' +str(reversalNum)+ ' reversals at '+ str( round(reversalTimesEachRing[numRing][reversalNum-1],1) )
msg=msg+ 'and still going (only allocated the following:' + str( np.around(reversalTimesEachRing[numRing],1) )+ ' n= ' + str(round(n,1))
msg=msg+ ' current time ='+str( round(n/refreshRate,2) )+' asking for time of next one, will assume no more reversals'
logging.error(msg)
print(msg)
nextReversalTime = 9999 #didn't allocate enough, will just not reverse any more
else: #allocated enough reversals
nextReversalTime = reversalTimesEachRing[numRing][ reversalNum ]
if n > refreshRate * nextReversalTime: #have now exceeded time for this next reversal
isReversed[numRing] = -1*isReversed[numRing]
reversalNumEachRing[numRing] +=1
if drawingAsGrating or debugDrawBothAsGratingAndAsBlobs:
angleObject0Deg = alignAngleWithBlobs(angleObject0Rad)
ringRadial[numRing].setOri(angleObject0Deg)
ringRadial[numRing].setContrast( contrast )
ringRadial[numRing].draw()
if (blobToCueEachRing[numRing] != -999) and n< cueFrames: #-999 means there's no? target in that ring
#if blobToCue!=currentlyCuedBlob: #if blobToCue now is different from what was cued the first time the rings were constructed, have to make new rings
#even though this will result in skipping frames
cueRing[numRing].setOri(angleObject0Deg)
#gradually make the cue become transparent until it disappears completely (opacity=0), revealing the object
opacity = 1 - n*1.0/cueFrames #linear ramp from 1 to 0
#The above makes it transparent too quickly, so pass through a nonlinearity
# curve that decelerates towards 1,1, so will stay white for longer
opacity = sqrt( cos( (opacity-1)*pi/2 ) ) # https://www.desmos.com/calculator/jsk2ppb1yu
cueRing[numRing].setOpacity(opacity)
cueRing[numRing].draw()
#draw tracking cue on top with separate object? Because might take longer than frame to draw the entire texture
#so create a second grating which is all transparent except for one white sector. Then, rotate sector to be on top of target
if (not drawingAsGrating) or debugDrawBothAsGratingAndAsBlobs: #drawing objects individually, not as grating. This just means can't keep up with refresh rate if more than 4 objects or so
#Calculate position of each object for this frame and draw them
for nobject in range(numObjects):
angleThisObjectRad = angleObject0Rad + (2*pi)/numObjects*nobject
x,y = xyThisFrameThisAngle(thisTrial['basicShape'],radii,numRing,angleThisObjectRad,n,speed)
x += offsetXYeachRing[numRing][0]
y += offsetXYeachRing[numRing][1]
if nobject==blobToCueEachRing[numRing] and n< cueFrames: #cue in white
weightToTrueColor = n*1.0/cueFrames #compute weighted average to ramp from white to correct color
blobColor = (1.0-weightToTrueColor)*cueColor + weightToTrueColor*colors_all[nobject]
blobColor *= contrast #also might want to change contrast, if everybody's contrast changing in contrast ramp
#print('weightToTrueColor=',weightToTrueColor,' n=',n, ' blobColor=',blobColor)
else: blobColor = colors_all[0]*contrast
#referenceCircle.setPos(offsetXYeachRing[numRing]); referenceCircle.draw()
blob.setColor( blobColor, log=autoLogging )
blob.setPos([x,y])
blob.draw()
if labelBlobs: #for debugging purposes such as to check alignment with grating version
blobLabels[nobject].setPos([x,y])
blobLabels[nobject].draw()
#Drawing fixation after stimuli rather than before because gratings don't seem to mask properly, leaving them showing at center
if n%2:
fixation.draw()#flicker fixation on and off at framerate to see when skip frame
else:
fixationBlank.draw()
fixationPoint.draw()
if quickMeasurement: #be careful - drawing text in Psychopy is time-consuming, so don't do this in real testing / high frame rate
speedText.setText( str(round(currentSpeed,1)) )
speedText.draw()
if blindspotFill:
blindspotStim.draw()
return angleIniEachRingRad,currAngleRad,isReversed,reversalNumEachRing
# #######End of function that displays the stimuli #####################################
########################################################################################
showClickableRegions = False #Every time you click, show every disc's clickable region
showClickedRegion = True #Problem with code is it shows the largest ring's region always, even if the smaller ring is clicked
showClickedRegionFinal = True #Show the last click, that's actually on the cued ring
mouseClickAreaFractionOfSpaceAvailable = 0.9 #0.9 means 90% of the space available to the object is clickable
def calcMouseChoiceRadiusForRing(ring):
#For ring, need to calculate the smallest distance to another object,
# and set mouseChoiceRadius for that ring to smaller than that
#Determine the max number of objects that ever occur in a ring, because that determines how big the mouse click radius can be
# together with how far apart the rings are.
maxNumObjects = max(numObjsInRing)
#Calculate for all rings even though just want to know one
mouseChoiceRadiusEachRing = np.zeros(numRings)
minAngleBetweenObjectsOnRing = 2*pi / maxNumObjects #angle between objects on a ring
#ring0
ring0distToNextRing = radii[1] - radii[0] #distance between centers of rings 0 and 1
#Find distance between objects using the formula for a chord of a circle, 2r*sin(theta/2)
distBetweenObjectsInRing0 = 2*radii[0]*sin(minAngleBetweenObjectsOnRing/2)
mouseChoiceRadius = min(ring0distToNextRing/2, distBetweenObjectsInRing0/2)
mouseChoiceRadiusEachRing[0] = mouseChoiceRadius
#ring1
if numRings > 1:
if numRings > 2:
ring2distToRing1 = radii[2] - radii[1] #distance between centers of rings 2 and 1
ring1closestRingDist = min(ring0distToNextRing,ring2distToRing1)
else:
ring1closestRingDist = ring0distToNextRing
distBetweenObjectsInRing1 = 2*radii[1]*sin(minAngleBetweenObjectsOnRing/2) #formula for chord of a circle
mouseChoiceRadius = min(ring1closestRingDist/2, distBetweenObjectsInRing1/2)
mouseChoiceRadiusEachRing[1] = mouseChoiceRadius
#ring2
if numRings > 2: #Calculate closest distance from ring2 to the other two rings
ring2distToRing1 = radii[2] - radii[1] #distance between centers of rings 2 and 1
distBetweenObjectsInRing2 = 2*radii[2]*sin(minAngleBetweenObjectsOnRing/2) #formula for chord of a circle
mouseChoiceRadius = min(ring2distToRing1,distBetweenObjectsInRing2/2)
mouseChoiceRadiusEachRing[2] = mouseChoiceRadius
#print('Closest distance to another object, for each ring: ',mouseChoiceRadiusEachRing)
return mouseClickAreaFractionOfSpaceAvailable * mouseChoiceRadiusEachRing[ring]
def collectResponses(thisTrial,speed,n,responses,responsesAutopilot, respPromptSoundFileNum, offsetXYeachRing,respRadius,currAngle,expStop):
optionSets=numRings
#Draw/play response cues
timesRespPromptSoundPlayed=0
if timesRespPromptSoundPlayed<1: #2
if numRings > 1:
if useSound: respPromptSounds[respPromptSoundFileNum].play()
timesRespPromptSoundPlayed +=1
#respText.draw()
respondedEachToken = np.zeros([numRings,numObjects])
optionIdexs=list();baseSeq=list();numOptionsEachSet=list();numRespsNeeded=list()
numRespsNeeded = np.zeros(numRings) #potentially one response for each ring
for ring in range(numRings):
optionIdexs.append([])
noArray=list()
for k in range(numObjects):
noArray.append(colors_all[0])
baseSeq.append(np.array(noArray))
for i in range(numObjects):
optionIdexs[ring].append(baseSeq[ring][i % len(baseSeq[ring])] )
if ring == thisTrial['ringToQuery']:
numRespsNeeded[ ring ] = 1
else: numRespsNeeded[ ring ] = 0
numOptionsEachSet.append(len(optionIdexs[ring]))
respcount = 0; tClicked = 0; lastClickState=0; mouse1=0
for ring in range(optionSets):
responses.append( list() )
zeros = [0]*int(numRespsNeeded[ring])
responsesAutopilot.append( zeros ) #autopilot response is 0
passThisTrial = False;
numTimesRespSoundPlayed=0
while respcount < sum(numRespsNeeded): #collecting response
for optionSet in range(optionSets): #draw this group (ring) of options
for ncheck in range( numOptionsEachSet[optionSet] ): #draw each available to click on in this ring
angle = (angleIniEachRing[optionSet]+currAngle[optionSet]) + ncheck*1.0/numOptionsEachSet[optionSet] *2.*pi
stretchOutwardRingsFactor = 1
x,y = xyThisFrameThisAngle(thisTrial['basicShape'],radii,optionSet,angle,n,speed)
x = x+ offsetXYeachRing[optionSet][0]
y = y+ offsetXYeachRing[optionSet][1]
if not drawingAsGrating and not debugDrawBothAsGratingAndAsBlobs:
blob.setColor( colors_all[0], log=autoLogging )
blob.setPos([x,y])
blob.draw()
#draw circles around selected items. Colors are drawn in order they're in in optionsIdxs
opts=optionIdexs;
if respondedEachToken[optionSet][ncheck]: #draw circle around this one to indicate this option has been chosen
optionChosenCircle.setColor(array([1,-1,1]), log=autoLogging)
optionChosenCircle.setPos([x,y])
optionChosenCircle.draw()
#end loop for individual blobs
if drawingAsGrating: #then blobs are actually rectangles, to mimic grating wedges
ringRadial[optionSet].draw()
#end loop through rings
#Draw visual response cue, usually ring to indicate which ring is queried
if visuallyPostCue:
circlePostCue.setPos( offsetXYeachRing[ thisTrial['ringToQuery'] ] )
circlePostCue.setRadius( radii[ thisTrial['ringToQuery'] ] )
circlePostCue.lineWidth = 4 * (thisTrial['ringToQuery'] + 1) #line width scales with eccentricity, via ring number
circlePostCue.draw()
if drawingAsGrating:
circlePostCue.opacity = 0.3
mouse1, mouse2, mouse3 = myMouse.getPressed()
if mouse1 and lastClickState==0: #only count this event if is a new click. Problem is that mouse clicks continue to be pressed for along time
mouseX,mouseY = myMouse.getPos()
#supposedly in units of myWin, which is degrees, BUT
mouseFactor = 1
if (has_retina_scrn and scrn==0): #Because of a bug in Psychopy triggered by retina displays
mouseFactor = 0.5
mouseX = mouseX * mouseFactor
mouseY = mouseY * mouseFactor
if showClickedRegion:
#Determine choiceRadius for the ring the person needs to respond to
mouseChoiceRadius = calcMouseChoiceRadiusForRing( thisTrial['ringToQuery'] )
clickedRegion.setPos([mouseX,mouseY])
clickedRegion.setRadius(mouseChoiceRadius)
clickedRegion.draw()
for optionSet in range(optionSets):
mouseChoiceRadius = calcMouseChoiceRadiusForRing(optionSet)
#print('mouseChoiceRadius=',round(mouseChoiceRadius,1), 'for optionSet=',optionSet)
for ncheck in range( numOptionsEachSet[optionSet] ):
angle = (angleIniEachRing[optionSet]+currAngle[optionSet]) + ncheck*1.0/numOptionsEachSet[optionSet] *2.*pi #radians
x,y = xyThisFrameThisAngle(thisTrial['basicShape'],radii,optionSet,angle,n,speed)
x = x+ offsetXYeachRing[optionSet][0]
y = y+ offsetXYeachRing[optionSet][1]
#check whether mouse click was close to any of the colors
if showClickableRegions: #every disc's region revealed every time you click
clickableRegion.setPos([x,y])
clickableRegion.setRadius(mouseChoiceRadius)
clickableRegion.draw()
#print('mouseXY=',round(mouseX,2),',',round(mouseY,2),'xy=',round(x,2),',',round(y,2), ' distance=',distance, ' mouseChoiceRadius=',mouseChoiceRadius)
#Colors were drawn in order they're in in optionsIdxs
distance = sqrt(pow((x-mouseX),2)+pow((y-mouseY),2))