tracky-mouse 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tracky-mouse",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Add facial mouse accessibility to JavaScript applications",
5
5
  "license": "MIT",
6
6
  "author": {
package/tracky-mouse.css CHANGED
@@ -268,4 +268,22 @@ body:not(.tracky-mouse-manual-takeback) .tracky-mouse-manual-takeback-indicator
268
268
  .tracky-mouse-ui a:link,
269
269
  .tracky-mouse-ui a:visited {
270
270
  color: rgb(135 0 191);
271
+ }
272
+
273
+ .tracky-mouse-controls details {
274
+ border: 1px solid rgba(0, 0, 0, 0.3);
275
+ border-radius: 4px;
276
+ margin-bottom: 8px;
277
+ }
278
+
279
+ .tracky-mouse-controls details summary {
280
+ cursor: pointer;
281
+ user-select: none;
282
+ font-weight: bold;
283
+ background: rgba(135, 0, 191, 0.1);
284
+ padding: 8px;
285
+ }
286
+
287
+ .tracky-mouse-controls details .tracky-mouse-details-body {
288
+ padding: 8px;
271
289
  }
package/tracky-mouse.js CHANGED
@@ -565,87 +565,129 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
565
565
  <button class="tracky-mouse-start-stop-button" aria-pressed="false" aria-keyshortcuts="F9">Start</button>
566
566
  <br>
567
567
  <br>
568
- <label class="tracky-mouse-control-row">
569
- <span class="tracky-mouse-label-text">Horizontal Sensitivity</span>
570
- <span class="tracky-mouse-labeled-slider">
571
- <input type="range" min="0" max="100" value="25" class="tracky-mouse-sensitivity-x">
572
- <span class="tracky-mouse-min-label">Slow</span>
573
- <span class="tracky-mouse-max-label">Fast</span>
574
- </span>
575
- </label>
576
- <label class="tracky-mouse-control-row">
577
- <span class="tracky-mouse-label-text">Vertical Sensitivity</span>
578
- <span class="tracky-mouse-labeled-slider">
579
- <input type="range" min="0" max="100" value="50" class="tracky-mouse-sensitivity-y">
580
- <span class="tracky-mouse-min-label">Slow</span>
581
- <span class="tracky-mouse-max-label">Fast</span>
582
- </span>
583
- </label>
584
- <!-- <label class="tracky-mouse-control-row">
585
- <span class="tracky-mouse-label-text">Smoothing</span>
586
- <span class="tracky-mouse-labeled-slider">
587
- <input type="range" min="0" max="100" value="50" class="tracky-mouse-smoothing">
588
- <span class="tracky-mouse-min-label"></span>
589
- <span class="tracky-mouse-max-label"></span>
590
- </span>
591
- </label> -->
592
- <label class="tracky-mouse-control-row">
593
- <span class="tracky-mouse-label-text">Acceleration</span>
594
- <span class="tracky-mouse-labeled-slider">
595
- <input type="range" min="0" max="100" value="50" class="tracky-mouse-acceleration">
596
- <!-- TODO: "Linear" could be described as "Fast", and the other "Fast" labels are on the other side. Should it be swapped? What does other software with acceleration control look like? In Windows it's just a checkbox apparently, but it could go as far as a custom curve editor. -->
597
- <span class="tracky-mouse-min-label">Linear</span>
598
- <span class="tracky-mouse-max-label">Smooth</span>
599
- </span>
600
- </label>
601
- <!-- <label class="tracky-mouse-control-row">
602
- <span class="tracky-mouse-label-text">Easy Stop (min distance to move)</span>
603
- <span class="tracky-mouse-labeled-slider">
604
- <input type="range" min="0" max="100" value="50" class="tracky-mouse-min-distance">
605
- <span class="tracky-mouse-min-label">Jittery</span>
606
- <span class="tracky-mouse-max-label">Steady</span>
607
- </span>
608
- </label> -->
609
- <br>
610
- <!-- special interest: jspaint wants label not to use parent-child relationship so that os-gui's 98.css checkbox styles can work -->
611
- <!-- though this option might not be wanted in jspaint; might be good to hide it in the embedded case, or make it optional -->
612
- <!-- also TODO: add description of what this is for: on Windows, currently, when buttons are swapped at the system level, it affects serenade-driver's click() -->
613
- <!-- also this may be seen as a weirdly named/designed option for right-clicking -->
614
- <!-- btw: label is selected based on 'for' attribute -->
615
- <div class="tracky-mouse-control-row">
616
- <input type="checkbox" id="tracky-mouse-swap-mouse-buttons"/>
617
- <label for="tracky-mouse-swap-mouse-buttons"><span class="tracky-mouse-label-text">Swap mouse buttons</span></label>
618
- </div>
619
- <div class="tracky-mouse-control-row">
620
- <label for="tracky-mouse-clicking-mode"><span class="tracky-mouse-label-text">Clicking mode:</span></label>
621
- <select id="tracky-mouse-clicking-mode">
622
- <option value="dwell">Dwell to click</option>
623
- <option value="blink">Wink to click (Experimental)</option>
624
- <option value="open-mouth">Open mouth to click (Experimental)</option>
625
- <option value="off">Off</option>
626
- </select>
627
- </div>
628
- <br>
629
- <!-- special interest: jspaint wants label not to use parent-child relationship so that os-gui's 98.css checkbox styles can work -->
630
- <!-- opposite, "Start paused", might be clearer, especially if I add a "pause" button -->
631
- <div class="tracky-mouse-control-row">
632
- <input type="checkbox" id="tracky-mouse-start-enabled"/>
633
- <label for="tracky-mouse-start-enabled"><span class="tracky-mouse-label-text">Start enabled</span></label>
634
- </div>
635
- <br>
636
- <!-- special interest: jspaint wants label not to use parent-child relationship so that os-gui's 98.css checkbox styles can work -->
637
- <div class="tracky-mouse-control-row">
638
- <input type="checkbox" id="tracky-mouse-run-at-login"/>
639
- <label for="tracky-mouse-run-at-login"><span class="tracky-mouse-label-text">Run at login</span></label>
640
- </div>
641
- <br>
642
- <!-- special interest: jspaint wants label not to use parent-child relationship so that os-gui's 98.css checkbox styles can work -->
643
- <!-- TODO: try moving this to the corner of the camera view, so it's clearer it applies only to the camera view -->
644
- <div class="tracky-mouse-control-row">
645
- <input type="checkbox" checked id="tracky-mouse-mirror"/>
646
- <label for="tracky-mouse-mirror"><span class="tracky-mouse-label-text">Mirror</span></label>
647
- </div>
648
- <br>
568
+ <details>
569
+ <summary>Head Tracking</summary>
570
+ <div class="tracky-mouse-details-body">
571
+ <label class="tracky-mouse-control-row">
572
+ <span class="tracky-mouse-label-text">Horizontal sensitivity</span>
573
+ <span class="tracky-mouse-labeled-slider">
574
+ <input type="range" min="0" max="100" value="25" class="tracky-mouse-sensitivity-x">
575
+ <span class="tracky-mouse-min-label">Slow</span>
576
+ <span class="tracky-mouse-max-label">Fast</span>
577
+ </span>
578
+ </label>
579
+ <label class="tracky-mouse-control-row">
580
+ <span class="tracky-mouse-label-text">Vertical sensitivity</span>
581
+ <span class="tracky-mouse-labeled-slider">
582
+ <input type="range" min="0" max="100" value="50" class="tracky-mouse-sensitivity-y">
583
+ <span class="tracky-mouse-min-label">Slow</span>
584
+ <span class="tracky-mouse-max-label">Fast</span>
585
+ </span>
586
+ </label>
587
+ <!-- <label class="tracky-mouse-control-row">
588
+ <span class="tracky-mouse-label-text">Smoothing</span>
589
+ <span class="tracky-mouse-labeled-slider">
590
+ <input type="range" min="0" max="100" value="50" class="tracky-mouse-smoothing">
591
+ <span class="tracky-mouse-min-label"></span>
592
+ <span class="tracky-mouse-max-label"></span>
593
+ </span>
594
+ </label> -->
595
+ <label class="tracky-mouse-control-row">
596
+ <span class="tracky-mouse-label-text">Acceleration</span>
597
+ <span class="tracky-mouse-labeled-slider">
598
+ <input type="range" min="0" max="100" value="50" class="tracky-mouse-acceleration">
599
+ <!-- TODO: "Linear" could be described as "Fast", and the other "Fast" labels are on the other side. Should it be swapped? What does other software with acceleration control look like? In Windows it's just a checkbox apparently, but it could go as far as a custom curve editor. -->
600
+ <span class="tracky-mouse-min-label">Linear</span>
601
+ <span class="tracky-mouse-max-label">Smooth</span>
602
+ </span>
603
+ </label>
604
+ <label class="tracky-mouse-control-row">
605
+ <span class="tracky-mouse-label-text">Motion threshold</span>
606
+ <span class="tracky-mouse-labeled-slider">
607
+ <input type="range" min="0" max="10" value="0" class="tracky-mouse-min-distance">
608
+ <span class="tracky-mouse-min-label">Free</span>
609
+ <span class="tracky-mouse-max-label">Steady</span>
610
+ </span>
611
+ </label>
612
+ </div>
613
+ </details>
614
+ <!--
615
+ Only dwell clicking is supported by the web library right now.
616
+ Currently it's a separate API (TrackyMouse.initDwellClicking)
617
+ TODO: bring more of desktop app functionality into core
618
+ https://github.com/1j01/tracky-mouse/issues/72
619
+
620
+ Also, the "Swap mouse buttons" setting is likely not useful for
621
+ web apps embedding Tracky Mouse and designed for head trackers,
622
+ since it necessitates mode switching for dwell clicker usage,
623
+ so it may make sense to hide (or not) even if it is supported there in the future.
624
+ The main point of this option is to counteract the system-level mouse button setting,
625
+ which awkwardly affects what mouse button serenade-driver sends; this doesn't affect the web version.
626
+ -->
627
+ <details class="tracky-mouse-desktop-only">
628
+ <summary>Clicking</summary>
629
+ <div class="tracky-mouse-details-body">
630
+ <div class="tracky-mouse-control-row">
631
+ <label for="tracky-mouse-clicking-mode"><span class="tracky-mouse-label-text">Clicking mode:</span></label>
632
+ <select id="tracky-mouse-clicking-mode">
633
+ <option value="dwell">Dwell to click</option>
634
+ <option value="blink">Wink to click</option>
635
+ <option value="open-mouth">Open mouth to click</option>
636
+ <option value="off">Off</option>
637
+ </select>
638
+ </div>
639
+ <br>
640
+ <!-- special interest: jspaint wants label not to use parent-child relationship so that os-gui's 98.css checkbox styles can work -->
641
+ <!-- though this option might not be wanted in jspaint; might be good to hide it in the embedded case, or make it optional -->
642
+ <!-- also TODO: add description of what this is for: on Windows, currently, when buttons are swapped at the system level, it affects serenade-driver's click() -->
643
+ <!-- also this may be seen as a weirdly named/designed option for right-clicking -->
644
+ <!-- btw: label is selected based on 'for' attribute -->
645
+ <div class="tracky-mouse-control-row">
646
+ <input type="checkbox" id="tracky-mouse-swap-mouse-buttons"/>
647
+ <label for="tracky-mouse-swap-mouse-buttons"><span class="tracky-mouse-label-text">Swap mouse buttons</span></label>
648
+ </div>
649
+ <br>
650
+ <label class="tracky-mouse-control-row">
651
+ <!--
652
+ This setting could called "click stabilization", "drag delay", "delay before dragging", "click drag delay", "drag prevention", etc.
653
+ with slider labels "Easy to click -> Easy to drag" or "Easier to click -> Easier to drag" or "Short -> Long"
654
+ This could generalize into "never allow dragging" at the extreme, if it's special cased to jump to infinity
655
+ at the end of the slider, although you shouldn't need to do that to effectively avoid dragging when trying to click,
656
+ and it might complicate the design of the slider labeling.
657
+ -->
658
+ <span class="tracky-mouse-label-text">Delay before dragging&nbsp;&nbsp;&nbsp;</span>
659
+ <span class="tracky-mouse-labeled-slider">
660
+ <input type="range" min="0" max="1000" value="0" class="tracky-mouse-delay-before-dragging">
661
+ <span class="tracky-mouse-min-label">Easy to drag</span>
662
+ <span class="tracky-mouse-max-label">Easy to click</span>
663
+ </span>
664
+ </label>
665
+ </div>
666
+ </details>
667
+ <details>
668
+ <summary>General</summary>
669
+ <div class="tracky-mouse-details-body">
670
+ <!-- special interest: jspaint wants label not to use parent-child relationship so that os-gui's 98.css checkbox styles can work -->
671
+ <!-- opposite, "Start paused", might be clearer, especially if I add a "pause" button -->
672
+ <div class="tracky-mouse-control-row">
673
+ <input type="checkbox" id="tracky-mouse-start-enabled"/>
674
+ <label for="tracky-mouse-start-enabled"><span class="tracky-mouse-label-text">Start enabled</span></label>
675
+ </div>
676
+ <br>
677
+ <!-- special interest: jspaint wants label not to use parent-child relationship so that os-gui's 98.css checkbox styles can work -->
678
+ <div class="tracky-mouse-control-row tracky-mouse-desktop-only">
679
+ <input type="checkbox" id="tracky-mouse-run-at-login"/>
680
+ <label for="tracky-mouse-run-at-login"><span class="tracky-mouse-label-text">Run at login</span></label>
681
+ </div>
682
+ <br class="tracky-mouse-desktop-only">
683
+ <!-- special interest: jspaint wants label not to use parent-child relationship so that os-gui's 98.css checkbox styles can work -->
684
+ <!-- TODO: try moving this to the corner of the camera view, so it's clearer it applies only to the camera view -->
685
+ <div class="tracky-mouse-control-row">
686
+ <input type="checkbox" checked id="tracky-mouse-mirror"/>
687
+ <label for="tracky-mouse-mirror"><span class="tracky-mouse-label-text">Mirror</span></label>
688
+ </div>
689
+ </div>
690
+ </details>
649
691
  </div>
650
692
  <div class="tracky-mouse-canvas-container-container">
651
693
  <div class="tracky-mouse-canvas-container">
@@ -675,6 +717,8 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
675
717
  var sensitivityXSlider = uiContainer.querySelector(".tracky-mouse-sensitivity-x");
676
718
  var sensitivityYSlider = uiContainer.querySelector(".tracky-mouse-sensitivity-y");
677
719
  var accelerationSlider = uiContainer.querySelector(".tracky-mouse-acceleration");
720
+ var minDistanceSlider = uiContainer.querySelector(".tracky-mouse-min-distance");
721
+ var delayBeforeDraggingSlider = uiContainer.querySelector(".tracky-mouse-delay-before-dragging");
678
722
  var useCameraButton = uiContainer.querySelector(".tracky-mouse-use-camera-button");
679
723
  var useDemoFootageButton = uiContainer.querySelector(".tracky-mouse-use-demo-footage-button");
680
724
  var errorMessage = uiContainer.querySelector(".tracky-mouse-error-message");
@@ -692,21 +736,9 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
692
736
  runAtLoginCheckbox.disabled = !isPackaged;
693
737
  });
694
738
  } else {
695
- // Hide the mouse button swapping option if we're not in the desktop app,
696
- // since the system-level mouse button setting doesn't apply,
697
- // and the feature isn't implemented for the web version.
698
- // It could be implemented for the web version, but if you're designing an app for facial mouse users,
699
- // you might want to avoid right-clicking altogether.
700
- swapMouseButtonsCheckbox.parentElement.hidden = true;
701
-
702
- // Hide clicking mode option if not in desktop app,
703
- // since dwell clicking in the web version is a separate API (TrackyMouse.initDwellClicking)
704
- // and wouldn't automatically be controlled by this UI.
705
- // TODO: bring more of desktop app functionality into core
706
- clickingModeDropdown.parentElement.hidden = true;
707
-
708
- // Hide the "run at login" option if we're not in the desktop app.
709
- runAtLoginCheckbox.parentElement.hidden = true;
739
+ for (const elementToHide of uiContainer.querySelectorAll('.tracky-mouse-desktop-only')) {
740
+ elementToHide.hidden = true;
741
+ }
710
742
  }
711
743
 
712
744
  var canvas = uiContainer.querySelector(".tracky-mouse-canvas");
@@ -735,15 +767,17 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
735
767
  var maxPoints = 1000;
736
768
  var mouseX = 0;
737
769
  var mouseY = 0;
738
- var prevMovementX = 0;
739
- var prevMovementY = 0;
740
- var enableTimeTravel = false;
741
- // var movementXSinceFacemeshUpdate = 0;
742
- // var movementYSinceFacemeshUpdate = 0;
743
770
  var cameraFramesSinceFacemeshUpdate = [];
744
771
  var sensitivityX;
745
772
  var sensitivityY;
746
773
  var acceleration;
774
+ var minDistance;
775
+ var delayBeforeDragging;
776
+ var buttonStates = {
777
+ left: false,
778
+ right: false,
779
+ };
780
+ var lastMouseDownTime = -Infinity;
747
781
  var face;
748
782
  var faceScore = 0;
749
783
  var faceScoreThreshold = 0.5;
@@ -752,9 +786,9 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
752
786
  var pointsBasedOnFaceScore = 0;
753
787
  var paused = true;
754
788
  var mouseNeedsInitPos = true;
755
- var debugTimeTravel = false;
756
789
  var debugAcceleration = false;
757
790
  var showDebugText = false;
791
+ var showDebugEyelidContours = false;
758
792
  var mirror;
759
793
  var startEnabled;
760
794
  var runAtLogin;
@@ -781,27 +815,10 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
781
815
  var facemeshEstimateFaces;
782
816
  var faceInViewConfidenceThreshold = 0.7;
783
817
  var pointsBasedOnFaceInViewConfidence = 0;
818
+ var blinkInfo;
819
+ var mouthInfo;
784
820
 
785
- // scale of size of frames that are passed to worker and then computed several at once when backtracking for latency compensation
786
- // reducing this makes it much more likely to drop points and thus not work
787
- // THIS IS DISABLED and using a performance optimization of currentCameraImageData instead of getCameraImageData;
788
- // (the currentCameraImageData is also scaled differently, to the fixed canvas size instead of using the native camera image size)
789
- // const frameScaleForWorker = 1;
790
-
791
- var mainOops;
792
- var workerSyncedOops;
793
-
794
- // const frameCanvas = document.createElement("canvas");
795
- // const frameCtx = frameCanvas.getContext("2d");
796
- // const getCameraImageData = () => {
797
- // if (cameraVideo.videoWidth * frameScaleForWorker * cameraVideo.videoHeight * frameScaleForWorker < 1) {
798
- // return;
799
- // }
800
- // frameCanvas.width = cameraVideo.videoWidth * frameScaleForWorker;
801
- // frameCanvas.height = cameraVideo.videoHeight * frameScaleForWorker;
802
- // frameCtx.drawImage(cameraVideo, 0, 0, frameCanvas.width, frameCanvas.height);
803
- // return frameCtx.getImageData(0, 0, frameCanvas.width, frameCanvas.height);
804
- // };
821
+ var pointTracker;
805
822
 
806
823
  let currentCameraImageData;
807
824
  let detector;
@@ -883,6 +900,14 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
883
900
  acceleration = settings.globalSettings.headTrackingAcceleration;
884
901
  accelerationSlider.value = acceleration * 100;
885
902
  }
903
+ if (settings.globalSettings.headTrackingMinDistance !== undefined) {
904
+ minDistance = settings.globalSettings.headTrackingMinDistance;
905
+ minDistanceSlider.value = minDistance;
906
+ }
907
+ if (settings.globalSettings.delayBeforeDragging !== undefined) {
908
+ delayBeforeDragging = settings.globalSettings.delayBeforeDragging;
909
+ delayBeforeDraggingSlider.value = delayBeforeDragging;
910
+ }
886
911
  if (settings.globalSettings.startEnabled !== undefined) {
887
912
  startEnabled = settings.globalSettings.startEnabled;
888
913
  startEnabledCheckbox.checked = startEnabled;
@@ -912,6 +937,8 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
912
937
  headTrackingSensitivityX: sensitivityX,
913
938
  headTrackingSensitivityY: sensitivityY,
914
939
  headTrackingAcceleration: acceleration,
940
+ headTrackingMinDistance: minDistance,
941
+ delayBeforeDragging: delayBeforeDragging,
915
942
  // TODO:
916
943
  // eyeTrackingSensitivityX,
917
944
  // eyeTrackingSensitivityY,
@@ -969,6 +996,22 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
969
996
  setOptions({ globalSettings: { headTrackingAcceleration: acceleration } });
970
997
  }
971
998
  };
999
+ minDistanceSlider.onchange = (event) => {
1000
+ minDistance = minDistanceSlider.value;
1001
+ // HACK: using event argument as a flag to indicate when it's not the initial setup,
1002
+ // to avoid saving the default settings before the actual preferences are loaded.
1003
+ if (event) {
1004
+ setOptions({ globalSettings: { headTrackingMinDistance: minDistance } });
1005
+ }
1006
+ };
1007
+ delayBeforeDraggingSlider.onchange = (event) => {
1008
+ delayBeforeDragging = delayBeforeDraggingSlider.value;
1009
+ // HACK: using event argument as a flag to indicate when it's not the initial setup,
1010
+ // to avoid saving the default settings before the actual preferences are loaded.
1011
+ if (event) {
1012
+ setOptions({ globalSettings: { delayBeforeDragging: delayBeforeDragging } });
1013
+ }
1014
+ };
972
1015
  mirrorCheckbox.onchange = (event) => {
973
1016
  mirror = mirrorCheckbox.checked;
974
1017
  // HACK: using event argument as a flag to indicate when it's not the initial setup,
@@ -1019,6 +1062,8 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1019
1062
  sensitivityXSlider.onchange();
1020
1063
  sensitivityYSlider.onchange();
1021
1064
  accelerationSlider.onchange();
1065
+ minDistanceSlider.onchange();
1066
+ delayBeforeDraggingSlider.onchange();
1022
1067
  paused = !startEnabled;
1023
1068
 
1024
1069
  // Handle right click on "swap mouse buttons", so it doesn't leave users stranded right-clicking.
@@ -1149,8 +1194,6 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1149
1194
  cameraVideo.height = cameraVideo.videoHeight;
1150
1195
  canvas.width = cameraVideo.videoWidth;
1151
1196
  canvas.height = cameraVideo.videoHeight;
1152
- debugFramesCanvas.width = cameraVideo.videoWidth;
1153
- debugFramesCanvas.height = cameraVideo.videoHeight;
1154
1197
  debugPointsCanvas.width = cameraVideo.videoWidth;
1155
1198
  debugPointsCanvas.height = cameraVideo.videoHeight;
1156
1199
 
@@ -1159,10 +1202,7 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1159
1202
  canvasContainer.style.aspectRatio = `${cameraVideo.videoWidth} / ${cameraVideo.videoHeight}`;
1160
1203
  canvasContainer.style.setProperty('--aspect-ratio', cameraVideo.videoWidth / cameraVideo.videoHeight);
1161
1204
 
1162
- mainOops = new OOPS();
1163
- if (useFacemesh) {
1164
- workerSyncedOops = new OOPS();
1165
- }
1205
+ pointTracker = new OOPS();
1166
1206
  });
1167
1207
  cameraVideo.addEventListener('play', () => {
1168
1208
  clmTracker.reset();
@@ -1187,11 +1227,6 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1187
1227
  cameraVideo.width = defaultWidth;
1188
1228
  cameraVideo.height = defaultHeight;
1189
1229
 
1190
- const debugFramesCanvas = document.createElement("canvas");
1191
- debugFramesCanvas.width = canvas.width;
1192
- debugFramesCanvas.height = canvas.height;
1193
- const debugFramesCtx = debugFramesCanvas.getContext("2d");
1194
-
1195
1230
  const debugPointsCanvas = document.createElement("canvas");
1196
1231
  debugPointsCanvas.width = canvas.width;
1197
1232
  debugPointsCanvas.height = canvas.height;
@@ -1346,18 +1381,19 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1346
1381
  }
1347
1382
  }
1348
1383
 
1384
+ // FIXME: can't click to add points because canvas is covered by .tracky-mouse-canvas-overlay
1349
1385
  canvas.addEventListener('click', (event) => {
1350
- if (!mainOops) {
1386
+ if (!pointTracker) {
1351
1387
  return;
1352
1388
  }
1353
1389
  const rect = canvas.getBoundingClientRect();
1354
1390
  if (mirror) {
1355
- mainOops.addPoint(
1391
+ pointTracker.addPoint(
1356
1392
  (rect.right - event.clientX) / rect.width * canvas.width,
1357
1393
  (event.clientY - rect.top) / rect.height * canvas.height,
1358
1394
  );
1359
1395
  } else {
1360
- mainOops.addPoint(
1396
+ pointTracker.addPoint(
1361
1397
  (event.clientX - rect.left) / rect.width * canvas.width,
1362
1398
  (event.clientY - rect.top) / rect.height * canvas.height,
1363
1399
  );
@@ -1392,6 +1428,22 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1392
1428
  oops.addPoint(x, y);
1393
1429
  }
1394
1430
 
1431
+ /** Returns the distance between a point and a line defined by two points, with the sign indicating which side of the line the point is on */
1432
+ function signedDistancePointLine(point, a, b) {
1433
+ const [px, py] = point;
1434
+ const [x1, y1] = a;
1435
+ const [x2, y2] = b;
1436
+
1437
+ const dx = x2 - x1;
1438
+ const dy = y2 - y1;
1439
+
1440
+ // Perpendicular (normal) vector
1441
+ const nx = -dy;
1442
+ const ny = dx;
1443
+
1444
+ return ((px - x1) * nx + (py - y1) * ny) / Math.hypot(nx, ny);
1445
+ }
1446
+
1395
1447
  function draw(update = true) {
1396
1448
  ctx.resetTransform(); // in case there is an error, don't flip constantly back and forth due to mirroring
1397
1449
  ctx.clearRect(0, 0, canvas.width, canvas.height); // in case there's no footage
@@ -1406,7 +1458,7 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1406
1458
  ctx.drawImage(cameraVideo, 0, 0, canvas.width, canvas.height);
1407
1459
  }
1408
1460
 
1409
- if (!mainOops) {
1461
+ if (!pointTracker) {
1410
1462
  return;
1411
1463
  }
1412
1464
 
@@ -1461,7 +1513,7 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1461
1513
  console.warn("Falling back to clmtrackr");
1462
1514
  }
1463
1515
  // If you've switched desktop sessions, it will presumably fail to get a new webgl context until you've switched back
1464
- // Is this setInterval useful, vs just starting the worker?
1516
+ // Is this setInterval useful, vs just starting the worker?**
1465
1517
  // It probably has a faster cycle, with the code as it is now, but maybe not inherently.
1466
1518
  // TODO: do the extra getContext() calls add to a GPU process crash limit
1467
1519
  // that makes it only able to recover a couple times (outside the electron app)?
@@ -1479,7 +1531,7 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1479
1531
  // Once we can create a webgl2 canvas...
1480
1532
  document.createElement("canvas").getContext("webgl2");
1481
1533
  clearInterval(fallbackTimeoutID);
1482
- // It's worth trying to re-initialize [a web worker for facemesh]...
1534
+ // It's worth trying to re-initialize [a web worker** for facemesh]...
1483
1535
  setTimeout(() => {
1484
1536
  console.warn("Re-initializing facemesh");
1485
1537
  initFacemesh();
@@ -1510,25 +1562,12 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1510
1562
  clearTimeout(fallbackTimeoutID);
1511
1563
 
1512
1564
  if (!facemeshPrediction) {
1565
+ blinkInfo = null;
1566
+ mouthInfo = null;
1513
1567
  return;
1514
1568
  }
1515
1569
  facemeshPrediction.faceInViewConfidence = 0.9999; // TODO: any equivalent in new API?
1516
1570
 
1517
- // this applies to facemeshPrediction.annotations as well, which references the same points
1518
- // facemeshPrediction.scaledMesh.forEach((point) => {
1519
- // point[0] /= frameScaleForWorker;
1520
- // point[1] /= frameScaleForWorker;
1521
- // });
1522
-
1523
- // time travel latency compensation
1524
- // keep a history of camera frames since the prediction was requested,
1525
- // and analyze optical flow of new points over that history
1526
-
1527
- // mainOops.filterPoints(() => false); // for DEBUG, empty points (could probably also just set pointCount = 0;
1528
-
1529
- workerSyncedOops.filterPoints(() => false); // empty points (could probably also just set pointCount = 0;
1530
-
1531
- // const { annotations } = facemeshPrediction;
1532
1571
  const getPoint = (index) =>
1533
1572
  facemeshPrediction.keypoints[index] ?
1534
1573
  [facemeshPrediction.keypoints[index].x, facemeshPrediction.keypoints[index].y] :
@@ -1586,77 +1625,30 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1586
1625
  const annotations = Object.fromEntries(Object.entries(MESH_ANNOTATIONS).map(([key, indices]) => {
1587
1626
  return [key, indices.map(getPoint)];
1588
1627
  }));
1628
+
1589
1629
  // nostrils
1590
- workerSyncedOops.addPoint(annotations.noseLeftCorner[0][0], annotations.noseLeftCorner[0][1]);
1591
- workerSyncedOops.addPoint(annotations.noseRightCorner[0][0], annotations.noseRightCorner[0][1]);
1630
+ maybeAddPoint(pointTracker, annotations.noseLeftCorner[0][0], annotations.noseLeftCorner[0][1]);
1631
+ maybeAddPoint(pointTracker, annotations.noseRightCorner[0][0], annotations.noseRightCorner[0][1]);
1592
1632
  // midway between eyes
1593
- workerSyncedOops.addPoint(annotations.midwayBetweenEyes[0][0], annotations.midwayBetweenEyes[0][1]);
1633
+ maybeAddPoint(pointTracker, annotations.midwayBetweenEyes[0][0], annotations.midwayBetweenEyes[0][1]);
1594
1634
  // inner eye corners
1595
- // workerSyncedOops.addPoint(annotations.leftEyeLower0[8][0], annotations.leftEyeLower0[8][1]);
1596
- // workerSyncedOops.addPoint(annotations.rightEyeLower0[8][0], annotations.rightEyeLower0[8][1]);
1597
-
1598
- // console.log(workerSyncedOops.pointCount, cameraFramesSinceFacemeshUpdate.length, workerSyncedOops.curXY);
1599
- if (enableTimeTravel) {
1600
- debugFramesCtx.clearRect(0, 0, debugFramesCanvas.width, debugFramesCanvas.height);
1601
- setTimeout(() => {
1602
- debugPointsCtx.clearRect(0, 0, debugPointsCanvas.width, debugPointsCanvas.height);
1603
- }, 900);
1604
- cameraFramesSinceFacemeshUpdate.forEach((imageData, _index) => {
1605
- /*
1606
- if (debugTimeTravel) {
1607
- debugFramesCtx.save();
1608
- debugFramesCtx.globalAlpha = 0.1;
1609
- // debugFramesCtx.globalCompositeOperation = index % 2 === 0 ? "xor" : "xor";
1610
- frameCtx.putImageData(imageData, 0, 0);
1611
- // debugFramesCtx.putImageData(imageData, 0, 0);
1612
- debugFramesCtx.drawImage(frameCanvas, 0, 0, canvas.width, canvas.height);
1613
- debugFramesCtx.restore();
1614
- debugPointsCtx.fillStyle = "aqua";
1615
- workerSyncedOops.draw(debugPointsCtx);
1616
- }
1617
- */
1618
- workerSyncedOops.update(imageData);
1619
- });
1620
- }
1635
+ // maybeAddPoint(pointTracker, annotations.leftEyeLower0[8][0], annotations.leftEyeLower0[8][1]);
1636
+ // maybeAddPoint(pointTracker, annotations.rightEyeLower0[8][0], annotations.rightEyeLower0[8][1]);
1621
1637
 
1622
- // Bring points from workerSyncedOops to realtime mainOops
1623
- for (var pointIndex = 0; pointIndex < workerSyncedOops.pointCount; pointIndex++) {
1624
- const pointOffset = pointIndex * 2;
1625
- maybeAddPoint(mainOops, workerSyncedOops.curXY[pointOffset], workerSyncedOops.curXY[pointOffset + 1]);
1626
- }
1627
- // Don't do this! It's not how this is supposed to work.
1628
- // mainOops.pointCount = workerSyncedOops.pointCount;
1629
- // for (var pointIndex = 0; pointIndex < workerSyncedOops.pointCount; pointIndex++) {
1630
- // const pointOffset = pointIndex * 2;
1631
- // mainOops.curXY[pointOffset] = workerSyncedOops.curXY[pointOffset];
1632
- // mainOops.curXY[pointOffset+1] = workerSyncedOops.curXY[pointOffset+1];
1633
- // mainOops.prevXY[pointOffset] = workerSyncedOops.prevXY[pointOffset];
1634
- // mainOops.prevXY[pointOffset+1] = workerSyncedOops.prevXY[pointOffset+1];
1635
- // }
1636
-
1637
- // naive latency compensation
1638
- // Note: this applies to facemeshPrediction.annotations as well which references the same point objects
1639
- // Note: This latency compensation only really works if it's already tracking well
1640
- // if (prevFaceInViewConfidence > 0.99) {
1641
- // facemeshPrediction.keypoints.forEach((point) => {
1642
- // point.x += movementXSinceFacemeshUpdate;
1643
- // point.y += movementYSinceFacemeshUpdate;
1644
- // });
1645
- // }
1638
+
1639
+ // console.log(pointTracker.pointCount, cameraFramesSinceFacemeshUpdate.length, pointTracker.curXY);
1646
1640
 
1647
1641
  pointsBasedOnFaceInViewConfidence = facemeshPrediction.faceInViewConfidence;
1648
1642
 
1649
1643
  // TODO: separate confidence threshold for removing vs adding points?
1650
1644
 
1651
1645
  // cull points to those within useful facial region
1652
- // TODO: use time travel for this too, probably! with a history of the points
1653
- // a complexity would be that points can be removed over time and we need to keep them identified
1654
- mainOops.filterPoints((pointIndex) => {
1646
+ pointTracker.filterPoints((pointIndex) => {
1655
1647
  var pointOffset = pointIndex * 2;
1656
1648
  // distance from tip of nose (stretched so make an ellipse taller than wide)
1657
1649
  var distance = Math.hypot(
1658
- (annotations.noseTip[0][0] - mainOops.curXY[pointOffset]) * 1.4,
1659
- annotations.noseTip[0][1] - mainOops.curXY[pointOffset + 1]
1650
+ (annotations.noseTip[0][0] - pointTracker.curXY[pointOffset]) * 1.4,
1651
+ annotations.noseTip[0][1] - pointTracker.curXY[pointOffset + 1]
1660
1652
  );
1661
1653
  var headSize = Math.hypot(
1662
1654
  annotations.leftCheek[0][0] - annotations.rightCheek[0][0],
@@ -1669,12 +1661,12 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1669
1661
  // distance to outer corners of eyes
1670
1662
  distance = Math.min(
1671
1663
  Math.hypot(
1672
- annotations.leftEyeLower0[0][0] - mainOops.curXY[pointOffset],
1673
- annotations.leftEyeLower0[0][1] - mainOops.curXY[pointOffset + 1]
1664
+ annotations.leftEyeLower0[0][0] - pointTracker.curXY[pointOffset],
1665
+ annotations.leftEyeLower0[0][1] - pointTracker.curXY[pointOffset + 1]
1674
1666
  ),
1675
1667
  Math.hypot(
1676
- annotations.rightEyeLower0[0][0] - mainOops.curXY[pointOffset],
1677
- annotations.rightEyeLower0[0][1] - mainOops.curXY[pointOffset + 1]
1668
+ annotations.rightEyeLower0[0][0] - pointTracker.curXY[pointOffset],
1669
+ annotations.rightEyeLower0[0][1] - pointTracker.curXY[pointOffset + 1]
1678
1670
  ),
1679
1671
  );
1680
1672
  if (distance < headSize * 0.42) {
@@ -1683,62 +1675,148 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1683
1675
  return true;
1684
1676
  });
1685
1677
 
1678
+ let clickButton = -1;
1686
1679
  if (clickingMode === "blink") {
1680
+ // Note: currently head tilt matters a lot, but ideally it should not.
1681
+ // - When moving closer to the camera, theoretically the eye size to head size ratio increases.
1682
+ // (if you can hold your eye still, you can test by moving nearer to / further from the camera (or moving the camera))
1683
+ // - When tilting your head left or right, the contour of one closed eyelid becomes more curved* (as it wraps around your head),
1684
+ // while the other stays near center of the visual region of your head and thus stays relatively straight (experiencing less projection distortion).
1685
+ // - When tilting your head down, the contour of a closed eyelid becomes more curved, which can lead to false negatives.
1686
+ // - When tilting your head up, the contour of an open eyelid becomes more straight, which can lead to false positives.
1687
+ // - *This is a geometric explanation, but in practice, facemesh loses the ability to detect
1688
+ // whether the eye is closed when the head is tilted beyond a point.
1689
+ // Enable `showDebugEyelidContours` to see the shapes we're dealing with here.
1690
+ // - Facemesh uses an "attention mesh model", enabled with `refineLandmarks: true`,
1691
+ // which adjusts points near the eyes and lips to be more accurate (and is 100% necessary for this blink detection to work).
1692
+ // This is what we might ideally target to improve blink detection.
1687
1693
  // TODO: try variations, e.g.
1688
- // - is mid the best point to use? maybe floor or ceil would give a different point that might be better?
1689
- // - would using the eye size instead of head size be different? can compare to see how much variation there is in eye size : head size ratio
1690
- // - as I noted here https://github.com/1j01/tracky-mouse/issues/1#issuecomment-2053931136
1694
+ // - As I noted here: https://github.com/1j01/tracky-mouse/issues/1#issuecomment-2053931136
1691
1695
  // sometimes a fully closed eye isn't detected as fully closed, and an eye can be open and detected at a
1692
- // similar squinty level, however, if one eye is detected as fully closed, and the other eye is at that squinty level,
1696
+ // similar squinty level; however, if one eye is detected as fully closed, and the other eye is at that squinty level,
1693
1697
  // I think it can be assumed that the squinty eye is open, and otherwise, if neither eye is detected as fully closed,
1694
1698
  // then a squinty level can be assumed to be closed. So it might make sense to bias the blink detection, taking into account both eyes.
1695
1699
  // (When you blink one eye, you naturally squint with the other a bit, but not necessarily as much as the model reports.
1696
- // I think this physical phenomenon may have biased the model since eye blinking and opposite eye squinting are correlated.)
1697
- const mid = Math.round(annotations.leftEyeUpper0.length / 2);
1698
- // TODO: rename these variables to be clearly distances not openness
1699
- const leftEyeOpenness = Math.hypot(
1700
- annotations.leftEyeUpper0[mid][0] - annotations.leftEyeLower0[mid][0],
1701
- annotations.leftEyeUpper0[mid][1] - annotations.leftEyeLower0[mid][1]
1702
- );
1703
- const rightEyeOpenness = Math.hypot(
1704
- annotations.rightEyeUpper0[mid][0] - annotations.rightEyeLower0[mid][0],
1705
- annotations.rightEyeUpper0[mid][1] - annotations.rightEyeLower0[mid][1]
1706
- );
1707
- const headSize = Math.hypot(
1708
- annotations.leftCheek[0][0] - annotations.rightCheek[0][0],
1709
- annotations.leftCheek[0][1] - annotations.rightCheek[0][1]
1710
- );
1711
- const threshold = headSize * 0.1;
1712
- // console.log("leftEyeOpenness", leftEyeOpenness, "rightEyeOpenness", rightEyeOpenness, "threshold", threshold);
1713
- const leftEyeOpen = leftEyeOpenness > threshold;
1714
- const rightEyeOpen = rightEyeOpenness > threshold;
1715
- // TODO: remove global debounce hack
1716
- // and prevent clicking until both eyes are open again
1717
- // ideally keeping the mouse button held
1718
- let clickButton = -1;
1719
- if (leftEyeOpen && !rightEyeOpen) {
1720
- clickButton = 0;
1721
- } else if (!leftEyeOpen && rightEyeOpen) {
1722
- clickButton = 2;
1700
+ // I suspect this physical phenomenon may have biased the model since eye blinking and opposite eye squinting are correlated.)
1701
+ // - Maybe measure several points instead of just the middle or extreme points
1702
+ // - Can we use a 3D version of the facemesh instead of 2D, to help with ignoring head tilt??
1703
+ // That might be the most important improvement...
1704
+ // We can get z by making getPoint return the z value as well, but this is still camera-relative.
1705
+ // We could transform it using some reference points, but do we have to?
1706
+ // https://chuoling.github.io/mediapipe/solutions/face_mesh.html
1707
+ // This mentions a "face pose transformation matrix" which sounds useful...
1708
+ // - Adjust threshold based on head tilt
1709
+ // - When head is tilted up, make it consider eye open with a thinner eye shape.
1710
+ // Out-of-the-box ideas:
1711
+ // - Use a separate model for eye state detection, using images of the eye region as input.
1712
+ // - I've thought about using "Teachable Machine" for this, it's meant to make training models easy, idk if it's still relevant
1713
+ // - Use multiple cameras. Having a camera on either side would allow seeing the eye from a clear angle in at least one camera,
1714
+ // with significant left/right head tilt.
1715
+ // - It might also help to improve tracking accuracy, by averaging two face meshes, if we can get them into the same coordinate space.
1716
+ // - We might want to ditch the point tracking and just use the facemesh points at that point, although it should still
1717
+ // be possible to use point tracking as long as it's tracked separately and averaged, and the cameras are placed symmetrically.
1718
+ // - Use mirrors. Instead of multiple cameras, imagine two mirrors on either side of the user, angled to reflect the user's head into the camera.
1719
+ // - Fiducial markers on the frames of the mirrors could be used to help with the coordinate space transformation.
1720
+ // - Music stands could be used to hold the mirrors, or they could be hung from the ceiling.
1721
+ // - One might worry about breaking mirrors, but sandbags on stand bases or padding on the mirror frames could help to be safe.
1722
+ // - Lighting integrated into the mirror frames would be a bonus; this is a feature of some vanity mirrors.
1723
+ // - Fewer video streams to process, but more video processing steps, so I'm not sure how it would shake out performance-wise.
1724
+ // - If you're hoping for it to improve tracking, remember that the tracking can be janky when the face is cut off,
1725
+ // and the mirrors would introduce more edges.
1726
+ // - The larger the mirror the better, but the more expensive and unwieldy and thus unlikely to be used.
1727
+ // - If you were to try to avoid using results from faces that are cut off,
1728
+ // you would likely be trying to use the same janky tracking results to determine whether the face is cut off.
1729
+ // It *might* work, but it also might be a bit of a chicken-and-egg problem.
1730
+
1731
+ function getEyeMetrics(eyeUpper, eyeLower) {
1732
+ // The lower eye keypoints have the corners
1733
+ const corners = [eyeLower[0], eyeLower[eyeLower.length - 1]];
1734
+ // Excluding the corners isn't really important since their measures will be 0.
1735
+ const otherPoints = eyeUpper.concat(eyeLower).filter(point => !corners.includes(point));
1736
+ let highest = 0;
1737
+ let lowest = 0;
1738
+ for (const point of otherPoints) {
1739
+ const distance = signedDistancePointLine(point, corners[0], corners[1]);
1740
+ if (distance < lowest) {
1741
+ lowest = distance;
1742
+ }
1743
+ if (distance > highest) {
1744
+ highest = distance;
1745
+ }
1746
+ }
1747
+
1748
+ const eyeWidth = Math.hypot(
1749
+ corners[0][0] - corners[1][0],
1750
+ corners[0][1] - corners[1][1]
1751
+ );
1752
+ const eyeHeight = highest - lowest;
1753
+ const eyeAspectRatio = eyeHeight / eyeWidth;
1754
+ return {
1755
+ corners,
1756
+ upperContour: eyeUpper,
1757
+ lowerContour: eyeLower,
1758
+ highest,
1759
+ lowest,
1760
+ eyeAspectRatio,
1761
+ };
1723
1762
  }
1724
- if (window._debouncedClick) {
1725
- return;
1763
+
1764
+ const leftEye = getEyeMetrics(annotations.leftEyeUpper0, annotations.leftEyeLower0);
1765
+ const rightEye = getEyeMetrics(annotations.rightEyeUpper0, annotations.rightEyeLower0);
1766
+
1767
+ const thresholdHigh = 0.2;
1768
+ const thresholdLow = 0.16;
1769
+ leftEye.open = leftEye.eyeAspectRatio > (blinkInfo?.leftEye.open ? thresholdLow : thresholdHigh);
1770
+ rightEye.open = rightEye.eyeAspectRatio > (blinkInfo?.rightEye.open ? thresholdLow : thresholdHigh);
1771
+
1772
+ // An attempt at biasing the blink detection based on the other eye's state
1773
+ // (I'm not sure if this is the same as the idea I had noted above)
1774
+ // const threshold = 0.16;
1775
+ // const bias = 0.3;
1776
+ // leftEye.open = leftEye.eyeAspectRatio - threshold - ((rightEye.eyeAspectRatio - threshold) * bias) > 0;
1777
+ // rightEye.open = rightEye.eyeAspectRatio - threshold - ((leftEye.eyeAspectRatio - threshold) * bias) > 0;
1778
+
1779
+ // Involuntary blink rejection
1780
+ const blinkRejectDuration = 100; // milliseconds
1781
+ const currentTime = performance.now();
1782
+ // TODO: DRY
1783
+ if (leftEye.open === blinkInfo?.leftEye.open) {
1784
+ leftEye.timeSinceChange = blinkInfo?.leftEye.timeSinceChange ?? currentTime;
1785
+ } else {
1786
+ leftEye.timeSinceChange = currentTime;
1726
1787
  }
1727
- window._debouncedClick = true;
1728
- setTimeout(() => {
1729
- window._debouncedClick = false;
1730
- }, 1500);
1731
- if (clickButton !== -1) {
1732
- // console.log("Would click button", clickButton);
1733
- window.electronAPI.clickAtCurrentMousePosition(clickButton === 2);
1788
+ if (rightEye.open === blinkInfo?.rightEye.open) {
1789
+ rightEye.timeSinceChange = blinkInfo?.rightEye.timeSinceChange ?? currentTime;
1790
+ } else {
1791
+ rightEye.timeSinceChange = currentTime;
1734
1792
  }
1793
+ const timeSinceChange = currentTime - Math.max(leftEye.timeSinceChange, rightEye.timeSinceChange);
1794
+ leftEye.winking = timeSinceChange > blinkRejectDuration && rightEye.open && !leftEye.open;
1795
+ rightEye.winking = timeSinceChange > blinkRejectDuration && leftEye.open && !rightEye.open;
1796
+
1797
+ if (rightEye.winking) {
1798
+ clickButton = 0;
1799
+ } else if (leftEye.winking) {
1800
+ clickButton = 2;
1801
+ }
1802
+ blinkInfo = {
1803
+ leftEye,
1804
+ rightEye
1805
+ };
1806
+ } else {
1807
+ blinkInfo = null;
1735
1808
  }
1736
1809
  if (clickingMode === "open-mouth") {
1737
1810
  // TODO: modifiers with eye closing or eyebrow raising to trigger different buttons
1738
- // TODO: DRY and refactor and move this code (it's too nested)
1739
- const mid = Math.round(annotations.lipsLowerInner.length / 2);
1740
- // TODO: rename these variables to be clearly distances not openness
1741
- const mouthOpenness = Math.hypot(
1811
+ // TODO: refactor and move this code (it's too nested)
1812
+ // TODO: headSize is not a perfect measurement; try alternative measurements, e.g.
1813
+ // - mouth width (implies making an "O" mouth shape would be favored over a wide open mouth shape)
1814
+ const mid = Math.floor(annotations.lipsLowerInner.length / 2);
1815
+ const mouthTopBottomPoints = [
1816
+ annotations.lipsUpperInner[mid],
1817
+ annotations.lipsLowerInner[mid]
1818
+ ];
1819
+ const mouthTopBottomDistance = Math.hypot(
1742
1820
  annotations.lipsUpperInner[mid][0] - annotations.lipsLowerInner[mid][0],
1743
1821
  annotations.lipsUpperInner[mid][1] - annotations.lipsLowerInner[mid][1]
1744
1822
  );
@@ -1746,26 +1824,45 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1746
1824
  annotations.leftCheek[0][0] - annotations.rightCheek[0][0],
1747
1825
  annotations.leftCheek[0][1] - annotations.rightCheek[0][1]
1748
1826
  );
1749
- const threshold = headSize * 0.1;
1750
- // console.log("mouthOpenness", mouthOpenness, "threshold", threshold);
1751
- const mouthOpen = mouthOpenness > threshold;
1752
- // TODO: remove global debounce hack
1753
- // and prevent clicking until both eyes are open again
1754
- // ideally keeping the mouse button held
1755
- let clickButton = -1;
1827
+ const thresholdHigh = headSize * 0.15;
1828
+ const thresholdLow = headSize * 0.1;
1829
+ // console.log("mouthTopBottomDistance", mouthTopBottomDistance, "threshold", threshold);
1830
+ const mouthOpen = mouthTopBottomDistance > (mouthInfo?.mouthOpen ? thresholdLow : thresholdHigh);
1756
1831
  if (mouthOpen) {
1757
1832
  clickButton = 0;
1758
1833
  }
1759
- if (window._debouncedClick) {
1760
- return;
1834
+ mouthInfo = {
1835
+ mouthOpen,
1836
+ mouthTopBottomPoints,
1837
+ corners: [annotations.lipsUpperInner[0], annotations.lipsUpperInner[annotations.lipsUpperInner.length - 1]],
1838
+ mouthOpenDistance: mouthTopBottomDistance / headSize,
1839
+ };
1840
+ } else {
1841
+ mouthInfo = null;
1842
+ }
1843
+
1844
+ // TODO: implement these clicking modes for the web library version
1845
+ // and unhide the "Clicking mode" setting in the UI
1846
+ // https://github.com/1j01/tracky-mouse/issues/72
1847
+ if ((clickButton === 0) !== buttonStates.left) {
1848
+ window.electronAPI?.setMouseButtonState(false, clickButton === 0);
1849
+ buttonStates.left = clickButton === 0;
1850
+ if ((clickButton === 0)) {
1851
+ lastMouseDownTime = performance.now();
1852
+ } else {
1853
+ // Limit "Delay Before Dragging" effect to the duration of a click.
1854
+ // TODO: consider how this affects releasing a mouse button if two are pressed (not currently possible)
1855
+ // TODO: rename variable, maybe change it to store a cool-down timer? but that would need more state management just for concept clarity
1856
+ lastMouseDownTime = -Infinity; // sorry, making this variable a misnomer
1761
1857
  }
1762
- window._debouncedClick = true;
1763
- setTimeout(() => {
1764
- window._debouncedClick = false;
1765
- }, 1500);
1766
- if (clickButton !== -1) {
1767
- // console.log("Would click button", clickButton);
1768
- window.electronAPI.clickAtCurrentMousePosition(clickButton === 2);
1858
+ }
1859
+ if ((clickButton === 2) !== buttonStates.right) {
1860
+ window.electronAPI?.setMouseButtonState(true, clickButton === 2);
1861
+ buttonStates.right = clickButton === 2;
1862
+ if ((clickButton === 2)) {
1863
+ lastMouseDownTime = performance.now();
1864
+ } else {
1865
+ lastMouseDownTime = -Infinity; // sorry, making this variable a misnomer
1769
1866
  }
1770
1867
  }
1771
1868
  }, () => {
@@ -1774,11 +1871,15 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1774
1871
  });
1775
1872
  }
1776
1873
  }
1777
- mainOops.update(imageData);
1874
+ pointTracker.update(imageData);
1778
1875
  }
1779
1876
 
1780
1877
  if (window.electronAPI) {
1781
- window.electronAPI.notifyCameraFeedDiagnostics({ headNotFound: !face && !facemeshPrediction });
1878
+ window.electronAPI.updateInputFeedback({
1879
+ headNotFound: !face && !facemeshPrediction,
1880
+ blinkInfo,
1881
+ mouthInfo,
1882
+ });
1782
1883
  }
1783
1884
 
1784
1885
  if (facemeshPrediction) {
@@ -1786,17 +1887,10 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1786
1887
 
1787
1888
  const bad = facemeshPrediction.faceInViewConfidence < faceInViewConfidenceThreshold;
1788
1889
  ctx.fillStyle = bad ? 'rgb(255,255,0)' : 'rgb(130,255,50)';
1789
- if (!bad || mainOops.pointCount < 3 || facemeshPrediction.faceInViewConfidence > pointsBasedOnFaceInViewConfidence + 0.05) {
1890
+ if (!bad || pointTracker.pointCount < 3 || facemeshPrediction.faceInViewConfidence > pointsBasedOnFaceInViewConfidence + 0.05) {
1790
1891
  if (bad) {
1791
1892
  ctx.fillStyle = 'rgba(255,0,255)';
1792
1893
  }
1793
- if (update && useFacemesh) {
1794
- // this should just be visual, since we only add/remove points based on the facemesh data when receiving it
1795
- facemeshPrediction.keypoints.forEach((point) => {
1796
- point.x += prevMovementX;
1797
- point.y += prevMovementY;
1798
- });
1799
- }
1800
1894
  facemeshPrediction.keypoints.forEach(({ x, y }) => {
1801
1895
  ctx.fillRect(x, y, 1, 1);
1802
1896
  });
@@ -1807,10 +1901,92 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1807
1901
  }
1808
1902
  }
1809
1903
 
1904
+ if (clickingMode === "blink" && blinkInfo) {
1905
+ ctx.save();
1906
+ ctx.lineWidth = 2;
1907
+ const drawEye = (eye) => {
1908
+ ctx.strokeStyle = eye.winking ? "red" : eye.open ? "cyan" : "yellow";
1909
+ ctx.beginPath();
1910
+ ctx.moveTo(eye.corners[0][0], eye.corners[0][1]);
1911
+ ctx.lineTo(eye.corners[1][0], eye.corners[1][1]);
1912
+ ctx.stroke();
1913
+ // draw extents as a rectangle
1914
+ ctx.save();
1915
+ ctx.translate(eye.corners[0][0], eye.corners[0][1]);
1916
+ ctx.rotate(Math.atan2(eye.corners[1][1] - eye.corners[0][1], eye.corners[1][0] - eye.corners[0][0]));
1917
+ ctx.beginPath();
1918
+ ctx.rect(0, eye.lowest, Math.hypot(eye.corners[1][0] - eye.corners[0][0], eye.corners[1][1] - eye.corners[0][1]), eye.highest - eye.lowest);
1919
+ ctx.stroke();
1920
+ ctx.restore();
1921
+ // Zoom in and show the eyelid contour SHAPE, for qualitative debugging
1922
+ // This helps to show that the facemesh model doesn't really know whether your eye is open or closed beyond a certain head angle.
1923
+ // Therefore there's not much we can do using the eyelid contour to improve blink detection.
1924
+ // We might be able to tease a little more accuracy out of it using surrounding points in some clever way, 3D information, etc.
1925
+ // but fundamentally, garbage in, garbage out.
1926
+ if (showDebugEyelidContours) {
1927
+ const eyeCenter = [(eye.corners[0][0] + eye.corners[1][0]) / 2, (eye.corners[0][1] + eye.corners[1][1]) / 2];
1928
+ ctx.save();
1929
+ ctx.translate(eyeCenter[0], eyeCenter[1]);
1930
+ ctx.scale(5, 5);
1931
+ ctx.translate(-eyeCenter[0], -eyeCenter[1]);
1932
+ ctx.strokeStyle = "green";
1933
+ ctx.beginPath();
1934
+ for (const contour of [eye.upperContour, eye.lowerContour]) {
1935
+ for (let i = 0; i < contour.length; i++) {
1936
+ const [x, y] = contour[i];
1937
+ if (i === 0) {
1938
+ ctx.moveTo(x, y);
1939
+ } else {
1940
+ ctx.lineTo(x, y);
1941
+ }
1942
+ }
1943
+ }
1944
+ ctx.lineWidth = 2 / 5;
1945
+ ctx.stroke();
1946
+ ctx.restore();
1947
+ }
1948
+ };
1949
+ drawEye(blinkInfo.leftEye);
1950
+ drawEye(blinkInfo.rightEye);
1951
+ ctx.restore();
1952
+ }
1953
+ if (clickingMode === "open-mouth" && mouthInfo) {
1954
+ ctx.save();
1955
+ ctx.lineWidth = 2;
1956
+ ctx.strokeStyle = mouthInfo.mouthOpen ? "red" : "cyan";
1957
+ ctx.beginPath();
1958
+ // ctx.moveTo(mouthInfo.mouthTopBottomPoints[0][0], mouthInfo.mouthTopBottomPoints[0][1]);
1959
+ // ctx.lineTo(mouthInfo.corners[0][0], mouthInfo.corners[0][1]);
1960
+ // ctx.lineTo(mouthInfo.mouthTopBottomPoints[1][0], mouthInfo.mouthTopBottomPoints[1][1]);
1961
+ // ctx.lineTo(mouthInfo.corners[1][0], mouthInfo.corners[1][1]);
1962
+ // ctx.closePath();
1963
+ const mouthCenter = [
1964
+ (mouthInfo.corners[0][0] + mouthInfo.corners[1][0]) / 2,
1965
+ (mouthInfo.corners[0][1] + mouthInfo.corners[1][1]) / 2
1966
+ ];
1967
+ const extents = mouthInfo.mouthTopBottomPoints.map(point => signedDistancePointLine(point, mouthInfo.corners[0], mouthInfo.corners[1]));
1968
+ // Draw as two lines rather than a rectangle (or ellipse) to indicate that it's not using aspect ratio of the mouth currently
1969
+ // const highest = Math.max(...extents);
1970
+ // const lowest = Math.min(...extents);
1971
+ // const mouthWidth = Math.hypot(mouthInfo.corners[1][0] - mouthInfo.corners[0][0], mouthInfo.corners[1][1] - mouthInfo.corners[0][1]);
1972
+ const mouthWidth = 50;
1973
+ ctx.translate(mouthCenter[0], mouthCenter[1]);
1974
+ ctx.rotate(Math.atan2(mouthInfo.corners[1][1] - mouthInfo.corners[0][1], mouthInfo.corners[1][0] - mouthInfo.corners[0][0]));
1975
+ ctx.beginPath();
1976
+ // ctx.rect(-mouthWidth / 2, lowest, mouthWidth, highest - lowest);
1977
+ for (const extent of extents) {
1978
+ ctx.moveTo(-mouthWidth / 2, extent);
1979
+ ctx.lineTo(mouthWidth / 2, extent);
1980
+ }
1981
+ ctx.stroke();
1982
+ ctx.restore();
1983
+ }
1984
+
1985
+
1810
1986
  if (face) {
1811
1987
  const bad = faceScore < faceScoreThreshold;
1812
1988
  ctx.strokeStyle = bad ? 'rgb(255,255,0)' : 'rgb(130,255,50)';
1813
- if (!bad || mainOops.pointCount < 2 || faceScore > pointsBasedOnFaceScore + 0.05) {
1989
+ if (!bad || pointTracker.pointCount < 2 || faceScore > pointsBasedOnFaceScore + 0.05) {
1814
1990
  if (bad) {
1815
1991
  ctx.strokeStyle = 'rgba(255,0,255)';
1816
1992
  }
@@ -1818,21 +1994,21 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1818
1994
  pointsBasedOnFaceScore = faceScore;
1819
1995
 
1820
1996
  // nostrils
1821
- maybeAddPoint(mainOops, face[42][0], face[42][1]);
1822
- maybeAddPoint(mainOops, face[43][0], face[43][1]);
1997
+ maybeAddPoint(pointTracker, face[42][0], face[42][1]);
1998
+ maybeAddPoint(pointTracker, face[43][0], face[43][1]);
1823
1999
  // inner eye corners
1824
- // maybeAddPoint(mainOops, face[25][0], face[25][1]);
1825
- // maybeAddPoint(mainOops, face[30][0], face[30][1]);
2000
+ // maybeAddPoint(pointTracker, face[25][0], face[25][1]);
2001
+ // maybeAddPoint(pointTracker, face[30][0], face[30][1]);
1826
2002
 
1827
2003
  // TODO: separate confidence threshold for removing vs adding points?
1828
2004
 
1829
2005
  // cull points to those within useful facial region
1830
- mainOops.filterPoints((pointIndex) => {
2006
+ pointTracker.filterPoints((pointIndex) => {
1831
2007
  var pointOffset = pointIndex * 2;
1832
2008
  // distance from tip of nose (stretched so make an ellipse taller than wide)
1833
2009
  var distance = Math.hypot(
1834
- (face[62][0] - mainOops.curXY[pointOffset]) * 1.4,
1835
- face[62][1] - mainOops.curXY[pointOffset + 1]
2010
+ (face[62][0] - pointTracker.curXY[pointOffset]) * 1.4,
2011
+ face[62][1] - pointTracker.curXY[pointOffset + 1]
1836
2012
  );
1837
2013
  // distance based on outer eye corners
1838
2014
  var headSize = Math.hypot(
@@ -1854,20 +2030,16 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1854
2030
  clmTracker.draw(canvas, undefined, undefined, true);
1855
2031
  }
1856
2032
  }
1857
- if (debugTimeTravel) {
1858
- ctx.save();
1859
- ctx.globalAlpha = 0.8;
1860
- ctx.drawImage(debugFramesCanvas, 0, 0);
1861
- ctx.restore();
1862
- ctx.drawImage(debugPointsCanvas, 0, 0);
1863
- }
1864
2033
  ctx.fillStyle = "lime";
1865
- mainOops.draw(ctx);
2034
+ pointTracker.draw(ctx);
1866
2035
  debugPointsCtx.fillStyle = "green";
1867
- mainOops.draw(debugPointsCtx);
2036
+ pointTracker.draw(debugPointsCtx);
1868
2037
 
1869
2038
  if (update) {
1870
- var [movementX, movementY] = mainOops.getMovement();
2039
+ const screenWidth = window.electronAPI ? screen.width : innerWidth;
2040
+ const screenHeight = window.electronAPI ? screen.height : innerHeight;
2041
+
2042
+ var [movementX, movementY] = pointTracker.getMovement();
1871
2043
 
1872
2044
  // Acceleration curves add a lot of stability,
1873
2045
  // letting you focus on a specific point without jitter, but still move quickly.
@@ -1880,6 +2052,37 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1880
2052
  var deltaX = accelerate(movementX * sensitivityX, distance);
1881
2053
  var deltaY = accelerate(movementY * sensitivityY, distance);
1882
2054
 
2055
+ // Mimicking eViacam's "Motion Threshold" implementation
2056
+ // https://github.com/cmauri/eviacam/blob/a4032ed9c59def5399a93e74f5ea84513d2f42b1/wxutil/mousecontrol.cpp#L310-L312
2057
+ // (a threshold on instantaneous Manhattan distance, or in other words, x and y speed, separately)
2058
+ // - It's applied after acceleration, following eViacam's lead,
2059
+ // which makes sense in order to have the setting's unit make sense as "pixels",
2060
+ // rather than "pixels before applying a function",
2061
+ // to say nothing of the qualitative differences there might be in reordering the operations.
2062
+ // - Note that it causes jumps which are increasingly noticeable as the setting is increased.
2063
+ // - TODO: consider a "leash" behavior, or a hybrid perhaps
2064
+ // Note that a leash behavior might be less responsive to direction changes,
2065
+ // and might not achieve the goal of stability unless you move back slightly,
2066
+ // since if you've just pulled the leash left for instance, pulling it left
2067
+ // will move it no matter how small, which might turn a click into a drag (if the "Delay Before Dragging" setting doesn't prevent it).
2068
+ // You have to be in the center of the leash region for it to provide stability.
2069
+ // I'm not sure what a hybrid would look like; it might make more sense as two
2070
+ // separate settings, "motion threshold" and "leash distance".
2071
+ if (Math.abs(deltaX * screenWidth) < minDistance) {
2072
+ deltaX = 0;
2073
+ }
2074
+ if (Math.abs(deltaY * screenHeight) < minDistance) {
2075
+ deltaY = 0;
2076
+ }
2077
+ // Avoid dragging when trying to click by ignoring movement for a short time after a mouse down.
2078
+ // This applied previously also to release, to help with double clicks,
2079
+ // but this felt bad, and I find personally that I can still do double clicks without that help.
2080
+ const timeSinceMouseDown = performance.now() - lastMouseDownTime;
2081
+ if (timeSinceMouseDown < delayBeforeDragging) {
2082
+ deltaX = 0;
2083
+ deltaY = 0;
2084
+ }
2085
+
1883
2086
  if (debugAcceleration) {
1884
2087
  const graphWidth = 200;
1885
2088
  const graphHeight = 150;
@@ -1912,9 +2115,6 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1912
2115
  }
1913
2116
 
1914
2117
  if (!paused) {
1915
- const screenWidth = window.electronAPI ? screen.width : innerWidth;
1916
- const screenHeight = window.electronAPI ? screen.height : innerHeight;
1917
-
1918
2118
  mouseX -= deltaX * screenWidth;
1919
2119
  mouseY += deltaY * screenHeight;
1920
2120
 
@@ -1939,25 +2139,6 @@ TrackyMouse.init = function (div, { statsJs = false } = {}) {
1939
2139
  TrackyMouse.onPointerMove(mouseX, mouseY);
1940
2140
  }
1941
2141
  }
1942
- prevMovementX = movementX;
1943
- prevMovementY = movementY;
1944
- // movementXSinceFacemeshUpdate += movementX;
1945
- // movementYSinceFacemeshUpdate += movementY;
1946
- /*
1947
- if (enableTimeTravel) {
1948
- if (facemeshEstimating) {
1949
- const imageData = getCameraImageData();
1950
- if (imageData) {
1951
- cameraFramesSinceFacemeshUpdate.push(imageData);
1952
- }
1953
- // limit this buffer size in case something goes wrong
1954
- if (cameraFramesSinceFacemeshUpdate.length > 500) {
1955
- // maybe just clear it entirely, because a partial buffer might not be useful
1956
- cameraFramesSinceFacemeshUpdate.length = 0;
1957
- }
1958
- }
1959
- }
1960
- */
1961
2142
  }
1962
2143
  ctx.restore();
1963
2144