-
Notifications
You must be signed in to change notification settings - Fork 7
/
glassesViewer.m
3667 lines (3344 loc) · 150 KB
/
glassesViewer.m
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
function hm = glassesViewer(settings,selectedDir)
close all
% Cite as: Niehorster, D.C., Hessels, R.S., and Benjamins, J.S. (2020).
% GlassesViewer: Open-source software for viewing and analyzing data from
% the Tobii Pro Glasses 2 eye tracker. Behavior Research Methods. doi:
% 10.3758/s13428-019-01314-1
% temporarily silence some warnings while constructing GUI
oldWarn = warning('off','MATLAB:HandleGraphics:ObsoletedProperty:JavaFrame');
c = onCleanup(@() warning(oldWarn)); % reset warning
warning('off','MATLAB:ui:javaframe:PropertyToBeRemoved');
warning('off','MATLAB:ui:javacomponent:FunctionToBeRemoved');
warning('off','MATLAB:im2java:functionToBeRemoved');
qDEBUG = false;
if qDEBUG
dbstop if error
end
myDir = fileparts(mfilename('fullpath'));
if nargin<1 || isempty(settings)
if ~isempty(which('matlab.internal.webservices.fromJSON'))
jsondecoder = @matlab.internal.webservices.fromJSON;
elseif ~isempty(which('jsondecode'))
jsondecoder = @jsondecode;
else
error('Your MATLAB version does not provide a way to decode json (which means its really old), upgrade to something newer');
end
settings = jsondecoder(fileread(fullfile(myDir,'defaults.json')));
end
addpath(genpath(fullfile(myDir,'function_library')),...
genpath(fullfile(myDir,'user_functions')),...
genpath(fullfile(myDir,'SDparser')));
if nargin<2 || isempty(selectedDir)
% For the Glasses 2, select either the folder of a specific recording
% to open, or the projects directory copied from the SD card itself.
% So, if "projects" is the project folder on the SD card, there are
% three places that you can point the software to:
% 1. the projects folder itself
% 2. the folder of a specific project. An example of a specific project
% is: projects\raoscyb.
% 3. the folder of a specific recording. An example of a specific
% recording is: projects\raoscyb\recordings\gzz7stc. Note that the
% higher level folders are not needed when opening a recording, so
% you can just copy the "gzz7stc" folder of this example somewhere
% and open it in isolation.
%
% For the Glasses 3, select either a folder containing Glasses 3
% recording folders, or the folder of a specific recording. So if
% "root" refers to the root of the SD card itself, there are two places
% that you can point the software to:
% 1. the root folder itself
% 2. the folder of a specific recording. An example of a specific
% recording is: root\20220310T130724Z. Note that only this folder in
% isolation is needed when opening a recording, so you can just copy
% the "20220310T130724Z" folder of this example somewhere and open
% it in isolation.
if 1
selectedDir = uigetdir('','Select projects, project or recording folder');
else
% for easy use, hardcode a folder.
if 1
% example of where projects directory is selected, shows recording
% selector
selectedDir = fullfile(myDir,'demo_data','projects');
elseif 0
% example of where directory of a specific project is selected,
% shows recording selector
selectedDir = fullfile(myDir,'demo_data','projects','raoscyb');
else
% example of where a recording is directly selected
selectedDir = fullfile(myDir,'demo_data','projects','raoscyb','recordings','gzz7stc');
end
end
end
if ~selectedDir
return
end
% find out if this is G2 or G3 data and if this is a projects folder, a
% folder with recordings, or the folder of an individual recording. Take
% appropriate action
if exist(fullfile(selectedDir,'segments'),'dir') && exist(fullfile(selectedDir,'recording.json'),'file')
% G2 recording folder
recordingDir = selectedDir;
elseif exist(fullfile(selectedDir,'recording.g3'),'file')
% G3 recording folder
recordingDir = selectedDir;
else
% assume this is a project dir or a directory containing multiple
% recordings. G2ProjectParser and G3ProjectParser will fail if it is
% neither
success = G2ProjectParser(selectedDir,true);
if ~success
success = G3ProjectParser(selectedDir,true);
if ~success
error('Could not find Glasses 2 or Glasses 3 recordings or projects in the folder: %s',selectedDir);
end
end
recordingDir = recordingSelector(selectedDir);
if isempty(recordingDir)
return
end
end
% check if we have a G2 or a G3 recording selected
isG2 = exist(fullfile(recordingDir,'segments'),'dir') && exist(fullfile(recordingDir,'recording.json'),'file');
%% init figure
hm=figure();
hm.Name='Tobii Pro Glasses 2/3 Viewer';
hm.NumberTitle = 'off';
hm.Units = 'pixels';
hm.MenuBar = 'none';
hm.DockControls = 'off';
hm.ToolBar = 'none';
hm.UserData.fileDir = recordingDir;
% menu bar, to be filled later, but create early so that free space in
% figure is correctly detected
hm.UserData.menu.export .hndl = uimenu(hm,'Text','&Export');
hm.UserData.menu.coding .hndl = uimenu(hm,'Text','&Coding');
hm.UserData.menu.settings .hndl = uimenu(hm,'Text','&Settings');
% set figure to near full screen
if isprop(hm,'WindowState')
hm.WindowState = 'Maximized';
drawnow, pause(0.5); % on some systems, it appears drawnow doesn't finish executing before we hit the next line, so add a pause
pos = hm.OuterPosition;
set(hm,'WindowState','normal','OuterPosition',pos);
else
hmmar = [0 0 0 40]; % left right top bottom
ws = get(0,'ScreenSize');
hm.OuterPosition = [ws(1) + hmmar(1), ws(2) + hmmar(4), ws(3)-hmmar(1)-hmmar(2), ws(4)-hmmar(3)-hmmar(4)];
drawnow
end
hm.Visible = 'off';
drawnow
% setup callbacks for interaction
hm.CloseRequestFcn = @KillCallback;
hm.WindowKeyPressFcn = @KeyPress;
hm.WindowButtonMotionFcn= @MouseMove;
hm.WindowButtonDownFcn = @MouseClick;
hm.WindowButtonUpFcn = @MouseRelease;
% need to figure out if any DPI scaling active, some components work in
% original screen space
hm.UserData.ui.DPIScale = getDPIScale();
%% global options and starting values
hm.UserData.settings = settings;
%% load data
% read glasses data
if isG2
hm.UserData.data = readG2DataFiles(hm.UserData.fileDir,hm.UserData.settings.userStreams,qDEBUG);
else
hm.UserData.data = readG3DataFiles(hm.UserData.fileDir,hm.UserData.settings.userStreams,qDEBUG);
end
hm.UserData.data.quality = computeDataQuality(hm.UserData.fileDir, hm.UserData.data, hm.UserData.settings.dataQuality.windowLength);
hm.UserData.ui.haveEyeVideo = isfield(hm.UserData.data.video,'eye');
%% get coding setup
if isfield(hm.UserData.settings,'coding') && isfield(hm.UserData.settings.coding,'streams') && ~isempty(hm.UserData.settings.coding.streams)
hm.UserData.coding = getCodingData(hm.UserData.fileDir, '', hm.UserData.settings.coding, hm.UserData.data);
hm.UserData.coding.hasCoding = ~isempty(hm.UserData.coding.mark);
% if a coding.mat file already existed, the coding settings from there
% are taken, overwriting whatever was in the settings provided for this
% run. That is important, else an inadvertent settings change makes a
% coding.mat file unusable
% Therefore, delete hm.UserData.settings.coding, and in the below only
% use hm.UserData.coding.settings
hm.UserData.settings = rmfield(hm.UserData.settings,'coding');
else
hm.UserData.coding.hasCoding = false;
end
% update figure title
hm.Name = [hm.Name ' (' hm.UserData.data.subjName '-' hm.UserData.data.recName ')'];
%% setup time
% setup main time and timer for smooth playback
hm.UserData.time.tickPeriod = 0.05; % 20Hz hardcoded (doesn't have to update so frequently, that can't be displayed by this GUI anyway)
hm.UserData.time.timeIncrement = hm.UserData.time.tickPeriod; % change to play back at slower rate
hm.UserData.time.currentTime = max(0,hm.UserData.data.time.startTime);
hm.UserData.time.startTime = hm.UserData.data.time.startTime;
hm.UserData.time.endTime = hm.UserData.data.time.endTime;
hm.UserData.time.mainTimer = timer('Period', hm.UserData.time.tickPeriod, 'ExecutionMode', 'fixedRate', 'TimerFcn', @(~,evt) timerTick(evt,hm), 'BusyMode', 'drop', 'TasksToExecute', inf, 'StartFcn',@(~,evt) initPlayback(evt,hm));
hm.UserData.ui.doubleClickInterval = java.awt.Toolkit.getDefaultToolkit.getDesktopProperty("awt.multiClickInterval");
if isempty(hm.UserData.ui.doubleClickInterval)
% it seems the java call sometimes returns nothing, then hardcode to
% 550 ms, which is its value on my machine. If its set to something
% longer on a user's machine, the experience would not be optimal, but
% so be it.
hm.UserData.ui.doubleClickInterval = 550;
end
% this timer executes if there was no second click within double-click
% interval
hm.UserData.ui.doubleClickTimer = timer('ExecutionMode', 'singleShot', 'TimerFcn', @(~,~) clickOnAxis(hm), 'StartDelay', hm.UserData.ui.doubleClickInterval/1000);
%% setup data axes
% make test axis to see how much the margins are
temp = axes('Units','pixels','OuterPosition',[0 floor(hm.Position(4)/2) floor(hm.Position(3)/2) floor(hm.Position(4)/6)],'YLim',[-200 200]);
drawnow
opos = temp.OuterPosition;
pos = temp.Position;
temp.YLabel.String = 'azi (deg)';
drawnow
opos2 = temp.OuterPosition;
posy = temp.Position;
temp.XLabel.String = 'time (s)';
drawnow
opos3 = temp.OuterPosition;
posxy = temp.Position;
delete(temp);
assert(isequal(opos,opos2,opos3))
% determine margins
hm.UserData.plot.margin.base = pos -opos;
hm.UserData.plot.margin.y = posy -opos-hm.UserData.plot.margin.base;
hm.UserData.plot.margin.xy = posxy-opos-hm.UserData.plot.margin.base-hm.UserData.plot.margin.y;
hm.UserData.plot.margin.between = 8;
% setup plot axes
panels = getBuiltInPanels();
isUserStream= false(1,length(panels));
if isfield(hm.UserData.data,'user')
% get user streams
userStreams = fieldnames(hm.UserData.data.user);
panels = [panels userStreams(:).'];
isUserStream = [isUserStream true(1,length(userStreams))];
end
if ~hm.UserData.coding.hasCoding % if don't have coding, make sure scarf panel is not in list of panels that can be shown, nor in user setup
panels(strcmp(panels,'scarf')) = [];
hm.UserData.settings.plot.panelOrder(strcmp(hm.UserData.settings.plot.panelOrder,'scarf')) = [];
end
check = {'gyroscope','gyro';'accelerometer','acc';'magnetometer','magno'};
for p=1:size(check,1)
if ~isfield(hm.UserData.data,check{p,1})
isUserStream(strcmp(panels,check{p,2})) = [];
panels (strcmp(panels,check{p,2})) = [];
hm.UserData.settings.plot.panelOrder(strcmp(hm.UserData.settings.plot.panelOrder,check{p,2})) = [];
end
end
setupPlots(hm,panels);
% make axes and plot data
% we have:
nPanel = length(panels);
hm.UserData.plot.ax = gobjects(1,nPanel);
hm.UserData.plot.defaultValueScale = zeros(2,nPanel);
commonPropAxes = {'XGrid','on','GridLineStyle','-','NextPlot','add','Parent',hm,'XTickLabel',{},'Units','pixels','XLim',[0 hm.UserData.settings.plot.timeWindow],'Layer','top'};
commonPropPlot = {'HitTest','off','LineWidth',hm.UserData.settings.plot.lineWidth};
clrs.lr = {[1 0 0],[0 0 1]};
clrs.xyz = {[233 105 12]/255, [193 89 255]/255, [164 191 6]/255};
for a=1:nPanel
tag = panels{a};
if isfield(hm.UserData.settings.plot.streamNames,panels{a})
lbl = hm.UserData.settings.plot.streamNames.(panels{a});
else
lbl = tag;
end
if strcmp(panels{a},'scarf')
% scarf plot special axis
hm.UserData.plot.defaultValueScale(:,a) = [.5 length(hm.UserData.coding.codeCats)+.5];
hm.UserData.plot.ax(a) = axes(commonPropAxes{:},'Position',hm.UserData.plot.axPos(a,:),'YLim',hm.UserData.plot.defaultValueScale(:,a),'Tag',tag,'UserData',lbl,'YTick',0,'YTickLabel','\color{red}\rightarrow','YDir','reverse');
% for arrow indicating current stream
hm.UserData.plot.ax(a).YAxis.FontSize = 12;
hm.UserData.plot.ax(a).YAxis.TickLength(1) = 0;
else
qReverseY = false;
switch panels{a}
case 'azi' % azimuth
qReverseY = true; % left is up in plot, right is down in plot
pDat = {{hm.UserData.data.eye.left.ts , hm.UserData.data.eye.right.ts},
{hm.UserData.data.eye.left.azi, hm.UserData.data.eye.right.azi}};
case 'ele' % elevation
qReverseY = true; % so that left in plot is up, and right in plot is down
pDat = {{hm.UserData.data.eye.left.ts , hm.UserData.data.eye.right.ts},
{hm.UserData.data.eye.left.ele, hm.UserData.data.eye.right.ele}};
case 'videoGaze' % gaze point video
qReverseY = true; % to be consistent with azi and ele
pDat = {{hm.UserData.data.eye.binocular.ts}, num2cell(hm.UserData.data.eye.binocular.gp,1)};
case 'gazePoint3D' % 3D gaze point (intersection of gaze vectors)
pDat = {{hm.UserData.data.eye.binocular.ts}, num2cell(hm.UserData.data.eye.binocular.gp3,1)};
case 'vel' % velocity
hm.UserData.settings.plot.SGWindowVelocity = max(2,round(hm.UserData.settings.plot.SGWindowVelocity/1000*hm.UserData.data.eye.fs))*1000/hm.UserData.data.eye.fs; % min SG window is 2*sample duration
velL = getVelocity(hm,hm.UserData.data.eye. left,hm.UserData.settings.plot.SGWindowVelocity,hm.UserData.data.eye.fs);
velR = getVelocity(hm,hm.UserData.data.eye.right,hm.UserData.settings.plot.SGWindowVelocity,hm.UserData.data.eye.fs);
pDat = {{hm.UserData.data.eye.left.ts, hm.UserData.data.eye.right.ts},
{velL , velR}};
case 'pup' % pupil
pDat = {{hm.UserData.data.eye.left.ts, hm.UserData.data.eye.right.ts},
{hm.UserData.data.eye.left.pd, hm.UserData.data.eye.right.pd}};
case {'pupCentLeft','pupCentRight'} % pupil center
field = lower(strrep(panels{a},'pupCent',''));
pDat = {{hm.UserData.data.eye.(field) .ts}, num2cell(hm.UserData.data.eye.(field).pc,1)};
case 'gyro' % gyroscope
pDat = {{hm.UserData.data.gyroscope .ts}, num2cell(hm.UserData.data.gyroscope.gy,1)};
case 'magno' % magnetometer
pDat = {{hm.UserData.data.magnetometer.ts}, num2cell(hm.UserData.data.magnetometer.mag,1)};
case 'acc' % accelerometer
ac = hm.UserData.data.accelerometer.ac;
if hm.UserData.settings.plot.removeAccDC
ac = ac-nanmean(ac,1);
end
pDat = {{hm.UserData.data.accelerometer.ts}, num2cell(ac,1)};
otherwise
if isUserStream(a)
pDat = {{hm.UserData.data.user.(tag).ts}, num2cell(hm.UserData.data.user.(tag).data,1)};
else
error('data panel type ''%s'' not understood',tag);
end
end
[yLim,unit,pType] = getPlotLimUnitType(panels{a}, pDat, hm.UserData.settings.plot, hm.UserData.data.video.scene);
hm.UserData.plot.defaultValueScale(:,a) = yLim;
hm.UserData.plot.ax(a) = axes(commonPropAxes{:},'Position',hm.UserData.plot.axPos(a,:),'YLim',yLim,'Tag',tag,'UserData',lbl);
hm.UserData.plot.ax(a).YLabel.String = sprintf('%s (%s)',lbl,unit);
if qReverseY
hm.UserData.plot.ax(a).YDir = 'reverse';
end
for p=1:length(pDat{2})
plot(pDat{1}{min(p,end)},pDat{2}{p},'Color',clrs.(pType){p},'Parent',hm.UserData.plot.ax(a),'Tag',['data|' pType(p)],commonPropPlot{:});
end
end
end
% setup x axis of bottom plot
hm.UserData.plot.ax(end).XLabel.String = 'time (s)';
hm.UserData.plot.ax(end).XTickLabelMode = 'auto';
% setup time indicator line on each plot
hm.UserData.plot.timeIndicator = gobjects(size(hm.UserData.plot.ax));
for p=1:length(hm.UserData.plot.ax)
hm.UserData.plot.timeIndicator(p) = plot([nan nan], [-10^6 10^6],'r-','Parent',hm.UserData.plot.ax(p),'Tag',['timeIndicator|' hm.UserData.plot.ax(p).Tag]);
end
% setup coder marks
for p=1:length(hm.UserData.plot.ax)
if ~strcmp(hm.UserData.plot.ax(p).Tag,'scarf')
hm.UserData.plot.coderMarks(p) = plot([nan nan], [nan nan],'k-','Parent',hm.UserData.plot.ax(p),'Tag',['codeMark|' hm.UserData.plot.ax(p).Tag]);
end
end
if hm.UserData.coding.hasCoding
% prepare coder popup panel
createCoderPanel(hm);
% check if any of the streams are file or classifier coding streams
hm.UserData.coding.fileOrClass = ismember(lower(hm.UserData.coding.stream.type),{'classifier','filestream'});
% bool for user to indicate if data is crap
if ~isfield(hm.UserData.coding,'dataIsCrap')
hm.UserData.coding.dataIsCrap = false;
end
% save starting point
hm.UserData.ui.savedCoding = [];
saveCodingData(hm);
% set up coding shades
for p=1:length(hm.UserData.plot.ax)
if ~strcmp(hm.UserData.plot.ax(p).Tag,'scarf')
hm.UserData.plot.codeShade(p) = patch('Faces',[],'Vertices',[].','FaceVertexCData',[],'FaceColor','interp','FaceAlpha','flat','AlphaDataMapping','none','LineStyle','none','Parent',hm.UserData.plot.ax(p),'Tag',['codeShade|' hm.UserData.plot.ax(p).Tag]);
uistack(hm.UserData.plot.codeShade(p),'bottom'); % make sure shade is on the bottom
end
end
% set up scarf
ax = hm.UserData.plot.ax(strcmp({hm.UserData.plot.ax.Tag},'scarf'));
for p=1:length(hm.UserData.coding.type)
hm.UserData.plot.scarfs(p) = patch('Faces',[],'Vertices',[].','FaceVertexCData',[],'FaceColor','interp','FaceAlpha','flat','AlphaDataMapping','none','LineStyle','none','Parent',ax,'Tag',sprintf('code|%d',p));
end
uistack(hm.UserData.plot.scarfs,'bottom'); % make sure scarf is on the bottom so time indicator is on top of it
% draw actual coding, if any
hm.UserData.ui.coding.currentStream = nan;
changeCoderStream(hm,1);
updateScarf(hm);
else
delete(hm.UserData.menu.coding);
end
% set up menu
setupMenu(hm);
% plot UI for dragging time and scrolling the whole window
hm.UserData.ui.hoveringTime = false;
hm.UserData.ui.grabbedTime = false;
hm.UserData.ui.grabbedTimeLoc = nan;
hm.UserData.ui.justMovedTimeByMouse = false;
hm.UserData.ui.scrollRef = [nan nan];
hm.UserData.ui.scrollRefAx = matlab.graphics.GraphicsPlaceholder;
% UI for dragging coding markers
hm.UserData.ui.coding.grabbedMarker = false;
hm.UserData.ui.coding.grabbedMarkerLoc = [];
hm.UserData.ui.coding.hoveringMarker = false;
hm.UserData.ui.coding.hoveringWhichMarker = nan;
% UI for adding event in middle of another
hm.UserData.ui.coding.addingIntervening = false;
hm.UserData.ui.coding.addingInterveningStream = [];
hm.UserData.ui.coding.interveningTempLoc = nan;
hm.UserData.ui.coding.interveningTempElem = matlab.graphics.GraphicsPlaceholder;
% reset plot limits button
axRect = hm.UserData.plot.axRect(find(~isnan(hm.UserData.plot.axRect(:,1)), 1,'last'),:);
butPos = [axRect(3)+10 axRect(2) 100 30];
hm.UserData.ui.resetPlotLimitsButton = uicomponent('Style','pushbutton', 'Parent', hm,'Units','pixels','Position',butPos, 'String','Reset plot Y-limits','Tag','resetValueLimsButton','Callback',@(~,~,~) resetPlotValueLimits(hm));
% legend (faked with just an axis)
axHeight = 100;
axPos = [butPos(1) sum(butPos([2 4]))+10 butPos(3)*.7 axHeight];
hm.UserData.ui.signalLegend = axes('NextPlot','add','Parent',hm,'XTick',[],'YTick',[],'Units','pixels','Position',axPos,'Box','on','XLim',[0 .7],'YLim',[0 1],'YDir','reverse');
tcommon = {'VerticalAlignment','middle','Parent',hm.UserData.ui.signalLegend};
lcommon = {'Parent',hm.UserData.ui.signalLegend,'LineWidth',2};
% text+line+line+text+line+line+line = 7 elements
height = diff(hm.UserData.ui.signalLegend.YLim);
heightEach = height/6.6;
% header
text(.05,heightEach*.5,'legend','FontWeight','bold',tcommon{:});
lbls = {'left','right'};
for p=1:2
plot([.05 0.20],heightEach*(.5+p).*[1 1],'Color',clrs.lr{p},lcommon{:})
text(.25,heightEach*(.5+p),lbls{p},tcommon{:});
end
for p=1:3
plot([.05 0.20],heightEach*(3+p).*[1 1],'Color',clrs.xyz{p},lcommon{:})
text(.25,heightEach*(3+p),char('W'+p),tcommon{:});
end
%% load videos
for s=1:length(hm.UserData.data.video.scene.file)
for p=1:1+hm.UserData.ui.haveEyeVideo
switch p
case 1
field= 'scene';
case 2
field= 'eye';
end
hm.UserData.vid.objs(s,p) = makeVideoReader(fullfile(hm.UserData.fileDir,hm.UserData.data.video.(field).file{s}),false);
% for warmup, read first frame
hm.UserData.vid.objs(s,p).StreamHandle.read(1);
end
end
%% setup video on figure
% determine axis locations
if hm.UserData.ui.haveEyeVideo
% 1. right half for video. 70% of its width for scene, 30% for eye
sceneVidWidth = .7;
eyeVidWidth = .3;
assert(sceneVidWidth+eyeVidWidth==1)
sceneVidAxSz = hm.Position(3)/2*sceneVidWidth.*[1 1./hm.UserData.vid.objs(1,1).AspectRatio];
eyeVidAxSz = hm.Position(3)/2* eyeVidWidth.*[1 1./hm.UserData.vid.objs(1,2).AspectRatio];
if eyeVidAxSz(2)>hm.Position(4)
% scale down to fit
eyeVidAxSz= eyeVidAxSz.*(hm.Position(4)/eyeVidAxSz(2));
end
if eyeVidAxSz(1)+sceneVidAxSz(1)<hm.Position(3)/2
% enlarge scene video, we have some space left
leftOver = hm.Position(3)/2-eyeVidAxSz(1)-sceneVidAxSz(1);
sceneVidAxSz = (sceneVidAxSz(1)+leftOver).*[1 1./hm.UserData.vid.objs(1,1).AspectRatio];
end
axpos(1,:) = [hm.Position(3)/2 hm.Position(4)-round(sceneVidAxSz(2)) round(sceneVidAxSz(1)) round(sceneVidAxSz(2))];
axpos(2,:) = [axpos(1,1)+axpos(1,3) hm.Position(4)-round(eyeVidAxSz(2)) round(eyeVidAxSz(1)) round(eyeVidAxSz(2))];
else
% 40% of interface is for scene video
sceneVidAxSz = hm.Position(3)*.4.*[1 1./hm.UserData.vid.objs(1,1).AspectRatio];
axpos(1,:) = [hm.Position(3)*.6 hm.Position(4)-round(sceneVidAxSz(2)) round(sceneVidAxSz(1)) round(sceneVidAxSz(2))];
end
% create axes
for p=1:1+hm.UserData.ui.haveEyeVideo
hm.UserData.vid.ax(p) = axes('units','pixels','position',axpos(p,:),'visible','off');
% Setup the default axes for video display.
set(hm.UserData.vid.ax(p), ...
'Visible','off', ...
'XLim',[0.5 hm.UserData.vid.objs(1,p).Dimensions(2)+.5], ...
'YLim',[0.5 hm.UserData.vid.objs(1,p).Dimensions(1)+.5], ...
'YDir','reverse', ...
'XLimMode','manual',...
'YLimMode','manual',...
'ZLimMode','manual',...
'CLimMode','manual',...
'ALimMode','manual',...
'Layer','bottom',...
'HitTest','off',...
'NextPlot','add', ...
'DataAspectRatio',[1 1 1]);
if p==2
% for eye video, need to reverse axis
hm.UserData.vid.ax(p).XDir = 'reverse';
end
% image plot type
hm.UserData.vid.im(p) = image(...
'XData', [1 hm.UserData.vid.objs(1,p).Dimensions(2)], ...
'YData', [1 hm.UserData.vid.objs(1,p).Dimensions(1)], ...
'Tag', 'VideoImage',...
'Parent',hm.UserData.vid.ax(p),...
'HitTest','off',...
'CData',zeros(hm.UserData.vid.objs(1,p).Dimensions,'uint8'));
end
% create data trail on video
hm.UserData.vid.gt = plot(nan,nan,'r-','Parent',hm.UserData.vid.ax(1),'Visible','off','HitTest','off');
% create gaze marker (NB: size is marker area, not diameter or radius)
hm.UserData.vid.gm = scatter(0,0,'Marker','o','SizeData',10^2,'MarkerFaceColor',[0 1 0],'MarkerFaceAlpha',0.6,'MarkerEdgeColor','none','Parent',hm.UserData.vid.ax(1),'HitTest','off');
% if recording consists of multiple segments, find switch point
hm.UserData.vid.switchFrames(:,1) = [0 cumsum(hm.UserData.data.video.scene.segframes)];
if hm.UserData.ui.haveEyeVideo
hm.UserData.vid.switchFrames(:,2) = [0 cumsum(hm.UserData.data.video.eye.segframes)];
end
hm.UserData.vid.currentFrame = [0 0];
%% setup play controls
hm.UserData.ui.VCR.state.playing = false;
hm.UserData.ui.VCR.state.cyclePlay = false;
vidPos = hm.UserData.vid.ax(1).Position;
% slider for rapid time navigation
sliderSz = [vidPos(3) 40];
sliderPos= [vidPos(1) vidPos(2)-sliderSz(2) sliderSz];
hm.UserData.ui.VCR.slider.fac= 100;
hm.UserData.ui.VCR.slider.raw = com.jidesoft.swing.RangeSlider(0,hm.UserData.time.endTime*hm.UserData.ui.VCR.slider.fac,0,hm.UserData.settings.plot.timeWindow*hm.UserData.ui.VCR.slider.fac);
hm.UserData.ui.VCR.slider.jComp = uicomponent(hm.UserData.ui.VCR.slider.raw,'Parent',hm,'Units','pixels','Position',sliderPos);
hm.UserData.ui.VCR.slider.jComp.StateChangedCallback = @(hndl,evt) sliderChange(hm,hndl,evt);
hm.UserData.ui.VCR.slider.jComp.MousePressedCallback = @(hndl,evt) sliderClick(hm,hndl,evt);
hm.UserData.ui.VCR.slider.jComp.KeyPressedCallback = @(hndl,evt) KeyPress(hm,hndl,evt);
hm.UserData.ui.VCR.slider.jComp.SnapToTicks = false;
hm.UserData.ui.VCR.slider.jComp.PaintTicks = true;
hm.UserData.ui.VCR.slider.jComp.PaintLabels = true;
hm.UserData.ui.VCR.slider.jComp.RangeDraggable = false; % doesn't work together with overridden click handling logic. don't want to try and detect dragging and then cancel click logic, too complicated
% draw extra line indicating timepoint
% Need end points of actual range in slider, get later when GUI is fully
% instantiated
hm.UserData.ui.VCR.slider.left = nan;
hm.UserData.ui.VCR.slider.right = nan;
hm.UserData.ui.VCR.slider.offset= sliderPos(1:2);
lineSz = round([2 sliderSz(2)/2]*hm.UserData.ui.DPIScale);
hm.UserData.ui.VCR.line.raw = javax.swing.JLabel(javax.swing.ImageIcon(im2java(cat(3,ones(lineSz([2 1])),zeros(lineSz([2 1])),zeros(lineSz([2 1]))))));
hm.UserData.ui.VCR.line.jComp = uicomponent(hm.UserData.ui.VCR.line.raw,'Parent',hm,'Units','pixels','Position',[vidPos(1) vidPos(2)-lineSz(2)*2/hm.UserData.ui.DPIScale lineSz./hm.UserData.ui.DPIScale]);
% figure out tick spacing and make custom labels
labelTable = java.util.Hashtable();
% divide into no more than 12 intervals
if ceil(hm.UserData.time.endTime/11)>60
% minutes
stepLbls = 0:ceil(hm.UserData.time.endTime/60/11):hm.UserData.time.endTime/60;
steps = stepLbls*60;
toolTip = 'time (minutes)';
else
% seconds
stepLbls = 0:ceil(hm.UserData.time.endTime/11):hm.UserData.time.endTime;
steps = stepLbls;
toolTip = 'time (seconds)';
end
for p=1:length(stepLbls)
labelTable.put( int32( steps(p)*hm.UserData.ui.VCR.slider.fac ), javax.swing.JLabel(sprintf('%d',stepLbls(p))) );
end
hm.UserData.ui.VCR.slider.jComp.ToolTipText = toolTip;
hm.UserData.ui.VCR.slider.jComp.LabelTable=labelTable;
hm.UserData.ui.VCR.slider.jComp.MajorTickSpacing = steps(2)*hm.UserData.ui.VCR.slider.fac;
hm.UserData.ui.VCR.slider.jComp.MinorTickSpacing = steps(2)/5*hm.UserData.ui.VCR.slider.fac;
% usual VCR buttons, and a few special ones
butSz = [30 30];
gfx = load('icons');
seekShort = hm.UserData.settings.VCR.seekShort/hm.UserData.data.eye.fs;
seekLong = hm.UserData.settings.VCR.seekLong /hm.UserData.data.eye.fs;
buttons = {
'pushbutton','PrevWindow','|jump_to','Previous window',@(~,~,~) jumpWin(hm,-1),{}
'pushbutton','NextWindow','jump_to','Next window',@(~,~,~) jumpWin(hm, 1),{}
'space','','','','',''
'pushbutton','GotoStart','goto_start_default','Go to start',@(~,~,~) seek(hm,-inf),{}
'pushbutton','Rewind','rewind_default','Jump back (1 s)',@(~,~,~) seek(hm,-seekLong),{}
'pushbutton','StepBack','step_back','Step back (1 sample)',@(~,~,~) seek(hm,-seekShort),{}
%'Stop',{'stop_default'}
'pushbutton','Play',{'play_on', 'pause_default'},{'Play','Pause'},@(src,~,~) startStopPlay(hm,-1,src),{}
'pushbutton','StepFwd','step_fwd', 'Step forward (1 sample)',@(~,~,~) seek(hm,seekShort),{}
'pushbutton','FFwd','ffwd_default', 'Jump forward (1 s)',@(~,~,~) seek(hm,seekLong),{}
'pushbutton','GotoEnd','goto_end_default', 'Go to end',@(~,~,~) seek(hm,inf),{}
'space','','','','',''
'togglebutton','Cycle','repeat_on', {'Cycle in time window','Play normally'},@(src,~,~) toggleCycle(hm,src),{}
'togglebutton','Trail','revertToScope', {'Switch on data trail','Switch off data trail'},@(src,~,~) toggleDataTrail(hm,src),{}
};
totSz = [size(buttons,1)*butSz(1) butSz(2)];
left = vidPos(1)+vidPos(3)/2-totSz(1)/2;
bottom = vidPos(2)-2-butSz(2)-sliderSz(2);
% get gfx
for p=1:size(buttons,1)
if strcmp(buttons{p,1},'space')
continue;
end
if iscell(buttons{p,3})
buttons{p,3} = cellfun(@(x) getIcon(gfx,x),buttons{p,3},'uni',false);
else
buttons{p,3} = getIcon(gfx,buttons{p,3});
end
end
% create buttons
hm.UserData.ui.VCR.but = gobjects(1,size(buttons,1));
for p=1:size(buttons,1)
if strcmp(buttons{p,1},'space')
continue;
end
icon = buttons{p,3};
toolt= buttons{p,4};
UsrDat = [];
if iscell(buttons{p,3})
icon = buttons{p,3}{1};
UsrDat= [UsrDat buttons(p,3)];
end
if iscell(buttons{p,4})
toolt= buttons{p,4}{1};
UsrDat= [UsrDat buttons(p,4)];
end
hm.UserData.ui.VCR.but(p) = uicontrol(...
'Style',buttons{p,1},...
'Tag',buttons{p,2},...
'Position',[left+(p-1)*butSz(1) bottom butSz],...
'TooltipString',toolt,...
'CData',icon,...
'Callback',buttons{p,5},...
buttons{p,6}{:},...
'UserData',UsrDat...
);
end
%% all done, make sure GUI is shown
hm.Visible = 'on';
drawnow;
doPostInit(hm,panels);
updateTime(hm);
drawnow;
if nargout==0
% assign hm in base, so we can just run this function with F5 and still
% observe state of the GUI from the command line
assignin('base','hm',hm);
end
end
%% helpers etc
function setupMenu(hm)
% export
% data streams
mitem = uimenu(hm.UserData.menu.export.hndl,'Text','&Data streams');
mitem2 = uimenu(mitem,'Text','Export &full recording to tsv','Callback',@(~,~) saveDataStreamsTSV(hm));
mitem2 = uimenu(mitem,'Text','Export &segments to tsv');
setupCodingStreamMenu(hm,mitem2, @(s,c) @(~,~) saveDataStreamsTSV(hm,s,c));
% data quality
mitem = uimenu(hm.UserData.menu.export.hndl,'Text','Data &quality');
mitem2 = uimenu(mitem,'Text','Store to tsv for &full recording','CallBack',@(~,~) saveDataQualityTSV(hm));
mitem2 = uimenu(mitem,'Text','Store to tsv for &segments');
setupCodingStreamMenu(hm,mitem2, @(s,c) @(~,~) saveDataQualityTSV(hm,s,c));
% scene video
hm.UserData.menu.export.video = uimenu(hm.UserData.menu.export.hndl,...
'Text','Export scene &video with gaze',...
'CallBack',@(~,~) saveSceneVideo(hm,hm.UserData.settings.export.sceneVideo));
% coding
if hm.UserData.coding.hasCoding
% save coding to mat file
mitem = uimenu(hm.UserData.menu.coding.hndl,'Text','Save to coding.&mat');
mitem.MenuSelectedFcn = @(~,~) saveCodingData(hm);
hm.UserData.menu.coding.matSave = mitem;
% save coding to tsv
mitem = uimenu(hm.UserData.menu.coding.hndl,'Text','Save coding stream to &tsv');
for s=1:length(hm.UserData.coding.codeCats)
uimenu(mitem,...
'Text', sprintf('&%d: %s', s, hm.UserData.coding.stream.lbls{s}),...
'CallBack', @(~,~) saveCodingDataTSV(hm,s));
end
% crap data tick
uimenu(hm.UserData.menu.coding.hndl,'Text','Mark this recording as &crap data','Separator','on',...
'MenuSelectedFcn',@(hndl,~) toggleCrapData(hm,hndl));
% reload coding button (in effect undoes manual changes)
if any(hm.UserData.coding.fileOrClass)
mitem = uimenu(hm.UserData.menu.coding.hndl,...
'Text','&Remove manual coding changes', 'Separator','on');
iStream = find(hm.UserData.coding.fileOrClass);
for s=1:length(iStream)
txt = 'Reload file';
if strcmpi(hm.UserData.coding.stream.type{iStream(s)},'classifier')
txt = 'Recompute classification';
end
hm.UserData.menu.coding.reload(s).stream = iStream(s);
hm.UserData.menu.coding.reload(s).obj = uimenu(mitem,...
'Text', sprintf('%s for &%d: %s', txt, iStream(s), hm.UserData.coding.stream.lbls{s}),...
'CallBack', @(~,~) executeCodingReload(hm,s));
end
end
% classifier settings
iClass = find(strcmpi(hm.UserData.coding.stream.type,'classifier'));
qHasSettable = cellfun(@(p) any(cellfun(@(x) isfield(x,'settable') && x.settable,p)),hm.UserData.coding.stream.classifier.currentSettings(iClass));
iClass(~qHasSettable) = [];
if ~isempty(iClass)
mitem = uimenu(hm.UserData.menu.coding.hndl, 'Text', 'Classifier &settings');
for s=1:length(iClass)
uimenu(mitem,...
'Text', sprintf('&%d: %s', iClass(s), hm.UserData.coding.stream.lbls{iClass(s)}),...
'CallBack', @(~,~) openClassifierSettingsPanel(hm,s)); % NB: not iClass(s)!
end
createClassifierPopups(hm,iClass);
else
hm.UserData.ui.coding.classifierSettingPopup = [];
end
% make sure menus are in correct state
updateCodingMenuStates(hm);
end
% settings
uimenu(hm.UserData.menu.settings.hndl,'Text','&Change plot order and shown axes','CallBack',@(~,~)showPlotArrangerPopup(hm));
createPlotArrangerPopup(hm);
% NB: other menu items are added to settings menu in doPostInit()
end
function saveDataStreamsTSV(hm,csIdx,cIdx)
qFullFile = nargin<2;
if qFullFile
saveDataToTSV(hm.UserData.data,hm.UserData.fileDir,'full');
else
filenameSuffix = makeValidFilename(sprintf('coding_%d_%s_%d_%s',csIdx,hm.UserData.coding.stream.lbls{csIdx},cIdx,getCodingCategoryName(hm.UserData.coding.codeCats{csIdx}{cIdx,1})));
% get
iCode = find(~~bitand(hm.UserData.coding.type{csIdx},hm.UserData.coding.codeCats{csIdx}{cIdx,2}));
intervalTs = [hm.UserData.coding.mark{csIdx}(iCode); hm.UserData.coding.mark{csIdx}(iCode+1)].';
% store
saveDataToTSV(hm.UserData.data,hm.UserData.fileDir,filenameSuffix,{},intervalTs);
end
end
function saveDataQualityTSV(hm,csIdx,cIdx)
qFullFile = nargin<2;
if qFullFile
saveDataQualityToTSV(hm.UserData.data,hm.UserData.fileDir,hm.UserData.settings.dataQuality.windowLength,'full');
else
filenameSuffix = makeValidFilename(sprintf('coding_%d_%s_%d_%s',csIdx,hm.UserData.coding.stream.lbls{csIdx},cIdx,getCodingCategoryName(hm.UserData.coding.codeCats{csIdx}{cIdx,1})));
% get
iCode = find(~~bitand(hm.UserData.coding.type{csIdx},hm.UserData.coding.codeCats{csIdx}{cIdx,2}));
intervalTs = [hm.UserData.coding.mark{csIdx}(iCode); hm.UserData.coding.mark{csIdx}(iCode+1)].';
% store
saveDataQualityToTSV(hm.UserData.data,hm.UserData.fileDir,hm.UserData.settings.dataQuality.windowLength,filenameSuffix,intervalTs);
end
end
function setupCodingStreamMenu(hm,parent,callback)
for s=1:length(hm.UserData.coding.codeCats)
stream = uimenu(parent,'Text',sprintf('&%d: %s',s,hm.UserData.coding.stream.lbls{s}));
for c=1:size(hm.UserData.coding.codeCats{s},1)
name = getCodingCategoryName(hm.UserData.coding.codeCats{s}{c,1});
uimenu(stream,'Text',sprintf('&%d: %s',hm.UserData.coding.codeCats{s}{c,2},name),'CallBack',callback(s,c));
end
end
end
function saveSceneVideo(hm,exportSettings)
text = hm.UserData.menu.export.video.Text;
hm.UserData.menu.export.video.Enable = 'off';
callback = @(x) textUpdater(hm.UserData.menu.export.video, sprintf('Exporting scene video, %d%%...',x));
ffmpegPath = '';
if isfield(exportSettings,'ffmpegPath')
ffmpegPath = exportSettings.ffmpegPath;
end
saveSceneVideoWithGaze(hm.UserData.data,hm.UserData.fileDir,exportSettings.clrs,exportSettings.alpha,ffmpegPath,callback);
hm.UserData.menu.export.video.Text = text;
hm.UserData.menu.export.video.Enable = 'on';
end
function textUpdater(object,text)
object.Text = text;
end
function name = getCodingCategoryName(name)
name(name=='*'|name=='+') = [];
end
function saveCodingData(hm)
coding = rmfield(hm.UserData.coding,'hasCoding');
fname = 'coding.mat';
save(fullfile(hm.UserData.fileDir,fname),'-struct', 'coding');
% store copy of what we just saved, so we can check if new save is needed
% by user
hm.UserData.ui.savedCoding = coding;
updateCodingMenuStates(hm);
end
function saveCodingDataTSV(hm,sIdx)
fname = makeValidFilename(sprintf('coding_%d_%s.tsv',sIdx,hm.UserData.coding.stream.lbls{sIdx}));
fid = fopen(fullfile(hm.UserData.fileDir,fname),'wt');
fprintf(fid,'index\tcategory\tstart_time\tend_time\tcam_pos_x\tcam_pos_y\tleft_azi\tleft_ele\tright_azi\tright_ele\n');
% make labels
catNames= hm.UserData.coding.codeCats{sIdx}(:,1);
for c=1:size(catNames,1)
catNames{c} = getCodingCategoryName(catNames{c});
end
% run through codings, store to file
for c=1:length(hm.UserData.coding.type{sIdx})
bits = find(getCodeBits(hm.UserData.coding.type{sIdx}(c)));
times = hm.UserData.coding.mark{sIdx}(c:c+1);
qDat = hm.UserData.data.eye.binocular.ts>=times(1) & hm.UserData.data.eye.binocular.ts<=times(2);
camPos= mean(hm.UserData.data.eye.binocular.gp(qDat,:),1,'omitnan');
qDat = hm.UserData.data.eye. left.ts>=times(1) & hm.UserData.data.eye. left.ts<=times(2);
oriL = mean([hm.UserData.data.eye. left.azi(qDat) hm.UserData.data.eye. left.ele(qDat)],1,'omitnan');
qDat = hm.UserData.data.eye. right.ts>=times(1) & hm.UserData.data.eye. right.ts<=times(2);
oriR = mean([hm.UserData.data.eye. right.azi(qDat) hm.UserData.data.eye.right.ele(qDat)],1,'omitnan');
for b=1:length(bits)
fprintf(fid,'%d\t%s\t%.4f\t%.4f\t%.2f\t%.2f\t%.4f\t%.4f\t%.4f\t%.4f\n',c,catNames{bits(b)},times,camPos,oriL,oriR);
end
end
fclose(fid);
end
function filename = makeValidFilename(filename)
% just delete illegal characters (cross-platform set of illegal chars)
illegal = [ '/', char(10), char(13), char(9), char(0), char(12), '`', '?', '*', '\', '<', '>', '|', '"', ':' ]; %#ok<CHARTEN>
filename(ismember(filename,illegal)) = [];
end
function bits = getCodeBits(code)
bits = fliplr(rem(floor(code*pow2(1-16:0)),2)); % up to 16 codes
end
function updateCodingMenuStates(hm)
% save button
if isfield(hm.UserData.ui,'savedCoding')
if hasUnsavedCoding(hm)
hm.UserData.menu.coding.matSave.Enable = 'on';
else
hm.UserData.menu.coding.matSave.Enable = 'off';
end
end
% reload actions, if any
if isfield(hm.UserData.ui.coding,'reload')
for s=1:length(hm.UserData.menu.coding.reload)
stream = hm.UserData.menu.coding.reload(s).stream;
isManuallyChanged = ~isequal(hm.UserData.coding.mark{stream},hm.UserData.coding.original.mark{stream}) || ~isequal(hm.UserData.coding.type{stream},hm.UserData.coding.original.type{stream});
if isManuallyChanged
hm.UserData.menu.coding.reload(s).obj.Enable = 'on';
else
hm.UserData.menu.coding.reload(s).obj.Enable = 'off';
end
end
end
end
function hasUnsaved = hasUnsavedCoding(hm)
% ignore log when checking for equality
testCoding = hm.UserData.coding;
hasUnsaved = isfield(hm.UserData.ui,'savedCoding') && ~isequal(rmFieldOrContinue(hm.UserData.ui.savedCoding,'log'),rmFieldOrContinue(testCoding,{'log','hasCoding'}));
end
function setupPlots(hm,plotOrder,nTotal)
nPanel = length(plotOrder);
iScarf = find(strcmp(plotOrder,'scarf'));
qHaveScarf = ~isempty(iScarf);
nFullPanel = nPanel-numel(iScarf);
if nargin<3
nTotal = nPanel;
end
if hm.UserData.ui.haveEyeVideo
widthFac = .5;
else
widthFac = .6;
end
if qHaveScarf
scarfHeight = hm.UserData.settings.plot.scarfHeight*length(hm.UserData.coding.codeCats);
else
scarfHeight = 0;
end
width = widthFac*hm.Position(3)-hm.UserData.plot.margin.base(1)-hm.UserData.plot.margin.y(1); % half of window width, but leave space left of axis for tick labels and axis label
height = (hm.Position(4) -(nPanel-1)*hm.UserData.plot.margin.between -hm.UserData.plot.margin.base(2)-hm.UserData.plot.margin.xy(2) -scarfHeight*qHaveScarf)/nFullPanel; % vertical height of window, minus nPanel-1 times space between panels, minus space below axis for tick labels and axis label
left = hm.UserData.plot.margin.base(1)+hm.UserData.plot.margin.y(1); % leave space left of axis for tick labels and axis label
heights = repmat(height,nPanel,1);
heights(iScarf) = scarfHeight;
bottom = repmat(hm.Position(4),nPanel,1)-cumsum(heights)-cumsum([0; repmat(hm.UserData.plot.margin.between,nPanel-1,1)]);
hm.UserData.plot.axPos = [repmat(left,nPanel,1) bottom repmat(width,nPanel,1) heights];
if nPanel<nTotal
% add place holders, need to preserve shape
hm.UserData.plot.axPos = [hm.UserData.plot.axPos; nan(nTotal-nPanel,4)];
end
hm.UserData.plot.axRect= [hm.UserData.plot.axPos(:,1:2) hm.UserData.plot.axPos(:,1:2)+hm.UserData.plot.axPos(:,3:4)];
end
function vel = getVelocity(hm,data,velWindow,fs)
% span of filter, use minimum length of saccade. Its very important to not
% make the filter window much wider than the narrowest feature we are
% interested in, or we'll smooth out those features too much.
window = ceil(velWindow/1000*fs);
% number of filter taps
ntaps = 2*ceil(window)-1;
% polynomial order
pn = 2;
% differentiation order
dn = 1;
tempV = [data.azi data.ele];
if pn < ntaps
% smoothed deriv
tempV = -savitzkyGolayFilt(tempV,pn,dn,ntaps) * fs;
else
% numerical deriv
tempV = diff(tempV,1,1);
% make same length as position trace by repeating first sample
tempV = tempV([1 1:end],:) * fs;
end
% indicate too small window by coloring spinner red
if isfield(hm.UserData.menu,'settings') && isfield(hm.UserData.menu.settings,'LWSpinner')
obj = hm.UserData.menu.settings.LWSpinner;
obj = obj.Editor().getTextField().getBackground;
clr = [obj.getRed obj.getGreen obj.getBlue]./255;
obj = hm.UserData.menu.settings.SGSpinner;
if pn >= ntaps
clr(2:3) = .5;
end
obj.Editor().getTextField().setBackground(javax.swing.plaf.ColorUIResource(clr(1),clr(2),clr(3)));
end
% Calculate eye velocity and acceleration straightforwardly by applying
% Pythagoras' theorem. This gives us no information about the
% instantaneous axis of the eye rotation, but eye velocity is
% calculated correctly. Apply scale for velocity, as a 10deg azimuth
% rotation at 0deg elevation does not cover same distance as it does at
% 45deg elevation: sqrt(theta_dot^2*cos^2 phi + phi_dot^2)
vel = hypot(tempV(:,1).*cosd(data.ele), tempV(:,2));
end
function doZoom(hm,evt)
ax = evt.Axes;
% set new time window size
setTimeWindow(hm,diff(ax.XLim),false);
% set new left of it
setPlotView(hm,ax.XLim(1));
% nothing to do for vertical scaling, all elements by far exceed reasonable
% axis limits
end
function scrollFunc(hm,~,evt)
% scroll wheel was spun:
% 1. if control held down: zoom the time axis
% 2. if shift held down: zoom value axis
if evt.isControlDown || evt.isShiftDown
ax = hitTestType(hm,'axes'); % works because we have a WindowButtonMotionFcn installed
if ~isempty(ax) && any(ax==hm.UserData.plot.ax)
posInDat = ax.CurrentPoint(1,1:2);
if evt.isControlDown
% zoom time axis
% get wheel rotation (1: top of wheel toward user, -1 top of wheel
% away from user). Toward will be zoom in, away zoom out
zoomFac = 1-evt.getPreciseWheelRotation*.05;
% determine new timeWindow
setTimeWindow(hm,min(zoomFac*hm.UserData.settings.plot.timeWindow,hm.UserData.time.endTime),false);
% determine left of window such that time under cursor does not
% move
bottom = max(posInDat(1)-(posInDat(1)-ax.XLim(1))*zoomFac,0);
% apply new limits
setPlotView(hm,bottom);
else
% zoom value axis
% if scarf plot, do not scale
if strcmp(ax.Tag,'scarf')
return
end
% get current range
range = diff(ax.YLim);