Compare commits

...

565 Commits

Author SHA1 Message Date
Prohurtz
240a5d464d
Merge v2 dev to main
Some checks failed
App Builder / build (ubuntu-latest) (push) Has been cancelled
App Builder / build (windows-latest) (push) Has been cancelled
App Builder / Deploy (push) Has been cancelled
App Builder / Cleanup actions (push) Has been cancelled
Push v2 app to main
2025-04-03 11:54:11 -07:00
Prohurtz
b001b15ed8 Merge remote-tracking branch 'origin/main' into v2.0-beta-feature-branch
# Conflicts:
#	.github/workflows/build-jekyll.txt
#	EyeTrackApp/one_euro_filter.py
#	LICENSE
#	README.md
#	pyproject.toml
2025-04-03 13:53:35 -05:00
Prohurtz
64ebbc2c58 adjust leap settings behavior to not force leap lid 2025-04-03 13:50:14 -05:00
Prohurtz
f4e7cc1fd2 update links 2025-04-03 11:44:09 -07:00
Prohurtz
064158de41 bump ver to 2.0 2025-04-02 16:50:20 -07:00
Prohurtz
5c86243d58 adjust leap settings behavior to not force leap lid 2025-04-02 14:21:46 -05:00
Prohurtz
ef24e3bf5a update default settings 2025-04-02 14:17:38 -05:00
Prohurtz
ce348b0106
Merge pull request #134 from Fracas42/v2.0-fix-osc-recalibrate
fix(OSC): recalibrate not working + refacto
2025-04-02 13:58:01 -05:00
Prohurtz
151c38bf00
Merge pull request #135 from Fracas42/v2.0-fix-one-euro-filter-return-none
fix: one euro filter return none
2025-04-02 13:33:34 -05:00
DeltaNeverUsed
17b49f67c0
fix(linux): serial devices with longer names than 5 getting treated as url (#138) 2025-03-29 20:55:23 +01:00
Fracas
da396f584b refacto: remove redundant warning 2025-02-19 16:56:24 -06:00
Fracas
5797fb342f fix: one euro filter return none 2025-02-19 16:55:32 -06:00
Fracas
a2593ea90a fix(OSC): recalibrate not working + refacto 2025-02-18 20:36:35 -06:00
Prohurtz
1010427e3f Update license 2025-02-18 14:30:57 -06:00
Prohurtz
4c0d542817 Merge remote-tracking branch 'origin/v2.0-beta-feature-branch' into v2.0-beta-feature-branch 2025-02-18 14:30:45 -06:00
Prohurtz
869c71ab2b Update license 2025-02-18 14:29:47 -06:00
Blabz
59f2aa14ec
Update camera_widget.py - Fix OSC recenter/ recalibrate (#131) 2025-02-06 20:52:23 +01:00
Prohurtz
4836bc55c5 fix: update gitignore 2025-01-08 16:04:49 -06:00
Prohurtz
ccb8571670 fix: IBO does not work with LEAP as main algo 2024-12-27 21:27:00 -06:00
Prohurtz
141ed6ee9e fix: cleanup 2024-12-27 21:17:41 -06:00
Prohurtz
4a351b0c44 fix: cleanup 2024-12-27 21:14:03 -06:00
Prohurtz
88f19d9645
Merge pull request #130 from Fracas42/v2.0-fix-ahsfm-call-eyeprocessor
fix(AHSFM): fix eyeprocessor call to ahsf
2024-12-27 19:05:50 -08:00
Prohurtz
52956eda87
Merge pull request #129 from Fracas42/v2.0-save-leap-calibration
feat: LEAP save calibration
2024-12-27 19:05:14 -08:00
Prohurtz
258615d87d
Merge pull request #128 from Fracas42/v2.0-fix-leap-calibration-samples-settings-not-working
fix: leap calibration samples not saved or having any effect
2024-12-27 19:01:14 -08:00
Prohurtz
222e1c0591
Merge pull request #126 from Raeschen/v2.0_beta_overlay_fix
Fix centering overlay not working
2024-12-27 18:48:39 -08:00
Fracas
d38fc4ba56 feat: leap save calibration 2024-12-21 13:41:02 -05:00
Fracas
4160eb8329 fix(AHSFM): fix eyeprocessor call to ahsf 2024-12-18 16:40:42 -06:00
Fracas
8c78bc9937 fix: leap calibration samples not saved or having any effect 2024-12-16 00:51:41 -06:00
Raeschen
fc446fb67b remove bat files 2024-12-04 10:28:56 +01:00
Raeschen
85d73f46d2 os.startfile use tweak 2024-12-03 14:39:14 +01:00
Prohurtz
52e7ee0fd8
Merge pull request #120 from SnuffSocket/timer-branch
Feat: Set timer resolution (1ms)
2024-11-27 12:00:10 -08:00
Prohurtz
f595fb53c6 fix import statement position to prevent errors on non windows machines 2024-11-27 13:59:58 -06:00
Prohurtz
9a64b1e38a
Merge pull request #119 from SnuffSocket/focus-branch
Feat: Lower CPU usage when not in focus
2024-11-27 11:56:22 -08:00
Prohurtz
0d3128d397 minor: move variables for clarity and complete usage 2024-11-27 13:55:57 -06:00
Prohurtz
9c814453ba
Merge pull request #118 from SnuffSocket/misc-branch
Fix: Misc fixes/changes
2024-11-27 11:48:45 -08:00
Prohurtz
1e0ac5867b Revert print removal
it isn't console spam if it is important logging.
2024-11-27 13:48:15 -06:00
Prohurtz
e83b8f6811 fix: leap lid tuning 2024-11-02 20:10:54 -05:00
Prohurtz
fae1a953cb feat: expose LEAP calibration samples 2024-11-02 19:59:45 -05:00
Prohurtz
da9cdb5436 fix: change IBO default setting to off 2024-11-02 19:25:25 -05:00
Prohurtz
872f9675b0 fix: center.bat not in exe 2024-11-02 19:17:57 -05:00
SnuffSocket
3e1a3a05d4 Fix: Made intended behavior more clear to the user 2024-10-26 14:39:33 +03:00
SnuffSocket
58dc2e8d07 Fix: Colors 'n use "is_nt" 2024-10-23 08:11:13 +03:00
SnuffSocket
e3b2e8ed95 Feat: Lower CPU usage when not in focus by stopping 'n slowing down the GUI loop. 2024-10-15 04:06:20 +03:00
SnuffSocket
fd40ffedd3 Feat: Set timer resolution (1ms) 2024-10-15 04:03:33 +03:00
SnuffSocket
ce4942a54e Fix: shutil.SameFileError, removed "[INFO] Config Saved Successfully" print spam 2024-10-07 01:55:44 +03:00
SnuffSocket
9e4b345928 Fix: Misc fixes/changes 2024-10-07 01:55:23 +03:00
Prohurtz
a291b2fc4f fix: remove cuda 2024-09-21 11:50:34 -05:00
Prohurtz
71ddd8ceba fix: add new leap simplified model 2024-09-19 19:12:06 -07:00
Prohurtz
9798fb13a6 fix: AHSF dual eye jitter 2024-09-07 14:42:09 -07:00
Prohurtz
a32a08e89b feat: bitrate, fps fixes, new leap model 2024-09-07 13:41:28 -07:00
Prohurtz
db78c951a6 feat: remove rotation slider on main page 2024-08-18 17:52:23 -05:00
Prohurtz
6024a67fc8 fix: algo crashes, frame size issues 2024-08-18 15:19:47 -05:00
Prohurtz
dbda86d845 fix: algo crashes, frame size issues 2024-08-18 14:27:51 -05:00
Prohurtz
8acaa1c643 fix: leap and leap lid both enabled crash 2024-08-17 17:13:02 -05:00
Prohurtz
bf4b77abe7 fix: type comparason error 2024-08-17 11:24:55 -05:00
Prohurtz
9a130a1de9 fix: center.bat not foind (potentially) 2024-08-17 11:15:13 -05:00
Prohurtz
b4221319ae fix: test pupil dilation fixes 2024-07-30 16:17:33 -05:00
Prohurtz
f0fd2565f6 fix: test pupil dilation fixes 2024-07-30 16:12:21 -05:00
Prohurtz
c2e7281b76 feat: lower frame queue max size 2024-07-30 15:46:59 -05:00
Prohurtz
aa0b0903fc fix: re-add try 2024-07-30 15:40:54 -05:00
Prohurtz
df29fc0380 fix: gui off mem leak, frame buildup 2024-07-30 15:39:43 -05:00
Prohurtz
e927fd25ce fix: dual eye jitter in GUI off 2024-07-30 15:21:10 -05:00
Prohurtz
8a0ba4dfb4 feat: fix .spec file to include bat and tools folder 2024-07-30 14:44:12 -05:00
Prohurtz
0d7cc4f648 feat: single eye no gui fix 2024-07-30 10:38:47 -05:00
Prohurtz
10d3bab5f3 feat: single eye no gui fix 2024-07-30 10:38:14 -05:00
Prohurtz
26c4f3c1ca fix: esp reboot on source change/tab change 2024-07-29 23:08:11 -05:00
Prohurtz
88b3e1e783 feat: clean up files, remove uneeded code, tune leap filter tune 2024-07-29 19:37:10 -05:00
Prohurtz
4dd28ede14 feat: NO GUI mode fully functional 2024-07-29 18:43:10 -05:00
Prohurtz
9134337290 feat: no gui fixes (lid not working) 2024-07-29 18:36:26 -05:00
Prohurtz
d82942967a LEAP testing stuff, + test commit 2024-07-23 13:45:28 -05:00
Prohurtz
461fbaad7e feat: LEAP closed tuning 2024-07-22 18:49:28 -05:00
Prohurtz
57e9348ca2 feat: no gui mode remove debug prints 2024-07-22 18:15:58 -05:00
Prohurtz
2938c49b63 feat: no gui mode fix window close bug 2024-07-22 18:14:53 -05:00
Prohurtz
bbd7c9d4d5 feat: no gui mode color tweaks 2024-07-22 18:13:34 -05:00
Prohurtz
4ae620eece feat: no gui mode working 2024-07-22 18:07:27 -05:00
Prohurtz
e6ab12e30e feat: leap velocity blink avg tweak 2024-07-22 11:00:33 -05:00
Prohurtz
b7958ce59b feat: leap velocity blink avg tweak 2024-07-22 10:59:42 -05:00
Prohurtz
932fc782cf feat: gui off WIP 2024-07-22 10:57:44 -05:00
Prohurtz
9b9763b132 feat: no gui mode fixes 2024-07-22 10:32:49 -05:00
Prohurtz
11f1e68fcc feat: LEAP velocity blink tweak 2024-07-22 10:05:05 -05:00
Prohurtz
c0ac123fde feat: GUI off, leap tweaks 2024-07-22 09:58:27 -05:00
Prohurtz
ed599a1b94 feat: velocity blink, leap blink fixes final 2024-07-21 15:58:18 -05:00
Prohurtz
b7d39151a1 feat: velocity blink, leap blink fixes 2024-07-21 15:46:43 -05:00
Prohurtz
0538b1149c fix: fix eye openess line vis logic 2024-07-21 15:27:57 -05:00
Prohurtz
50d96566e9 feat: bump ver 2024-07-19 13:14:42 -05:00
Prohurtz
1f56ca3a0f fix: AHSF jitter, LEAP delay 2024-07-19 13:14:14 -05:00
Prohurtz
582e03c61f feat: AHSF major fixes 2024-07-10 18:48:57 -05:00
Prohurtz
3a7418290f
Merge pull request #100 from SafariMonkey/improve-roi-rotation
Improve UX of rotated crop
2024-07-10 16:51:03 -05:00
Prohurtz
8d01217c8c
Merge branch 'v2.0-beta-feature-branch' into improve-roi-rotation 2024-07-10 16:50:50 -05:00
Prohurtz
376e2fa799 feat: new LEAP models 2024-07-10 16:45:53 -05:00
Prohurtz
f8961d8616
Update eye_processor.py 2024-07-10 14:45:39 -07:00
Prohurtz
1d69fc110f
Update config.py 2024-07-10 14:45:25 -07:00
Prohurtz
9c98f62ce3
Update camera_widget.py to new (fixes merge conflicts due to very old imp) 2024-07-10 14:44:54 -07:00
Prohurtz
be7e04535d feat: more work on AHSF and LEAP revert 2024-07-10 16:27:58 -05:00
Lorow
1006d8644b
fix: Fix blinking in native eye tracking being very twitchy (#115) 2024-07-05 20:34:00 +02:00
Prohurtz
1fda521f3f fix: app hang on close temp fix 2024-07-04 21:39:41 -05:00
Prohurtz
ee7a19fb9c fix: close serial cams and wireless cams separate 2024-07-04 21:23:56 -05:00
Prohurtz
3cb5e727a1 fix: add try to cam release 2024-07-04 21:21:10 -05:00
Prohurtz
cda06b830b fix: revert LEAP bug fix 2024-07-04 19:39:17 -05:00
Prohurtz
c220521172 fix: revert LEAP threading 2024-07-04 19:22:03 -05:00
Prohurtz
b91519721f fix: AHSF tuning, prep for further tuning 2024-07-04 19:19:11 -05:00
Prohurtz
f957550fec feat: v2 pupil dilation support 2024-07-04 18:41:38 -05:00
Prohurtz
fffcf96d69 feat: v1 pupil dilation support 2024-07-04 18:07:00 -05:00
Lorow
7e41a65aac
Feature/etvr module support (#106)
* initial changes

* Mostly clean up, refactor registering listeners to make sense, backport tests

* Add initial implementation of VRCFTModuleSender

* Add basic GUI for the modules settings

* Fix tooltip descriptions

# TODO:
# - there's ghosts in the machine - vrc osc is not working properly
# - min/maxing will require field combinators in the modules lmao

* Fix type validation bugs, fix typos

# TODO:
# - there's ghosts in the machine - vrc osc is not working properly
# - min/maxing will require field combinators in the modules lmao

* Add checkbox to switch to ETVR Module

# TODO:
# - there's ghosts in the machine - vrc osc is not working properly
# - min/maxing will require field combinators in the modules lmao

* Black stuff

# TODO:
# - there's ghosts in the machine - vrc osc is not working properly
# - min/maxing will require field combinators in the modules lmao

* Remove coverage by default

# TODO:
# - there's ghosts in the machine - vrc osc is not working properly
# - min/maxing will require field combinators in the modules lmao

* Fix timeout in tests

# TODO:
# - there's ghosts in the machine - vrc osc is not working properly
# - min/maxing will require field combinators in the modules lmao

* HEAVY WIP: Refactor native output,

NOTE:

I brought back the entire old OSC implementation as a live reference, this will be removed once I'm done.

This also lays ground for other modes as they're pretty similar

# TODO:
# - there's ghosts in the machine - vrc osc is not working properly
# - min/maxing will require field combinators in the modules lmao

* HEAVY WIP: Refactor v1 params output,

# TODO:
# - min/maxing will require field combinators in the modules lmao

* HEAVY WIP: Refactor v2 params output

# TODO:
# - min/maxing will require field combinators in the modules lmao

* Finish refactoring v2 and v1, fixup tests, refactor native

# TODO:
# - min/maxing will require field combinators in the modules lmao

* Add tests for v1 params

# TODO:
# - min/maxing will require field combinators in the modules lmao

* Add tests for native params

# TODO:
# - min/maxing will require field combinators in the modules lmao

* Fix OSC not getting up after config reset. Remove reset command, config sends everything changed anyway, sunset the idea of using single client and thus simplify the code a bit

# TODO:
# - min/maxing will require field combinators in the modules lmao

* Rename gui_PortNumber to gui_VRCFTModulePort for readability

# TODO:
# - min/maxing will require field combinators in the modules lmao

* Cleanup EyeID usage

# TODO:
# - min/maxing will require field combinators in the modules lmao

* Cleanup osc after rebase

# TODO:
# - min/maxing will require field combinators in the modules lmao

* Make VRChatOSCSender a bit more readable

# TODO:
# - min/maxing will require field combinators in the modules lmao

* Remove unsued VRChatOSCReceiver, this is taken care of by generic OSCReceiver

# TODO:
# - min/maxing will require field combinators in the modules lmao

* Commit crimes with try_convert_to_float to make osc, pysimplegui and pydantic happy

* Cleanup after merge

* Disable emulation by default

* Fix OSCReceiver crashing on unknown addresses

* Adjust VRCFT Module settings to look better in game

* Fix recalibrate and recenter for OSC only working for the right eye

* Fix save and restart button not restarting the tracking

* Fix broken tracking on v1 params for eye_x, clean up implementation

* Fix regular value being passed to OSC listeners instead of OSCMessage

* Add a TODO, probably to be ignored

* Add support for custom ETVR VRCFT Module listening address
2024-07-04 20:51:19 +02:00
Prohurtz
e171ad169b feat: more work on 3d calib, unfinished 2024-06-23 17:54:47 -05:00
Prohurtz
ca81ad724a feat: 3d calibration now can recive both eye's calib points at a single place in calib3d.py 2024-06-23 16:36:36 -05:00
Prohurtz
cc7df74e78 feat: 3d calibration now can recive both eye's calib points at a single place in calib3d.py 2024-06-23 16:36:15 -05:00
Prohurtz
8776c761d8 feat: add LEAP credits 2024-06-22 18:46:46 -05:00
Prohurtz
d7dae8724d feat: new LEAP model 2024-06-21 16:41:09 -05:00
Prohurtz
5d5eea2ce1 feat: 3d calib beginnings, fix build crash, new LEAP model 2024-06-21 08:48:21 -05:00
Prohurtz
c4f1bb116e feat: 3d calib beginnings, fix build crash, new LEAP model 2024-06-21 08:42:37 -05:00
Prohurtz
7dae037dd5 fix: latency with LEAP 2024-06-12 18:33:46 -05:00
Prohurtz
ba42576498 fix: update to pysimplegui 4 foss as pysimple gui <= 4 has been deleted. 2024-06-12 12:47:08 -05:00
Prohurtz
cb0c618ce9 fix: LEAP point scaling 2024-05-30 09:59:50 -05:00
Prohurtz
cc780526b1 feat: new LEAP model beta 2024-05-30 09:52:47 -05:00
Prohurtz
fc8bbfc8db fix: update license in poetry toml 2024-05-27 20:14:05 -05:00
Prohurtz
b11485f3fb fix: bump binary blink open value 2024-05-27 18:41:45 -05:00
Prohurtz
2243e63f3c
Merge pull request #110 from Blu3u/feature/mac-os-support
Serial camera macOS support
2024-05-27 18:40:25 -05:00
Sebastian Fitt
bfbcc9ca6d fix: Division by zero 2024-05-24 21:54:58 +02:00
Sebastian Fitt
30b8ac46ce feat: MacOS support 2024-05-24 21:53:32 +02:00
Sebastian Fitt
5e4bfadffa fix: Path handling across different operating systems 2024-05-24 21:52:46 +02:00
Prohurtz
0a1749459d
Merge pull request #105 from EyeTrackVR/SummerSigh-patch-1
Update LICENSE to add dual licensing for algorithms
2024-04-13 19:18:37 -07:00
Prohurtz
b69d4d7c8e Update README.md 2024-04-13 21:15:45 -05:00
Prohurtz
e37376aba5 fix final typos. ready to merge 2024-04-13 21:13:51 -05:00
Prohurtz
25591b6104 fix: eye falloff dom eye bugged 2024-03-28 07:23:37 -05:00
Prohurtz
7c9ed1dc5c test: add some filtering 2024-03-27 21:47:38 -05:00
Prohurtz
e70913bc2e test: add some filtering 2024-03-27 21:42:22 -05:00
Prohurtz
bea837ccca feat: begin 3d calibration math 2024-03-27 16:49:58 -05:00
Prohurtz
193c6a9653 fix: remove unused code and math 2024-03-27 15:56:27 -05:00
Prohurtz
578d968fb0 fix: remove unused math to increase perf overhead 2024-03-27 15:45:03 -05:00
Prohurtz
04e003b422 test: eye widen? 2024-03-27 15:43:21 -05:00
Prohurtz
ac1e0add11 test: eye widen? 2024-03-27 15:43:11 -05:00
Prohurtz
bc2da43cec test: eye widen? 2024-03-27 15:41:08 -05:00
Prohurtz
170bb5bb3f test: new calibration method for leap 2024-03-27 10:29:46 -05:00
Prohurtz
d8c34c83d9 test: new calibration method for leap 2024-03-27 10:29:12 -05:00
Prohurtz
6a0a4d976d test: new calibration method for leap 2024-03-27 10:22:21 -05:00
Prohurtz
129375054a test: new calibration method for leap 2024-03-27 09:54:16 -05:00
Prohurtz
c2537b5d38 test: new calibration method for leap 2024-03-27 09:52:42 -05:00
Prohurtz
bcc48a67d1 test: new calibration method for leap 2024-03-27 09:52:16 -05:00
Prohurtz
ec1d4c8056 test: new calibration method for leap 2024-03-27 09:46:10 -05:00
Prohurtz
584eea117f fix: falloff bug 2024-03-26 14:09:39 -05:00
Prohurtz
714b78fe2f Update AHSF.py 2024-03-11 12:09:58 -05:00
Prohurtz
8b129170fb Update README.md 2024-03-10 11:22:52 -05:00
Prohurtz
8f6fd230ce Update Licenses 2024-03-10 11:21:23 -05:00
Prohurtz
5b48d5d735
Merge pull request #104 from lorow/bugfix/v2-params-fix
Bugfix/v2 params fix
2024-02-21 07:18:30 -06:00
lorow
9215015fd9 Fix: Pyinstaller imports during runtime
Break: Pytest-cov, there's a workaround
2024-02-20 13:23:03 +01:00
Summer
aa27f9bf25
Update LICENSE to add dual licensing for algorithms 2024-02-20 00:32:09 -08:00
Prohurtz
377c5ab635 fix: import paths? 2024-02-19 14:11:49 -08:00
Prohurtz
446590a7f2 feat: remove prints 2024-02-19 13:30:45 -08:00
Prohurtz
db20afda69 fix: beta 11 ready 2024-02-19 13:27:29 -08:00
Prohurtz
eb865ab544 fix: eye falloff logic and handling, add comments 2024-02-19 12:53:47 -08:00
Prohurtz
2ceb0619e0 fix: remove feature creep buttons 2024-02-17 11:59:38 -06:00
Prohurtz
6696c68832 feat: beta 10 stuff 2024-02-17 09:16:16 -08:00
lorow
a063d97996 Restore imports in config and osc to fix the makefile 2024-02-13 19:24:01 +01:00
Prohurtz
285ab927a7
Merge pull request #101 from lorow/feature/remove-legacy-requirements
Remove legacy requirements
2024-02-12 09:33:04 -06:00
Prohurtz
f5354ff1e3
Merge pull request #103 from lorow/fix/fix-v2-params
Fix: fix v2 params behaving not correctly
2024-02-12 09:30:58 -06:00
lorow
82e707b9a8 Add tests for binary blink and 2024-02-09 23:39:29 +01:00
lorow
55d52c0171 Add pytest cov, improve tests, Fix FT in params
# TODO test binary blink and outer eye falloff
2024-02-09 23:39:29 +01:00
lorow
704f9474a8 Initial implementation of OSC tests with mocking 2024-02-09 23:39:27 +01:00
Prohurtz
a7d4b3200d fix: HSF algo order number 2024-02-08 13:20:10 -06:00
Prohurtz
262b2e42c5 radius fixes 2024-01-06 14:13:48 -08:00
Prohurtz
250a8c1c35 fix rotation causing crop 2024-01-06 13:58:51 -08:00
Prohurtz
52cde5f8f5 fix stall between tab switch, beginning 3d calib 2024-01-06 11:16:08 -08:00
Prohurtz
077d4b2816
Merge pull request #102 from RamesTheGeneric/v2.0-beta-feature-branch
Set queue max sizes equal to one another
2024-01-05 16:49:14 -06:00
Rames The Generic
60e20e8f2e Set queue max sizes equal to one another
I don't know why but this seems to make the app freezing significantly less frequent when changing tabs.
2024-01-05 17:23:04 -05:00
Prohurtz
61a97cd80b add model file 2024-01-02 19:44:09 -08:00
Prohurtz
f17189d53b fix frame buildup, AHSF fixes 2024-01-02 13:53:50 -08:00
Prohurtz
a0d99eb20d fix frame buildup, AHSF fixes 2024-01-02 13:52:46 -08:00
Prohurtz
36de6a6e22 tune AHSF more, add new LEAP model 2024-01-02 13:52:45 -08:00
Prohurtz
a3d2f4b992 fix: MacOS Leap, algo order bug 2023-12-24 16:14:03 -06:00
Prohurtz
cf90914f74 re-add lock file 2023-12-22 13:06:13 -08:00
Prohurtz
45df46ac07 force push with python upgrade 2023-12-22 12:57:20 -08:00
Prohurtz
43e86c2ae8 an actuall cpu perf increase 2023-12-19 14:50:39 -08:00
Prohurtz
49675dc672 cpu performance booooossstt 2023-12-19 13:51:46 -08:00
lorow
4495965076 Remove unused requirements.txt file 2023-12-14 00:00:11 +01:00
Prohurtz
01e0c6c53b fix: bug fix in pupil dia 2023-11-24 19:10:10 -06:00
Prohurtz
3cee994366 fix: file path, and folders 2023-11-24 18:22:58 -06:00
Prohurtz
1ecdbdc142 fix: dilation crash 2023-11-24 18:08:49 -06:00
Prohurtz
5329911404 feat: center point calibration overlay 2023-11-24 18:03:55 -06:00
Prohurtz
ce7c7f22df feat: properly tune tracking algo AHSF 2023-11-22 14:29:27 -06:00
Prohurtz
81334e60a6 fix: adjust limits on size AHSF 2023-11-20 13:54:44 -06:00
Prohurtz
4cbe0ea751 fix: adjust limits on size AHSF 2023-11-20 13:49:54 -06:00
Prohurtz
0d4d99f01d fix: remove limits on size AHSF 2023-11-20 13:49:23 -06:00
Prohurtz
3fc41ba9cd fix: remove limits on size AHSF 2023-11-20 13:48:10 -06:00
Prohurtz
305107d1ac fix: remove debug prints 2023-11-20 13:44:17 -06:00
Prohurtz
3c84245e8b fix: AHSF not handle crash 2023-11-20 13:28:42 -06:00
Prohurtz
88d9700f23 fix: falloff code delete 2023-11-20 13:26:46 -06:00
Prohurtz
6e847ff0fe fix: falloff code delete 2023-11-20 13:26:40 -06:00
Prohurtz
b4d54fcb68 fix: LEAP frame and visualization 2023-11-20 13:25:53 -06:00
Prohurtz
129803bbdc fix: LEAP getting visualized frame other call 2023-11-20 13:13:35 -06:00
Prohurtz
7d7fb6e3a3 fix: LEAP getting visualized frame 2023-11-20 13:06:59 -06:00
Prohurtz
dc6fbd34d1 fix: blur crash 2023-11-20 12:44:34 -06:00
Prohurtz
a7f0fd10a9 fix: camera handel, crash fix 2023-11-19 19:34:55 -06:00
Prohurtz
a8cf676730 fix: fix several bugs that caused crashes 2023-11-19 17:43:21 -06:00
Prohurtz
cfe7c73c44 fix: serial cam crash on first app launch 2023-11-19 16:41:48 -06:00
Prohurtz
ceb320115a feat: bump ver 2023-11-19 16:16:26 -06:00
Charlton Rodda
8f0acab6ce Fix image shape axis order 2023-11-11 23:18:51 +00:00
Prohurtz
cf707a92c9 fix: algo priority num 2023-11-11 16:56:37 -06:00
Prohurtz
ac342f30e9 feat: update overlay app 2023-11-11 16:55:11 -06:00
Prohurtz
2b232a1e89 feat: credits 2023-11-11 16:04:35 -06:00
Prohurtz
bdc91313fe feat: AHSFRAC 2023-11-11 16:02:30 -06:00
Prohurtz
f539b02388 feat: Initial AHSF imp (HSF successor) 2023-11-11 15:36:17 -06:00
Prohurtz
3f6efcb315 feat: change ovr overlay port number 2023-11-09 12:54:20 -06:00
Prohurtz
92af3d6c4f fix: properly define var lol 2023-11-09 09:42:46 -06:00
Prohurtz
b1a6acb1c5 fix: calling overlay too many times 2023-11-09 09:38:36 -06:00
Prohurtz
9e358f64ae fix: overlay call 2023-11-09 09:34:35 -06:00
Prohurtz
a38f61977f fix: fix slash 2023-11-09 09:29:20 -06:00
Prohurtz
f6345e0a47 fix: missing import 2023-11-09 09:27:37 -06:00
Prohurtz
4cde26609f fix: path fix? 2023-11-09 09:27:11 -06:00
Prohurtz
0ea6523ec1 fix: remove try:s so we can get proper error 2023-11-09 09:24:56 -06:00
Prohurtz
86cf3bd843 fix: fix file path of overlay? 2023-11-09 09:23:47 -06:00
Prohurtz
661fc3d8c2 fix: fix overlay arg 2023-11-09 09:20:45 -06:00
Prohurtz
582813cc01 feat: revert port number for now 2023-11-09 09:16:16 -06:00
Charlton Rodda
c231b004b9 Fix some mutually destructive sign issues 2023-11-07 23:08:47 +00:00
Charlton Rodda
98a2448f5c Use vec2s where possible 2023-11-07 23:08:47 +00:00
Charlton Rodda
3641751a22 Load ROI from config.roi_window 2023-11-07 19:55:12 +00:00
Charlton Rodda
8eae8d89d3 Add option to pad ROI widget for rotation 2023-11-07 19:27:47 +00:00
Charlton Rodda
29084baa29 Style the ROI cursor and crop lines with dashes 2023-11-07 19:27:47 +00:00
Charlton Rodda
41e1d33a19 Add crosshair to ROI editor 2023-11-07 18:42:55 +00:00
Charlton Rodda
826afb2f30 Limit ROI crop to padded region 2023-11-07 18:42:55 +00:00
Charlton Rodda
666022d0e5 Dynamically adjust image padding 2023-11-07 18:42:55 +00:00
Charlton Rodda
de764e57ca Improve UX of rotated ROI selection 2023-11-07 18:42:55 +00:00
Charlton Rodda
1b961ae477 Crop and rotate image in one operation 2023-11-07 18:42:55 +00:00
Prohurtz
9620d50ccb feat: Trigger center point only calibration overlay when recenter 2023-11-07 08:57:47 -06:00
Prohurtz
a6fcabf9be feat: commit 2023-11-07 08:02:24 -06:00
Prohurtz
f06db29e1b feat: add calibration overlay files 2023-11-04 19:39:35 -05:00
Prohurtz
590fc70164 feat: add buttons for future markup crop mode 2023-11-02 09:20:02 -05:00
Prohurtz
58ee8e3033 fix: variable falloff not defined 2023-10-31 18:30:22 -05:00
Prohurtz
4c668f9b47 fix: eye dominant falloff (may still be broke) 2023-10-31 13:05:22 -05:00
Prohurtz
55a0df1b66 feat: untested eye dominant falloff 2023-10-31 08:58:38 -05:00
Prohurtz
37aa4c05c0 feat: working dual eye falloff 2023-10-31 08:51:54 -05:00
Prohurtz
9b3c9b4bcf feat: initial foundation of new dual eye falloff (non functional) 2023-10-26 09:10:35 -05:00
Prohurtz
ada4936922 fix: VRCFT V2 param addresses 2023-10-17 09:04:46 -05:00
Prohurtz
d7c9b3b70b fix: tune LEAP filter 2023-10-12 09:16:06 -05:00
Prohurtz
d685ad18ea fix: safe crop int erros
fix: pupil dilation filtering, calib
2023-10-12 08:24:32 -05:00
Prohurtz
0ee5404f5d
Merge pull request #93 from lorow/feature/config-rewrite-with-defualts-reset
Settings rewrite for easier data validation and resseting to defaults
2023-10-11 23:13:48 -05:00
lorow
7068d8539b Add validation, refactor some more stuff 2023-10-08 19:36:14 +02:00
semantic-release-bot
f0fd59198b chore(minor): 1.0.0-HSF-and-new-algos-feature-branch.11 [skip ci]
## [1.0.0-HSF-and-new-algos-feature-branch.11](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.10...v1.0.0-HSF-and-new-algos-feature-branch.11) (2023-10-08)

### 🍕 Features

* Merge pull request [#94](https://github.com/EyeTrackVR/EyeTrackVR/issues/94) from DeltaNeverUsed/HSF-and-new-algos-feature-branch ([e1203aa](e1203aa9a7))
2023-10-08 13:32:19 +00:00
Prohurtz
e1203aa9a7
feat: Merge pull request #94 from DeltaNeverUsed/HSF-and-new-algos-feature-branch
basic vrcft v2 support
2023-10-08 08:28:35 -05:00
DeltaNeverUsed
355068c0be basic vrcft v2 support 2023-10-08 14:49:04 +02:00
lorow
6a9a27578d Fix eye dilation and vrcft2 settings 2023-10-07 01:01:39 +02:00
lorow
61b2c88960 Merge remote-tracking branch 'upstream/HSF-and-new-algos-feature-branch' into feature/config-rewrite-with-defualts-reset
# Conflicts:
#	EyeTrackApp/algo_settings_widget.py
#	EyeTrackApp/general_settings_widget.py
#	EyeTrackApp/osc.py
2023-10-06 22:23:44 +02:00
lorow
5a84217402 Style the button 2023-10-06 22:11:35 +02:00
lorow
3190364a72 Add config reset, clean-up modules 2023-10-06 00:33:37 +02:00
lorow
aa3be78f54 Refactor settings into base settings, fixup modules
TODO: reset
2023-10-05 22:57:34 +02:00
semantic-release-bot
75f4bddb6d chore(minor): 1.0.0-HSF-and-new-algos-feature-branch.10 [skip ci]
## [1.0.0-HSF-and-new-algos-feature-branch.10](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.9...v1.0.0-HSF-and-new-algos-feature-branch.10) (2023-10-05)

### 🍕 Features

* pupil dilation initial imp ([6200ddf](6200ddfcab))
* pupil dilation work start ([e694054](e694054cb4))
2023-10-05 14:51:00 +00:00
Prohurtz
6200ddfcab feat: pupil dilation initial imp 2023-10-05 09:47:02 -05:00
Prohurtz
e694054cb4 feat: pupil dilation work start 2023-10-05 09:47:02 -05:00
lorow
ff5974ec8d WIP: refactor algo settings into modules 2023-10-05 00:44:47 +02:00
lorow
c93e279157 Initial settings rewrite into modules in preparation for resetting to defaults and for better settings handling 2023-10-03 22:11:52 +02:00
semantic-release-bot
6b7f5a63ca chore(minor): 1.0.0-HSF-and-new-algos-feature-branch.9 [skip ci]
## [1.0.0-HSF-and-new-algos-feature-branch.9](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.8...v1.0.0-HSF-and-new-algos-feature-branch.9) (2023-10-03)

### 🍕 Features

* stage one of new output formats ([8e28092](8e28092601))

### 🐛 Bug Fixes

* temp disable leap low priority for MetalCanyon ([9bab5b4](9bab5b4555))
2023-10-03 19:57:43 +00:00
Prohurtz
9bab5b4555 fix: temp disable leap low priority for MetalCanyon 2023-10-03 14:52:00 -05:00
Prohurtz
8e28092601 feat: stage one of new output formats 2023-10-03 14:51:36 -05:00
Prohurtz
62fba485ba
Merge pull request #92 from NickDuijndam/feature/osc-config
Make the eye X & Y OSC addresses configurable through the config file
2023-10-02 20:51:42 -07:00
Nick Duijndam
62ba685f86 Make the eye X & Y OSC addresses configurable through the config file 2023-10-02 21:22:37 +02:00
semantic-release-bot
a2bc803e16 chore(patch): 1.0.0-HSF-and-new-algos-feature-branch.8 [skip ci]
## [1.0.0-HSF-and-new-algos-feature-branch.8](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.7...v1.0.0-HSF-and-new-algos-feature-branch.8) (2023-10-01)

### 🐛 Bug Fixes

* LEAP Eyelid, tune RANSAC BLINK ([5a6248b](5a6248b897))
* remove prints ([46427e3](46427e3533))
2023-10-01 01:09:09 +00:00
Prohurtz
46427e3533 fix: remove prints 2023-09-30 20:04:57 -05:00
Prohurtz
5a6248b897 fix: LEAP Eyelid, tune RANSAC BLINK 2023-09-30 14:51:57 -05:00
semantic-release-bot
03a1a965e5 chore(patch): 1.0.0-HSF-and-new-algos-feature-branch.7 [skip ci]
## [1.0.0-HSF-and-new-algos-feature-branch.7](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.6...v1.0.0-HSF-and-new-algos-feature-branch.7) (2023-09-28)

### 🐛 Bug Fixes

* left eye ransac thresh set to right ([c6fbfe8](c6fbfe8c70))
* left eye ransac thresh set to right ([d11f94f](d11f94f5af))
2023-09-28 18:23:31 +00:00
Prohurtz
c6fbfe8c70 fix: left eye ransac thresh set to right 2023-09-28 13:19:17 -05:00
Prohurtz
d11f94f5af fix: left eye ransac thresh set to right 2023-09-28 13:19:17 -05:00
semantic-release-bot
403c419cb2 chore(patch): 1.0.0-HSF-and-new-algos-feature-branch.6 [skip ci]
## [1.0.0-HSF-and-new-algos-feature-branch.6](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.5...v1.0.0-HSF-and-new-algos-feature-branch.6) (2023-09-27)

### 🐛 Bug Fixes

* remove y axis lock (temp) ([2ec7d2d](2ec7d2d4ee))
* tighten blink vec (needs testing/re-write, just minor fix) ([1c2760b](1c2760b382))
2023-09-27 11:52:16 +00:00
Prohurtz
2ec7d2d4ee fix: remove y axis lock (temp) 2023-09-27 06:48:18 -05:00
Prohurtz
1c2760b382 fix: tighten blink vec (needs testing/re-write, just minor fix) 2023-09-27 06:48:18 -05:00
semantic-release-bot
66292a5a8c chore(minor): 1.0.0-HSF-and-new-algos-feature-branch.5 [skip ci]
## [1.0.0-HSF-and-new-algos-feature-branch.5](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.4...v1.0.0-HSF-and-new-algos-feature-branch.5) (2023-09-26)

### 🍕 Features

* LEAP Lid blink algo ([ab631fa](ab631fad19))

### 🐛 Bug Fixes

* small DADDY optimizations (test for FPS limit) ([6297ce3](6297ce327c))
2023-09-26 13:54:12 +00:00
Prohurtz
ab631fad19 feat: LEAP Lid blink algo 2023-09-26 08:46:52 -05:00
Prohurtz
6297ce327c fix: small DADDY optimizations (test for FPS limit) 2023-09-26 08:46:52 -05:00
semantic-release-bot
351db43fae chore(patch): 1.0.0-HSF-and-new-algos-feature-branch.4 [skip ci]
## [1.0.0-HSF-and-new-algos-feature-branch.4](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.3...v1.0.0-HSF-and-new-algos-feature-branch.4) (2023-09-24)

### 🐛 Bug Fixes

* LEAP full closed thresh ([4ad1eaf](4ad1eaffc4))
2023-09-24 14:34:05 +00:00
Prohurtz
4ad1eaffc4 fix: LEAP full closed thresh 2023-09-24 09:29:32 -05:00
Prohurtz
9caa70340a remove debug prints, adjust LEAP opennes filter 2023-09-24 09:27:35 -05:00
Prohurtz
3a98a586a6 remove debug prints 2023-09-23 20:02:27 -05:00
semantic-release-bot
fad00b71df chore(patch): 1.0.0-HSF-and-new-algos-feature-branch.3 [skip ci]
## [1.0.0-HSF-and-new-algos-feature-branch.3](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.2...v1.0.0-HSF-and-new-algos-feature-branch.3) (2023-09-24)

### 🐛 Bug Fixes

* UVC cameras not working ([5e28ee3](5e28ee34e5))
* UVC cameras not working2 ([5209a44](5209a441b6))
2023-09-24 00:56:10 +00:00
Prohurtz
5209a441b6 fix: UVC cameras not working2 2023-09-23 19:52:12 -05:00
Prohurtz
5e28ee34e5 fix: UVC cameras not working 2023-09-23 19:49:41 -05:00
Prohurtz
8f2709253c fix blink algo crash, do not resize small frames 2023-09-23 17:26:28 -05:00
semantic-release-bot
58a86f158f chore(minor): 1.0.0-HSF-and-new-algos-feature-branch.2 [skip ci]
## [1.0.0-HSF-and-new-algos-feature-branch.2](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.1...v1.0.0-HSF-and-new-algos-feature-branch.2) (2023-09-23)

### 🍕 Features

* ready for new beta ([ac6f8b9](ac6f8b98d2))

### 🐛 Bug Fixes

* BETA 7 ([0cd9f46](0cd9f46a02))
* Merge pull request [#91](https://github.com/EyeTrackVR/EyeTrackVR/issues/91) from ShyAssassin/ci-cd-fix ([2a7dd65](2a7dd65e08))

### 🧑‍💻 Code Refactoring

* BETA 7 ([1d79445](1d79445889))

### 🤖 Build System

* BETA 7 ([1e94577](1e94577d90))
* fixed github action to early exit ([9562d4d](9562d4de6f))
2023-09-23 21:09:29 +00:00
Prohurtz
2a7dd65e08
fix: Merge pull request #91 from ShyAssassin/ci-cd-fix
fixed semantic versioning early exit
2023-09-23 16:05:26 -05:00
[Assassin]
9562d4de6f build: fixed github action to early exit 2023-09-23 23:02:45 +02:00
Prohurtz
0cd9f46a02 fix: BETA 7 2023-09-23 15:43:19 -05:00
Prohurtz
1e94577d90 build: BETA 7 2023-09-23 15:37:34 -05:00
Prohurtz
1d79445889 refactor: BETA 7 2023-09-23 15:20:50 -05:00
Prohurtz
ac6f8b98d2 feat: ready for new beta 2023-09-23 15:05:13 -05:00
Prohurtz
73ec950fac set default crop size to normal esp32 cam 240x240 2023-09-23 14:50:54 -05:00
Prohurtz
0b7b1bed07 limit image size to 480 to prevent issues
macos fixes
2023-09-23 14:47:58 -05:00
RedHawk989
916967c560 (not fully tested) Legacy ransac toggle and thresh settings 2023-09-10 12:05:30 -05:00
semantic-release-bot
bec811f61e chore(major): 1.0.0-HSF-and-new-algos-feature-branch.1 [skip ci]
## 1.0.0-HSF-and-new-algos-feature-branch.1 (2023-08-24)

### ⚠ BREAKING CHANGES

* CHANGES

### 🍕 Features

* add colorama instead of using escape codes ([1d9dfea](1d9dfeae19))
* add dev container ([a5e36ad](a5e36ad4c4))
* add taskipy to run tasks via poetry ([2f1d3d9](2f1d3d9275))
* add v2 serial comms with packet headers ([c2aad0e](c2aad0e859))
* more readable logging with colorama ([2efa3c3](2efa3c3589))
* show bitrate, fps, latency in tracking mode ([6163744](61637442c6))
* slightly reduce dropped frame count ([3b5582d](3b5582de6e))
* updated poetry.lock ([632ec2a](632ec2a838))

### 🐛 Bug Fixes

* bring the existing impl to a usable state ([b3f444e](b3f444ee3a))
* ensure serial is closed when thread crashed ([4def965](4def965759))
* identify and mitigate latency issues ([295f104](295f10476d))
* module leap not found ([437b88a](437b88a4bd))
* module leap not found ([a29db80](a29db80909))
* Only ever return one blob when blob tracking ([5a451b4](5a451b4aa5)), closes [#20](https://github.com/EyeTrackVR/EyeTrackVR/issues/20)
* Output all info to OSC unless we're failing to find any data ([764baa5](764baa539d)), closes [#21](https://github.com/EyeTrackVR/EyeTrackVR/issues/21)
* pinv for pseudo inverse when singular matrix ([ca56222](ca56222445))
* pyserial is the correct dep, not serial ([09a9fbb](09a9fbb052))
* rate limit error ([129e07b](129e07bc1c))
* standalone exe ([35e71a2](35e71a2123))
* Update graph background color on events ([5580d48](5580d482f9)), closes [#24](https://github.com/EyeTrackVR/EyeTrackVR/issues/24)
* when address is set but no devices connected ([6d7630c](6d7630cf60))

### 🧑‍💻 Code Refactoring

* cleanup imports ([446a20c](446a20cea8))
* edit readme and dummy commit to trigger actions ([f5695f0](f5695f00a8))
* extract EyeInfo enums and dataclasses ([d6d1938](d6d19383bd))

### 🤖 Build System

* setup pre-release build pipeline ([aa5e6b8](aa5e6b8b34))
* setup pre-release build pipeline ([a8b73bf](a8b73bf224))
* setup pre-release build pipeline ([9b27720](9b27720bf9))
* setup pre-release build pipeline ([e250e4e](e250e4e353))
2023-08-24 21:43:50 +00:00
DaOfficialWizard
f5695f00a8
refactor: edit readme and dummy commit to trigger actions 2023-08-24 22:40:11 +01:00
semantic-release-bot
6e761d4201 chore(major): 1.0.0-HSF-and-new-algos-feature-branch.1 [skip ci]
## 1.0.0-HSF-and-new-algos-feature-branch.1 (2023-08-24)

### ⚠ BREAKING CHANGES

* CHANGES

### 🍕 Features

* add colorama instead of using escape codes ([1d9dfea](1d9dfeae19))
* add dev container ([a5e36ad](a5e36ad4c4))
* add taskipy to run tasks via poetry ([2f1d3d9](2f1d3d9275))
* add v2 serial comms with packet headers ([c2aad0e](c2aad0e859))
* more readable logging with colorama ([2efa3c3](2efa3c3589))
* show bitrate, fps, latency in tracking mode ([6163744](61637442c6))
* slightly reduce dropped frame count ([3b5582d](3b5582de6e))
* updated poetry.lock ([632ec2a](632ec2a838))

### 🐛 Bug Fixes

* bring the existing impl to a usable state ([b3f444e](b3f444ee3a))
* ensure serial is closed when thread crashed ([4def965](4def965759))
* identify and mitigate latency issues ([295f104](295f10476d))
* module leap not found ([437b88a](437b88a4bd))
* module leap not found ([a29db80](a29db80909))
* Only ever return one blob when blob tracking ([5a451b4](5a451b4aa5)), closes [#20](https://github.com/EyeTrackVR/EyeTrackVR/issues/20)
* Output all info to OSC unless we're failing to find any data ([764baa5](764baa539d)), closes [#21](https://github.com/EyeTrackVR/EyeTrackVR/issues/21)
* pinv for pseudo inverse when singular matrix ([ca56222](ca56222445))
* pyserial is the correct dep, not serial ([09a9fbb](09a9fbb052))
* rate limit error ([129e07b](129e07bc1c))
* standalone exe ([35e71a2](35e71a2123))
* Update graph background color on events ([5580d48](5580d482f9)), closes [#24](https://github.com/EyeTrackVR/EyeTrackVR/issues/24)
* when address is set but no devices connected ([6d7630c](6d7630cf60))

### 🧑‍💻 Code Refactoring

* cleanup imports ([446a20c](446a20cea8))
* extract EyeInfo enums and dataclasses ([d6d1938](d6d19383bd))

### 🤖 Build System

* setup pre-release build pipeline ([aa5e6b8](aa5e6b8b34))
* setup pre-release build pipeline ([a8b73bf](a8b73bf224))
* setup pre-release build pipeline ([9b27720](9b27720bf9))
* setup pre-release build pipeline ([e250e4e](e250e4e353))
2023-08-24 12:39:47 +00:00
DaOfficialWizard
437b88a4bd fix: module leap not found 2023-08-24 13:35:46 +01:00
semantic-release-bot
db29827ae5 chore(major): 1.0.0-HSF-and-new-algos-feature-branch.1 [skip ci]
## 1.0.0-HSF-and-new-algos-feature-branch.1 (2023-08-24)

### ⚠ BREAKING CHANGES

* CHANGES

### 🍕 Features

* add colorama instead of using escape codes ([1d9dfea](1d9dfeae19))
* add dev container ([a5e36ad](a5e36ad4c4))
* add taskipy to run tasks via poetry ([2f1d3d9](2f1d3d9275))
* add v2 serial comms with packet headers ([c2aad0e](c2aad0e859))
* more readable logging with colorama ([2efa3c3](2efa3c3589))
* show bitrate, fps, latency in tracking mode ([6163744](61637442c6))
* slightly reduce dropped frame count ([3b5582d](3b5582de6e))
* updated poetry.lock ([632ec2a](632ec2a838))

### 🐛 Bug Fixes

* bring the existing impl to a usable state ([b3f444e](b3f444ee3a))
* ensure serial is closed when thread crashed ([4def965](4def965759))
* identify and mitigate latency issues ([295f104](295f10476d))
* module leap not found ([a29db80](a29db80909))
* Only ever return one blob when blob tracking ([5a451b4](5a451b4aa5)), closes [#20](https://github.com/EyeTrackVR/EyeTrackVR/issues/20)
* Output all info to OSC unless we're failing to find any data ([764baa5](764baa539d)), closes [#21](https://github.com/EyeTrackVR/EyeTrackVR/issues/21)
* pinv for pseudo inverse when singular matrix ([ca56222](ca56222445))
* pyserial is the correct dep, not serial ([09a9fbb](09a9fbb052))
* rate limit error ([129e07b](129e07bc1c))
* standalone exe ([35e71a2](35e71a2123))
* Update graph background color on events ([5580d48](5580d482f9)), closes [#24](https://github.com/EyeTrackVR/EyeTrackVR/issues/24)
* when address is set but no devices connected ([6d7630c](6d7630cf60))

### 🧑‍💻 Code Refactoring

* cleanup imports ([446a20c](446a20cea8))
* extract EyeInfo enums and dataclasses ([d6d1938](d6d19383bd))

### 🤖 Build System

* setup pre-release build pipeline ([aa5e6b8](aa5e6b8b34))
* setup pre-release build pipeline ([a8b73bf](a8b73bf224))
* setup pre-release build pipeline ([9b27720](9b27720bf9))
* setup pre-release build pipeline ([e250e4e](e250e4e353))
2023-08-24 12:12:54 +00:00
DaOfficialWizard
a29db80909 fix: module leap not found 2023-08-24 13:05:46 +01:00
semantic-release-bot
a26a2e75f4 chore(patch): 1.0.0-HSF-and-new-algos-feature-branch.2 [skip ci]
## [1.0.0-HSF-and-new-algos-feature-branch.2](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.1...v1.0.0-HSF-and-new-algos-feature-branch.2) (2023-08-24)

### 🐛 Bug Fixes

* rate limit error ([129e07b](129e07bc1c))
2023-08-24 12:04:34 +00:00
DaOfficialWizard
fd8647aaf7 Merge branch 'HSF-and-new-algos-feature-branch' of https://github.com/EyeTrackVR/EyeTrackVR into HSF-and-new-algos-feature-branch 2023-08-24 13:00:09 +01:00
DaOfficialWizard
129e07bc1c fix: rate limit error 2023-08-24 12:59:48 +01:00
semantic-release-bot
4f1b021b62 chore(major): 1.0.0-HSF-and-new-algos-feature-branch.1 [skip ci]
## 1.0.0-HSF-and-new-algos-feature-branch.1 (2023-08-24)

### ⚠ BREAKING CHANGES

* CHANGES

### 🍕 Features

* add colorama instead of using escape codes ([1d9dfea](1d9dfeae19))
* add dev container ([a5e36ad](a5e36ad4c4))
* add taskipy to run tasks via poetry ([2f1d3d9](2f1d3d9275))
* add v2 serial comms with packet headers ([c2aad0e](c2aad0e859))
* more readable logging with colorama ([2efa3c3](2efa3c3589))
* show bitrate, fps, latency in tracking mode ([6163744](61637442c6))
* slightly reduce dropped frame count ([3b5582d](3b5582de6e))
* updated poetry.lock ([632ec2a](632ec2a838))

### 🐛 Bug Fixes

* bring the existing impl to a usable state ([b3f444e](b3f444ee3a))
* ensure serial is closed when thread crashed ([4def965](4def965759))
* identify and mitigate latency issues ([295f104](295f10476d))
* Only ever return one blob when blob tracking ([5a451b4](5a451b4aa5)), closes [#20](https://github.com/EyeTrackVR/EyeTrackVR/issues/20)
* Output all info to OSC unless we're failing to find any data ([764baa5](764baa539d)), closes [#21](https://github.com/EyeTrackVR/EyeTrackVR/issues/21)
* pinv for pseudo inverse when singular matrix ([ca56222](ca56222445))
* pyserial is the correct dep, not serial ([09a9fbb](09a9fbb052))
* standalone exe ([35e71a2](35e71a2123))
* Update graph background color on events ([5580d48](5580d482f9)), closes [#24](https://github.com/EyeTrackVR/EyeTrackVR/issues/24)
* when address is set but no devices connected ([6d7630c](6d7630cf60))

### 🧑‍💻 Code Refactoring

* cleanup imports ([446a20c](446a20cea8))
* extract EyeInfo enums and dataclasses ([d6d1938](d6d19383bd))

### 🤖 Build System

* setup pre-release build pipeline ([aa5e6b8](aa5e6b8b34))
* setup pre-release build pipeline ([a8b73bf](a8b73bf224))
* setup pre-release build pipeline ([9b27720](9b27720bf9))
* setup pre-release build pipeline ([e250e4e](e250e4e353))
2023-08-24 11:54:31 +00:00
DaOfficialWizard
35e71a2123 fix: standalone exe
- setup standalone executable
- fix external file bundling for standalone
- fix some spelling errors in gui

BREAKING CHANGES
2023-08-24 12:48:18 +01:00
semantic-release-bot
8a972adc71 chore(minor): 1.0.0-HSF-and-new-algos-feature-branch.1 [skip ci]
## 1.0.0-HSF-and-new-algos-feature-branch.1 (2023-08-24)

### 🍕 Features

* add colorama instead of using escape codes ([1d9dfea](1d9dfeae19))
* add dev container ([a5e36ad](a5e36ad4c4))
* add taskipy to run tasks via poetry ([2f1d3d9](2f1d3d9275))
* add v2 serial comms with packet headers ([c2aad0e](c2aad0e859))
* more readable logging with colorama ([2efa3c3](2efa3c3589))
* show bitrate, fps, latency in tracking mode ([6163744](61637442c6))
* slightly reduce dropped frame count ([3b5582d](3b5582de6e))
* updated poetry.lock ([632ec2a](632ec2a838))

### 🐛 Bug Fixes

* bring the existing impl to a usable state ([b3f444e](b3f444ee3a))
* ensure serial is closed when thread crashed ([4def965](4def965759))
* identify and mitigate latency issues ([295f104](295f10476d))
* Only ever return one blob when blob tracking ([5a451b4](5a451b4aa5)), closes [#20](https://github.com/EyeTrackVR/EyeTrackVR/issues/20)
* Output all info to OSC unless we're failing to find any data ([764baa5](764baa539d)), closes [#21](https://github.com/EyeTrackVR/EyeTrackVR/issues/21)
* pinv for pseudo inverse when singular matrix ([ca56222](ca56222445))
* pyserial is the correct dep, not serial ([09a9fbb](09a9fbb052))
* Update graph background color on events ([5580d48](5580d482f9)), closes [#24](https://github.com/EyeTrackVR/EyeTrackVR/issues/24)
* when address is set but no devices connected ([6d7630c](6d7630cf60))

### 🧑‍💻 Code Refactoring

* cleanup imports ([446a20c](446a20cea8))
* extract EyeInfo enums and dataclasses ([d6d1938](d6d19383bd))

### 🤖 Build System

* setup pre-release build pipeline ([aa5e6b8](aa5e6b8b34))
* setup pre-release build pipeline ([a8b73bf](a8b73bf224))
* setup pre-release build pipeline ([9b27720](9b27720bf9))
* setup pre-release build pipeline ([e250e4e](e250e4e353))
2023-08-24 08:09:35 +00:00
DaOfficialWizard
aa5e6b8b34 build: setup pre-release build pipeline 2023-08-24 09:06:27 +01:00
DaOfficialWizard
a8b73bf224 build: setup pre-release build pipeline 2023-08-24 09:03:24 +01:00
DaOfficialWizard
9b27720bf9 build: setup pre-release build pipeline 2023-08-24 09:00:42 +01:00
DaOfficialWizard
e250e4e353 build: setup pre-release build pipeline 2023-08-24 08:55:15 +01:00
RedHawk989
abf7f88ae5 fix algo priority, placement 2023-08-10 20:42:19 -05:00
Prohurtz
5198cddd8f add mommy model files 2023-08-09 17:03:09 -05:00
Prohurtz
969e06eca0 new blink algo (almost done) bug fixes, small perf improvement? 2023-07-25 20:49:43 -05:00
Prohurtz
a6a8991a61 new blink algo (almost done) bug fixes, small perf improvement? 2023-07-25 20:27:57 -05:00
Prohurtz
09bf6737a2 [IN TESTING] blink fixes, y axis lock, IBO bug fix 2023-07-14 18:46:58 -05:00
Prohurtz
0cd0ad6334 revert camera thread, util opencv bug fix instead 2023-07-12 09:38:34 -05:00
Prohurtz
d5462e09fe IBO fix, new RANSAC blink 2023-07-10 21:28:34 -05:00
Prohurtz
c6a11ae62d test push 2023-07-10 20:09:17 -05:00
Prohurtz
3fee1a4e53 fix HSF 2023-07-10 20:04:50 -05:00
Prohurtz
8e5836575e mommy gets divorced (renamed to LEAP) 2023-06-29 13:26:17 -05:00
Prohurtz
5686604dcb mommy gets divorced (renamed to LEAP) 2023-06-29 13:25:31 -05:00
Prohurtz
6be461be85 mommy gets divorced (renamed to LEAP) 2023-06-29 13:24:15 -05:00
Prohurtz
eacaa1fbba fix mommy file dir 2023-06-24 18:58:54 -05:00
Prohurtz
3d7d06764c fix app close stall 2023-06-24 14:35:23 -05:00
Prohurtz
cb6d27a3a4 update poetry stuffs (remove torch) 2023-06-24 14:19:15 -05:00
Prohurtz
45a7b25a0a remove torch for smaller binary. kill bug for mommy 2023-06-24 14:15:46 -05:00
Prohurtz
338591617e fix config calibration saves 2023-06-24 13:48:00 -05:00
Prohurtz
bbe1629ebd fix none type crashes 2023-06-24 13:29:22 -05:00
Prohurtz
96372a7aa7 fix none type crashes 2023-06-24 12:17:35 -05:00
Prohurtz
d7b0136dff fix build file stuff 2023-06-23 18:34:03 -05:00
Prohurtz
eb6a48df91 fix opencv hang when no cameras
Add MOMMY v1 (early small model)
2023-06-23 12:29:44 -05:00
Prohurtz
911a63ae18
Merge pull request #87 from EtheriVR/Etheri-Improve-Convergance
Improves convergance
2023-06-23 08:42:41 -07:00
Etheri
544f2b05b8 Improves convergance
Improve convergance by simply sending the propper y values
2023-06-23 02:32:09 +02:00
Prohurtz
9a4e04d145
Merge pull request #86 from EtheriVR/Etheri-Fix-OSC-Calibrate
Fixes calibration values
2023-06-19 11:56:44 -07:00
Etheri
0c825af3e4 Fixes calibration values
There was a bug that made calibration values all default to 1 if there was a failure of the try/except, fixed the code so it does error checking before running
2023-06-18 00:13:10 +02:00
Prohurtz
c784918531 black format ep 2023-06-17 12:33:26 -05:00
Prohurtz
635173f42a black format IBO 2023-06-17 12:32:56 -05:00
Prohurtz
a00823b154
Merge pull request #85 from EtheriVR/Etheri-Improvements-And-Fixes
Etheri improvements and fixes
2023-06-17 10:26:46 -07:00
Prohurtz
41b11fe134 Expose IBO settings, Fix UVC cameras 2023-06-17 12:25:06 -05:00
Etheri
7081723ac0 Osc had a different name than settings 2023-06-17 17:16:17 +02:00
Etheri
a1aecc9cdb Added eyelids to config
Adding the eyelids to config and adding an invert bool makes this work with most peoples currently setup eyelids if they used eyetracking before native
2023-06-17 15:56:45 +02:00
Etheri
80af953b29 Fixed left and right squeeze sending same value 2023-06-17 15:36:15 +02:00
Etheri
6bfdc8a08f Param rename 2023-06-16 13:12:51 +02:00
Etheri
9284f97b09 Adds new config vars fixes filter pop error
In additon resets caibration data for IBO on recalibrate
2023-06-16 13:06:36 +02:00
Etheri
8569009fb1 Fixes numpy type error 2023-06-16 13:00:59 +02:00
Etheri
203ac58b16 Remove globals to restore dual eye 2023-06-16 13:00:16 +02:00
Prohurtz
fe02b86cbf bump brightness on rotation background 2023-06-02 16:23:24 -05:00
Prohurtz
a38cd4369e remove debug prints 2023-06-02 16:15:50 -05:00
Prohurtz
5fa9b3a07c remove debug print, fix IBO eye saves 2023-06-02 16:14:54 -05:00
Prohurtz
d5252124a4 fis HSRAC, fix HSF radius sliders 2023-06-02 16:06:05 -05:00
Prohurtz
056e21d6e2 Merge remote-tracking branch 'origin/HSF-and-new-algos-feature-branch' into HSF-and-new-algos-feature-branch 2023-06-02 10:58:42 -05:00
Prohurtz
373c29ceb6 fix ransac thresh add, add setup config 2023-06-02 10:58:18 -05:00
Prohurtz
f5f836ae75 [Dazbme] fix IBO filter 2023-06-02 08:42:16 -07:00
Prohurtz
2465df6697 fix BLOB remove debug prints 2023-06-01 12:12:12 -05:00
Prohurtz
1c7c6d964b IBO fix, HSRAC fix 2023-06-01 12:03:04 -05:00
Prohurtz
ea33273b73 remove debug print, add t & e back 2023-05-31 20:37:53 -05:00
Prohurtz
c97c498d62 remove HSRAC.py use HSF and RANSAC sep calls, HSRAC circle crop with avg color 2023-05-31 20:37:18 -05:00
Prohurtz
507283ed7e sep settings pages, eyelid visualizations 2023-05-31 19:37:16 -05:00
Prohurtz
4c0c907a07 [NOT WORKING] start work on algo settings page 2023-05-31 17:27:36 -05:00
Prohurtz
73fef266c6 fit circle crop in ransac3d 2023-05-31 17:14:10 -05:00
Prohurtz
786c9e3d5f fit wired fps calc 2023-05-31 16:29:30 -05:00
Prohurtz
c965756f27 fit wireless fps calc 2023-05-29 17:57:52 -05:00
Prohurtz
d310b0a8b9 print cleanup 2023-05-11 16:07:27 -05:00
Prohurtz
30077496e3 fix cams, bump ver 2023-05-11 16:00:07 -05:00
Prohurtz
3c451b41a8
clean 2023-05-11 11:31:48 -07:00
Prohurtz
9ef1f1f893
cleanup 2023-05-11 09:55:14 -07:00
Prohurtz
a526c29960
cleanup 2023-05-11 09:39:38 -07:00
Prohurtz
22e9c6df85
cleanup, update credits, rename 2023-05-11 09:04:30 -07:00
Prohurtz
8d29d24127
complete implimentation of per eye cirlcle crop in ransac 2023-05-11 08:58:07 -07:00
Prohurtz
e554d8bf2c
circle crop back in settings 2023-05-11 08:46:06 -07:00
Prohurtz
3c9c8b2eb8
flip tab layout to make sense 2023-05-11 08:18:08 -07:00
Prohurtz
f42d64bf02
part of re imp circle crop, settings placement change 2023-05-11 08:16:42 -07:00
Prohurtz
478b2e198f
remove ibo call causing crash 2023-05-11 07:42:11 -07:00
Prohurtz
8ff33f0461
rename functions to be more correct 2023-05-11 07:37:42 -07:00
Prohurtz
9d2de778b0
fix wireless cam fps and image size reporting 2023-05-11 07:36:06 -07:00
Prohurtz
c5a9842dc7
Merge pull request #81 from Blu3u/feature/usb_improvements
Improvements in tracking over USB, added taskipy, colorama, and realtime tracking info display
2023-05-11 07:18:57 -07:00
Sebastian Fitt
61637442c6 feat: show bitrate, fps, latency in tracking mode 2023-04-29 15:48:29 +02:00
Sebastian Fitt
2efa3c3589 feat: more readable logging with colorama 2023-04-29 14:40:44 +02:00
Sebastian Fitt
6d7630cf60 fix: when address is set but no devices connected 2023-04-29 14:32:54 +02:00
Sebastian Fitt
3b5582de6e feat: slightly reduce dropped frame count 2023-04-29 14:32:54 +02:00
Sebastian Fitt
4def965759 fix: ensure serial is closed when thread crashed 2023-04-29 14:32:54 +02:00
Sebastian Fitt
ca56222445 fix: pinv for pseudo inverse when singular matrix 2023-04-29 14:32:54 +02:00
Sebastian Fitt
446a20cea8 refactor: cleanup imports 2023-04-29 14:32:54 +02:00
Sebastian Fitt
d6d19383bd refactor: extract EyeInfo enums and dataclasses 2023-04-29 14:32:54 +02:00
Sebastian Fitt
632ec2a838 feat: updated poetry.lock 2023-04-29 14:32:54 +02:00
Sebastian Fitt
1d9dfeae19 feat: add colorama instead of using escape codes 2023-04-29 14:32:54 +02:00
Sebastian Fitt
2f1d3d9275 feat: add taskipy to run tasks via poetry 2023-04-29 14:32:53 +02:00
Prohurtz
4ce8531136 fix blob and ransac outputs 2023-04-27 18:14:40 -05:00
Prohurtz
52297424de fix HSRAC crash 2023-04-23 16:47:12 -05:00
Prohurtz
70eeb54e5c fix HSRAC crash 2023-04-23 16:29:58 -05:00
Prohurtz
29f75b2054 one euro filter in IBO
blink algo recalibrates when tracking does
2023-04-22 17:44:33 -05:00
Prohurtz
e5469cf42c one euro filter in IBO
messing around in IBO
2023-04-22 17:41:57 -05:00
Prohurtz
a763098bad fix algos, ransac crash fix 2023-04-16 17:36:26 -05:00
Prohurtz
25d7417c62 fix disk usage 2023-04-16 13:20:37 -05:00
Prohurtz
28a306454b native vrc ET
spelling fixes
2023-04-15 21:39:34 -05:00
Prohurtz
89365ba730 fix ransac bug and auto radius fix 2023-04-15 19:34:38 -05:00
Prohurtz
0b5f10d9a6 fix single eye to dual eye not working 2023-04-14 16:07:46 -05:00
Prohurtz
d0fcfe7f9a add HSRAC visualizations.
(something might be wrong with IBO)
2023-04-12 21:04:38 -05:00
Prohurtz
87c2f1f7eb fix daddy files
update .spec
2023-04-12 20:36:30 -05:00
Prohurtz
bbd8b96978 fix rotation edge bug?
revert HSRAC crop output to normal szie
2023-04-12 17:29:20 -05:00
Prohurtz
e3a932fb5d
Merge pull request #80 from Blu3u/HSF-and-new-algos-feature-branch
Fix issues in existing USB-based eye tracker implementation to enable proper functionality.
2023-04-09 18:18:20 -05:00
Sebastian Fitt
c2aad0e859 feat: add v2 serial comms with packet headers
* feat: serial read no longer drops frames on buffer reset
* fix: fps now calculated properly
* fix: remove unnecessary logs
2023-04-09 11:05:50 +02:00
Prohurtz
dd29a6834f add basic filter to IBO to help jitters 2023-04-08 20:55:51 -05:00
Sebastian Fitt
295f10476d fix: identify and mitigate latency issues 2023-04-08 19:58:13 +02:00
Sebastian Fitt
b3f444ee3a fix: bring the existing impl to a usable state
* fix: existing impl always passed None as the port, it never worked
* feat: initial handling of unplugging usb serial device
* feat: check if port exists before trying to open it
* fix: existing impl caused division by zero when calculating fps
* fix: existing impl did not exit when image not found then crashed
* fix: warn logs were marked as info
2023-04-08 18:30:19 +02:00
Sebastian Fitt
09a9fbb052 fix: pyserial is the correct dep, not serial
* fix: remove duplicated requests dep
2023-04-08 18:24:23 +02:00
Prohurtz
e2ff11a081 format, vrc native ET test init 2023-04-06 18:14:11 -05:00
Prohurtz
cdba5a55e4 Update osc.py 2023-04-06 17:55:09 -05:00
Prohurtz
0ca9f27175
Merge pull request #79 from RedHawk989/feature/dev_container
feat: add dev container
2023-04-06 14:00:50 -07:00
Prohurtz
33ec3f20d7
Merge branch 'HSF-and-new-algos-feature-branch' into feature/dev_container 2023-04-06 14:00:39 -07:00
Prohurtz
4121789875 format settings page better for high res screens 2023-04-01 15:38:25 -07:00
Prohurtz
e621c8197c fix linux compatibility, add requests to poetry 2023-04-01 15:23:11 -07:00
ZanzyTHEbar
a5e36ad4c4 feat: add dev container 2023-03-30 12:22:29 +01:00
Prohurtz
c1f63dd400 finish save calib after restart 2023-03-22 17:15:16 -05:00
Prohurtz
cbd20fdac8 save calib values (NOT FINISHED) 2023-03-20 21:23:12 -05:00
Prohurtz
cca9536b0b force calib stop 2023-03-20 20:43:58 -05:00
Prohurtz
2293f74bfb allow videos to be played (if they have .mp4 in the name) 2023-03-19 18:34:45 -05:00
Prohurtz
c90517b6c0 bd_blink from HSRAC aid IBO 2023-03-19 18:15:19 -05:00
Prohurtz
68a0a291d3 remove unused imports 2023-03-19 17:32:59 -05:00
Prohurtz
fe01805c1f remove useless function 2023-03-19 17:32:38 -05:00
Prohurtz
8bd6848ba0 Update requirements.txt 2023-03-19 17:30:31 -05:00
Prohurtz
f6905c48eb fix update check, replace win10toast with winotify 2023-03-19 17:30:01 -05:00
Prohurtz
137f3004f7 fix flip right axis, fix http add 2023-03-19 17:07:05 -05:00
Prohurtz
5c0b1b45fd cleaning 2023-03-19 16:46:10 -05:00
Prohurtz
7836a5022b long needed osc.py re-write 2023-03-19 16:31:59 -05:00
Prohurtz
c5c809af1f depreciate sync blink in favor of dual eye falloff 2023-03-19 15:48:59 -05:00
Prohurtz
0e0ea2df4a toggle for OSC recieve 2023-03-19 15:45:17 -05:00
Prohurtz
b6683b3038 fix DADDY tooltip 2023-03-19 15:31:39 -05:00
Prohurtz
2495a66704 lower thresh add 2023-03-19 15:24:08 -05:00
Prohurtz
cc304f855d make auto radius on by default 2023-03-19 15:11:17 -05:00
Prohurtz
5610297d3e Create requirements.txt 2023-03-19 15:08:57 -05:00
Prohurtz
8c668555b8 fix serial cam, cleaning 2023-03-19 15:03:45 -05:00
Prohurtz
57f854ec44 HSF/HSRAC update settings when changed in settings tab 2023-03-19 15:01:37 -05:00
Prohurtz
d2d934c496 cleanup 2023-03-18 19:11:54 -05:00
Prohurtz
d94c71c26f fix algo output data 2023-03-18 19:10:58 -05:00
Prohurtz
5ea1a39209 add http to camera address if not found 2023-03-18 19:04:30 -05:00
Prohurtz
65f6aa7181 Seaweed Serial cam support 2023-03-18 18:50:43 -05:00
Prohurtz
fdb4e7eb1f Merge branch 'HSF-and-new-algos-feature-branch' of https://github.com/RedHawk989/EyeTrackVR into HSF-and-new-algos-feature-branch 2023-03-18 17:30:58 -05:00
Prohurtz
5f46519cac IBO bump adjustment 2023-03-18 17:30:57 -05:00
Prohurtz
052af15a33
Merge pull request #77 from PallasNeko/feature/DADDY-Simple-output
Made Daddy more competent
2023-03-18 17:08:49 -05:00
PallasNeko
43c216ff1f Daddy's output is changed 2023-03-19 02:58:14 +09:00
Prohurtz
c91eb003a8
Merge pull request #76 from PallasNeko/fix/HSRAC-GUIThreshold
Fix HSRAC&GUI
2023-03-18 12:03:56 -05:00
PallasNeko
299db700c1 Use value of setting 2023-03-19 01:52:42 +09:00
Prohurtz
38c9c0a6ca
Merge pull request #75 from PallasNeko/fix/RANSAC3D-IBO
Fix/ransac3d ibo
2023-03-18 09:48:17 -05:00
PallasNeko
33ee1fc31d temporary fix 2023-03-18 23:39:26 +09:00
Prohurtz
8d0e9eb45f remove IBO print 2023-03-17 17:14:10 -05:00
Prohurtz
bfa147b292 small tweaks 2023-03-17 16:40:56 -05:00
Prohurtz
3d08b8a4da make sure crop is not out of bounds 2023-03-17 16:37:56 -05:00
Prohurtz
3525aed134 safe crop on IBO 2023-03-17 16:26:01 -05:00
Prohurtz
bc1c2d1f68 add INNO setup script 2023-03-17 14:41:03 -05:00
Prohurtz
dcf87e492d update .spec to 3.10 2023-03-17 14:29:39 -05:00
Prohurtz
e149db16e0 remove debug prints in IBO 2023-03-17 14:29:05 -05:00
Prohurtz
bfb588ef6f tuning, minor adjustments 2023-03-17 14:27:44 -05:00
Prohurtz
4b361a74db adjust thresh, IBO 2023-03-17 11:14:13 -05:00
Prohurtz
ae4c49d49c
Merge pull request #74 from PallasNeko/feature/HSRAC-Stabilized
hsrac stabilized
2023-03-17 10:56:15 -05:00
Prohurtz
237209ccf9 add DADDY to settings 2023-03-17 10:53:35 -05:00
PallasNeko
17cc788b07 UPDATE hsrac 2023-03-18 00:50:12 +09:00
Prohurtz
5e2a2635f2 bump back py ver to 3.10 for DADDY 2023-03-17 10:45:01 -05:00
PallasNeko
86a5c8a4ae UPDATE bench_hsrac 2023-03-18 00:42:39 +09:00
Prohurtz
55f12c56c5
Merge pull request #68 from PallasNeko/feature/DeepEyeLandmark
deep eye landmark
2023-03-17 10:40:54 -05:00
Prohurtz
a116f7afc7 use radius to crop for IBO 2023-03-17 10:37:47 -05:00
PallasNeko
fa57746f99 Fixed PyCharm detection 2023-03-17 16:22:48 +09:00
PallasNeko
3e7a1083e2 Temporary symptomatic fix 2023-03-17 16:08:29 +09:00
PallasNeko
100c17c912 DADDY got BEER 2023-03-17 00:04:41 +09:00
Prohurtz
9597482b1d Update .gitignore 2023-03-16 10:02:51 -05:00
Prohurtz
9b7beb413d
Merge pull request #73 from PallasNeko/fix/IBO-saveload
Fix IBO Save and Load
2023-03-16 10:01:18 -05:00
Prohurtz
0527bddc1b
Merge pull request #72 from PallasNeko/feature/HSRAC-imp
HSF and HASRAC improvements
2023-03-16 10:00:55 -05:00
PallasNeko
647b2a9aed Fix Save and Load 2023-03-16 19:45:25 +09:00
PallasNeko
83d9698db9 Remove second contour detection 2023-03-16 03:33:54 +09:00
PallasNeko
6019f44177 Fixes import to work correctly in non-PyCharm environments 2023-03-16 03:15:31 +09:00
PallasNeko
7e1c138136 Improve hsrac using bench_hsrac 2023-03-16 02:15:55 +09:00
PallasNeko
5bf696cf6f Improve haar_surround_feature using bench_hsrac 2023-03-15 21:13:01 +09:00
PallasNeko
d54893e7fc improvement 2023-03-15 20:49:17 +09:00
PallasNeko
30bee44dd8 improvement 2023-03-14 23:20:06 +09:00
PallasNeko
50f489ec63 clean up and improvement 2023-03-14 23:11:57 +09:00
PallasNeko
1c3f382f40 clean up 2023-03-14 20:31:52 +09:00
PallasNeko
c6413f8ee1 Remove unused functions 2023-03-14 17:46:46 +09:00
PallasNeko
56509d60fe Temporary experimental code 2023-03-14 17:43:38 +09:00
Prohurtz
1045f7b180 correct frame shape 2023-02-18 16:27:30 -06:00
Prohurtz
a3657a223f fix typos 2023-02-18 16:09:23 -06:00
PallasNeko
b120194da7 add onnxruntime 2023-02-18 16:26:04 +09:00
PallasNeko
cb70e960cf add DADDY 2023-02-18 16:26:03 +09:00
Prohurtz
ad0857f8eb minor fixes/cleanup 2023-02-17 18:52:59 -06:00
Prohurtz
c26c336a3e Merge branch 'HSF-and-new-algos-feature-branch' of https://github.com/RedHawk989/EyeTrackVR into HSF-and-new-algos-feature-branch 2023-02-17 18:12:10 -06:00
Prohurtz
0ff5152545 Update .gitignore 2023-02-17 18:12:01 -06:00
Prohurtz
91463707b9
Merge pull request #71 from PallasNeko/fix/save-load-IBO
Fix IBO
2023-02-17 18:11:39 -06:00
PallasNeko
aadb016796 Support for both eyes.
Modified to initialise data if the ROI saved in the image is different from the current ROI.
Fixed calculation.
2023-02-13 18:55:21 +09:00
Prohurtz
3f2742717c Update intensity_eye_open.py 2023-02-12 18:55:06 -08:00
Prohurtz
0d300a43d5 fix IBO dual eye 2023-02-12 18:28:54 -08:00
Prohurtz
5ec32e5b96 Merge branch 'HSF-and-new-algos-feature-branch' of https://github.com/RedHawk989/EyeTrackVR into HSF-and-new-algos-feature-branch 2023-02-12 17:45:18 -06:00
Prohurtz
de5c71af3d small 2023-02-12 17:45:16 -06:00
Prohurtz
9a7a376ae8
Merge pull request #70 from PallasNeko/feature/Wrapper-PlaySound
Easier to use PlaySound functions.
2023-02-12 10:56:18 -06:00
Prohurtz
f4533744d5
Merge pull request #69 from PallasNeko/fix/config-save-load
Fix configuration file loading
2023-02-12 10:55:24 -06:00
PallasNeko
adf3d86401 Added code to determine if the OS is Win or Linux.
Changed so that if Linux, PlaySound does nothing when executed.
2023-02-12 23:36:03 +09:00
PallasNeko
7faf4b2ae5 Prevents configuration files from failing to load. 2023-02-12 22:40:55 +09:00
Prohurtz
5428ef6d53
Merge pull request #67 from PallasNeko/fix/autoradius
Fix autoradius and skip_autoradius
2023-02-10 16:07:24 -06:00
PallasNeko
0504e01a9e Fixed Autoradius not working. 2023-02-10 20:39:10 +09:00
Prohurtz
c7f39646fa Merge branch 'HSF-and-new-algos-feature-branch' of https://github.com/RedHawk989/EyeTrackVR into HSF-and-new-algos-feature-branch 2023-02-09 21:19:34 -06:00
Prohurtz
32c31d293b bug fixes 2023-02-09 21:19:30 -06:00
Prohurtz
447a8bf399
Merge pull request #65 from PallasNeko/remove-requests
remove unused dependency(requests)
2023-02-09 20:28:49 -06:00
Prohurtz
2b89745825
Merge pull request #59 from Meister1593/HSF-and-new-algos-feature-branch
Linux build/runtime fixes
2023-02-09 20:27:17 -06:00
Prohurtz
12304b5b3a
Merge branch 'HSF-and-new-algos-feature-branch' into HSF-and-new-algos-feature-branch 2023-02-09 20:27:04 -06:00
PallasNeko
b025f07aa3 remove unused dependency(requests) 2023-02-08 13:33:26 +09:00
Prohurtz
975cb80dec IBO stuff 2023-02-05 18:21:59 -06:00
Prohurtz
dac77cbadc
remove dependency 2023-02-05 15:18:25 -08:00
Prohurtz
3d194c6d18
remove unused dependency 2023-02-05 15:17:51 -08:00
Prohurtz
67ae604a8f
Merge pull request #64 from PallasNeko/Remove-pandas
Remove pandas
2023-02-05 17:16:51 -06:00
Prohurtz
951cf7e624
Merge pull request #63 from PallasNeko/Remove_BeautifulSoup
Remove BeautifulSoup
2023-02-05 17:14:25 -06:00
Prohurtz
2b48be16db
Merge pull request #62 from PallasNeko/fix_safecrop
fix and move
2023-02-05 17:13:39 -06:00
PallasNeko
eb14f1d651 Additional processing similar to the original 2023-02-03 21:34:09 +09:00
PallasNeko
ccc50f6b0c Remove pandas 2023-02-03 21:18:56 +09:00
PallasNeko
7e6f2a7199 Remove BeautifulSoup 2023-02-02 19:17:52 +09:00
PallasNeko
fc31c0c054 Fixing the call 2023-02-02 01:32:59 +09:00
PallasNeko
ec83c3831f Merge remote-tracking branch 'origin/fix_safecrop' into fix_safecrop
# Conflicts:
#	EyeTrackApp/eye_processor.py
2023-02-02 01:23:16 +09:00
PallasNeko
e3b47f0b68 Fixing the call 2023-02-02 01:21:52 +09:00
Prohurtz
2624080d58 intensity dual eye & bug hunting 2023-02-02 01:07:54 +09:00
Prohurtz
ac4447f0ba Revert "Cleaning+SafeCrop" 2023-02-02 01:07:06 +09:00
PallasNeko
8124ba79ac
Merge branch 'HSF-and-new-algos-feature-branch' into fix_safecrop 2023-02-02 00:37:58 +09:00
PallasNeko
dd23dcf838 fix and move 2023-02-01 23:50:08 +09:00
Prohurtz
6e2f8b0847 intensity dual eye & bug hunting 2023-01-31 16:05:47 -06:00
Prohurtz
ba7ada67cc
Merge pull request #61 from RedHawk989/revert-60-HSF-HSRAC-reformat
Revert "Cleaning+SafeCrop"
2023-01-31 14:08:33 -06:00
Prohurtz
fd88b794cf
Merge branch 'HSF-and-new-algos-feature-branch' into revert-60-HSF-HSRAC-reformat 2023-01-31 14:08:26 -06:00
Prohurtz
a6105a3f51
Revert "Cleaning+SafeCrop" 2023-01-31 12:02:25 -08:00
Prohurtz
88c5ecdf99 fix imports 2023-01-31 13:54:42 -06:00
Prohurtz
f6d3ea0659
Merge pull request #60 from PallasNeko/HSF-HSRAC-reformat
Cleaning+SafeCrop
2023-01-31 11:49:40 -06:00
PallasNeko
b736fa7f8d Cleaning
== None as None
! = None as not None
2023-02-01 02:36:41 +09:00
PallasNeko
582255a698 Cleaning
Reduction of duplicate functions
Appropriate function calls
Use of safe cropping
2023-02-01 02:10:11 +09:00
Prohurtz
b3c214d5ce minor optimization and fixes 2023-01-30 21:14:21 -06:00
plyshka
28f06b4640 Linux fixes: win10toast_click dependency is now included only in windows compilation; Added pandas dependency as it was required in clean build for runtime; Added platform specific checks to disable winsound and triggers for it in codebase. 2023-01-28 23:10:45 +05:00
Prohurtz
4e8db0d0d0 osc 2023-01-28 11:20:34 -06:00
Prohurtz
7b869abc3b Update intensity_eye_open.py 2023-01-25 17:54:59 -06:00
Prohurtz
c10f4502e4 Delete IBO.csv 2023-01-25 17:05:53 -06:00
Prohurtz
b723032a26 IBO all the algos lel 2023-01-25 16:54:28 -06:00
Prohurtz
b4b368bf05 IBO main OSC support 2023-01-25 16:52:12 -06:00
Prohurtz
0622820491 auto create file for IBO 2023-01-25 16:48:43 -06:00
Prohurtz
a67535ce01 HSRAC re imp
no idea if this is tracking properly due to no setup but it doesnt crash so yay lmao
2023-01-25 16:35:21 -06:00
Prohurtz
9fb190aa0f todo 2023-01-23 22:19:00 -06:00
Prohurtz
91b81b98bc small tuning 2023-01-23 22:16:52 -06:00
Prohurtz
9abb30d000 small eyeopen cleanup 2023-01-23 17:03:51 +00:00
Prohurtz
56392bd65b EYE OPENESS 2023-01-22 19:41:33 -06:00
Prohurtz
2573d04ce8 add toggle for update check 2023-01-22 19:17:37 -06:00
Prohurtz
f492e3fdab add colors to text prints + cleanup 2023-01-22 19:09:56 -06:00
Prohurtz
a47b746962 add beginnings of intensity openness 2023-01-19 20:02:45 -06:00
DaOfficialWizard
f5612a3b11
Delete build-jekyll.yml 2023-01-19 20:44:19 +00:00
Prohurtz
c7873e4dd8 update 2023-01-15 12:35:37 -06:00
Prohurtz
d4bfcf31d8 end of day
finally found the reason tracking would freeze sometimes, it happens with the crop goes over the actual frame size. haven't decided what way of preventing this is best
2023-01-12 20:38:06 -06:00
Prohurtz
d5c2270464 somthin funky wunky with cropped image output or something? 2023-01-11 21:30:15 -06:00
Prohurtz
b2ce947b24 fix dumb init issue with HSF. 2023-01-11 20:35:45 -06:00
Prohurtz
dfe8a9a436 HSF is still broke.....
aaaaaaaaaaaaaaaaaaaaah
2023-01-03 10:06:54 -08:00
Prohurtz
db1883929a nightly update
fall back to HSF in HSRAC is not correct. TODO
2023-01-02 15:36:29 -08:00
Prohurtz
1ecbde4f7b save priority values as int 2023-01-02 09:24:08 -08:00
Prohurtz
3d4ac0946d Update eye_processor.py 2022-12-29 11:41:40 -08:00
Prohurtz
bda5047752 small update, flow POC 2022-12-28 10:23:01 -08:00
Prohurtz
77b2389a03 end of day update 2022-12-26 12:52:20 -08:00
Prohurtz
df8efd55d1 hsrac sep 2022-12-26 12:04:51 -08:00
Prohurtz
e533399ffb separate blob 2022-12-26 09:14:18 -08:00
Prohurtz
7ad7ada8f5 separate HSF 2022-12-26 09:05:40 -08:00
Prohurtz
a7e9f007a6 split osc cal and value filter to own file 2022-12-26 08:48:09 -08:00
Prohurtz
c0004eec3f small update 2022-12-23 09:52:39 -08:00
Prohurtz
631410ac4d end of day update 2022-12-22 15:26:37 -08:00
Prohurtz
ec7e58fdae HSRAC POC working 2022-12-22 12:33:49 -08:00
Prohurtz
d5f9fabf09 new ransac + print tags and colors 2022-12-22 11:52:19 -08:00
Prohurtz
0812ad4c0b new HSF imp 2022-12-22 11:29:35 -08:00
Prohurtz
d73f559f06 update 2022-12-21 14:48:34 -08:00
Prohurtz
76b3536704 update 2022-12-21 14:07:48 -08:00
Prohurtz
a27d1f241e initial RANSACAHA imp 2022-12-21 12:43:27 -08:00
Prohurtz
80418faabb fix OEF 2022-12-21 11:23:35 -08:00
Prohurtz
6b171393d7 fix blob 2022-12-21 10:19:44 -08:00
Prohurtz
f50aa4cde1 fixes config, slow progress 2022-12-20 16:41:13 -08:00
Prohurtz
a2377c722c some what working now 2022-12-20 15:24:31 -08:00
Prohurtz
d87ab06415 credits! 2022-12-19 18:25:27 -08:00
Prohurtz
422d0c7df3 fix blob config save 2022-12-19 18:15:07 -08:00
Prohurtz
79647ad024 add algo toggles and rename blob 2022-12-19 18:13:56 -08:00
Prohurtz
61b1a4ad9c initial commit, beginning to part things out 2022-12-19 17:58:52 -08:00
92 changed files with 273869 additions and 2608 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

6
.coveragerc Normal file
View File

@ -0,0 +1,6 @@
[run]
omit=
tests/*
conftest.py
source =
EyeTrackApp

View File

@ -0,0 +1,48 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile
{
"name": "EyeTrackVR App Backend",
"build": {
// Sets the run context to one level up instead of the .devcontainer folder.
"context": "..",
// Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename.
"dockerfile": "../Dockerfile"
},
"features": {
"ghcr.io/akhildevelops/devcontainer-features/apt:0": {},
"ghcr.io/devcontainers/features/python:1": {
"installTools": true,
"optimize": true,
"version": "3.10"
}
},
"customizations": {
"vscode": {
"settings": {},
"extensions": [
"streetsidesoftware.code-spell-checker",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-azuretools.vscode-docker",
"wayou.vscode-todo-highlight",
"gruntfuggly.todo-tree",
"eamodio.gitlens",
"github.vscode-pull-request-github",
"rangav.vscode-thunder-client"
]
}
},
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
8000
],
// Uncomment the next line to run commands after the container is created.
"postCreateCommand": "make install && make"
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "devcontainer"
}

27
.dockerignore Normal file
View File

@ -0,0 +1,27 @@
**/__pycache__
**/.venv
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

64
.github/scripts/prepareCMD.sh vendored Normal file
View File

@ -0,0 +1,64 @@
#!/bin/bash
# create a vairable to hold a passed in argument
# this argument is the next release version
# this is passed in from the .releaserc file
sudo apt-get install -y jq
nextReleaseVersion=$1
TARGET_KEY="version"
# parse all letters a-z and A-Z and replace with nothing
# this will remove all letters from the version string
# this is to ensure that the version string is a valid semver
# check if there is a letter in the version string
# if there is a letter, then remove it
# if there is no letter, then do nothing
if [[ $nextReleaseVersion =~ [a-zA-Z] ]]; then
nextReleaseVersion=$(echo $nextReleaseVersion | sed 's/[a-zA-Z]//g')
# check if there is a dash in the version string
# if there is a dash, then replace it with a dot
# if there is no dash, then do nothing
if [[ $nextReleaseVersion =~ "-" ]]; then
# parse all dashes and replace with dots
# this is to ensure that the version string is a valid semver
nextReleaseVersion=$(echo $nextReleaseVersion | sed 's/-/./g')
# remove everything after the third dot and the dot itself
# this is to ensure that the version string is a valid semver
nextReleaseVersion=$(echo $nextReleaseVersion | sed 's/\.[0-9]*$//g')
# remove the last dot
nextReleaseVersion=$(echo $nextReleaseVersion | sed 's/\.$//g')
fi
fi
# print the next release version
printf "[prepareCMD.sh]: Next version: ${nextReleaseVersion}\n"
# This script is used to execute the prepareCMD.sh script on the remote host
printf "[prepareCMD.sh]: Executing prepareCMD.sh on remote host \n"
#printf "Update the version in the Cargo.toml file \n"
#
#sed -i "s/version = \"[0-9\\.]*\"/version = \"${nextReleaseVersion}\"/g" ./GUI/ETVR/src-tauri/Cargo.toml
# Install the dependencies for toml file
printf "[prepareCMD.sh]: Installing the dependencies for the toml file \n"
pip3 install yq
export PATH="~/.local/bin:$PATH"
source ~/.bashrc
tmp=$(mktemp)
tomlq -t --arg version "$nextReleaseVersion" '.tool.poetry.version |= $version' ./pyproject.toml > "$tmp" && mv "$tmp" ./pyproject.toml -f
# validate the Cargo.toml file
#printf "[prepareCMD.sh]: Validating the Cargo.toml file \n"
#cat ./GUI/ETVR/src-tauri/Cargo.toml
printf "[prepareCMD.sh]: Done, continuing with release. \n"

View File

@ -1,29 +0,0 @@
name: Build and Deploy to Github Pages
on:
push:
branches: [docs]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
# Use GitHub Actions' cache to cache dependencies on servers
- uses: actions/cache@v2
with:
path: vendor/bundle
key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-gems-
# Use GitHub Deploy Action to build and deploy to Github
- uses: jeffreytse/jekyll-deploy-action@v0.3.1
with:
provider: 'github'
token: ${{ secrets.GITHUB_TOKEN }} # It's your Personal Access Token(PAT)
jekyll_src: './docs' # Default is root directory

129
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,129 @@
name: App Builder
on:
workflow_dispatch:
push:
tags:
- "v*"
branches:
- main
- master
- HSF-and-new-algos-feature-branch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
defaults:
run:
shell: bash
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
token: ${{ env.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: 3.10.11
- name: Setup Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
installer-parallel: true
- name: Load cached wheels
id: cached-pip-wheels
uses: actions/cache@v3
with:
path: ~/.cache
key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}
#----------------------------------------------
# load cached venv if cache exists
#----------------------------------------------
- name: Load cached venv
id: cached-poetry-dependencies
uses: actions/cache@v3
with:
path: .venv
key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}
- name: Install python dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install
- name: Install Linux dependencies
if: ${{ matrix.os == 'ubuntu-latest'}}
run: sudo apt install build-essential curl wget libssl-dev
- run: source $VENV
- name: Build App
run: poetry run pyinstaller --noconfirm EyeTrackApp/eyetrackapp.spec EyeTrackApp/eyetrackapp.py
- name: Rename Linux Binary
if: ${{ matrix.os == 'ubuntu-latest' }}
run: |
ls -a build
mv ./dist/eyetrackapp ./dist/eyetrackapp.bin
- uses: actions/upload-artifact@v2
with:
name: production-files
path: dist
deploy:
runs-on: ubuntu-latest
name: Deploy
needs: [build]
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Create Directory
run: mkdir -p dist
- name: Download artifact
uses: actions/download-artifact@v2
with:
name: production-files
path: ./dist
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 18
- run: npm install -g conventional-changelog-conventionalcommits@6
- run: npm install -g semantic-release@v19.0.5
- run: npm install -g @semantic-release/exec
- run: npm install -g @semantic-release/git
- run: npm install -g @semantic-release/release-notes-generator
- run: npm install -g @semantic-release/changelog
- run: npm install -g @semantic-release/github
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
chmod +x ./.github/scripts/prepareCMD.sh
semantic-release
cleanup:
name: Cleanup actions
needs:
- deploy
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "♻️ remove build artifacts"
uses: geekyeggo/delete-artifact@v1
with:
name: production-files

View File

@ -1,92 +0,0 @@
name: Tasmota CI
on:
workflow_dispatch:
pull_request:
branches: development
paths:
- '**.c'
- '**.cpp'
- '**.h'
- '**.hpp'
- '**.ino'
- '**.json'
- '**.properties'
- 'pio-tools/*.py'
- '**.ini'
- '.github/workflows/build_all_the_things.yml'
jobs:
base-images:
runs-on: ubuntu-latest
if: github.repository == 'arendst/Tasmota'
strategy:
matrix:
variant:
- tasmota
- tasmota4M
- tasmota-display
- tasmota-ir
- tasmota-knx
- tasmota-lite
- tasmota-minimal
- tasmota-sensors
- tasmota-zbbridge
- tasmota-zigbee
- tasmota32
- tasmota32-webcam
- tasmota32-bluetooth
- tasmota32-core2
- tasmota32-display
- tasmota32-ir
- tasmota32-lvgl
- tasmota32s2
- tasmota32c3
- tasmota32c3usb
- tasmota32solo1
- tasmota32solo1-safeboot
- tasmota32-safeboot
- tasmota32c3-safeboot
- tasmota32c3usb-safeboot
- tasmota32s2-safeboot
- tasmota32s3-safeboot
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v1
- name: Install dependencies
run: |
#python -m pip install --upgrade pip
pip install -U platformio
#platformio upgrade --dev
#platformio update
- name: Run PlatformIO
run: platformio run -e ${{ matrix.variant }}
- uses: actions/upload-artifact@v2
with:
name: firmware
path: ./build_output
language-images:
runs-on: ubuntu-latest
if: github.repository == 'arendst/Tasmota'
strategy:
matrix:
variant: [ tasmota ]
language: [ AF, BG, BR, CN, CZ, DE, ES, FR, FY, GR, HE, HU, IT, KO, NL, PL, PT, RO, RU, SE, SK, TR, TW, UK, VN ]
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v1
- name: Install dependencies
run: |
#python -m pip install --upgrade pip
pip install -U platformio
#platformio upgrade --dev
#platformio update
- name: Run PlatformIO
run: platformio run -e ${{ matrix.variant }}-${{ matrix.language }}
- uses: actions/upload-artifact@v2
with:
name: firmware
path: ./build_output

View File

@ -1,162 +0,0 @@
name: Build_development
on:
workflow_dispatch: # Manually start a workflow
push:
branches: development
paths-ignore:
- '.github/**' # Ignore changes towards the .github directory
- '**.md' # Do no build if *.md files changes
# Ensures that only one deploy task per branch/environment will run at a time.
concurrency:
group: environment-${{ github.ref }}
cancel-in-progress: true
jobs:
base-images:
runs-on: ubuntu-latest
if: github.repository == 'arendst/Tasmota'
continue-on-error: true
strategy:
matrix:
variant:
- tasmota
- tasmota4M
- tasmota-minimal
- tasmota-display
- tasmota-ir
- tasmota-knx
- tasmota-lite
- tasmota-sensors
- tasmota-zbbridge
- tasmota-zigbee
- tasmota32
- tasmota32-webcam
- tasmota32-bluetooth
- tasmota32-display
- tasmota32-ir
- tasmota32-lvgl
- tasmota32c3
- tasmota32c3usb
- tasmota32solo1
- tasmota32solo1-safeboot
- tasmota32-safeboot
- tasmota32c3-safeboot
- tasmota32c3usb-safeboot
- tasmota32s2-safeboot
- tasmota32s3-safeboot
steps:
- uses: actions/checkout@v3
with:
ref: development
- name: Set up Python
uses: actions/setup-python@v2
- name: Install dependencies
run: |
pip install -U platformio
- name: Run PlatformIO
run: platformio run -e ${{ matrix.variant }}
- uses: actions/upload-artifact@v2
with:
name: firmware
path: ./build_output
language-images:
runs-on: ubuntu-latest
if: github.repository == 'arendst/Tasmota'
continue-on-error: true
strategy:
matrix:
variant: [ tasmota, tasmota32 ]
language: [ AF, BG, BR, CN, CZ, DE, ES, FR, FY, GR, HE, HU, IT, KO, NL, PL, PT, RO, RU, SE, SK, TR, TW, UK, VN ]
steps:
- uses: actions/checkout@v3
with:
ref: development
- name: Set up Python
uses: actions/setup-python@v2
- name: Install dependencies
run: |
pip install -U platformio
- name: Run PlatformIO
run: platformio run -e ${{ matrix.variant }}-${{ matrix.language }}
- uses: actions/upload-artifact@v2
with:
name: firmware
path: ./build_output
Upload:
needs: [base-images, language-images]
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/download-artifact@v2
with:
name: firmware
path: ./mv_firmware
- name: Display structure of downloaded files
run: ls -R
working-directory: ./mv_firmware
- name: Move firmware files in sub-folders
run: |
mkdir -p ./firmware/tasmota/languages
mkdir -p ./firmware/tasmota32/languages
mkdir -p ./firmware/map
[ ! -f ./mv_firmware/map/* ] || mv ./mv_firmware/map/* ./firmware/map/
[ ! -f ./mv_firmware/firmware/tasmota.* ] || mv ./mv_firmware/firmware/tasmota.* ./firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota4M.* ] || mv ./mv_firmware/firmware/tasmota4M.* ./firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-sensors.* ] || mv ./mv_firmware/firmware/tasmota-sensors.* ./firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-minimal.bin.gz ] || mv ./mv_firmware/firmware/tasmota-minimal.bin.gz ./firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-lite.* ] || mv ./mv_firmware/firmware/tasmota-lite.* ./firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-ir*.* ] || mv ./mv_firmware/firmware/tasmota-ir*.* ./firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-display.* ] || mv ./mv_firmware/firmware/tasmota-display.* ./firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-knx.* ] || mv ./mv_firmware/firmware/tasmota-knx.* ./firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-zbbridge.* ] || mv ./mv_firmware/firmware/tasmota-zbbridge.* ./firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-zigbee.* ] || mv ./mv_firmware/firmware/tasmota-zigbee.* ./firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota32.* ] || mv ./mv_firmware/firmware/tasmota32.* ./firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32solo1*.* ] || mv ./mv_firmware/firmware/tasmota32solo1*.* ./firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-ir*.* ] || mv ./mv_firmware/firmware/tasmota32-ir*.* ./firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-display.* ] || mv ./mv_firmware/firmware/tasmota32-display.* ./firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-lvgl.* ] || mv ./mv_firmware/firmware/tasmota32-lvgl.* ./firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-web*.* ] || mv ./mv_firmware/firmware/tasmota32-web*.* ./firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-odroidgo.* ] || mv ./mv_firmware/firmware/tasmota32-odroidgo.* ./firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-core2.* ] || mv ./mv_firmware/firmware/tasmota32-core2.* ./firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-bluetooth.* ] || mv ./mv_firmware/firmware/tasmota32-bluetooth.* ./firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32c3*.* ] || mv ./mv_firmware/firmware/tasmota32c3*.* ./firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-safeboot* ] || mv ./mv_firmware/firmware/tasmota32-safeboot* ./firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-* ] || mv ./mv_firmware/firmware/tasmota32-* ./firmware/tasmota32/languages/
[ ! -f ./mv_firmware/firmware/tasmota32* ] || mv ./mv_firmware/firmware/tasmota32* ./firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/* ] || mv ./mv_firmware/firmware/* ./firmware/tasmota/languages/
- name: Display files to transfer
run: ls -R ./*
- name: Push Firmware files to tmp_copy repo
uses: Jason2866/copy_file_to_another_repo_action@main
env:
API_TOKEN_GITHUB: ${{ secrets.API_TOKEN_GITHUB }}
with:
source_file: 'firmware'
destination_repo: 'arendst/tmp_copy'
destination_branch: 'firmware'
user_email: 'github-actions@github.com'
user_name: 'github-actions'
Start_final_copy:
needs: Upload
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Creat trigger.txt
run: |
echo ${GITHUB_SHA} &> trigger.txt
echo "$(<trigger.txt)"
- name: Push trigger.txt to start workflow copy in tmp repo
uses: Jason2866/copy_file_to_another_repo_action@main
env:
API_TOKEN_GITHUB: ${{ secrets.API_TOKEN_GITHUB }}
with:
source_file: 'trigger.txt'
destination_repo: 'arendst/tmp_copy'
destination_branch: 'action-development'
user_email: 'github-actions@github.com'
user_name: 'github-actions'

View File

@ -1,170 +0,0 @@
name: Build_firmware_master
on:
push:
branches: master
paths-ignore:
- '.github/**' # Ignore changes towards the .github directory
- '**.md' # Do no build if *.md files changes
# Ensures that only one deploy task per branch/environment will run at a time.
concurrency:
group: environment-${{ github.ref }}
cancel-in-progress: true
jobs:
base-images:
runs-on: ubuntu-latest
if: github.repository == 'arendst/Tasmota'
continue-on-error: true
strategy:
matrix:
variant:
- tasmota
- tasmota4M
- tasmota-minimal
- tasmota-display
- tasmota-ir
- tasmota-knx
- tasmota-lite
- tasmota-sensors
- tasmota-zbbridge
- tasmota-zigbee
- tasmota32
- tasmota32-webcam
- tasmota32-bluetooth
- tasmota32-display
- tasmota32-ir
- tasmota32-lvgl
- tasmota32c3
- tasmota32c3usb
- tasmota32solo1
- tasmota32solo1-safeboot
- tasmota32-safeboot
- tasmota32c3-safeboot
- tasmota32s2-safeboot
- tasmota32s3-safeboot
- tasmota32c3usb-safeboot
steps:
- uses: actions/checkout@v3
with:
ref: master
- name: Set up Python
uses: actions/setup-python@v2
- name: Install dependencies
run: |
pip install -U platformio
- name: Run PlatformIO
run: platformio run -e ${{ matrix.variant }}
- uses: actions/upload-artifact@v2
with:
name: firmware
path: ./build_output
language-images:
runs-on: ubuntu-latest
if: github.repository == 'arendst/Tasmota'
continue-on-error: true
strategy:
matrix:
variant: [ tasmota, tasmota32 ]
language: [ AF, BG, BR, CN, CZ, DE, ES, FR, FY, GR, HE, HU, IT, KO, NL, PL, PT, RO, RU, SE, SK, TR, TW, UK, VN ]
steps:
- uses: actions/checkout@v3
with:
ref: master
- name: Set up Python
uses: actions/setup-python@v2
- name: Install dependencies
run: |
pip install -U platformio
- name: Run PlatformIO
run: platformio run -e ${{ matrix.variant }}-${{ matrix.language }}
- uses: actions/upload-artifact@v2
with:
name: firmware
path: ./build_output
Upload:
needs: [base-images, language-images]
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v3
- uses: actions/download-artifact@v2
with:
name: firmware
path: ./mv_firmware
- name: Display structure of downloaded files
run: ls -R ./mv_firmware/
- name: Release
uses: softprops/action-gh-release@v1
#if: startsWith(github.ref, 'refs/tags/')
with:
tag_name: ${{ github.run_number }}
files: ./mv_firmware/firmware/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Move firmware files in sub-folders
run: |
mkdir -p ./release-firmware/tasmota/languages
mkdir -p ./release-firmware/tasmota32/languages
mkdir -p ./release-firmware/map
[ ! -f ./mv_firmware/map/* ] || mv ./mv_firmware/map/* ./release-firmware/map/
[ ! -f ./mv_firmware/firmware/tasmota.* ] || mv ./mv_firmware/firmware/tasmota.* ./release-firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota4M.* ] || mv ./mv_firmware/firmware/tasmota4M.* ./release-firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-sensors.* ] || mv ./mv_firmware/firmware/tasmota-sensors.* ./release-firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-minimal.bin.gz ] || mv ./mv_firmware/firmware/tasmota-minimal.bin.gz ./release-firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-lite.* ] || mv ./mv_firmware/firmware/tasmota-lite.* ./release-firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-ir*.* ] || mv ./mv_firmware/firmware/tasmota-ir*.* ./release-firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-display.* ] || mv ./mv_firmware/firmware/tasmota-display.* ./release-firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-knx.* ] || mv ./mv_firmware/firmware/tasmota-knx.* ./release-firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-zbbridge.* ] || mv ./mv_firmware/firmware/tasmota-zbbridge.* ./release-firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota-zigbee.* ] || mv ./mv_firmware/firmware/tasmota-zigbee.* ./release-firmware/tasmota/
[ ! -f ./mv_firmware/firmware/tasmota32.* ] || mv ./mv_firmware/firmware/tasmota32.* ./release-firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32solo1*.* ] || mv ./mv_firmware/firmware/tasmota32solo1*.* ./release-firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-ir*.* ] || mv ./mv_firmware/firmware/tasmota32-ir*.* ./release-firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-display.* ] || mv ./mv_firmware/firmware/tasmota32-display.* ./release-firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-lvgl.* ] || mv ./mv_firmware/firmware/tasmota32-lvgl.* ./release-firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-web*.* ] || mv ./mv_firmware/firmware/tasmota32-web*.* ./release-firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-odroidgo.* ] || mv ./mv_firmware/firmware/tasmota32-odroidgo.* ./release-firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-core2.* ] || mv ./mv_firmware/firmware/tasmota32-core2.* ./release-firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-bluetooth.* ] || mv ./mv_firmware/firmware/tasmota32-bluetooth.* ./release-firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32c3*.* ] || mv ./mv_firmware/firmware/tasmota32c3*.* ./release-firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-safeboot* ] || mv ./mv_firmware/firmware/tasmota32-safeboot* ./release-firmware/tasmota32/
[ ! -f ./mv_firmware/firmware/tasmota32-* ] || mv ./mv_firmware/firmware/tasmota32-* ./release-firmware/tasmota32/languages/
[ ! -f ./mv_firmware/firmware/tasmota32* ] || mv ./mv_firmware/firmware/tasmota32* ./release-firmware/tasmota32/languages/
[ ! -f ./mv_firmware/firmware/* ] || mv ./mv_firmware/firmware/* ./release-firmware/tasmota/languages/
- name: Display files to transfer
run: ls -R ./*
- name: Push Firmware files to tmp_copy repo
uses: Jason2866/copy_file_to_another_repo_action@main
env:
API_TOKEN_GITHUB: ${{ secrets.API_TOKEN_GITHUB }}
with:
source_file: 'release-firmware'
destination_repo: 'arendst/tmp_copy'
destination_branch: 'firmware'
user_email: 'github-actions@github.com'
user_name: 'github-actions'
Start_final_copy:
needs: Upload
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Creat trigger.txt
run: |
echo ${GITHUB_SHA} &> trigger.txt
echo "$(<trigger.txt)"
- name: Push trigger.txt to start workflow copy in tmp repo
uses: Jason2866/copy_file_to_another_repo_action@main
env:
API_TOKEN_GITHUB: ${{ secrets.API_TOKEN_GITHUB }}
with:
source_file: 'trigger.txt'
destination_repo: 'arendst/tmp_copy'
destination_branch: 'action-master'
user_email: 'github-actions@github.com'
user_name: 'github-actions'

17
.gitignore vendored
View File

@ -3,6 +3,23 @@
**/__pycache__/**
**/*.pyc
*.cfg
*.log
EyeTrackApp/venv
EyeTrackApp/eyetrack_settings.json
EyeTrackApp/eyetrack_settings.backup
EyeTrackApp/build
EyeTrackApp/dist
build
dist
EyeTrackApp/IBO_LEFT.png
EyeTrackApp/IBO_RIGHT.png
EyeTrackApp/IBO_LEFT.png
EyeTrackApp/IBO_RIGHT.png
/EBPD_LEFT.png
/EyeTrackApp/EBPD_RIGHT.png
/EBPD_RIGHT.png
/IBO_RIGHT.png
/IBO_LEFT.png
/eyetrack_settings.backup
/eyetrack_settings.json

189
.releaserc Normal file
View File

@ -0,0 +1,189 @@
{
"branches": [
"main",
"master",
"release",
{
"name": "HSF-and-new-algos-feature-branch",
"prerelease": true
}
],
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits",
"parserOpts": {
"noteKeywords": [
"BREAKING CHANGE",
"BREAKING CHANGES",
"BREAKING"
]
},
"releaseRules": [
{
"breaking": true,
"release": "major"
},
{
"type": "feat",
"release": "minor"
},
{
"type": "fix",
"release": "patch"
},
{
"type": "perf",
"release": "patch"
},
{
"type": "revert",
"release": "patch"
},
{
"type": "docs",
"scope": "docs-*",
"release": "minor"
},
{
"type": "docs",
"release": false
},
{
"type": "style",
"release": "patch"
},
{
"type": "refactor",
"release": "patch"
},
{
"type": "test",
"release": "patch"
},
{
"type": "build",
"release": "patch"
},
{
"type": "ci",
"scope": "ci-*",
"release": "patch"
},
{
"type": "chore",
"release": false
},
{
"type": "no-release",
"release": false
}
]
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits",
"parserOpts": {
"noteKeywords": [
"BREAKING CHANGE",
"BREAKING CHANGES",
"BREAKING"
]
},
"writerOpts": {
"commitsSort": [
"subject",
"scope"
]
},
"presetConfig": {
"types": [
{
"type": "feat",
"section": "🍕 Features"
},
{
"type": "feature",
"section": "🍕 Features"
},
{
"type": "fix",
"section": "🐛 Bug Fixes"
},
{
"type": "perf",
"section": "🔥 Performance Improvements"
},
{
"type": "revert",
"section": "⏩ Reverts"
},
{
"type": "docs",
"section": "📝 Documentation"
},
{
"type": "style",
"section": "🎨 Styles"
},
{
"type": "refactor",
"section": "🧑‍💻 Code Refactoring"
},
{
"type": "test",
"section": "✅ Tests"
},
{
"type": "build",
"section": "🤖 Build System"
},
{
"type": "ci",
"section": "🔁 Continuous Integration"
}
]
}
}
],
[
"@semantic-release/changelog",
{
"changelogTitle": "# 📦 Changelog \n[![conventional commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow.svg)](https://conventionalcommits.org)\n[![semantic versioning](https://img.shields.io/badge/semantic%20versioning-2.0.0-green.svg)](https://semver.org)\n> All notable changes to this project will be documented in this file"
}
],
[
"@semantic-release/exec",
{
"prepareCmd": "./.github/scripts/prepareCMD.sh ${nextRelease.version}",
"publishCmd": "echo Publishing ${nextRelease.version}"
}
],
[
"@semantic-release/github",
{
"addReleases": "bottom",
"assets": [
{
"path": "./dist/**/*.{msi,deb,rpm,AppImage,dmg,sha256sum,bin,exe}"
}
],
"successComment": false,
"failTitle": false
}
],
[
"@semantic-release/git",
{
"assets": [
"LICENSE*",
"CHANGELOG.md"
],
"message": "chore(${nextRelease.type}): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
]
}

347
CHANGELOG.md Normal file
View File

@ -0,0 +1,347 @@
# 📦 Changelog
[![conventional commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow.svg)](https://conventionalcommits.org)
[![semantic versioning](https://img.shields.io/badge/semantic%20versioning-2.0.0-green.svg)](https://semver.org)
> All notable changes to this project will be documented in this file
## [1.0.0-HSF-and-new-algos-feature-branch.11](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.10...v1.0.0-HSF-and-new-algos-feature-branch.11) (2023-10-08)
### 🍕 Features
* Merge pull request [#94](https://github.com/EyeTrackVR/EyeTrackVR/issues/94) from DeltaNeverUsed/HSF-and-new-algos-feature-branch ([e1203aa](https://github.com/EyeTrackVR/EyeTrackVR/commit/e1203aa9a73b62684aa17e04fa424e68b2ddcb3c))
## [1.0.0-HSF-and-new-algos-feature-branch.10](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.9...v1.0.0-HSF-and-new-algos-feature-branch.10) (2023-10-05)
### 🍕 Features
* pupil dilation initial imp ([6200ddf](https://github.com/EyeTrackVR/EyeTrackVR/commit/6200ddfcab2297e59c689edfa72ddb47fbd8faa1))
* pupil dilation work start ([e694054](https://github.com/EyeTrackVR/EyeTrackVR/commit/e694054cb43d72977cdae746576bf45f02c4fc14))
## [1.0.0-HSF-and-new-algos-feature-branch.9](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.8...v1.0.0-HSF-and-new-algos-feature-branch.9) (2023-10-03)
### 🍕 Features
* stage one of new output formats ([8e28092](https://github.com/EyeTrackVR/EyeTrackVR/commit/8e2809260180f7e091ceba97c19eb52f883a3b91))
### 🐛 Bug Fixes
* temp disable leap low priority for MetalCanyon ([9bab5b4](https://github.com/EyeTrackVR/EyeTrackVR/commit/9bab5b45551f4744e79ee9a715221b4f2719e08f))
## [1.0.0-HSF-and-new-algos-feature-branch.8](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.7...v1.0.0-HSF-and-new-algos-feature-branch.8) (2023-10-01)
### 🐛 Bug Fixes
* LEAP Eyelid, tune RANSAC BLINK ([5a6248b](https://github.com/EyeTrackVR/EyeTrackVR/commit/5a6248b897cf37a857148c099bd3336f3dc7f5cd))
* remove prints ([46427e3](https://github.com/EyeTrackVR/EyeTrackVR/commit/46427e35330e7a15501ecc9fe668a30572a82832))
## [1.0.0-HSF-and-new-algos-feature-branch.7](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.6...v1.0.0-HSF-and-new-algos-feature-branch.7) (2023-09-28)
### 🐛 Bug Fixes
* left eye ransac thresh set to right ([c6fbfe8](https://github.com/EyeTrackVR/EyeTrackVR/commit/c6fbfe8c70b5f75de7060bc3a62b3b8aedbe34f3))
* left eye ransac thresh set to right ([d11f94f](https://github.com/EyeTrackVR/EyeTrackVR/commit/d11f94f5afe90c0ffb2a9f9eb8a76bc986c8500d))
## [1.0.0-HSF-and-new-algos-feature-branch.6](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.5...v1.0.0-HSF-and-new-algos-feature-branch.6) (2023-09-27)
### 🐛 Bug Fixes
* remove y axis lock (temp) ([2ec7d2d](https://github.com/EyeTrackVR/EyeTrackVR/commit/2ec7d2d4eeed06a7bee0aad31497f55d70749751))
* tighten blink vec (needs testing/re-write, just minor fix) ([1c2760b](https://github.com/EyeTrackVR/EyeTrackVR/commit/1c2760b382efd52d368a18434f0684ba0383db3e))
## [1.0.0-HSF-and-new-algos-feature-branch.5](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.4...v1.0.0-HSF-and-new-algos-feature-branch.5) (2023-09-26)
### 🍕 Features
* LEAP Lid blink algo ([ab631fa](https://github.com/EyeTrackVR/EyeTrackVR/commit/ab631fad198c23528cfee2376fa7f5af0d0b5818))
### 🐛 Bug Fixes
* small DADDY optimizations (test for FPS limit) ([6297ce3](https://github.com/EyeTrackVR/EyeTrackVR/commit/6297ce327cbd0efd286fdc03ec1dfe7507a437ae))
## [1.0.0-HSF-and-new-algos-feature-branch.4](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.3...v1.0.0-HSF-and-new-algos-feature-branch.4) (2023-09-24)
### 🐛 Bug Fixes
* LEAP full closed thresh ([4ad1eaf](https://github.com/EyeTrackVR/EyeTrackVR/commit/4ad1eaffc4f2f3600d2442f0a5c3939cf6381258))
## [1.0.0-HSF-and-new-algos-feature-branch.3](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.2...v1.0.0-HSF-and-new-algos-feature-branch.3) (2023-09-24)
### 🐛 Bug Fixes
* UVC cameras not working ([5e28ee3](https://github.com/EyeTrackVR/EyeTrackVR/commit/5e28ee34e58719d30f41b9a70fe94d3f8995dcac))
* UVC cameras not working2 ([5209a44](https://github.com/EyeTrackVR/EyeTrackVR/commit/5209a441b6cc603db00c463c1704f94172baa3a2))
## [1.0.0-HSF-and-new-algos-feature-branch.2](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.1...v1.0.0-HSF-and-new-algos-feature-branch.2) (2023-09-23)
### 🍕 Features
* ready for new beta ([ac6f8b9](https://github.com/EyeTrackVR/EyeTrackVR/commit/ac6f8b98d2aeb48a5a91b8a361d4836022f18678))
### 🐛 Bug Fixes
* BETA 7 ([0cd9f46](https://github.com/EyeTrackVR/EyeTrackVR/commit/0cd9f46a02a18457fa4aa15d53fb391e50c23540))
* Merge pull request [#91](https://github.com/EyeTrackVR/EyeTrackVR/issues/91) from ShyAssassin/ci-cd-fix ([2a7dd65](https://github.com/EyeTrackVR/EyeTrackVR/commit/2a7dd65e08b7b9ef949cec8a8eeb7b1dc1e150a7))
### 🧑‍💻 Code Refactoring
* BETA 7 ([1d79445](https://github.com/EyeTrackVR/EyeTrackVR/commit/1d79445889f6ae4e9f8301356319e3ca78031b44))
### 🤖 Build System
* BETA 7 ([1e94577](https://github.com/EyeTrackVR/EyeTrackVR/commit/1e94577d900f41f2cb54b24568907f2aa1059fe1))
* fixed github action to early exit ([9562d4d](https://github.com/EyeTrackVR/EyeTrackVR/commit/9562d4de6fcf74f1a218f1f89bb111c53e18c431))
## 1.0.0-HSF-and-new-algos-feature-branch.1 (2023-08-24)
### ⚠ BREAKING CHANGES
* CHANGES
### 🍕 Features
* add colorama instead of using escape codes ([1d9dfea](https://github.com/EyeTrackVR/EyeTrackVR/commit/1d9dfeae19cd3ab8fd8252cb466b47576428c1e5))
* add dev container ([a5e36ad](https://github.com/EyeTrackVR/EyeTrackVR/commit/a5e36ad4c468f25901ff473555e51ff1495d9521))
* add taskipy to run tasks via poetry ([2f1d3d9](https://github.com/EyeTrackVR/EyeTrackVR/commit/2f1d3d9275310db87e4bda041c072dd9572eb07e))
* add v2 serial comms with packet headers ([c2aad0e](https://github.com/EyeTrackVR/EyeTrackVR/commit/c2aad0e8590a5533f250c9d87d45e42828930611))
* more readable logging with colorama ([2efa3c3](https://github.com/EyeTrackVR/EyeTrackVR/commit/2efa3c35896d1b10e037afa8c12fc9b780c977be))
* show bitrate, fps, latency in tracking mode ([6163744](https://github.com/EyeTrackVR/EyeTrackVR/commit/61637442c61a8c82e2b38c491d3a409080ccf2a9))
* slightly reduce dropped frame count ([3b5582d](https://github.com/EyeTrackVR/EyeTrackVR/commit/3b5582de6eab3b3e65eb7d76c631cd089a06ee40))
* updated poetry.lock ([632ec2a](https://github.com/EyeTrackVR/EyeTrackVR/commit/632ec2a8384db57e7a70099d26accbe3f97db774))
### 🐛 Bug Fixes
* bring the existing impl to a usable state ([b3f444e](https://github.com/EyeTrackVR/EyeTrackVR/commit/b3f444ee3a066b0cd84e7c9f61c04aa7542fb0ef))
* ensure serial is closed when thread crashed ([4def965](https://github.com/EyeTrackVR/EyeTrackVR/commit/4def96575969e3f22a4a915c9e5e71576403c2c8))
* identify and mitigate latency issues ([295f104](https://github.com/EyeTrackVR/EyeTrackVR/commit/295f10476d2c4ecbe266fbb1544091e58f16e563))
* module leap not found ([437b88a](https://github.com/EyeTrackVR/EyeTrackVR/commit/437b88a4bd071f78d0ff861e70c2e5a64a1f8218))
* module leap not found ([a29db80](https://github.com/EyeTrackVR/EyeTrackVR/commit/a29db809099a5122430fba58bb8f5e2f288bd7fb))
* Only ever return one blob when blob tracking ([5a451b4](https://github.com/EyeTrackVR/EyeTrackVR/commit/5a451b4aa5b18dee1a1ce961fec04d653e2a77dd)), closes [#20](https://github.com/EyeTrackVR/EyeTrackVR/issues/20)
* Output all info to OSC unless we're failing to find any data ([764baa5](https://github.com/EyeTrackVR/EyeTrackVR/commit/764baa539dafa0382d95e53730e452cfc48710ec)), closes [#21](https://github.com/EyeTrackVR/EyeTrackVR/issues/21)
* pinv for pseudo inverse when singular matrix ([ca56222](https://github.com/EyeTrackVR/EyeTrackVR/commit/ca5622244528e60ad40e2cc994a80b39afe4bff0))
* pyserial is the correct dep, not serial ([09a9fbb](https://github.com/EyeTrackVR/EyeTrackVR/commit/09a9fbb052483cb96c7283cdbcc44bc57a8e078e))
* rate limit error ([129e07b](https://github.com/EyeTrackVR/EyeTrackVR/commit/129e07bc1c63e632c4c41b2a3d6ae7d6d858ccaf))
* standalone exe ([35e71a2](https://github.com/EyeTrackVR/EyeTrackVR/commit/35e71a212323ec539283a267079e032cb6891c34))
* Update graph background color on events ([5580d48](https://github.com/EyeTrackVR/EyeTrackVR/commit/5580d482f935c36a157053b1ad2a1d7234e6160b)), closes [#24](https://github.com/EyeTrackVR/EyeTrackVR/issues/24)
* when address is set but no devices connected ([6d7630c](https://github.com/EyeTrackVR/EyeTrackVR/commit/6d7630cf6029e2de78aa41bdd468ed4d59898951))
### 🧑‍💻 Code Refactoring
* cleanup imports ([446a20c](https://github.com/EyeTrackVR/EyeTrackVR/commit/446a20cea822189f1f5adf3d10b9d9e252b5186e))
* edit readme and dummy commit to trigger actions ([f5695f0](https://github.com/EyeTrackVR/EyeTrackVR/commit/f5695f00a80567d025c3d7ab2689b08b736b25a3))
* extract EyeInfo enums and dataclasses ([d6d1938](https://github.com/EyeTrackVR/EyeTrackVR/commit/d6d19383bd0dba3e8af39d2101658eb2dbbaf847))
### 🤖 Build System
* setup pre-release build pipeline ([aa5e6b8](https://github.com/EyeTrackVR/EyeTrackVR/commit/aa5e6b8b34dd13357ed3b4bd7989c0e6b6b29991))
* setup pre-release build pipeline ([a8b73bf](https://github.com/EyeTrackVR/EyeTrackVR/commit/a8b73bf2240aac6b7900ec409fa993bd2a692299))
* setup pre-release build pipeline ([9b27720](https://github.com/EyeTrackVR/EyeTrackVR/commit/9b27720bf9385ca7331895ae625c0b28e380c8b2))
* setup pre-release build pipeline ([e250e4e](https://github.com/EyeTrackVR/EyeTrackVR/commit/e250e4e3539dcf83cdc7ddee24a07b0303ee843e))
## 1.0.0-HSF-and-new-algos-feature-branch.1 (2023-08-24)
### ⚠ BREAKING CHANGES
* CHANGES
### 🍕 Features
* add colorama instead of using escape codes ([1d9dfea](https://github.com/EyeTrackVR/EyeTrackVR/commit/1d9dfeae19cd3ab8fd8252cb466b47576428c1e5))
* add dev container ([a5e36ad](https://github.com/EyeTrackVR/EyeTrackVR/commit/a5e36ad4c468f25901ff473555e51ff1495d9521))
* add taskipy to run tasks via poetry ([2f1d3d9](https://github.com/EyeTrackVR/EyeTrackVR/commit/2f1d3d9275310db87e4bda041c072dd9572eb07e))
* add v2 serial comms with packet headers ([c2aad0e](https://github.com/EyeTrackVR/EyeTrackVR/commit/c2aad0e8590a5533f250c9d87d45e42828930611))
* more readable logging with colorama ([2efa3c3](https://github.com/EyeTrackVR/EyeTrackVR/commit/2efa3c35896d1b10e037afa8c12fc9b780c977be))
* show bitrate, fps, latency in tracking mode ([6163744](https://github.com/EyeTrackVR/EyeTrackVR/commit/61637442c61a8c82e2b38c491d3a409080ccf2a9))
* slightly reduce dropped frame count ([3b5582d](https://github.com/EyeTrackVR/EyeTrackVR/commit/3b5582de6eab3b3e65eb7d76c631cd089a06ee40))
* updated poetry.lock ([632ec2a](https://github.com/EyeTrackVR/EyeTrackVR/commit/632ec2a8384db57e7a70099d26accbe3f97db774))
### 🐛 Bug Fixes
* bring the existing impl to a usable state ([b3f444e](https://github.com/EyeTrackVR/EyeTrackVR/commit/b3f444ee3a066b0cd84e7c9f61c04aa7542fb0ef))
* ensure serial is closed when thread crashed ([4def965](https://github.com/EyeTrackVR/EyeTrackVR/commit/4def96575969e3f22a4a915c9e5e71576403c2c8))
* identify and mitigate latency issues ([295f104](https://github.com/EyeTrackVR/EyeTrackVR/commit/295f10476d2c4ecbe266fbb1544091e58f16e563))
* module leap not found ([437b88a](https://github.com/EyeTrackVR/EyeTrackVR/commit/437b88a4bd071f78d0ff861e70c2e5a64a1f8218))
* module leap not found ([a29db80](https://github.com/EyeTrackVR/EyeTrackVR/commit/a29db809099a5122430fba58bb8f5e2f288bd7fb))
* Only ever return one blob when blob tracking ([5a451b4](https://github.com/EyeTrackVR/EyeTrackVR/commit/5a451b4aa5b18dee1a1ce961fec04d653e2a77dd)), closes [#20](https://github.com/EyeTrackVR/EyeTrackVR/issues/20)
* Output all info to OSC unless we're failing to find any data ([764baa5](https://github.com/EyeTrackVR/EyeTrackVR/commit/764baa539dafa0382d95e53730e452cfc48710ec)), closes [#21](https://github.com/EyeTrackVR/EyeTrackVR/issues/21)
* pinv for pseudo inverse when singular matrix ([ca56222](https://github.com/EyeTrackVR/EyeTrackVR/commit/ca5622244528e60ad40e2cc994a80b39afe4bff0))
* pyserial is the correct dep, not serial ([09a9fbb](https://github.com/EyeTrackVR/EyeTrackVR/commit/09a9fbb052483cb96c7283cdbcc44bc57a8e078e))
* rate limit error ([129e07b](https://github.com/EyeTrackVR/EyeTrackVR/commit/129e07bc1c63e632c4c41b2a3d6ae7d6d858ccaf))
* standalone exe ([35e71a2](https://github.com/EyeTrackVR/EyeTrackVR/commit/35e71a212323ec539283a267079e032cb6891c34))
* Update graph background color on events ([5580d48](https://github.com/EyeTrackVR/EyeTrackVR/commit/5580d482f935c36a157053b1ad2a1d7234e6160b)), closes [#24](https://github.com/EyeTrackVR/EyeTrackVR/issues/24)
* when address is set but no devices connected ([6d7630c](https://github.com/EyeTrackVR/EyeTrackVR/commit/6d7630cf6029e2de78aa41bdd468ed4d59898951))
### 🧑‍💻 Code Refactoring
* cleanup imports ([446a20c](https://github.com/EyeTrackVR/EyeTrackVR/commit/446a20cea822189f1f5adf3d10b9d9e252b5186e))
* extract EyeInfo enums and dataclasses ([d6d1938](https://github.com/EyeTrackVR/EyeTrackVR/commit/d6d19383bd0dba3e8af39d2101658eb2dbbaf847))
### 🤖 Build System
* setup pre-release build pipeline ([aa5e6b8](https://github.com/EyeTrackVR/EyeTrackVR/commit/aa5e6b8b34dd13357ed3b4bd7989c0e6b6b29991))
* setup pre-release build pipeline ([a8b73bf](https://github.com/EyeTrackVR/EyeTrackVR/commit/a8b73bf2240aac6b7900ec409fa993bd2a692299))
* setup pre-release build pipeline ([9b27720](https://github.com/EyeTrackVR/EyeTrackVR/commit/9b27720bf9385ca7331895ae625c0b28e380c8b2))
* setup pre-release build pipeline ([e250e4e](https://github.com/EyeTrackVR/EyeTrackVR/commit/e250e4e3539dcf83cdc7ddee24a07b0303ee843e))
## 1.0.0-HSF-and-new-algos-feature-branch.1 (2023-08-24)
### ⚠ BREAKING CHANGES
* CHANGES
### 🍕 Features
* add colorama instead of using escape codes ([1d9dfea](https://github.com/EyeTrackVR/EyeTrackVR/commit/1d9dfeae19cd3ab8fd8252cb466b47576428c1e5))
* add dev container ([a5e36ad](https://github.com/EyeTrackVR/EyeTrackVR/commit/a5e36ad4c468f25901ff473555e51ff1495d9521))
* add taskipy to run tasks via poetry ([2f1d3d9](https://github.com/EyeTrackVR/EyeTrackVR/commit/2f1d3d9275310db87e4bda041c072dd9572eb07e))
* add v2 serial comms with packet headers ([c2aad0e](https://github.com/EyeTrackVR/EyeTrackVR/commit/c2aad0e8590a5533f250c9d87d45e42828930611))
* more readable logging with colorama ([2efa3c3](https://github.com/EyeTrackVR/EyeTrackVR/commit/2efa3c35896d1b10e037afa8c12fc9b780c977be))
* show bitrate, fps, latency in tracking mode ([6163744](https://github.com/EyeTrackVR/EyeTrackVR/commit/61637442c61a8c82e2b38c491d3a409080ccf2a9))
* slightly reduce dropped frame count ([3b5582d](https://github.com/EyeTrackVR/EyeTrackVR/commit/3b5582de6eab3b3e65eb7d76c631cd089a06ee40))
* updated poetry.lock ([632ec2a](https://github.com/EyeTrackVR/EyeTrackVR/commit/632ec2a8384db57e7a70099d26accbe3f97db774))
### 🐛 Bug Fixes
* bring the existing impl to a usable state ([b3f444e](https://github.com/EyeTrackVR/EyeTrackVR/commit/b3f444ee3a066b0cd84e7c9f61c04aa7542fb0ef))
* ensure serial is closed when thread crashed ([4def965](https://github.com/EyeTrackVR/EyeTrackVR/commit/4def96575969e3f22a4a915c9e5e71576403c2c8))
* identify and mitigate latency issues ([295f104](https://github.com/EyeTrackVR/EyeTrackVR/commit/295f10476d2c4ecbe266fbb1544091e58f16e563))
* module leap not found ([a29db80](https://github.com/EyeTrackVR/EyeTrackVR/commit/a29db809099a5122430fba58bb8f5e2f288bd7fb))
* Only ever return one blob when blob tracking ([5a451b4](https://github.com/EyeTrackVR/EyeTrackVR/commit/5a451b4aa5b18dee1a1ce961fec04d653e2a77dd)), closes [#20](https://github.com/EyeTrackVR/EyeTrackVR/issues/20)
* Output all info to OSC unless we're failing to find any data ([764baa5](https://github.com/EyeTrackVR/EyeTrackVR/commit/764baa539dafa0382d95e53730e452cfc48710ec)), closes [#21](https://github.com/EyeTrackVR/EyeTrackVR/issues/21)
* pinv for pseudo inverse when singular matrix ([ca56222](https://github.com/EyeTrackVR/EyeTrackVR/commit/ca5622244528e60ad40e2cc994a80b39afe4bff0))
* pyserial is the correct dep, not serial ([09a9fbb](https://github.com/EyeTrackVR/EyeTrackVR/commit/09a9fbb052483cb96c7283cdbcc44bc57a8e078e))
* rate limit error ([129e07b](https://github.com/EyeTrackVR/EyeTrackVR/commit/129e07bc1c63e632c4c41b2a3d6ae7d6d858ccaf))
* standalone exe ([35e71a2](https://github.com/EyeTrackVR/EyeTrackVR/commit/35e71a212323ec539283a267079e032cb6891c34))
* Update graph background color on events ([5580d48](https://github.com/EyeTrackVR/EyeTrackVR/commit/5580d482f935c36a157053b1ad2a1d7234e6160b)), closes [#24](https://github.com/EyeTrackVR/EyeTrackVR/issues/24)
* when address is set but no devices connected ([6d7630c](https://github.com/EyeTrackVR/EyeTrackVR/commit/6d7630cf6029e2de78aa41bdd468ed4d59898951))
### 🧑‍💻 Code Refactoring
* cleanup imports ([446a20c](https://github.com/EyeTrackVR/EyeTrackVR/commit/446a20cea822189f1f5adf3d10b9d9e252b5186e))
* extract EyeInfo enums and dataclasses ([d6d1938](https://github.com/EyeTrackVR/EyeTrackVR/commit/d6d19383bd0dba3e8af39d2101658eb2dbbaf847))
### 🤖 Build System
* setup pre-release build pipeline ([aa5e6b8](https://github.com/EyeTrackVR/EyeTrackVR/commit/aa5e6b8b34dd13357ed3b4bd7989c0e6b6b29991))
* setup pre-release build pipeline ([a8b73bf](https://github.com/EyeTrackVR/EyeTrackVR/commit/a8b73bf2240aac6b7900ec409fa993bd2a692299))
* setup pre-release build pipeline ([9b27720](https://github.com/EyeTrackVR/EyeTrackVR/commit/9b27720bf9385ca7331895ae625c0b28e380c8b2))
* setup pre-release build pipeline ([e250e4e](https://github.com/EyeTrackVR/EyeTrackVR/commit/e250e4e3539dcf83cdc7ddee24a07b0303ee843e))
## [1.0.0-HSF-and-new-algos-feature-branch.2](https://github.com/EyeTrackVR/EyeTrackVR/compare/v1.0.0-HSF-and-new-algos-feature-branch.1...v1.0.0-HSF-and-new-algos-feature-branch.2) (2023-08-24)
### 🐛 Bug Fixes
* rate limit error ([129e07b](https://github.com/EyeTrackVR/EyeTrackVR/commit/129e07bc1c63e632c4c41b2a3d6ae7d6d858ccaf))
## 1.0.0-HSF-and-new-algos-feature-branch.1 (2023-08-24)
### ⚠ BREAKING CHANGES
* CHANGES
### 🍕 Features
* add colorama instead of using escape codes ([1d9dfea](https://github.com/EyeTrackVR/EyeTrackVR/commit/1d9dfeae19cd3ab8fd8252cb466b47576428c1e5))
* add dev container ([a5e36ad](https://github.com/EyeTrackVR/EyeTrackVR/commit/a5e36ad4c468f25901ff473555e51ff1495d9521))
* add taskipy to run tasks via poetry ([2f1d3d9](https://github.com/EyeTrackVR/EyeTrackVR/commit/2f1d3d9275310db87e4bda041c072dd9572eb07e))
* add v2 serial comms with packet headers ([c2aad0e](https://github.com/EyeTrackVR/EyeTrackVR/commit/c2aad0e8590a5533f250c9d87d45e42828930611))
* more readable logging with colorama ([2efa3c3](https://github.com/EyeTrackVR/EyeTrackVR/commit/2efa3c35896d1b10e037afa8c12fc9b780c977be))
* show bitrate, fps, latency in tracking mode ([6163744](https://github.com/EyeTrackVR/EyeTrackVR/commit/61637442c61a8c82e2b38c491d3a409080ccf2a9))
* slightly reduce dropped frame count ([3b5582d](https://github.com/EyeTrackVR/EyeTrackVR/commit/3b5582de6eab3b3e65eb7d76c631cd089a06ee40))
* updated poetry.lock ([632ec2a](https://github.com/EyeTrackVR/EyeTrackVR/commit/632ec2a8384db57e7a70099d26accbe3f97db774))
### 🐛 Bug Fixes
* bring the existing impl to a usable state ([b3f444e](https://github.com/EyeTrackVR/EyeTrackVR/commit/b3f444ee3a066b0cd84e7c9f61c04aa7542fb0ef))
* ensure serial is closed when thread crashed ([4def965](https://github.com/EyeTrackVR/EyeTrackVR/commit/4def96575969e3f22a4a915c9e5e71576403c2c8))
* identify and mitigate latency issues ([295f104](https://github.com/EyeTrackVR/EyeTrackVR/commit/295f10476d2c4ecbe266fbb1544091e58f16e563))
* Only ever return one blob when blob tracking ([5a451b4](https://github.com/EyeTrackVR/EyeTrackVR/commit/5a451b4aa5b18dee1a1ce961fec04d653e2a77dd)), closes [#20](https://github.com/EyeTrackVR/EyeTrackVR/issues/20)
* Output all info to OSC unless we're failing to find any data ([764baa5](https://github.com/EyeTrackVR/EyeTrackVR/commit/764baa539dafa0382d95e53730e452cfc48710ec)), closes [#21](https://github.com/EyeTrackVR/EyeTrackVR/issues/21)
* pinv for pseudo inverse when singular matrix ([ca56222](https://github.com/EyeTrackVR/EyeTrackVR/commit/ca5622244528e60ad40e2cc994a80b39afe4bff0))
* pyserial is the correct dep, not serial ([09a9fbb](https://github.com/EyeTrackVR/EyeTrackVR/commit/09a9fbb052483cb96c7283cdbcc44bc57a8e078e))
* standalone exe ([35e71a2](https://github.com/EyeTrackVR/EyeTrackVR/commit/35e71a212323ec539283a267079e032cb6891c34))
* Update graph background color on events ([5580d48](https://github.com/EyeTrackVR/EyeTrackVR/commit/5580d482f935c36a157053b1ad2a1d7234e6160b)), closes [#24](https://github.com/EyeTrackVR/EyeTrackVR/issues/24)
* when address is set but no devices connected ([6d7630c](https://github.com/EyeTrackVR/EyeTrackVR/commit/6d7630cf6029e2de78aa41bdd468ed4d59898951))
### 🧑‍💻 Code Refactoring
* cleanup imports ([446a20c](https://github.com/EyeTrackVR/EyeTrackVR/commit/446a20cea822189f1f5adf3d10b9d9e252b5186e))
* extract EyeInfo enums and dataclasses ([d6d1938](https://github.com/EyeTrackVR/EyeTrackVR/commit/d6d19383bd0dba3e8af39d2101658eb2dbbaf847))
### 🤖 Build System
* setup pre-release build pipeline ([aa5e6b8](https://github.com/EyeTrackVR/EyeTrackVR/commit/aa5e6b8b34dd13357ed3b4bd7989c0e6b6b29991))
* setup pre-release build pipeline ([a8b73bf](https://github.com/EyeTrackVR/EyeTrackVR/commit/a8b73bf2240aac6b7900ec409fa993bd2a692299))
* setup pre-release build pipeline ([9b27720](https://github.com/EyeTrackVR/EyeTrackVR/commit/9b27720bf9385ca7331895ae625c0b28e380c8b2))
* setup pre-release build pipeline ([e250e4e](https://github.com/EyeTrackVR/EyeTrackVR/commit/e250e4e3539dcf83cdc7ddee24a07b0303ee843e))
## 1.0.0-HSF-and-new-algos-feature-branch.1 (2023-08-24)
### 🍕 Features
* add colorama instead of using escape codes ([1d9dfea](https://github.com/EyeTrackVR/EyeTrackVR/commit/1d9dfeae19cd3ab8fd8252cb466b47576428c1e5))
* add dev container ([a5e36ad](https://github.com/EyeTrackVR/EyeTrackVR/commit/a5e36ad4c468f25901ff473555e51ff1495d9521))
* add taskipy to run tasks via poetry ([2f1d3d9](https://github.com/EyeTrackVR/EyeTrackVR/commit/2f1d3d9275310db87e4bda041c072dd9572eb07e))
* add v2 serial comms with packet headers ([c2aad0e](https://github.com/EyeTrackVR/EyeTrackVR/commit/c2aad0e8590a5533f250c9d87d45e42828930611))
* more readable logging with colorama ([2efa3c3](https://github.com/EyeTrackVR/EyeTrackVR/commit/2efa3c35896d1b10e037afa8c12fc9b780c977be))
* show bitrate, fps, latency in tracking mode ([6163744](https://github.com/EyeTrackVR/EyeTrackVR/commit/61637442c61a8c82e2b38c491d3a409080ccf2a9))
* slightly reduce dropped frame count ([3b5582d](https://github.com/EyeTrackVR/EyeTrackVR/commit/3b5582de6eab3b3e65eb7d76c631cd089a06ee40))
* updated poetry.lock ([632ec2a](https://github.com/EyeTrackVR/EyeTrackVR/commit/632ec2a8384db57e7a70099d26accbe3f97db774))
### 🐛 Bug Fixes
* bring the existing impl to a usable state ([b3f444e](https://github.com/EyeTrackVR/EyeTrackVR/commit/b3f444ee3a066b0cd84e7c9f61c04aa7542fb0ef))
* ensure serial is closed when thread crashed ([4def965](https://github.com/EyeTrackVR/EyeTrackVR/commit/4def96575969e3f22a4a915c9e5e71576403c2c8))
* identify and mitigate latency issues ([295f104](https://github.com/EyeTrackVR/EyeTrackVR/commit/295f10476d2c4ecbe266fbb1544091e58f16e563))
* Only ever return one blob when blob tracking ([5a451b4](https://github.com/EyeTrackVR/EyeTrackVR/commit/5a451b4aa5b18dee1a1ce961fec04d653e2a77dd)), closes [#20](https://github.com/EyeTrackVR/EyeTrackVR/issues/20)
* Output all info to OSC unless we're failing to find any data ([764baa5](https://github.com/EyeTrackVR/EyeTrackVR/commit/764baa539dafa0382d95e53730e452cfc48710ec)), closes [#21](https://github.com/EyeTrackVR/EyeTrackVR/issues/21)
* pinv for pseudo inverse when singular matrix ([ca56222](https://github.com/EyeTrackVR/EyeTrackVR/commit/ca5622244528e60ad40e2cc994a80b39afe4bff0))
* pyserial is the correct dep, not serial ([09a9fbb](https://github.com/EyeTrackVR/EyeTrackVR/commit/09a9fbb052483cb96c7283cdbcc44bc57a8e078e))
* Update graph background color on events ([5580d48](https://github.com/EyeTrackVR/EyeTrackVR/commit/5580d482f935c36a157053b1ad2a1d7234e6160b)), closes [#24](https://github.com/EyeTrackVR/EyeTrackVR/issues/24)
* when address is set but no devices connected ([6d7630c](https://github.com/EyeTrackVR/EyeTrackVR/commit/6d7630cf6029e2de78aa41bdd468ed4d59898951))
### 🧑‍💻 Code Refactoring
* cleanup imports ([446a20c](https://github.com/EyeTrackVR/EyeTrackVR/commit/446a20cea822189f1f5adf3d10b9d9e252b5186e))
* extract EyeInfo enums and dataclasses ([d6d1938](https://github.com/EyeTrackVR/EyeTrackVR/commit/d6d19383bd0dba3e8af39d2101658eb2dbbaf847))
### 🤖 Build System
* setup pre-release build pipeline ([aa5e6b8](https://github.com/EyeTrackVR/EyeTrackVR/commit/aa5e6b8b34dd13357ed3b4bd7989c0e6b6b29991))
* setup pre-release build pipeline ([a8b73bf](https://github.com/EyeTrackVR/EyeTrackVR/commit/a8b73bf2240aac6b7900ec409fa993bd2a692299))
* setup pre-release build pipeline ([9b27720](https://github.com/EyeTrackVR/EyeTrackVR/commit/9b27720bf9385ca7331895ae625c0b28e380c8b2))
* setup pre-release build pipeline ([e250e4e](https://github.com/EyeTrackVR/EyeTrackVR/commit/e250e4e3539dcf83cdc7ddee24a07b0303ee843e))

28
Dockerfile Normal file
View File

@ -0,0 +1,28 @@
# For more information, please refer to https://aka.ms/vscode-docker-python
FROM python:3.10-slim
EXPOSE 8000
# Keeps Python from generating .pyc files in the container
ENV PYTHONDONTWRITEBYTECODE=1
# Turns off buffering for easier container logging
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY . /app
# Install pip requirements
COPY pyproject.toml .
RUN apt-get update &&\
apt-get install ffmpeg libsm6 libxext6 -y &&\
apt install libgl1-mesa-glx &&\
apt install build-essential -y --no-install-recommends &&\
apt-get install make &&\
apt-get install python3-pip -y --no-install-recommends &&\
pip3 install poetry
# Creates a non-root user with an explicit UID and adds permission to access the /app folder
# For more info, please refer to https://aka.ms/vscode-docker-python-configure-containers
RUN adduser -u 5678 --disabled-password --gecos "" appuser && chown -R appuser /app
USER appuser

704
EyeTrackApp/AHSF.py Normal file
View File

@ -0,0 +1,704 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Adaptive Haar Surround Feature: Summer, PallasNeko (Optimization)
Algorithm App Implementations and Tweaks By: Prohurtz
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Summer Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
import functools
import math
import os
import sys
import time
import timeit
from logging import FileHandler, Formatter, INFO, StreamHandler, getLogger
from functools import lru_cache
import cv2
import numpy as np
# from line_profiler_pycharm import profile
class AHSF:
def __init__(self, video_src, save_logfile=False, imshow_enable=False, save_video=False):
self.this_file_basename = os.path.basename(__file__)
self.this_file_name = self.this_file_basename.replace(".py", "")
self.alg_ver = "PallasNekoV3"
self.save_logfile = save_logfile
self.imshow_enable = imshow_enable
self.save_video = save_video
self.VideoCapture_SRC = video_src
self.input_is_webcam = False
self.benchmark_flag = True if not self.input_is_webcam and not self.imshow_enable and not self.save_video else False
self.loop_num = 1 if self.imshow_enable or self.save_video else 10
self.output_video_path = f"./{self.this_file_name}.mp4"
self.logfilename = f"./{self.this_file_name}.log"
self.print_enable = False
self.lru_maxsize_vvs = 16
self.lru_maxsize_vs = 64
self.lru_maxsize_s = 128
self.logger = getLogger(__name__)
self.logger.setLevel(INFO)
formatter = Formatter("%(message)s")
handler = StreamHandler()
handler.setLevel(INFO)
handler.setFormatter(formatter)
self.logger.addHandler(handler)
if self.save_logfile:
handler = FileHandler(self.logfilename, encoding="utf8", mode="w")
handler.setLevel(INFO)
handler.setFormatter(formatter)
self.logger.addHandler(handler)
else:
self.save_logfile = False
self.video_wr = cv2.VideoWriter if self.save_video else None
def format_time(self, timespan, precision=3):
"""
https://github.com/ipython/ipython/blob/339c0d510a1f3cb2158dd8c6e7f4ac89aa4c89d8/IPython/core/magics/execution.py#L1473
Formats the timespan in a human readable form
"""
if timespan >= 60.0:
# we have more than a minute, format that in a human readable form
# Idea from http://snipplr.com/view/5713/
parts = [("d", 60 * 60 * 24), ("h", 60 * 60), ("min", 60), ("s", 1)]
time = []
leftover = timespan
for suffix, length in parts:
value = int(leftover / length)
if value > 0:
leftover = leftover % length
time.append("%s%s" % (str(value), suffix))
if leftover < 1:
break
return " ".join(time)
# Unfortunately the unicode 'micro' symbol can cause problems in
# certain terminals.
# See bug: https://bugs.launchpad.net/ipython/+bug/348466
# Try to prevent crashes by being more secure than it needs to
# E.g. eclipse is able to print a µ, but has no sys.stdout.encoding set.
units = ["s", "ms", "us", "ns"] # the save value
if hasattr(sys.stdout, "encoding") and sys.stdout.encoding:
try:
"\xb5".encode(sys.stdout.encoding)
units = ["s", "ms", "\xb5s", "ns"]
except:
pass
scaling = [1, 1e3, 1e6, 1e9]
if timespan > 0.0:
order = min(-int(math.floor(math.log10(timespan)) // 3), 3)
else:
order = 3
return "%.*g %s" % (precision, timespan * scaling[order], units[order])
def filter_light(self, img_gray, img_blur, tau):
for i in range(img_gray.shape[1]):
for j in range(img_gray.shape[0]):
if img_gray[j, i] > tau:
img_blur[j, i] = tau
else:
img_blur[j, i] = img_gray[j, i]
return img_blur
def pupil_detector_haar(self, img_gray, params):
frame_num = 0
img_down = cv2.resize(
img_gray,
(
img_gray.shape[1] // params["ratio_downsample"],
img_gray.shape[0] // params["ratio_downsample"],
),
)
img_boundary = (0, 0, img_down.shape[1], img_down.shape[0])
if params["use_init_rect"]:
tau = max(params["mu_outer"], params["mu_inner"] + 30)
self.filter_light(img_down, img_down, tau)
# Coarse Detection
(
pupil_rect_coarse,
outer_rect_coarse,
max_response_coarse,
mu_inner,
mu_outer,
) = self.coarse_detection(img_down, params)
print(
"Coarse Detection: ",
pupil_rect_coarse,
outer_rect_coarse,
max_response_coarse,
mu_inner,
mu_outer,
)
if params["use_init_rect"] and frame_num == 0:
mu_inner0 = mu_inner
mu_outer0 = mu_outer
kf = 2 - 0.01 * mu_inner0
img_coarse = cv2.cvtColor(img_down, cv2.COLOR_GRAY2BGR)
# show image
# Fine Detection
if mu_outer - mu_inner >= 5:
pupil_rect_fine = self.fine_detection(img_down, pupil_rect_coarse)
else:
pupil_rect_fine = pupil_rect_coarse
# Postprocessing
pupil_rect_coarse = self.rect_scale(pupil_rect_coarse, params["ratio_downsample"], False)
outer_rect_coarse = self.rect_scale(outer_rect_coarse, params["ratio_downsample"], False)
pupil_rect_fine = self.rect_scale(pupil_rect_fine, params["ratio_downsample"], False)
center_coarse = (
pupil_rect_coarse[0] + pupil_rect_coarse[2] // 2,
pupil_rect_coarse[1] + pupil_rect_coarse[3] // 2,
)
center_fine = (
pupil_rect_fine[0] + pupil_rect_fine[2] // 2,
pupil_rect_fine[1] + pupil_rect_fine[3] // 2,
)
return (
pupil_rect_coarse,
outer_rect_coarse,
pupil_rect_fine,
center_coarse,
center_fine,
)
# @lru_cache(maxsize=self.lru_maxsize_vvs)
def get_empty_array(self, frame_shape, width_min, width_max, wh_step, xy_step, roi, ratio_outer):
frame_int_dtype = np.intc
np_index_dtype = (
np.intc
) # memo: Better to use np.intp, but a little slower ref: https://numpy.org/doc/1.25/user/basics.indexing.html#detailed-notes
row, col = frame_shape
frame_int = np.empty((row + 1, col + 1), dtype=frame_int_dtype)
w_arr = np.arange(width_min, width_max + 1, wh_step, dtype=np_index_dtype)
h_arr = (w_arr / ratio_outer).astype(np.int16)
# memo: It is not smart code and needs to be changed.
y_out_n = np.hstack([np.arange(roi[1] + h, roi[3] - h, xy_step, dtype=np_index_dtype) for h in h_arr])
x_out_n = np.hstack([np.arange(roi[0] + w, roi[2] - w, xy_step, dtype=np_index_dtype) for w in w_arr])
y_out_h = np.hstack([np.arange(roi[1] + h, roi[3] - h, xy_step, dtype=np_index_dtype) + h for h in h_arr])
x_out_w = np.hstack([np.arange(roi[0] + w, roi[2] - w, xy_step, dtype=np_index_dtype) + w for w in w_arr])
out_h = y_out_h - y_out_n
out_w = x_out_w - x_out_n
y_in_n = np.hstack([np.arange(roi[1] + h, roi[3] - h, xy_step, dtype=np_index_dtype) + int(h / 4) for h in h_arr])
x_in_n = np.hstack([np.arange(roi[0] + w, roi[2] - w, xy_step, dtype=np_index_dtype) + int(w / 4) for w in w_arr])
y_in_h = np.hstack(
[np.arange(roi[1] + h, roi[3] - h, xy_step, dtype=np_index_dtype) + int(h / 4) + int(h / 2) for h in h_arr]
)
x_in_w = np.hstack(
[np.arange(roi[0] + w, roi[2] - w, xy_step, dtype=np_index_dtype) + int(w / 4) + int(w / 2) for w in w_arr]
)
in_h = y_in_h - y_in_n
in_w = x_in_w - x_in_n
# # memo: Unelegant code
# # memo: Non-transposed version
# wh_in_arr = np.hstack([np.full(((roi[3] - h) - (roi[1] + h) - 1) // xy_step + 1,int(h/2),dtype=np_index_dtype) for h in h_arr])[:, np.newaxis] * np.hstack([np.full(((roi[2] - w) - (roi[0] + w) - 1) // xy_step + 1,int(w/2),dtype=np_index_dtype) for w in w_arr])[np.newaxis, :]
# wh_out_arr = np.hstack([np.full(((roi[3] - h) - (roi[1] + h) - 1) // xy_step + 1,h,dtype=np_index_dtype) for h in h_arr])[:, np.newaxis] * np.hstack([np.full(((roi[2] - w) - (roi[0] + w) - 1) // xy_step + 1,w,dtype=np_index_dtype) for w in w_arr])[np.newaxis, :]
# memo: Unelegant code
# memo: transposed version
wh_in_arr = (
np.hstack(
[
np.full(
((roi[2] - w) - (roi[0] + w) - 1) // xy_step + 1,
int(w / 2),
dtype=np_index_dtype,
)
for w in w_arr
]
)[:, np.newaxis]
* np.hstack(
[
np.full(
((roi[3] - h) - (roi[1] + h) - 1) // xy_step + 1,
int(h / 2),
dtype=np_index_dtype,
)
for h in h_arr
]
)[np.newaxis, :]
)
wh_out_arr = (
np.hstack(
[
np.full(
((roi[2] - w) - (roi[0] + w) - 1) // xy_step + 1,
w,
dtype=np_index_dtype,
)
for w in w_arr
]
)[:, np.newaxis]
* np.hstack(
[
np.full(
((roi[3] - h) - (roi[1] + h) - 1) // xy_step + 1,
h,
dtype=np_index_dtype,
)
for h in h_arr
]
)[np.newaxis, :]
)
mu_outer_rect = cv2.subtract(
wh_out_arr, wh_in_arr
) # ,dst=) # == (outer_rect[2] * outer_rect[3] - inner_rect[2] * inner_rect[3])
wh_in_arr = 1 / wh_in_arr # .astype(np.float32)
# wh_out_arr=wh_out_arr.astype(np.float64)
mu_outer_rect = 1 / mu_outer_rect # .astype(np.float32)
mu_outer_rect2 = -1.0 * mu_outer_rect # cv2.merge([mu_outer_rect,-1.0*mu_outer_rect])
# 1/wh_in_arr == wh_in_arr_mul
return (
frame_int,
y_out_n,
x_out_n,
y_out_h,
x_out_w,
out_h,
out_w,
y_in_n,
x_in_n,
y_in_h,
x_in_w,
in_h,
in_w,
wh_in_arr,
wh_out_arr,
mu_outer_rect,
mu_outer_rect2,
)
# @profile
def coarse_detection(self, img_gray, params):
ratio_outer = params["ratio_outer"]
kf = params["kf"]
width_min = params["width_min"]
width_max = params["width_max"]
wh_step = params["wh_step"]
xy_step = params["xy_step"]
roi = params["roi"]
init_rect_flag = params["init_rect_flag"]
init_rect = params["init_rect"]
mu_inner = params["mu_inner"]
mu_outer = params["mu_outer"]
max_response_coarse = -255
imgboundary = (0, 0, img_gray.shape[1], img_gray.shape[0])
img_blur = np.copy(img_gray)
rectlist = []
response = []
# Assign values to avoid unassigned errors
pupil_rect_coarse = (10, 10, 10, 10)
outer_rect_coarse = (5, 5, 5, 5)
if init_rect_flag:
init_rect_down = self.rect_scale(init_rect, params["ratio_downsample"], False)
init_rect_down = self.intersect_rect(init_rect_down, imgboundary)
img_blur = img_gray[
init_rect_down[1]: init_rect_down[1] + init_rect_down[3],
init_rect_down[0]: init_rect_down[0] + init_rect_down[2],
]
(
frame_int,
y_out_n,
x_out_n,
y_out_h,
x_out_w,
out_h,
out_w,
y_in_n,
x_in_n,
y_in_h,
x_in_w,
in_h,
in_w,
wh_in_arr,
wh_out_arr,
mu_outer_rect,
mu_outer_rect2,
) = self.get_empty_array(img_blur.shape, width_min, width_max, wh_step, xy_step, roi, ratio_outer)
cv2.integral(
img_blur, sum=frame_int, sdepth=cv2.CV_32S
)
out_p_temp = frame_int.take(y_out_n, axis=0, mode="clip")
out_p_temp = cv2.transpose(out_p_temp)
out_p00 = out_p_temp.take(x_out_n, axis=0, mode="clip")
out_p01 = out_p_temp.take(x_out_w, axis=0, mode="clip")
out_p_temp = frame_int.take(y_out_h, axis=0, mode="clip")
out_p_temp = cv2.transpose(out_p_temp)
out_p11 = out_p_temp.take(x_out_w, axis=0, mode="clip")
out_p10 = out_p_temp.take(x_out_n, axis=0, mode="clip")
outer_sum = cv2.add(out_p00, out_p11)
cv2.subtract(outer_sum, out_p01, dst=outer_sum)
cv2.subtract(outer_sum, out_p10, dst=outer_sum)
in_p_temp = frame_int.take(y_in_n, axis=0, mode="clip")
in_p_temp = cv2.transpose(in_p_temp)
in_p00 = in_p_temp.take(x_in_n, axis=0, mode="clip")
in_p01 = in_p_temp.take(x_in_w, axis=0, mode="clip")
in_p_temp = frame_int.take(y_in_h, axis=0, mode="clip")
in_p_temp = cv2.transpose(in_p_temp)
in_p11 = in_p_temp.take(x_in_w, axis=0, mode="clip")
in_p10 = in_p_temp.take(x_in_n, axis=0, mode="clip")
inner_sum = cv2.add(in_p00, in_p11)
cv2.subtract(inner_sum, in_p01, dst=inner_sum)
cv2.subtract(inner_sum, in_p10, dst=inner_sum)
inner_sum_f = inner_sum.astype(np.float64)
outer_sum_f = outer_sum.astype(np.float64)
response_value = np.empty(outer_sum.shape, dtype=np.float64)
inout_rect_sum = mu_outer_rect2.copy()
inout_rect_mul = mu_outer_rect.copy()
cv2.multiply(inner_sum_f, inout_rect_mul, inout_rect_mul)
cv2.multiply(outer_sum_f, inout_rect_sum, inout_rect_sum)
cv2.add(inout_rect_mul, inout_rect_sum, dst=inout_rect_sum)
cv2.multiply(inner_sum_f, wh_in_arr, inner_sum_f, kf)
cv2.add(inout_rect_sum, inner_sum_f, dst=response_value)
min_response, max_response, min_loc, max_loc = cv2.minMaxLoc(response_value)
rec_o = (
x_out_n[min_loc[1]],
y_out_n[min_loc[0]],
out_w[min_loc[1]],
out_h[min_loc[0]],
)
rec_in = (
x_in_n[min_loc[1]],
y_in_n[min_loc[0]],
in_w[min_loc[1]],
in_h[min_loc[0]],
)
max_response_coarse = -min_response
pupil_rect_coarse = rec_in
outer_rect_coarse = rec_o
return pupil_rect_coarse, outer_rect_coarse, max_response_coarse, mu_inner, mu_outer
def fine_detection(self, img_gray, pupil_rect_coarse):
boundary = (0, 0, img_gray.shape[1], img_gray.shape[0])
valid_ratio = 1.2
valid_rect = self.intersect_rect(self.rect_scale(pupil_rect_coarse, valid_ratio), boundary)
img_pupil = img_gray[
valid_rect[1] : valid_rect[1] + valid_rect[3],
valid_rect[0] : valid_rect[0] + valid_rect[2],
]
img_pupil_blur = cv2.GaussianBlur(img_pupil, (5, 5), 0, 0)
edges_filter = self.detect_edges(img_pupil_blur)
# fit ellipse to edges
contours, hierarchy = cv2.findContours(edges_filter, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
# sort contours by area
contours = sorted(contours, key=lambda x: cv2.contourArea(x), reverse=True)
# fit ellipse to largest contour
try:
if len(contours) > 0 and len(contours[0]) >= 5:
pupil_contour = contours[0]
pupil_ellipse = cv2.fitEllipse(pupil_contour)
center_fitting = (
int(pupil_ellipse[0][0] + valid_rect[0]),
int(pupil_ellipse[0][1] + valid_rect[1]),
)
pupil_rect_fine = (
int(pupil_ellipse[0][0] - pupil_ellipse[1][0] / 2),
int(pupil_ellipse[0][1] - pupil_ellipse[1][1] / 2),
int(pupil_ellipse[1][0]),
int(pupil_ellipse[1][1]),
)
pupil_rect_fine = (
pupil_rect_fine[0] + valid_rect[0],
pupil_rect_fine[1] + valid_rect[1],
pupil_rect_fine[2],
pupil_rect_fine[3],
)
pupil_rect_fine = self.intersect_rect(pupil_rect_fine, boundary)
pupil_rect_fine = self.rect_scale(pupil_rect_fine, 1 / valid_ratio)
else:
pupil_rect_fine = pupil_rect_coarse
center_fitting = (
int(pupil_rect_fine[0] + pupil_rect_fine[2] / 2),
int(pupil_rect_fine[1] + pupil_rect_fine[3] / 2),
)
except:
pass
try:
return pupil_rect_fine, center_fitting
except:
pass
def detect_edges(self, img_pupil_blur):
tau1 = 1 - 20.0 / img_pupil_blur.shape[1]
edges = cv2.Canny(img_pupil_blur, 64, 128)
# img_bw = np.zeros_like(img_pupil_blur)
# img_bw[img_pupil_blur > 100] = 255
img_bw = cv2.compare(img_pupil_blur, 100, cv2.CMP_GT)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
img_bw = cv2.dilate(img_bw, kernel)
# edges_filter = edges & (~img_bw)
# or
edges_filter = cv2.bitwise_and(edges, cv2.bitwise_not(img_bw))
return edges_filter
def fit_pupil_ellipse_swirski(self, img_pupil, edges_filter):
contours, hierarchy = cv2.findContours(edges_filter, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
max_contour_area = 0
max_contour = None
#print("contours: ", contours)
for contour in contours:
area = cv2.contourArea(contour)
if area > max_contour_area:
max_contour_area = area
max_contour = contour
if max_contour is None:
return (0, 0, 0, 0), None
ellipse = cv2.fitEllipse(max_contour)
return ellipse
def rect_scale(self, rect, scale, round_up=True):
x, y, width, height = rect
new_width = int(width * scale)
new_height = int(height * scale)
if round_up:
new_width = int(np.ceil(width * scale))
new_height = int(np.ceil(height * scale))
new_x = x + int((width - new_width) / 2)
new_y = y + int((height - new_height) / 2)
return new_x, new_y, new_width, new_height
def intersect_rect(self, rect1, rect2):
x1, y1, w1, h1 = rect1
x2, y2, w2, h2 = rect2
x = max(x1, x2)
y = max(y1, y2)
w = min(x1 + w1, x2 + w2) - x
h = min(y1 + h1, y2 + h2) - y
return x, y, w, h
def rect_suppression(self, rectlist, response, rectlist_out, response_out):
for i in range(len(rectlist)):
flag_intersect = False
for j in range(len(rectlist_out)):
tmp = self.intersect_rect(rectlist[i], rectlist_out[j])
if tmp[2] > 0 and tmp[3] > 0:
flag_intersect = True
if response[i] > response_out[j]:
rectlist_out[j] = rectlist[i]
response_out[j] = response[i]
else:
continue
if not flag_intersect:
rectlist_out.append(rectlist[i])
response_out.append(response[i])
return rectlist_out, response_out
def put_number(self, img_bgr, number, position, color):
cv2.putText(
img_bgr,
str(number),
(int(position[0]) + 10, int(position[1]) - 10),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
color,
1,
cv2.LINE_AA,
)
def External_Run_AHSF(self, frame_gray):
average_color = np.mean(frame_gray)
height, width = frame_gray.shape
max_dimension = max(height, width)
square_background = np.full((max_dimension, max_dimension), average_color, dtype=np.uint8)
x_offset = (max_dimension - width) // 2
y_offset = (max_dimension - height) // 2
square_background[y_offset : y_offset + height, x_offset : x_offset + width] = frame_gray
frame_gray = cv2.resize(square_background, (100, 100))
frame_clear_resize = frame_gray.copy()
params = {
"ratio_downsample": 0.5,
"use_init_rect": False,
"mu_outer": 200,
"mu_inner": 50,
"ratio_outer": 1,
"kf": 1,
"width_min": 25,
"width_max": 50,
"wh_step": 1,
"xy_step": 5,
"roi": (0, 0, frame_gray.shape[1], frame_gray.shape[0]),
"init_rect_flag": False,
"init_rect": (0, 0, frame_gray.shape[1], frame_gray.shape[0]),
}
try:
pupil_rect_coarse, outer_rect_coarse, max_response_coarse, mu_inner, mu_outer = self.coarse_detection(frame_gray, params)
# ellipse_rect, center_fitting = self.fine_detection(frame_gray, pupil_rect_coarse)
except TypeError:
return frame_gray, frame_gray, 0, 0, 0
x_center = outer_rect_coarse[0] + outer_rect_coarse[2] / 2
y_center = outer_rect_coarse[1] + outer_rect_coarse[3] / 2
x, y, width, height = outer_rect_coarse
cv2.circle(frame_gray, (int(x_center), int(y_center)), 2, (255, 255, 255), -1)
thickness = 1
cv2.rectangle(frame_gray, (pupil_rect_coarse[0], pupil_rect_coarse[1]),
(pupil_rect_coarse[0] + pupil_rect_coarse[2], pupil_rect_coarse[1] + pupil_rect_coarse[3]),
(0, 255, 0), 2)
cv2.rectangle(frame_gray, (outer_rect_coarse[0], outer_rect_coarse[1]),
(outer_rect_coarse[0] + outer_rect_coarse[2], outer_rect_coarse[1] + outer_rect_coarse[3]),
(255, 0, 0), 2)
major_diameter = math.sqrt(width**2 + height**2)
minor_diameter = min(width, height)
average_diameter = (major_diameter + minor_diameter) / 2
return frame_gray, frame_clear_resize, x_center, y_center, abs(width - height)
class FPSResult(object):
"""
base https://github.com/ipython/ipython/blob/339c0d510a1f3cb2158dd8c6e7f4ac89aa4c89d8/IPython/core/magics/execution.py#L55
"""
def __init__(self, loops, repeat, best, worst, all_runs, precision):
self.loops = loops
self.repeat = repeat
self.best = 1 / best
self.worst = 1 / worst
self.all_runs = all_runs
self._precision = precision
self.fps = [1 / dt for dt in all_runs]
self.unit = "fps"
@property
def average(self):
return math.fsum(self.fps) / len(self.fps)
@property
def stdev(self):
mean = self.average
return (math.fsum([(x - mean) ** 2 for x in self.fps]) / len(self.fps)) ** 0.5
def __str__(self):
pm = "+-"
if hasattr(sys.stdout, "encoding") and sys.stdout.encoding:
try:
"\xb1".encode(sys.stdout.encoding)
pm = "\xb1"
except:
pass
return "min:{best} max:{worst} mean:{mean} {pm} {std} per loop (mean {pm} std. dev. of {runs} run{run_plural}, {loops:,} loop{loop_plural} each)".format(
pm=pm,
runs=self.repeat,
loops=self.loops,
loop_plural="" if self.loops == 1 else "s",
run_plural="" if self.repeat == 1 else "s",
mean="%.*g%s" % (self._precision, self.average, self.unit),
std="%.*g%s" % (self._precision, self.stdev, self.unit),
best="%.*g%s" % (self._precision, self.best, self.unit),
worst="%.*g%s" % (self._precision, self.worst, self.unit),
)
def _repr_pretty_(self, p, cycle):
unic = self.__str__()
p.text("<FPSResult : " + unic + ">")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,51 @@
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "EyeTrackVR"
#define MyAppVersion "0.2 BETA"
#define MyAppPublisher "EyeTrackVR"
#define MyAppURL "https://redhawk989.github.io/EyeTrackVR/"
#define MyAppExeName "eyetrackapp.exe"
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={{60B32C3C-819B-4685-AF40-49AB93539405}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
DisableProgramGroupPage=yes
; Uncomment the following line to run in non administrative install mode (install for current user only.)
;PrivilegesRequired=lowest
OutputBaseFilename=EyeTrackVR-Setup
SetupIconFile=C:\Users\beaul\OneDrive\Desktop\EyeTrackApp\Images\logo.ico
Compression=lzma/ultra64
SolidCompression=yes
WizardStyle=modern
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}";
[Files]
Source: "C:\Users\beaul\OneDrive\Desktop\EyeTrackVR\EyeTrackApp\dist\EyeTrackApp\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "C:\Users\beaul\OneDrive\Desktop\EyeTrackVR\EyeTrackApp\dist\EyeTrackApp\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Dirs]
Name: {app}; Permissions: users-full
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

BIN
EyeTrackApp/Purple_Dot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

78
EyeTrackApp/blink.py Normal file
View File

@ -0,0 +1,78 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Binary Intensity Based Blink by: Summer, Prohurtz
Algorithm App Implementations and tweaks By: Prohurtz
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Summer Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
import numpy as np
def BLINK(self):
if self.blink_clear == True:
self.max_ints = []
self.max_int = 0
self.frames = 0
intensity = np.sum(self.current_image_gray_clean)
if self.calibration_frame_counter == 300:
self.filterlist = [] # clear filter
if len(self.filterlist) < 300:
self.filterlist.append(intensity)
else:
self.filterlist.pop(0)
self.filterlist.append(intensity)
if (
intensity >= np.percentile(self.filterlist, 99)
or intensity <= np.percentile(self.filterlist, 1)
and len(self.max_ints) >= 1
): # filter abnormally high values
try: # I don't want this here but I cant get python to stop crying when it's not
intensity = min(self.max_ints)
except:
pass
self.frames = self.frames + 1
if intensity > self.max_int:
self.max_int = intensity
if self.frames > 300: # TODO: test this number more (make it a setting??)
self.max_ints.append(self.max_int)
if intensity < self.min_int:
self.min_int = intensity
if len(self.max_ints) > 1:
if intensity > min(self.max_ints):
blinkvalue = 0.0
else:
blinkvalue = 0.8
try:
return blinkvalue
except:
return 0.8
# print(self.blinkvalue, self.max_int, self.min_int, self.frames, intensity)

82
EyeTrackApp/blob.py Normal file
View File

@ -0,0 +1,82 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
BLOB By: Prohurtz
Algorithm App Implimentations By: Prohurtz, qdot (Inital App Creator)
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
import cv2
def BLOB(self):
global cct
# define circle
_, larger_threshold = cv2.threshold(
self.current_image_gray,
int(self.settings.gui_threshold),
255,
cv2.THRESH_BINARY,
)
try:
# Try rebuilding our contours
contours, _ = cv2.findContours(
larger_threshold, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE
)
contours = sorted(contours, key=lambda x: cv2.contourArea(x), reverse=True)
# If we have no contours, we have nothing to blob track. Fail here.
if len(contours) == 0:
raise RuntimeError("No contours found for image")
except:
self.failed = self.failed + 1
pass
rows, cols = larger_threshold.shape
for cnt in contours:
(x, y, w, h) = cv2.boundingRect(cnt)
# if our blob width/height are within boundaries, call that good.
if (
not self.settings.gui_blob_minsize <= h <= self.settings.gui_blob_maxsize
or not self.settings.gui_blob_minsize <= w <= self.settings.gui_blob_maxsize
):
continue
cx = x + int(w / 2)
cy = y + int(h / 2)
cv2.drawContours(self.current_image_gray, [cnt], -1, (0, 0, 0), 3)
cv2.rectangle(self.current_image_gray, (x, y), (x + w, y + h), (0, 0, 0), 2)
# out_x, out_y = cal_osc(self, cx, cy) #filter and calibrate values
self.failed = 0
return cx, cy, larger_threshold
self.failed = self.failed + 1
return 0, 0, larger_threshold

View File

@ -1,10 +1,63 @@
from config import EyeTrackConfig
from enum import Enum
import threading
import queue
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
import cv2
import numpy as np
import queue
import serial
import serial.tools.list_ports
import threading
import time
from colorama import Fore
from config import EyeTrackCameraConfig
from enum import Enum
import psutil, os
import sys
process = psutil.Process(os.getpid()) # set process priority to low
try:
sys.getwindowsversion()
except AttributeError:
process.nice(10) # UNIX: 0 low 10 high
process.nice()
else:
process.nice(psutil.HIGH_PRIORITY_CLASS) # Windows
process.nice()
# See https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-getpriorityclass#return-value for values
WAIT_TIME = 0.1
# Serial communication protocol:
# header-begin (2 bytes)
# header-type (2 bytes)
# packet-size (2 bytes)
# packet (packet-size bytes)
ETVR_HEADER = b"\xff\xa0"
ETVR_HEADER_FRAME = b"\xff\xa1"
ETVR_HEADER_LEN = 6
class CameraState(Enum):
@ -13,16 +66,26 @@ class CameraState(Enum):
DISCONNECTED = 2
def is_serial_capture_source(addr: str) -> bool:
"""
Returns True if the capture source address is a serial port.
"""
return (
addr.startswith("COM") or addr.startswith("/dev/cu") or addr.startswith("/dev/tty") # Windows # macOS # Linux
)
class Camera:
def __init__(
self,
config: EyeTrackConfig,
config: EyeTrackCameraConfig,
camera_index: int,
cancellation_event: "threading.Event",
capture_event: "threading.Event",
camera_status_outgoing: "queue.Queue[CameraState]",
camera_output_outgoing: "queue.Queue",
camera_output_outgoing: "queue.Queue(maxsize=20)",
):
self.camera_status = CameraState.CONNECTING
self.config = config
self.camera_index = camera_index
@ -32,37 +95,87 @@ class Camera:
self.capture_event = capture_event
self.cancellation_event = cancellation_event
self.current_capture_source = config.capture_source
self.wired_camera: "cv2.VideoCapture" = None
self.error_message = "Capture source {} not found, retrying"
self.cv2_camera: "cv2.VideoCapture" = None
self.serial_connection = None
self.last_frame_time = time.time()
self.frame_number = 0
self.fps = 0
self.bps = 0
self.start = True
self.buffer = b""
self.pf_fps = 0
self.prevft = 0
self.newft = 0
self.fl = [0]
self.error_message = f"{Fore.YELLOW}[WARN] Capture source {{}} not found, retrying...{Fore.RESET}"
def __del__(self):
if self.serial_connection is not None:
self.serial_connection.close()
def set_output_queue(self, camera_output_outgoing: "queue.Queue"):
self.camera_output_outgoing = camera_output_outgoing
def run(self):
OPENCV_PARAMS = [
cv2.CAP_PROP_OPEN_TIMEOUT_MSEC,
5000,
cv2.CAP_PROP_READ_TIMEOUT_MSEC,
5000,
]
while True:
if self.cancellation_event.is_set():
print("Exiting capture thread")
print(f"{Fore.CYAN}[INFO] Exiting Capture thread{Fore.RESET}")
# openCV won't switch to a new source if provided with one
# so, we have to manually release the camera on exit
addr = str(self.current_capture_source)
if is_serial_capture_source(addr):
pass # TODO: find a nicer way to stop the com port
# self.serial_connection.close()
else:
self.cv2_camera.release()
return
should_push = True
# If things aren't open, retry until they are. Don't let read requests come in any earlier
# than this, otherwise we can deadlock ourselves.
if (
self.config.capture_source != None and self.config.capture_source != ""
):
if (
self.wired_camera is None
or not self.wired_camera.isOpened()
or self.camera_status == CameraState.DISCONNECTED
or self.config.capture_source != self.current_capture_source
):
print(self.error_message.format(self.config.capture_source))
# This requires a wait, otherwise we can error and possible screw up the camera
# firmware. Fickle things.
if self.cancellation_event.wait(WAIT_TIME):
return
self.current_capture_source = self.config.capture_source
self.wired_camera = cv2.VideoCapture(self.current_capture_source)
should_push = False
if self.config.capture_source != None and self.config.capture_source != "":
self.current_capture_source = self.config.capture_source
addr = str(self.current_capture_source)
if is_serial_capture_source(addr):
if (
self.serial_connection is None
or self.camera_status == CameraState.DISCONNECTED
or self.config.capture_source != self.current_capture_source
):
port = self.config.capture_source
self.current_capture_source = port
self.start_serial_connection(port)
else:
if (
self.cv2_camera is None
or not self.cv2_camera.isOpened()
or self.camera_status == CameraState.DISCONNECTED
or self.config.capture_source != self.current_capture_source
):
print(self.error_message.format(self.config.capture_source))
# This requires a wait, otherwise we can error and possible screw up the camera
# firmware. Fickle things.
if self.cancellation_event.wait(WAIT_TIME):
return
self.current_capture_source = self.config.capture_source
# self.cv2_camera = cv2.VideoCapture(self.current_capture_source)
self.cv2_camera = cv2.VideoCapture()
self.cv2_camera.setExceptionMode(True)
# https://github.com/opencv/opencv/blob/4.8.0/modules/videoio/include/opencv2/videoio.hpp#L803
self.cv2_camera.open(self.current_capture_source)
should_push = False
else:
# We don't have a capture source to try yet, wait for one to show up in the GUI.
if self.cancellation_event.wait(WAIT_TIME):
@ -70,39 +183,159 @@ class Camera:
return
# Assuming we can access our capture source, wait for another thread to request a capture.
# Cycle every so often to see if our cancellation token has fired. This basically uses a
# python event as a contextless, resettable one-shot channel.
if should_push and not self.capture_event.wait(timeout=0.02):
# python event as a context-less, resettable one-shot channel.
if should_push and not self.capture_event.wait(timeout=0.001):
continue
if self.config.capture_source != None:
addr = str(self.current_capture_source)
if is_serial_capture_source(addr):
self.get_serial_camera_picture(should_push)
else:
self.get_cv2_camera_picture(should_push)
if not should_push:
# if we get all the way down here, consider ourselves connected
self.camera_status = CameraState.CONNECTED
self.get_wired_camera_picture(should_push)
if not should_push:
# if we get all the way down here, consider ourselves connected
self.camera_status = CameraState.CONNECTED
def get_wired_camera_picture(self, should_push):
def get_cv2_camera_picture(self, should_push):
try:
ret, image = self.wired_camera.read()
ret, image = self.cv2_camera.read()
height, width = image.shape[:2] # Calculate the aspect ratio
if int(width) > 680:
aspect_ratio = float(width) / float(
height
) # Determine the new height based on the desired maximum width
new_height = int(680 / aspect_ratio)
image = cv2.resize(image, (680, new_height))
if not ret:
self.wired_camera.set(cv2.CAP_PROP_POS_FRAMES, 0)
self.cv2_camera.set(cv2.CAP_PROP_POS_FRAMES, 0)
raise RuntimeError("Problem while getting frame")
frame_number = self.wired_camera.get(cv2.CAP_PROP_POS_FRAMES)
fps = self.wired_camera.get(cv2.CAP_PROP_FPS)
frame_number = self.cv2_camera.get(cv2.CAP_PROP_POS_FRAMES)
current_frame_time = time.time()
delta_time = current_frame_time - self.last_frame_time
if delta_time > 0:
current_fps = 1 / delta_time
else:
current_fps = 0
self.last_frame_time = current_frame_time
if len(self.fl) < 60:
self.fl.append(current_fps)
else:
self.fl.pop(0)
self.fl.append(current_fps)
self.fps = sum(self.fl) / len(self.fl)
self.bps = image.nbytes * self.fps
if should_push:
self.push_image_to_queue(image, frame_number, fps)
self.push_image_to_queue(image, frame_number, self.fps)
except:
print(
"Capture source problem, assuming camera disconnected, waiting for reconnect."
f"{Fore.YELLOW}[WARN] Capture source problem, assuming camera disconnected, waiting for reconnect.{Fore.RESET}"
)
self.camera_status = CameraState.DISCONNECTED
pass
def get_next_packet_bounds(self):
beg = -1
while beg == -1:
self.buffer += self.serial_connection.read(2048)
beg = self.buffer.find(ETVR_HEADER + ETVR_HEADER_FRAME)
# Discard any data before the frame header.
if beg > 0:
self.buffer = self.buffer[beg:]
beg = 0
# We know exactly how long the jpeg packet is
end = int.from_bytes(self.buffer[4:6], signed=False, byteorder="little")
self.buffer += self.serial_connection.read(end - len(self.buffer))
return beg, end
def get_next_jpeg_frame(self):
beg, end = self.get_next_packet_bounds()
jpeg = self.buffer[beg + ETVR_HEADER_LEN : end + ETVR_HEADER_LEN]
self.buffer = self.buffer[end + ETVR_HEADER_LEN :]
return jpeg
def get_serial_camera_picture(self, should_push):
conn = self.serial_connection
if conn is None:
return
try:
if conn.in_waiting:
jpeg = self.get_next_jpeg_frame()
if jpeg:
# Create jpeg frame from byte string
image = cv2.imdecode(np.fromstring(jpeg, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
if image is None:
print(f"{Fore.YELLOW}[WARN] Frame drop. Corrupted JPEG.{Fore.RESET}")
return
# Discard the serial buffer. This is due to the fact that it
# may build up some outdated frames. A bit of a workaround here tbh.
if conn.in_waiting >= 32768:
print(f"{Fore.CYAN}[INFO] Discarding the serial buffer ({conn.in_waiting} bytes){Fore.RESET}")
conn.reset_input_buffer()
self.buffer = b""
# Calculate the fps.
current_frame_time = time.time()
delta_time = current_frame_time - self.last_frame_time
self.last_frame_time = current_frame_time
self.fps = (self.fps + self.pf_fps) / 2
self.newft = time.time()
self.fps = 1 / (self.newft - self.prevft)
self.prevft = self.newft
self.fps = int(self.fps)
if len(self.fl) < 60:
self.fl.append(self.fps)
else:
self.fl.pop(0)
self.fl.append(self.fps)
self.fps = sum(self.fl) / len(self.fl)
self.bps = image.nbytes * self.fps
self.frame_number = self.frame_number + 1
if should_push:
self.push_image_to_queue(image, self.frame_number, self.fps)
except Exception:
print(
f"{Fore.YELLOW}[WARN] Serial capture source problem, assuming camera disconnected, waiting for reconnect.{Fore.RESET}"
)
conn.close()
self.camera_status = CameraState.DISCONNECTED
pass
def start_serial_connection(self, port):
if self.serial_connection is not None and self.serial_connection.is_open:
# Do nothing. The connection is already open on this port.
if self.serial_connection.port == port:
return
# Otherwise, close the connection before trying to reopen.
self.serial_connection.close()
com_ports = [tuple(p) for p in list(serial.tools.list_ports.comports())]
# Do not try connecting if no such port i.e. device was unplugged.
if not any(p for p in com_ports if port in p):
return
try:
rate = 115200 if sys.platform == "darwin" else 3000000 # Higher baud rate not working on macOS
conn = serial.Serial(baudrate=rate, port=port, xonxoff=False, dsrdtr=False, rtscts=False)
# Set explicit buffer size for serial.
if sys.platform == "win32":
buffer_size = 32768
conn.set_buffer_size(rx_size=buffer_size, tx_size=buffer_size)
print(f"{Fore.CYAN}[INFO] ETVR Serial Tracker device connected on {port}{Fore.RESET}")
self.serial_connection = conn
self.camera_status = CameraState.CONNECTED
except Exception:
print(f"{Fore.CYAN}[INFO] Failed to connect on {port}{Fore.RESET}")
self.camera_status = CameraState.DISCONNECTED
def push_image_to_queue(self, image, frame_number, fps):
# If there's backpressure, just yell. We really shouldn't have this unless we start getting
# some sort of capture event conflict though.
qsize = self.camera_output_outgoing.qsize()
if qsize > 1:
print(
f"CAPTURE QUEUE BACKPRESSURE OF {qsize}. CHECK FOR CRASH OR TIMING ISSUES IN ALGORITHM."
f"{Fore.YELLOW}[WARN] CAPTURE QUEUE BACKPRESSURE OF {qsize}. CHECK FOR CRASH OR TIMING ISSUES IN ALGORITHM.{Fore.RESET}"
)
self.camera_output_outgoing.put((image, frame_number, fps))
self.capture_event.clear()

View File

@ -1,22 +1,54 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
import PySimpleGUI as sg
from config import EyeTrackConfig
from config import EyeTrackSettingsConfig
from collections import deque
from threading import Event, Thread
from eye_processor import EyeProcessor, InformationOrigin
from enum import Enum
import math
from eye import EyeId
from eye_processor import EyeProcessor, EyeInfoOrigin
from queue import Queue, Empty
from camera import Camera, CameraState
from osc import EyeId
import cv2
from winsound import PlaySound, SND_FILENAME, SND_ASYNC
import traceback
from osc.OSCMessage import OSCMessageType, OSCMessage
from utils.misc_utils import PlaySound, SND_FILENAME, SND_ASYNC, resource_path
import numpy as np
# for clarity when indexing
X = 0
Y = 1
class CameraWidget:
def __init__(self, widget_id: EyeId, main_config: EyeTrackConfig, osc_queue: Queue):
self.gui_camera_addr = f"-CAMERAADDR{widget_id}-"
self.gui_threshold_slider = f"-THREADHOLDSLIDER{widget_id}-"
self.gui_rotation_slider = f"-ROTATIONSLIDER{widget_id}-"
self.gui_rotation_ui_padding = f"-ROTATIONUIPADDING{widget_id}-"
self.gui_roi_button = f"-ROIMODE{widget_id}-"
self.gui_roi_layout = f"-ROILAYOUT{widget_id}-"
self.gui_roi_selection = f"-GRAPH{widget_id}-"
@ -24,13 +56,18 @@ class CameraWidget:
self.gui_save_tracking_button = f"-SAVETRACKINGBUTTON{widget_id}-"
self.gui_tracking_layout = f"-TRACKINGLAYOUT{widget_id}-"
self.gui_tracking_image = f"-IMAGE{widget_id}-"
self.gui_tracking_fps = f"-TRACKINGFPS{widget_id}-"
self.gui_tracking_bps = f"-TRACKINGBPS{widget_id}-"
self.gui_output_graph = f"-OUTPUTGRAPH{widget_id}-"
self.gui_restart_calibration = f"-RESTARTCALIBRATION{widget_id}-"
self.gui_stop_calibration = f"-STOPCALIBRATION{widget_id}-"
self.gui_recenter_eyes = f"-RECENTEREYES{widget_id}-"
self.gui_mode_readout = f"-APPMODE{widget_id}-"
self.gui_circular_crop = f"-CIRCLECROP{widget_id}-"
self.gui_roi_message = f"-ROIMESSAGE{widget_id}-"
self.gui_mask_markup = f"-MARKUP{widget_id}-"
self.gui_mask_lighten = f"-LIGHTEN{widget_id}-"
self.last_eye_info = None
self.osc_queue = osc_queue
self.main_config = main_config
self.eye_id = widget_id
@ -43,91 +80,7 @@ class CameraWidget:
elif self.eye_id == EyeId.LEFT:
self.config = main_config.left_eye
else:
raise RuntimeError("Cannot have a camera widget represent both eyes!")
self.roi_layout = [
[
sg.Graph(
(640, 480),
(0, 480),
(640, 0),
key=self.gui_roi_selection,
drag_submits=True,
enable_events=True,
background_color='#424042',
)
]
]
# Define the window's contents
self.tracking_layout = [
[
sg.Text("Threshold", background_color='#424042'),
sg.Slider(
range=(0, 110),
default_value=self.config.threshold,
orientation="h",
key=self.gui_threshold_slider,
background_color='#424042'
),
],
[
sg.Text("Rotation", background_color='#424042'),
sg.Slider(
range=(0, 360),
default_value=self.config.rotation_angle,
orientation="h",
key=self.gui_rotation_slider,
background_color='#424042'
),
],
[
sg.Button("Restart Calibration", key=self.gui_restart_calibration, button_color='#6f4ca1'),
sg.Button("Recenter Eyes", key=self.gui_recenter_eyes, button_color='#6f4ca1'),
],
[
sg.Text("Mode:", background_color='#424042'),
sg.Text("Calibrating", key=self.gui_mode_readout, background_color='#424042'),
sg.Checkbox(
"Circle crop:",
default=self.config.gui_circular_crop,
key=self.gui_circular_crop,
background_color='#424042',
),
],
[sg.Image(filename="", key=self.gui_tracking_image)],
[
sg.Graph(
(200, 200),
(-100, 100),
(100, -100),
background_color="white",
key=self.gui_output_graph,
drag_submits=True,
enable_events=True,
),
sg.Text("Please set an Eye Cropping.", key=self.gui_roi_message, background_color='#424042', visible=False),
],
]
self.widget_layout = [
[
sg.Text("Camera Address", background_color='#424042'),
sg.InputText(self.config.capture_source, key=self.gui_camera_addr),
],
[
sg.Button("Save and Restart Tracking", key=self.gui_save_tracking_button, button_color='#6f4ca1'),
],
[
sg.Button("Tracking Mode", key=self.gui_tracking_button, button_color='#6f4ca1'),
sg.Button("Cropping Mode", key=self.gui_roi_button, button_color='#6f4ca1'),
],
[
sg.Column(self.tracking_layout, key=self.gui_tracking_layout, background_color='#424042'),
sg.Column(self.roi_layout, key=self.gui_roi_layout, background_color='#424042', visible=False),
],
]
raise RuntimeError("\033[91m[WARN] Cannot have a camera widget represent both eyes!\033[0m")
self.cancellation_event = Event()
# Set the event until start is called, otherwise we can block if shutdown is called.
@ -141,11 +94,13 @@ class CameraWidget:
self.ransac = EyeProcessor(
self.config,
self.settings_config,
main_config,
self.cancellation_event,
self.capture_event,
self.capture_queue,
self.image_queue,
self.eye_id,
self.osc_queue,
)
self.camera_status_queue = Queue()
@ -158,11 +113,210 @@ class CameraWidget:
self.capture_queue,
)
self.x0, self.y0 = None, None
self.x1, self.y1 = None, None
self.figure = None
self.hover = None
# cartesian co-ordinates in widget space are used during selection
self.xy0 = None
self.xy1 = None
self.cartesian_needs_update = False
# polar co-ordinates from the image center are the canonical representation
self.cr, self.ca = None, None
self.roi_size = None
self.clip_size = None
self.clip_pos = None
self.padded_size = [244, 244]
self.img_pos = None
self.roi_image_center = None
self.is_mouse_up = True
self.hover_pos = None
self.in_roi_mode = False
self.movavg_fps_queue = deque(maxlen=120)
self.movavg_bps_queue = deque(maxlen=120)
def get_widget_layout(self):
self.widget_layout = [
[
sg.Text("Camera Address", background_color="#424042"),
sg.InputText(
self.config.capture_source,
key=self.gui_camera_addr,
tooltip="Enter the IP address or UVC port of your camera. (Include the 'http://')",
),
],
[
sg.Button(
"Save and Restart Tracking",
key=self.gui_save_tracking_button,
button_color="#6f4ca1",
),
],
[
sg.Button(
"Tracking Mode",
key=self.gui_tracking_button,
button_color="#6f4ca1",
tooltip="Go here to track your eye.",
),
sg.Button(
"Cropping Mode",
key=self.gui_roi_button,
button_color="#6f4ca1",
tooltip="Go here to crop out your eye.",
),
],
[
sg.Column(
self.tracking_layout,
key=self.gui_tracking_layout,
background_color="#424042",
),
sg.Column(
self.roi_layout,
key=self.gui_roi_layout,
background_color="#424042",
visible=False,
),
],
]
def get_roi_layout(self):
self.roi_layout = [
[
# sg.Button(
# "Mark Out",
# key=self.gui_mask_markup,
# button_color="#6f4ca1",
# tooltip="Mark out stuff that is not your eye.",
# ),
# sg.Button(
# "Lighten",
# key=self.gui_mask_lighten,
# button_color="#6f4ca1",
# tooltip="Lighten shadowed areas.",
# ),
sg.Text("Rotation", background_color="#424042"),
sg.Slider(
range=(0, 360),
default_value=self.config.rotation_angle,
orientation="h",
key=self.gui_rotation_slider,
background_color="#424042",
tooltip="Adjust the rotation of your cameras, make them level.",
),
sg.Checkbox(
"Camera Widget Padding",
default=self.config.gui_rotation_ui_padding,
tooltip="Pad the camera view widget enough to allow a full rotation.",
key=self.gui_rotation_ui_padding,
background_color="#424042",
),
],
[
sg.Graph(
(640, 480),
(0, 480),
(640, 0),
key=self.gui_roi_selection,
drag_submits=True,
enable_events=True,
motion_events=True,
background_color="#424042",
),
],
]
def get_tracking_layout(self):
# Define the window's contents
self.tracking_layout = [
[
sg.Button(
"Start Calibration",
key=self.gui_restart_calibration,
button_color="#6f4ca1",
tooltip="Start eye calibration. Look all arround to all extreams without blinking until sound is heard.",
),
sg.Button(
"Stop Calibration",
key=self.gui_stop_calibration,
button_color="#6f4ca1",
tooltip="Stop eye calibration manualy.",
),
sg.Button(
"Recenter Eyes",
key=self.gui_recenter_eyes,
button_color="#6f4ca1",
tooltip="Make your eyes center again.",
),
],
[
sg.Text("Mode:", background_color="#424042"),
sg.Text("Calibrating", key=self.gui_mode_readout, background_color="#424042"),
sg.Text("", key=self.gui_tracking_fps, background_color="#424042"),
sg.Text("", key=self.gui_tracking_bps, background_color="#424042"),
# sg.Checkbox(
# "Circle crop:",
# default=self.config.gui_circular_crop,
# key=self.gui_circular_crop,
# background_color='#424042',
# tooltip = "Circle crop only applies to RANSAC3D and Blob.",
# ),
],
[sg.Image(filename="", key=self.gui_tracking_image)],
[
sg.Graph(
(200, 200),
(-100, 100),
(100, -100),
background_color="white",
key=self.gui_output_graph,
drag_submits=True,
enable_events=True,
),
sg.Text(
"Please set an Eye Cropping.",
key=self.gui_roi_message,
background_color="#424042",
visible=False,
),
],
]
def update_layouts(self):
self.get_roi_layout()
self.get_tracking_layout()
self.get_widget_layout()
def _movavg_fps(self, next_fps):
self.movavg_fps_queue.append(next_fps)
fps = round(sum(self.movavg_fps_queue) / len(self.movavg_fps_queue))
millisec = round((1 / fps if fps else 0) * 1000)
return f"{fps} Fps {millisec} ms"
def _movavg_bps(self, next_bps):
self.movavg_bps_queue.append(next_bps)
return f"{sum(self.movavg_bps_queue) / len(self.movavg_bps_queue) * 0.001 * 0.001 * 8:.3f} Mbps"
def _cartesian_to_polar(self):
if not (self.xy0 is None or self.xy1 is None):
roi_center = (self.xy0 + self.xy1) / 2 - self.roi_image_center
self.cr = np.linalg.norm(roi_center)
self.ca = math.atan2(roi_center[Y], roi_center[X]) + math.radians(self.config.rotation_angle)
self.roi_size = np.abs(self.xy1 - self.xy0)
def _polar_to_cartesian_at_angle(self, rotation_angle_radians):
if not (self.cr is None or self.ca is None or self.roi_size is None):
ca = self.ca - rotation_angle_radians
cx = math.cos(ca) * self.cr + self.roi_image_center[X]
cy = math.sin(ca) * self.cr + self.roi_image_center[Y]
roi_pos = np.array((int(cx), int(cy))) - self.roi_size // 2
return (roi_pos, roi_pos + self.roi_size)
else:
return (None, None)
def _polar_to_cartesian(self):
if not (self.cr is None or self.ca is None or self.roi_size is None):
(self.xy0), (self.xy1) = self._polar_to_cartesian_at_angle(math.radians(self.config.rotation_angle))
def started(self):
return not self.cancellation_event.is_set()
@ -185,148 +339,354 @@ class CameraWidget:
self.ransac_thread.join()
self.camera_thread.join()
def on_config_update(self, data):
keys = set(data.keys())
model_keys = set(self.config.model_fields.keys())
# we only want to restart our stuff, if our stuff got updated
# at the model level
if model_keys.intersection(keys):
self.stop()
self.start()
def recenter_eyes(self):
self.settings.gui_recenter_eyes = True
def recalibrate_eyes(self):
self.ransac.calibration_frame_counter = self.settings.calibration_samples
self.ransac.ibo.clear_filter()
PlaySound(resource_path("Audio/start.wav"), SND_FILENAME | SND_ASYNC)
def osc_recenter_eyes(self, osc_message: OSCMessage):
if not isinstance(osc_message.data, bool):
return # just incase we get anything other than bool
if osc_message.data:
self.recenter_eyes()
def osc_recalibrate_eyes(self, osc_message: OSCMessage):
if not isinstance(osc_message.data, bool):
return # just incase we get anything other than bool
if osc_message.data:
self.recalibrate_eyes()
def render(self, window, event, values):
changed = False
# If anything has changed in our configuration settings, change/update those.
if (
event == self.gui_save_tracking_button
and values[self.gui_camera_addr] != self.config.capture_source
):
print("New value: {}".format(values[self.gui_camera_addr]))
try:
# Try storing ints as ints, for those using wired cameras.
self.config.capture_source = int(values[self.gui_camera_addr])
except ValueError:
if values[self.gui_camera_addr] == "":
self.config.capture_source = None
else:
self.config.capture_source = values[self.gui_camera_addr]
changed = True
if self.config.threshold != values[self.gui_threshold_slider]:
self.config.threshold = int(values[self.gui_threshold_slider])
changed = True
if self.settings.gui_disable_gui == False:
if self.config.rotation_angle != values[self.gui_rotation_slider]:
self.config.rotation_angle = int(values[self.gui_rotation_slider])
changed = True
# If anything has changed in our configuration settings, change/update those.
if event == self.gui_save_tracking_button and values[self.gui_camera_addr] != self.config.capture_source:
print("\033[94m[INFO] New value: {}\033[0m".format(values[self.gui_camera_addr]))
try:
# Try storing ints as ints, for those using wired cameras.
self.config.capture_source = int(values[self.gui_camera_addr])
except ValueError:
if values[self.gui_camera_addr] == "":
self.config.capture_source = None
else:
if (
len(values[self.gui_camera_addr]) > 5
and "http" not in values[self.gui_camera_addr]
and ".mp4" not in values[self.gui_camera_addr]
and "/dev" not in values[self.gui_camera_addr]
): # If http is not in camera address, add it.
self.config.capture_source = f"http://{values[self.gui_camera_addr]}/"
else:
self.config.capture_source = values[self.gui_camera_addr]
changed = True
if self.config.gui_circular_crop != values[self.gui_circular_crop]:
self.config.gui_circular_crop = values[self.gui_circular_crop]
changed = True
if self.config.rotation_angle != int(values[self.gui_rotation_slider]):
self.config.rotation_angle = int(values[self.gui_rotation_slider])
changed = True
self.cartesian_needs_update = True
if changed:
self.main_config.save()
if self.config.gui_rotation_ui_padding != bool(values[self.gui_rotation_ui_padding]):
self.config.gui_rotation_ui_padding = bool(values[self.gui_rotation_ui_padding])
changed = True
self.cartesian_needs_update = True
if event == self.gui_tracking_button:
print("Moving to tracking mode")
self.in_roi_mode = False
self.camera.set_output_queue(self.capture_queue)
window[self.gui_roi_layout].update(visible=False)
window[self.gui_tracking_layout].update(visible=True)
# if self.config.gui_circular_crop != values[self.gui_circular_crop]:
# self.config.gui_circular_crop = values[self.gui_circular_crop]
# changed = True
if event == self.gui_roi_button:
print("Move to roi mode")
self.in_roi_mode = True
self.camera.set_output_queue(self.roi_queue)
window[self.gui_roi_layout].update(visible=True)
window[self.gui_tracking_layout].update(visible=False)
if event == "{}+UP".format(self.gui_roi_selection):
# Event for mouse button up in ROI mode
self.is_mouse_up = True
if abs(self.x0 - self.x1) != 0 and abs(self.y0 - self.y1) != 0:
self.config.roi_window_x = min([self.x0, self.x1])
self.config.roi_window_y = min([self.y0, self.y1])
self.config.roi_window_w = abs(self.x0 - self.x1)
self.config.roi_window_h = abs(self.y0 - self.y1)
if changed:
self.main_config.save()
if event == self.gui_roi_selection:
# Event for mouse button down or mouse drag in ROI mode
if self.is_mouse_up:
self.is_mouse_up = False
self.x0, self.y0 = values[self.gui_roi_selection]
self.x1, self.y1 = values[self.gui_roi_selection]
if event == self.gui_tracking_button:
self.get_tracking_layout()
print("\033[94m[INFO] Moving to tracking mode\033[0m")
self.in_roi_mode = False
self.camera.set_output_queue(self.capture_queue)
window[self.gui_roi_layout].update(visible=False)
window[self.gui_tracking_layout].update(visible=True)
if event == self.gui_restart_calibration:
self.ransac.calibration_frame_counter = 300
PlaySound('Audio/start.wav', SND_FILENAME | SND_ASYNC)
if event == self.gui_roi_button:
self.get_roi_layout()
print("\033[94m[INFO] Move to roi mode\033[0m")
self.in_roi_mode = True
self.camera.set_output_queue(self.roi_queue)
window[self.gui_roi_layout].update(visible=True)
window[self.gui_tracking_layout].update(visible=False)
if event == self.gui_recenter_eyes:
self.settings.gui_recenter_eyes = True
if event == "{}+UP".format(self.gui_roi_selection):
# Event for mouse button up in ROI mode
self.is_mouse_up = True
print("UP")
self.xy0 = np.clip(self.xy0, self.clip_pos, self.clip_pos + self.clip_size)
self.xy1 = np.clip(self.xy1, self.clip_pos, self.clip_pos + self.clip_size)
self._cartesian_to_polar()
if all(abs(self.xy0 - self.xy1) != 0):
xy0, xy1 = self._polar_to_cartesian_at_angle(0)
needs_roi_set = self.config.roi_window_h <= 0 or self.config.roi_window_w <= 0
self.config.roi_window_x, self.config.roi_window_y = (np.minimum(xy0, xy1) - self.img_pos).tolist()
self.config.roi_window_w, self.config.roi_window_h = (np.abs(xy0 - xy1)).tolist()
self.main_config.save()
if self.config.capture_source is None or self.config.capture_source == "":
window[self.gui_mode_readout].update("Waiting for camera address")
window[self.gui_roi_message].update(visible=False)
window[self.gui_output_graph].update(visible=False)
elif self.camera.camera_status == CameraState.CONNECTING:
window[self.gui_mode_readout].update("Camera Connecting")
elif self.camera.camera_status == CameraState.DISCONNECTED:
window[self.gui_mode_readout].update("CAMERA DISCONNECTED")
elif needs_roi_set:
window[self.gui_mode_readout].update("Awaiting Eye Cropping Setting")
elif self.ransac.calibration_frame_counter != None:
window[self.gui_mode_readout].update("Calibration")
else:
window[self.gui_mode_readout].update("Tracking")
if event == self.gui_roi_selection:
# Event for mouse button down or mouse drag in ROI mode
self.hover_pos = None
if self.in_roi_mode:
try:
if self.roi_queue.empty():
self.capture_event.set()
maybe_image = self.roi_queue.get(block=False)
imgbytes = cv2.imencode(".ppm", maybe_image[0])[1].tobytes()
graph = window[self.gui_roi_selection]
if self.figure:
graph.delete_figure(self.figure)
# INCREDIBLY IMPORTANT ERASE. Drawing images does NOT overwrite the buffer, the fucking
# graph keeps every image fed in until you call this. Therefore we have to make sure we
# erase before we redraw, otherwise we'll leak memory *very* quickly.
graph.erase()
graph.draw_image(data=imgbytes, location=(0, 0))
if None not in (self.x0, self.y0, self.x1, self.y1):
self.figure = graph.draw_rectangle(
(self.x0, self.y0), (self.x1, self.y1), line_color="#6f4ca1"
)
except Empty:
pass
else:
if needs_roi_set:
window[self.gui_roi_message].update(visible=True)
if self.is_mouse_up:
self.is_mouse_up = False
self.xy0 = np.array(values[self.gui_roi_selection])
self.xy1 = np.array(values[self.gui_roi_selection])
self._cartesian_to_polar()
if event == "{}+MOVE".format(self.gui_roi_selection):
if self.is_mouse_up:
self.hover_pos = np.array(values[self.gui_roi_selection])
if self.padded_size is not None:
if any(self.hover_pos > self.padded_size):
self.hover_pos = None
if event == self.gui_restart_calibration:
self.recalibrate_eyes()
if event == self.gui_stop_calibration:
self.ransac.calibration_frame_counter = 0
if event == self.gui_recenter_eyes:
self.recenter_eyes()
needs_roi_set = self.config.roi_window_h <= 0 or self.config.roi_window_w <= 0
# TODO: Refactor if statements below...
window[self.gui_tracking_fps].update("")
window[self.gui_tracking_bps].update("")
if self.config.capture_source is None or self.config.capture_source == "":
window[self.gui_mode_readout].update("Waiting for camera address")
window[self.gui_roi_message].update(visible=False)
window[self.gui_output_graph].update(visible=False)
return
elif self.camera.camera_status == CameraState.CONNECTING:
window[self.gui_mode_readout].update("Camera Connecting")
elif self.camera.camera_status == CameraState.DISCONNECTED:
window[self.gui_mode_readout].update("Camera Reconnecting...")
elif needs_roi_set:
window[self.gui_mode_readout].update("Awaiting Eye Crop")
elif self.ransac.calibration_frame_counter != None:
window[self.gui_mode_readout].update("Calibration")
else:
window[self.gui_mode_readout].update("Tracking")
window[self.gui_tracking_fps].update(self._movavg_fps(self.camera.fps))
window[self.gui_tracking_bps].update(self._movavg_bps(self.camera.bps))
# if event == self.gui_mask_lighten:
# while True:
# try:
# maybe_image = self.roi_queue.get(block=False)
# imgbytes = cv2.imencode(".ppm", maybe_image[0])[1].tobytes()
# image = cv2.imdecode(
# np.frombuffer(imgbytes, np.uint8), cv2.IMREAD_COLOR
# )
# cv2.imshow("Image", image)
# cv2.waitKey(1)
# cv2.destroyAllWindows()
# print("lighen")
# except Empty:
# pass
# if event == self.gui_mask_markup:
# print("markup")
if self.in_roi_mode:
try:
if self.roi_queue.empty():
self.capture_event.set()
maybe_image = self.roi_queue.get(block=False)
if maybe_image:
image = maybe_image[0]
img_h, img_w, _ = image.shape
hyp = math.ceil((img_w**2 + img_h**2) ** 0.5)
rotation_matrix = cv2.getRotationMatrix2D(
((img_w / 2), (img_h / 2)), self.config.rotation_angle, 1
)
# calculate position of all four corners of image
# calculate crop corner locations in original image space
x_coords, y_coords = np.matmul(
rotation_matrix,
np.transpose([[0, 0, 1], [img_w, 0, 1], [0, img_h, 1], [img_w, img_h, 1]]),
)
self.clip_size = np.array(
[math.ceil(max(x_coords) - min(x_coords)), math.ceil(max(y_coords) - min(y_coords))]
)
if self.config.gui_rotation_ui_padding:
self.padded_size = np.array([hyp, hyp])
else:
self.padded_size = self.clip_size
self.img_pos = ((self.padded_size - (img_w, img_h)) / 2).astype(np.int32)
self.clip_pos = ((self.padded_size - self.clip_size) / 2).astype(np.int32)
self.roi_image_center = self.padded_size / 2
# deferred to after roi_image_center is updated
if self.cartesian_needs_update:
self._polar_to_cartesian()
self.cartesian_needs_update = False
pad_matrix = np.float32([[1, 0, self.img_pos[X]], [0, 1, self.img_pos[Y]], [0, 0, 1]])
rotation_matrix_padded = cv2.getRotationMatrix2D(
self.roi_image_center, self.config.rotation_angle, 1
)
matrix = np.matmul(rotation_matrix_padded, pad_matrix)
image = cv2.warpAffine(
image,
matrix,
self.padded_size,
borderMode=cv2.BORDER_CONSTANT,
borderValue=(128, 128, 128),
)
maybe_image = (image, *maybe_image[1:])
imgbytes = cv2.imencode(".ppm", maybe_image[0])[1].tobytes()
graph = window[self.gui_roi_selection]
# INCREDIBLY IMPORTANT ERASE. Drawing images does NOT overwrite the buffer, the fucking
# graph keeps every image fed in until you call this. Therefore we have to make sure we
# erase before we redraw, otherwise we'll leak memory *very* quickly.
graph.erase()
graph.draw_image(data=imgbytes, location=(0, 0))
def make_dashed(spawn_item, dark="#000000", light="#ffffff", duty=1):
pixel_duty = math.floor(4 * duty)
for (color, dashoffset) in [(dark, 0), (light, 4)]:
item = spawn_item(color)
graph._TKCanvas2.itemconfig(item, dash=(pixel_duty, 8 - pixel_duty), dashoffset=dashoffset)
if self.xy0 is None or self.xy1 is None:
# roi_window rotates around roi center, we rotate around image center
# TODO: it would be nice if they were more consistent
roi_window_pos = (self.config.roi_window_x, self.config.roi_window_y)
roi_window_size = (self.config.roi_window_w, self.config.roi_window_h)
self.xy0 = roi_window_pos + self.img_pos
self.xy1 = self.xy0 + roi_window_size
self._cartesian_to_polar()
self.ca -= math.radians(self.config.rotation_angle)
self._polar_to_cartesian()
style = {}
if self.is_mouse_up:
style = {"dark": "#7f78ff", "light": "#d002ff", "duty": 0.5}
make_dashed(
lambda color: graph.draw_rectangle(
self.xy0,
self.xy1,
line_color=color,
),
**style,
)
if self.is_mouse_up and self.hover_pos is not None:
make_dashed(
lambda color: graph.draw_line(
(self.hover_pos[X], 0), (self.hover_pos[X], self.padded_size[Y]), color=color
)
)
make_dashed(
lambda color: graph.draw_line(
(0, self.hover_pos[Y]), (self.padded_size[X], self.hover_pos[Y]), color=color
)
)
except Empty:
pass
else:
if needs_roi_set:
window[self.gui_roi_message].update(visible=True)
window[self.gui_output_graph].update(visible=False)
return
try:
window[self.gui_roi_message].update(visible=False)
window[self.gui_output_graph].update(visible=True)
(maybe_image, eye_info) = self.image_queue.get(block=False)
imgbytes = cv2.imencode(".ppm", maybe_image)[1].tobytes()
window[self.gui_tracking_image].update(data=imgbytes)
# Update the GUI
graph = window[self.gui_output_graph]
graph.erase()
if eye_info.info_type != EyeInfoOrigin.FAILURE: # and not eye_info.blink:
graph.update(background_color="white")
if not np.isnan(eye_info.x) and not np.isnan(eye_info.y):
graph.draw_circle(
(eye_info.x * -100, eye_info.y * -100),
eye_info.pupil_dilation * 25,
fill_color="black",
line_color="white",
)
else:
graph.draw_circle(
(0.0 * -100, 0.0 * -100),
20,
fill_color="black",
line_color="white",
)
if not np.isnan(eye_info.blink):
graph.draw_line(
(-100, 100), # Start at the bottom (-100)
(-100, (eye_info.blink * 200) - 100), # Scale and adjust to the -100 to 100 range
color="#6f4ca1",
width=10,
)
else:
graph.draw_line((-100, 0.5 * 200), (-100, 100), color="#6f4ca1", width=10)
if eye_info.blink <= 0.0:
graph.update(background_color="#6f4ca1")
elif eye_info.info_type == EyeInfoOrigin.FAILURE:
graph.update(background_color="red")
except Empty:
pass
else:
def back(*args):
pass
try:
window[self.gui_roi_message].update(visible=False)
window[self.gui_output_graph].update(visible=True)
window[self.gui_output_graph].update(visible=False)
(maybe_image, eye_info) = self.image_queue.get(block=False)
imgbytes = cv2.imencode(".ppm", maybe_image)[1].tobytes()
window[self.gui_tracking_image].update(data=imgbytes)
# Update the GUI
graph = window[self.gui_output_graph]
graph.erase()
if eye_info.info_type != InformationOrigin.FAILURE and not eye_info.blink:
graph.update(background_color="white")
try:
graph.draw_circle(
(eye_info.x * -100, eye_info.y * -100),
25,
fill_color="black",
line_color="white",
)
except:
pass
elif eye_info.blink:
graph.update(background_color="#6f4ca1")
elif eye_info.info_type == InformationOrigin.FAILURE:
graph.update(background_color="red")
# Relay information to OSC
if eye_info.info_type != InformationOrigin.FAILURE:
self.osc_queue.put((self.eye_id, eye_info))
except Empty:
pass

View File

@ -1,28 +1,145 @@
from typing import Union, Dict
from osc import EyeId
import os.path
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
import json
import os.path
import shutil
from colorama import Fore
from pydantic import BaseModel
from typing import Any, Union, List
import os
from eye import EyeId
CONFIG_FILE_NAME: str = "eyetrack_settings.json"
BACKUP_CONFIG_FILE_NAME: str = "eyetrack_settings.backup"
class EyeTrackCameraConfig(BaseModel):
threshold: int = 50
gui_rotation_ui_padding: bool = True
rotation_angle: int = 0
roi_window_x: int = 0
roi_window_y: int = 0
roi_window_w: int = 0
roi_window_h: int = 0
roi_window_w: int = 240
roi_window_h: int = 240
focal_length: int = 30
capture_source: Union[int, str, None] = None
gui_circular_crop: bool = False
calib_XMAX: Union[float, None] = None
calib_XMIN: Union[float, None] = None
calib_YMAX: Union[float, None] = None
calib_YMIN: Union[float, None] = None
calib_XOFF: Union[float, None] = None
calib_YOFF: Union[float, None] = None
calibration_points: List[List[Union[float, None]]] = []
calibration_points_3d: List[List[Union[float, None]]] = []
leap_calibration_percentile_90: float = 0
leap_calibration_percentile_2: float = 0
leap_calibrated: bool = False
def update_capture_source(self, new_camera_address: str):
if not new_camera_address:
self.capture_source = None
return
if new_camera_address.isnumeric():
self.capture_source = int(new_camera_address)
return
# we were passed an IP, probably, lets add HTTP:// to it
if len(new_camera_address) > 5 and not (
not new_camera_address.startswith(("http", "/dev")) or not new_camera_address.endswith(".mp4")
):
self.capture_source = f"http://{new_camera_address}"
return
self.capture_source = new_camera_address
def update(self, data: dict[str, Any]) -> bool:
"""
Updates the model one field at a time based on the provided data dict.
The dict has to be defined like
```
data = {
"model_field": value
}
```
If stale data is provided,
ex. User clicked on save and restart but didn't provide a new field
we skip it, assuming that it was just a call to restart the tracking, or a miss-click.
Some fields may require more validation, we take care of that with special methods.
defining a method like
```
def update_custom_field(value: type):
pass
```
will cause it to be picked up by this method and called with the current value.
Return values are ignored.
"""
for key, value in data.items():
old_value = getattr(self, key, None)
# no reason to update if it's the same value
if old_value == value:
return False
if hasattr(self, f"update_{key}"):
update_attr = getattr(self, f"update_{key}")
if callable(update_attr):
update_attr(value)
else:
setattr(self, "key", value)
return True
else:
print(f"\033[93m[WARN] Field {key} does not exist on {self}.\033[0m")
return False
class EyeTrackSettingsConfig(BaseModel):
gui_flip_x_axis_left: bool = False
gui_flip_x_axis_right: bool = False
gui_flip_y_axis: bool = False
gui_blob_fallback: bool = True
gui_RANSAC3D: bool = False
gui_HSF: bool = False
gui_BLOB: bool = False
gui_BLINK: bool = False
gui_HSRAC: bool = False
gui_AHSFRAC: bool = False
gui_AHSF: bool = False
gui_DADDY: bool = False
gui_LEAP: bool = True
gui_HSF_radius: int = 15
gui_HSF_radius_left: int = 10
gui_HSF_radius_right: int = 10
gui_min_cutoff: str = "0.0004"
gui_speed_coefficient: str = "0.9"
gui_osc_address: str = "127.0.0.1"
@ -33,9 +150,72 @@ class EyeTrackSettingsConfig(BaseModel):
gui_blob_maxsize: float = 25
gui_blob_minsize: float = 10
gui_recenter_eyes: bool = False
gui_eye_falloff: bool = False
gui_3d_calibration: bool = False
grab_3d_point: bool = False
tracker_single_eye: int = 0
gui_blink_sync: bool = False
gui_threshold: int = 65
gui_AHSFRACP: int = 1
gui_AHSFP: int = 2
gui_HSRACP: int = 3
gui_HSFP: int = 4
gui_DADDYP: int = 5
gui_RANSAC3DP: int = 6
gui_BLOBP: int = 7
gui_LEAPP: int = 8
gui_IBO: bool = False
gui_skip_autoradius: bool = False
gui_thresh_add: int = 11
gui_update_check: bool = True
gui_ROSC: bool = False
gui_circular_crop_right: bool = False
gui_circular_crop_left: bool = False
ibo_filter_samples: int = 400
ibo_average_output_samples: int = 0
ibo_fully_close_eye_threshold: float = 0.3
leap_calibration_samples: int = 2000
calibration_samples: int = 600
osc_right_eye_close_address: str = "/avatar/parameters/RightEyeLidExpandedSqueeze"
osc_left_eye_close_address: str = "/avatar/parameters/LeftEyeLidExpandedSqueeze"
osc_left_eye_x_address: str = "/avatar/parameters/LeftEyeX"
osc_right_eye_x_address: str = "/avatar/parameters/RightEyeX"
osc_eyes_y_address: str = "/avatar/parameters/EyesY"
osc_eyes_pupil_dilation_address: str = "/avatar/parameters/EyesDilation"
osc_invert_eye_close: bool = False
gui_RANSACBLINK: bool = False
gui_disable_gui: bool = False
gui_right_eye_dominant: bool = False
gui_left_eye_dominant: bool = False
gui_outer_side_falloff: bool = False
gui_eye_dominant_diff_thresh: float = 0.3
gui_legacy_ransac: bool = False
gui_legacy_ransac_thresh_right: int = 80
gui_legacy_ransac_thresh_left: int = 80
gui_LEAP_lid: bool = True
gui_osc_vrcft_v1: bool = False
gui_osc_vrcft_v2: bool = False
gui_vrc_native: bool = True
gui_pupil_dilation: bool = False
gui_VRCFTModulePort: int = 8889
gui_VRCFTModuleIPAddress: str = "127.0.0.1"
gui_ShouldEmulateEyeWiden: bool = False
gui_ShouldEmulateEyeSquint: bool = False
gui_ShouldEmulateEyebrows: bool = False
gui_WidenThresholdV1_min: float = 0.60
gui_WidenThresholdV1_max: float = 1
gui_WidenThresholdV2_min: float = 0.60
gui_WidenThresholdV2_max: float = 1.05
gui_SqueezeThresholdV1_min: float = 0.07
gui_SqueezeThresholdV1_max: float = 0.5
gui_SqueezeThresholdV2_min: float = 0.07
gui_SqueezeThresholdV2_max: float = -1
gui_EyebrowThresholdRising: float = 0.8
gui_EyebrowThresholdLowering: float = 0.15
gui_OutputMultiplier: float = 1
gui_use_module: bool = False
class EyeTrackConfig(BaseModel):
@ -44,15 +224,112 @@ class EyeTrackConfig(BaseModel):
left_eye: EyeTrackCameraConfig = EyeTrackCameraConfig()
settings: EyeTrackSettingsConfig = EyeTrackSettingsConfig()
eye_display_id: EyeId = EyeId.RIGHT
__listeners = []
@staticmethod
def load():
if not os.path.exists(CONFIG_FILE_NAME):
print("No settings file, using base settings")
return EyeTrackConfig()
with open(CONFIG_FILE_NAME, "r") as settings_file:
return EyeTrackConfig(**json.load(settings_file))
try:
with open(CONFIG_FILE_NAME, "r") as settings_file:
return EyeTrackConfig(**json.load(settings_file))
except json.JSONDecodeError:
print("[INFO] Failed to load settings file")
load_config = None
if os.path.exists(BACKUP_CONFIG_FILE_NAME):
try:
with open(BACKUP_CONFIG_FILE_NAME, "r") as settings_file:
load_config = EyeTrackConfig(**json.load(settings_file))
print("[INFO] Using backup settings")
except json.JSONDecodeError:
pass
if load_config is None:
print("[INFO] using base settings")
load_config = EyeTrackConfig()
return load_config
def validate_camera_address_conflict(self, eye_id, capture_source):
match eye_id:
case EyeId.RIGHT:
if self.left_eye.capture_source == capture_source:
print(
f"{Fore.YELLOW}[WARN] Capture source {capture_source} already in use by the left camera.{Fore.RESET}"
)
return False
case EyeId.LEFT:
if self.right_eye.capture_source == capture_source:
print(
f"{Fore.YELLOW}[WARN] Capture source {capture_source} already in use by the right camera.{Fore.RESET}"
)
return False
case _:
return False
return True
def update_eye_model_config(self, eye_id: EyeId, data: dict, should_save=True, should_notify=True) -> bool:
"""
A more granular method for updating a particular model so that everything that relies on it
will get notified about any changes. Note, it acts a bit like pub-sub,
we don't care what changes got passed, we will notify the listeners with them.
It's the listeners job to check if they want that update.
"""
# The app really doesn't like address clashes, so we have to validate it as soon as possible
# otherwise we crash
if "capture_source" in data and not self.validate_camera_address_conflict(eye_id, data["capture_source"]):
return False
match eye_id:
case EyeId.RIGHT:
changed = self.right_eye.update(data)
case EyeId.LEFT:
changed = self.left_eye.update(data)
case _:
return False
if should_save:
self.save()
if should_notify:
self.__notify_listeners(data)
return changed
def update(self, data, save=False):
"""
More of an internal method for modules to be able to update the config
and have other parts of the system react to changes
"""
for field, value in data.items():
setattr(self.settings, field, value)
self.__notify_listeners(data)
if save:
self.save()
def save(self):
with open(CONFIG_FILE_NAME, "w+") as settings_file:
json.dump(obj=self.dict(), fp=settings_file)
# make sure this is only called if there is a change
if os.path.exists(CONFIG_FILE_NAME):
try:
# Verify existing configuration files.
with open(CONFIG_FILE_NAME, "r") as settings_file:
EyeTrackConfig(**json.load(settings_file))
shutil.copy(CONFIG_FILE_NAME, BACKUP_CONFIG_FILE_NAME)
# print("Backed up settings files.") # Comment out because it's too loud.
except shutil.SameFileError:
pass
except json.JSONDecodeError:
# No backup because the saved settings file is broken.
pass
with open(CONFIG_FILE_NAME, "w") as settings_file:
json.dump(obj=self.model_dump(warnings=False), fp=settings_file)
print(f"\033[92m[INFO] Config Saved Successfully\033[0m")
def register_listener_callback(self, callback):
print(f"[DEBUG] Registering listener {callback}")
self.__listeners.append(callback)
def __notify_listeners(self, data: dict):
for listener in self.__listeners:
listener(data)

356
EyeTrackApp/daddy.py Normal file
View File

@ -0,0 +1,356 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
DADDY By: PallasNeko Optimization
Algorithm App Implementations By: PallasNeko, Prohurtz
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
import sys
from typing import Tuple
import math
import platform
import numpy as np
import cv2
import onnxruntime
from one_euro_filter import OneEuroFilter
from utils.misc_utils import FastMedian, resource_path
import os
os.environ["OMP_NUM_THREADS"] = "1"
# DADDY
# Please change the name of this script and the name of the method if you have something better.
video_path = "ezgif.com-gif-maker.avi"
input_size = 192 # Do not change this number.
heatmap_size = 48 # Do not change this number.
kernel_size = 7
if platform.system() == "Darwin":
model_file = "Models/daddy230210.onnx" # The model file name will be changed when performance stabilises. # funny MacOS files issues :P
else:
model_file = "Models/daddy230210.onnx" # The model file name will be changed when performance stabilises.
# SHA256 for model version verification
# daddy230210.onnx = 59e59aa2a21024884200dd3acbd5e6a2e8d7209c46555fbdc727d4fe3adb68d3
imshow_enable = False
save_video = False
save_filepath = "output.mp4"
def get_max_preds(batch_heatmaps):
# base:https://github.com/ilovepose/DarkPose
batch_size = batch_heatmaps.shape[0]
num_joints = batch_heatmaps.shape[1]
width = batch_heatmaps.shape[3]
heatmaps_reshaped = batch_heatmaps.reshape((batch_size, num_joints, -1))
idx = np.argmax(heatmaps_reshaped, 2)
maxvals = np.amax(heatmaps_reshaped, 2)
maxvals = maxvals.reshape((batch_size, num_joints, 1))
idx = idx.reshape((batch_size, num_joints, 1))
preds = np.tile(idx, (1, 1, 2)).astype(np.float32)
preds[:, :, 0] = (preds[:, :, 0]) % width
preds[:, :, 1] = np.floor((preds[:, :, 1]) / width)
pred_mask = np.tile(np.greater(maxvals, 0.0), (1, 1, 2))
pred_mask = pred_mask.astype(np.float32)
preds *= pred_mask
return preds, maxvals
def taylor(hm, coord):
# base:https://github.com/ilovepose/DarkPose
heatmap_height = hm.shape[0]
heatmap_width = hm.shape[1]
px = int(coord[0])
py = int(coord[1])
if 1 < px < heatmap_width - 2 and 1 < py < heatmap_height - 2:
dx = 0.5 * (hm[py][px + 1] - hm[py][px - 1])
dy = 0.5 * (hm[py + 1][px] - hm[py - 1][px])
dxx = 0.25 * (hm[py][px + 2] - 2 * hm[py][px] + hm[py][px - 2])
dxy = 0.25 * (hm[py + 1][px + 1] - hm[py - 1][px + 1] - hm[py + 1][px - 1] + hm[py - 1][px - 1])
dyy = 0.25 * (hm[py + 2 * 1][px] - 2 * hm[py][px] + hm[py - 2 * 1][px])
derivative = np.matrix([[dx], [dy]])
hessian = np.matrix([[dxx, dxy], [dxy, dyy]])
if dxx * dyy - dxy**2 != 0:
hessianinv = hessian.I
offset = -hessianinv * derivative
offset = np.squeeze(np.array(offset.T), axis=0)
coord += offset
return coord
def gaussian_blur(hm, kernel):
# base:https://github.com/ilovepose/DarkPose
border = (kernel - 1) // 2
batch_size = hm.shape[0]
num_joints = hm.shape[1]
height = hm.shape[2]
width = hm.shape[3]
for i in range(batch_size):
for j in range(num_joints):
origin_max = np.max(hm[i, j])
dr = np.zeros((height + 2 * border, width + 2 * border))
dr[border:-border, border:-border] = hm[i, j].copy()
dr = cv2.GaussianBlur(dr, (kernel, kernel), 0)
hm[i, j] = dr[border:-border, border:-border].copy()
hm[i, j] *= origin_max / np.max(hm[i, j])
return hm
def get_final_preds(hm, realsize):
# base:https://github.com/ilovepose/DarkPose
coords, maxvals = get_max_preds(hm)
# post-processing
hm = gaussian_blur(hm, kernel_size)
hm = np.maximum(hm, 1e-10)
hm = np.log(hm)
for n in range(coords.shape[0]):
for p in range(coords.shape[1]):
coords[n, p] = taylor(hm[n][p], coords[n][p])
preds = coords.copy()
preds = (preds / heatmap_size) * realsize # input_size
# Transform back
# for i in range(coords.shape[0]):
# preds[i] = transform_preds(
# coords[i], center[i], scale[i], [heatmap_width, heatmap_height]
# )
return preds, maxvals
def resize_with_pad(
image: np.array, new_shape: Tuple[int, int], padding_color: Tuple[int] = (255, 255, 255)
) -> np.array:
"""
https://gist.github.com/IdeaKing/11cf5e146d23c5bb219ba3508cca89ec
Maintains aspect ratio and resizes with padding.
Params:
image: Image to be resized.
new_shape: Expected (width, height) of new image.
padding_color: Tuple in BGR of padding color
Returns:
image: Resized image with padding
"""
original_shape = (image.shape[1], image.shape[0])
ratio = float(max(new_shape)) / max(original_shape)
new_size = tuple([int(x * ratio) for x in original_shape])
image = cv2.resize(image, new_size)
delta_w = new_shape[0] - new_size[0]
delta_h = new_shape[1] - new_size[1]
top, bottom = delta_h // 2, delta_h - (delta_h // 2)
left, right = delta_w // 2, delta_w - (delta_w // 2)
image = cv2.copyMakeBorder(image, top, bottom, left, right, cv2.BORDER_CONSTANT, value=padding_color)
return image
# Better Eye aspEct Ratio
class BEER(object):
def __init__(self):
self.ear_th = 0.2
self.ear_min = 0.05
self.ear_max = 0.2
self.p03_med = FastMedian(k=256)
self.prev_ear = 0.5
# todo https://peerj.com/articles/cs-943/
def ear(self, pred):
p15 = np.linalg.norm(pred[1] - pred[5])
p24 = np.linalg.norm(pred[2] - pred[4])
p03 = np.linalg.norm(pred[0] - pred[3])
self.p03_med + p03
if p03 > self.p03_med.median() * 1.5:
return self.prev_ear
ear = (p15 + p24) / (2 * self.p03_med.median())
self.ear_minmax(ear)
norm_ear = self.ear_norm(ear)
self.prev_ear = norm_ear.copy()
return norm_ear
def ear_minmax(self, ear):
if ear < self.ear_min:
self.ear_min = ear.copy()
if ear > self.ear_max:
self.ear_max = ear.copy()
def ear_norm(self, ear):
return (ear - self.ear_min) / (
self.ear_max - self.ear_min
) # todo:It is better to add very small values to avoid zero division.
#
# loopnum = 0
#
# Deep leArning lanDmark Detection for eYes
class DADDY_cls(object):
def __init__(self):
onnxruntime.disable_telemetry_events()
options = onnxruntime.SessionOptions()
options.inter_op_num_threads = 1 # This number should be changed accordingly
options.intra_op_num_threads = 1 # This number should be changed accordingly
options.execution_mode = onnxruntime.ExecutionMode.ORT_SEQUENTIAL
options.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
ort_session = onnxruntime.InferenceSession(
resource_path(model_file), sess_options=options, providers=["CPUExecutionProvider"]
)
ort_session.set_providers(["CPUExecutionProvider"]) # only cpu mode
self.ort_session = ort_session
self.input_name = ort_session.get_inputs()[0].name
self.output_name = ort_session.get_outputs()[0].name
min_cutoff = 0.0004
beta = 0.9
input_point = np.zeros((11, 2)) # np.array([1, 1])
self.one_euro_filter = OneEuroFilter(input_point, min_cutoff=min_cutoff, beta=beta)
# self.ear_oef = OneEuroFilter(
# np.zeros(1),
# min_cutoff=min_cutoff,
# beta=beta
# ) # memo: Parameters need tuning
self.beer = BEER()
# filepath = 'test.mp4'
# codec = cv2.VideoWriter_fourcc(*"mp4v")
# video = cv2.VideoWriter(filepath, codec, 60.0, (200, 150), 0) # (60, 60)) # (150, 200))
# self.video = video
def open_video(self, video_path):
# Temporary implementation to run
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise IOError("Error opening video stream or file")
self.cap = cap
return True
def read_frame(self):
# Temporary implementation to run
if not self.cap.isOpened():
return False
ret, frame = self.cap.read()
if ret:
# I have set it to grayscale (1ch) just in case, but if the frame is 1ch, this line can be commented out.
# self.current_image=frame # debug code
self.current_image_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
return True
return False
def single_run(self):
# Temporary implementation to run
# todo: If it's the left hand eye, flip the image left to right.
gray_frame = self.current_image_gray.copy()
# frame_resize=resize_with_pad(gray_frame,(input_size,input_size))
# or
frame_resize = cv2.resize(gray_frame, (input_size, input_size))
imgs = np.divide(frame_resize[np.newaxis, np.newaxis], 255, dtype=np.float32) # input/255.0
pred_heatmap = self.ort_session.run(None, {self.input_name: imgs})[0] # .reshape((-1, 2))
# if imshow_enable:
# heatmap = pred_heatmap.reshape((-1, heatmap_size, heatmap_size))
# for i in range(heatmap.shape[0]):
# cv2.imshow("heatmap_{}".format(i + 1), heatmap[i])
pred, max_val = get_final_preds(
pred_heatmap, (self.current_image_gray.shape[1], self.current_image_gray.shape[0])
)
pred = pred.reshape((-1, 2))
# or
# pred, max_val = get_final_preds(pred_heatmap, input_size)
# pred = pred.reshape((-1, 2))
# height, width = self.current_image_gray.shape[:2]
# scale_x = input_size/ width
# scale_y = input_size / height
# pred[:, 0] *= scale_x
# pred[:, 1] *= scale_y
pred = self.one_euro_filter(pred)
kps = pred.astype(np.int32)
# eyecenter = kps[:6].mean(axis=0).astype(int)
ear = self.beer.ear(pred)
# ear=self.ear_oef(ear[np.newaxis])#memo: Parameters need tuning
pupil_center = pred[7:].mean(axis=0)
pupil_center_x = int(pupil_center[0])
pupil_center_y = int(pupil_center[1])
for i in range(kps.shape[0]):
if i < 6:
color = (0, 0, 255)
elif i == 6:
color = 128
else:
color = (255, 0, 0)
# todo: We should have a proper variable for drawing.
cv2.circle(self.current_image_gray, (kps[i, 0], kps[i, 1]), 1, color, 2)
# cv2.putText(self.current_image_gray, str(i), (kps[i, 0] - 10, kps[i, 1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 0, 255), 1)
# cv2.putText(self.current_image_gray, "EAR: "+str(ear), (self.current_image_gray.shape[1]//10, self.current_image_gray.shape[0]//10), cv2.FONT_HERSHEY_SIMPLEX, 0.75, (255,0,0), 1)
# global loopnum
# if loopnum < 1350*2:
# # self.video.write(cv2.resize(gray_frame.copy(), (200, 150), None))
# loopnum += 1
# else:
# # self.video.release()
# cv2.destroyAllWindows()
# sys.exit()
# if w_video:
# video.release()
# kps[i, :] = (x, y)
# i == [0:6] = Inner and outer corners of eyes and eyelids
# i == [6] = pupil
# i == [7:] = iris
return pupil_center_x, pupil_center_y, ear
class External_Run_DADDY(object):
def __init__(self):
self.algo = DADDY_cls()
def run(self, current_image_gray):
self.algo.current_image_gray = current_image_gray
pupil_x, pupil_y, ear = self.algo.single_run()
return pupil_x, pupil_y, ear
if __name__ == "__main__":
daddy = DADDY_cls()
daddy.open_video(video_path)
while daddy.read_frame():
_ = daddy.single_run()

View File

@ -0,0 +1,327 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Ellipse Based Pupil Dilation By: Prohurtz, PallasNeko (Optimization)
Algorithm App Implementations By: Prohurtz
Copyright (c) 2023 EyeTrackVR <3
LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
import numpy
import numpy as np
import time
import os
import cv2
from eye import EyeId
from one_euro_filter import OneEuroFilter
os.environ["OMP_NUM_THREADS"] = "1"
# Note.
# OpenCV on Windows will generate an error if the file path contains non-ASCII characters when using cv2.imread(), cv2.imwrite(), etc.
# https://stackoverflow.com/questions/43185605/how-do-i-read-an-image-from-a-path-with-unicode-characters
# https://github.com/opencv/opencv/issues/18305
def csv2data(frameshape, filepath):
# For data checking
frameshape = (frameshape[0], frameshape[1] + 1)
out = np.zeros(frameshape, dtype=np.uint32)
xy_list = []
val_list = []
with open(filepath, mode="r", encoding="utf-8") as in_f:
# Skip header.
_ = in_f.readline()
for s in in_f:
xyval = [int(val) for val in s.strip().split(",")]
xy_list.append((xyval[0], xyval[1]))
val_list.append(xyval[2])
xy_list = np.array(xy_list)
val_list = np.array(val_list)
out[xy_list[:, 1], xy_list[:, 0]] = val_list[:]
return out
def data2csv(data_u32, filepath):
# For data checking
nonzero_index = np.nonzero(data_u32) # (row,col)
data_list = data_u32[nonzero_index].tolist()
datalines = ["{},{},{}\n".format(x, y, val) for y, x, val in zip(*nonzero_index, data_list)]
with open(filepath, "w", encoding="utf-8") as out_f:
out_f.write("x,y,eyedilation\n")
out_f.writelines(datalines)
return
def u32_1ch_to_u16_3ch(img):
out = np.zeros((*img.shape[:2], 3), dtype=np.uint16)
# https://github.com/numpy/numpy/issues/2524
# https://stackoverflow.com/questions/52782511/why-is-numpy-slower-than-python-for-left-bit-shifts
out[:, :, 0] = img & np.uint32(65535)
out[:, :, 1] = (img >> np.uint32(16)) & np.uint32(65535)
return out
def u16_3ch_to_u32_1ch(img):
# The image format with the most bits that can be displayed on Windows without additional software and that opencv can handle is PNG's uint16
out = img[:, :, 0].astype(np.float64) # float64 = max 2^53
cv2.add(out, img[:, :, 1].astype(np.float64) * np.float64(65536), dst=out) # opencv did not have uint32 type
return out.astype(np.uint32) # cast
def newdata(frameshape):
print("\033[94m[INFO] Initialise data for dilation.\033[0m")
return np.zeros(frameshape, dtype=np.uint32)
# EBPD
class EllipseBasedPupilDilation:
def __init__(self, eye_id):
# todo: It is necessary to consider whether the filename can be changed in the configuration file, etc.
if eye_id in [EyeId.LEFT]:
self.imgfile = "EBPD_LEFT.png"
else:
pass
if eye_id in [EyeId.RIGHT]:
self.imgfile = "EBPD_RIGHT.png"
else:
pass
# self.data[0, -1] = maxval, [1, -1] = rotation, [2, -1] = x, [3, -1] = y
self.data = None
self.lct = None
self.maxval = 0
# self.img_roi = self.now_roi == {"rotation": 0, "x": 0, "y": 0}
self.img_roi = np.zeros(3, dtype=np.int32)
self.now_roi = np.zeros(3, dtype=np.int32)
self.prev_val = 0.5
self.avg_dilation = 0.0
self.old = []
self.color = []
self.x = []
self.fc = 0
self.filterlist = []
self.averageList = []
self.openlist = []
self.eye_id = eye_id
self.maxinten = 0
self.tri_filter = []
min_cutoff = 0.00001
beta = 0.05
noisy_point = np.array([1, 1])
self.one_euro_filter = OneEuroFilter(noisy_point, min_cutoff=min_cutoff, beta=beta)
def check(self, frameshape):
# 0 in data is used as the initial value.
# When assigning a value, +1 is added to the value to be assigned.
self.load(frameshape)
# self.maxval = self.data[0, -1]
if self.lct is None:
self.lct = time.time()
def load(self, frameshape):
req_newdata = False
# Not very clever, but increase the width by 1px to save the maximum value.
frameshape = (frameshape[0], frameshape[1] + 1)
if self.data is None:
print(f"\033[92m[INFO] Loaded data for pupil dilation: {self.imgfile}\033[0m")
if os.path.isfile(self.imgfile):
try:
img = cv2.imread(self.imgfile, flags=cv2.IMREAD_UNCHANGED)
# check code: cv2.absdiff(img,u32_1ch_to_u16_3ch(u16_3ch_to_u32_1ch(img)))
if img.shape[:2] != frameshape:
print("[WARN] Size does not match the input frame.")
req_newdata = True
else:
self.data = u16_3ch_to_u32_1ch(img)
self.img_roi[:] = self.data[1:4, -1]
if not np.array_equal(self.img_roi, self.now_roi):
# If the ROI recorded in the image file differs from the current ROI
req_newdata = True
else:
self.maxval = self.data[0, -1]
except:
print("[ERROR] File read error: {}".format(self.imgfile))
req_newdata = True
else:
print("\033[94m[INFO] File does not exist.\033[0m")
req_newdata = True
else:
if self.data.shape != frameshape or not np.array_equal(self.img_roi, self.now_roi):
# If the ROI recorded in the image file differs from the current ROI
# todo: Using the previous and current frame sizes and centre positions from the original, etc., the data can be ported to some extent, but there may be many areas where code changes are required.
print("[INFO] \033[94mFrame size changed.\033[0m")
req_newdata = True
if req_newdata:
self.data = newdata(frameshape)
self.maxval = 0
self.img_roi = self.now_roi.copy()
# data2csv(self.data, "a.csv")
# csv2data(frameshape,"a.csv")
def save(self):
self.data[0, -1] = self.maxval
self.data[1:4, -1] = self.now_roi
cv2.imwrite(self.imgfile, u32_1ch_to_u16_3ch(self.data))
# print("SAVED: {}".format(self.imgfile))
def change_roi(self, roiinfo: dict):
self.now_roi[:] = [v for v in roiinfo.values()]
def clear_filter(self):
self.data = None
self.filterlist.clear()
self.averageList.clear()
if os.path.exists(self.imgfile):
os.remove(self.imgfile)
def intense(self, w, h, x, y, frame, filterSamples, outputSamples):
# x,y = 0~(frame.shape[1 or 0]-1), frame = 1-channel frame cropped by ROI
self.check(frame.shape)
int_x, int_y = int(x), int(y)
if int_x < 0 or int_y < 0:
return self.prev_val
upper_x = min(
int_x + 25, frame.shape[1] - 1
) # TODO make this a setting NEEDS TO BE BASED ON HSF RADIUS if possible
lower_x = max(int_x - 25, 0)
upper_y = min(int_y + 25, frame.shape[0] - 1)
lower_y = max(int_y - 25, 0)
# The same can be done with cv2.integral, but since there is only one area of the rectangle for which we want to know the total value, there is no advantage in terms of computational complexity.
pupil_area = numpy.pi * (w / 2) * (h / 2)
if len(self.filterlist) < filterSamples:
self.filterlist.append(pupil_area)
else:
self.filterlist.pop(0)
self.filterlist.append(pupil_area)
try:
if pupil_area >= np.percentile(self.filterlist, 99): # filter abnormally high values
# print('filter, assume blink')
pupil_area = self.maxval
except:
pass
newval_flg = False
oob = False
if int_x >= frame.shape[1]:
int_x = frame.shape[1] - 1
oob = True
if int_x < 0:
int_x = True
oob = True
if int_y >= frame.shape[0]:
int_y = frame.shape[0] - 1
oob = True
if int_y < 0:
int_y = 1
oob = True
if oob != True and self.data.any():
data_val = self.data[int_y, int_x]
else:
data_val = 0
# max pupil per cord
if data_val == 0:
# The value of the specified coordinates has not yet been recorded.
self.data[int_y, int_x] = pupil_area
changed = True
newval_flg = True
else:
if pupil_area < data_val: # if current intensity value is less (more pupil), save that
self.data[int_y, int_x] = pupil_area # set value
changed = True
else:
pupil_areaa = max(
data_val + 5000, 1
) # if current intensity value is not less use this is an agressive adjust, test
self.data[int_y, int_x] = pupil_areaa # set value
changed = True
# min pupil global
if self.maxval == 0: # that value is not yet saved
self.maxval = pupil_area # set value at 0 index
else:
if pupil_area > self.maxval: # if current intensity value is more (less pupil), save that NOTE: we have the
self.maxval = pupil_area - 5 # set value at 0 index
else:
pupil_aread = max(
(self.maxval - 5), 1
) # continuously adjust closed intensity, will be set when user blink, used to allow eyes to close when lighting changes
self.maxval = pupil_aread # set value at 0 index
if newval_flg:
# Do the same thing as in the original version.
eyedilation = self.prev_val # 0.9
else:
maxp = float(self.data[int_y, int_x])
minp = float(self.maxval)
try:
if not np.isfinite(pupil_area) or not np.isfinite(maxp) or not np.isfinite(minp) or (minp - maxp) == 0:
eyedilation = 0.5
else:
eyedilation = (pupil_area - maxp) / (minp - maxp)
except:
eyedilation = 0.5
eyedilation = 1 - eyedilation
if outputSamples > 0:
if len(self.averageList) < outputSamples:
self.averageList.append(eyedilation)
else:
self.averageList.pop(0)
self.averageList.append(eyedilation)
eyedilation = np.average(self.averageList)
if eyedilation > 1: # clamp values
eyedilation = 1.0
if eyedilation < 0:
eyedilation = 0.0
if changed and ((time.time() - self.lct) > 15): # save every 5 seconds if something changed to save disk usage
self.save()
self.lct = time.time()
self.prev_val = eyedilation
try:
noisy_point = np.array([float(eyedilation), float(eyedilation)]) # fliter our values with a One Euro Filter
point_hat = self.one_euro_filter(noisy_point)
eyedilationx = point_hat[0]
eyedilationy = point_hat[1]
eyedilation = (eyedilationx + eyedilationy) / 2
except:
pass
return eyedilation

212
EyeTrackApp/enums.py Normal file
View File

@ -0,0 +1,212 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
from __future__ import annotations
import types
from collections import namedtuple
from typing import (
Any,
ClassVar,
Dict,
List,
TYPE_CHECKING,
Tuple,
Type,
TypeVar,
Iterator,
Mapping,
)
__all__ = (
"Enum",
"EyeLR",
)
if TYPE_CHECKING:
from typing_extensions import Self
def _create_value_cls(name: str, comparable: bool):
# All the type ignores here are due to the type checker being unable to recognise
# Runtime type creation without exploding.
cls = namedtuple("_EnumValue_" + name, "name value")
cls.__repr__ = lambda self: f"<{name}.{self.name}: {self.value!r}>" # type: ignore
cls.__str__ = lambda self: f"{name}.{self.name}" # type: ignore
if comparable:
cls.__le__ = lambda self, other: isinstance(other, self.__class__) and self.value <= other.value # type: ignore
cls.__ge__ = lambda self, other: isinstance(other, self.__class__) and self.value >= other.value # type: ignore
cls.__lt__ = lambda self, other: isinstance(other, self.__class__) and self.value < other.value # type: ignore
cls.__gt__ = lambda self, other: isinstance(other, self.__class__) and self.value > other.value # type: ignore
return cls
def _is_descriptor(obj):
return (
hasattr(obj, "__get__") or hasattr(obj, "__set__") or hasattr(obj, "__delete__")
)
class EnumMeta(type):
if TYPE_CHECKING:
__name__: ClassVar[str]
_enum_member_names_: ClassVar[List[str]]
_enum_member_map_: ClassVar[Dict[str, Any]]
_enum_value_map_: ClassVar[Dict[Any, Any]]
def __new__(
cls,
name: str,
bases: Tuple[type, ...],
attrs: Dict[str, Any],
*,
comparable: bool = False,
):
value_mapping = {}
member_mapping = {}
member_names = []
value_cls = _create_value_cls(name, comparable)
for key, value in list(attrs.items()):
is_descriptor = _is_descriptor(value)
if key[0] == "_" and not is_descriptor:
continue
# Special case classmethod to just pass through
if isinstance(value, classmethod):
continue
if is_descriptor:
setattr(value_cls, key, value)
del attrs[key]
continue
try:
new_value = value_mapping[value]
except KeyError:
new_value = value_cls(name=key, value=value)
value_mapping[value] = new_value
member_names.append(key)
member_mapping[key] = new_value
attrs[key] = new_value
attrs["_enum_value_map_"] = value_mapping
attrs["_enum_member_map_"] = member_mapping
attrs["_enum_member_names_"] = member_names
attrs["_enum_value_cls_"] = value_cls
actual_cls = super().__new__(cls, name, bases, attrs)
value_cls._actual_enum_cls_ = actual_cls # type: ignore # Runtime attribute isn't understood
return actual_cls
def __iter__(cls) -> Iterator[Any]:
return (cls._enum_member_map_[name] for name in cls._enum_member_names_)
def __reversed__(cls) -> Iterator[Any]:
return (
cls._enum_member_map_[name] for name in reversed(cls._enum_member_names_)
)
def __len__(cls) -> int:
return len(cls._enum_member_names_)
def __repr__(cls) -> str:
return f"<enum {cls.__name__}>"
@property
def __members__(cls) -> Mapping[str, Any]:
return types.MappingProxyType(cls._enum_member_map_)
def __call__(cls, value: str) -> Any:
try:
return cls._enum_value_map_[value]
except (KeyError, TypeError):
raise ValueError(f"{value!r} is not a valid {cls.__name__}")
def __getitem__(cls, key: str) -> Any:
return cls._enum_member_map_[key]
def __setattr__(cls, name: str, value: Any) -> None:
raise TypeError("Enums are immutable.")
def __delattr__(cls, attr: str) -> None:
raise TypeError("Enums are immutable")
def __instancecheck__(self, instance: Any) -> bool:
# isinstance(x, Y)
# -> __instancecheck__(Y, x)
try:
return instance._actual_enum_cls_ is self
except AttributeError:
return False
if TYPE_CHECKING:
from enum import Enum, IntEnum
else:
class Enum(metaclass=EnumMeta):
@classmethod
def try_value(cls, value):
try:
return cls._enum_value_map_[value]
except (KeyError, TypeError):
return value
E = TypeVar("E", bound="Enum")
def create_unknown_value(cls: Type[E], val: Any) -> E:
value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below
name = f"unknown_{val}"
return value_cls(name=name, value=val)
def try_enum(cls: Type[E], val: Any) -> E:
"""A function that tries to turn the value into enum ``cls``.
If it fails it returns a proxy invalid value instead.
"""
try:
return cls._enum_value_map_[val] # type: ignore # All errors are caught below
except (KeyError, TypeError, AttributeError):
return create_unknown_value(cls, val)
# The line above is based on the code in the following url
# https://github.com/Rapptz/discord.py/blob/f7e97954950ffb0e34238d70813454caa6f1a3ae/discord/enums.py
class EyeLR(Enum):
LEFT = 1
RIGHT = 2
def __str__(self) -> str:
return self.name
def __int__(self) -> int:
return self.value

58
EyeTrackApp/eye.py Normal file
View File

@ -0,0 +1,58 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
from dataclasses import dataclass
from enum import Enum, IntEnum
class EyeId(IntEnum):
RIGHT = 0
LEFT = 1
BOTH = 2
SETTINGS = 3
ALGOSETTINGS = 4
VRCFTMODULESETTINGS = 5
GUIOFF = 6
class EyeInfoOrigin(Enum):
RANSAC = 1
BLOB = 2
FAILURE = 3
HSF = 4
HSRAC = 5
DADDY = 6
LEAP = 7
@dataclass
class EyeInfo:
info_type: EyeInfoOrigin
x: float
y: float
pupil_dilation: float
blink: float
avg_velocity: float

File diff suppressed because it is too large Load Diff

View File

@ -1,94 +1,269 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
import os
from osc import VRChatOSCReceiver, VRChatOSC, EyeId
from config import EyeTrackConfig
from camera_widget import CameraWidget
from settings_widget import SettingsWidget
import queue
import threading
import PySimpleGUI as sg
import sys
from urllib.request import urlopen
from bs4 import BeautifulSoup
import queue
import requests
import threading
from camera_widget import CameraWidget
from config import EyeTrackConfig
from eye import EyeId
from settings.VRCFTModuleSettings import VRCFTSettingsWidget
from settings.general_settings_widget import SettingsWidget
from settings.algo_settings_widget import AlgoSettingsWidget
from osc.osc import OSCManager
from osc.OSCMessage import OSCMessage
from utils.misc_utils import is_nt, resource_path
import cv2
import numpy as np
import uuid
import webbrowser
winmm = None
if is_nt:
from winotify import Notification
from ctypes import windll, c_int
try:
winmm = windll.winmm
except OSError:
print("\033[91m[WARN] Failed to load winmm.dll\033[0m")
os.system("color") # init ANSI color
if sys.platform.startswith("win"):
from win10toast_click import ToastNotifier
# Random environment variable to speed up webcam opening on the MSMF backend.
# https://github.com/opencv/opencv/issues/17687
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
WINDOW_NAME = "EyeTrackApp"
RIGHT_EYE_NAME = "-RIGHTEYEWIDGET-"
LEFT_EYE_NAME = "-LEFTEYEWIDGET-"
SETTINGS_NAME = "-SETTINGSWIDGET-"
LEFT_EYE_RADIO_NAME = "-LEFTEYERADIO-"
RIGHT_EYE_RADIO_NAME = "-RIGHTEYERADIO-"
BOTH_EYE_RADIO_NAME = "-BOTHEYERADIO-"
SETTINGS_RADIO_NAME = '-SETTINGSRADIO-'
page_url = 'https://github.com/RedHawk989/EyeTrackVR/releases/latest'
def open_url():
try:
webbrowser.open_new(page_url)
print('Opening URL...')
except:
print('Failed to open URL. Unsupported variable type.')
page_url = "https://github.com/EyeTrackVR/EyeTrackVR/releases/latest"
appversion = "EyeTrackApp 0.2.0"
class KeyManager:
def __init__(self):
self.update_keys()
def update_keys(self):
unique_id = str(uuid.uuid4())
self.RIGHT_EYE_NAME = f"-RIGHTEYEWIDGET{unique_id}-"
self.LEFT_EYE_NAME = f"-LEFTEYEWIDGET{unique_id}-"
self.SETTINGS_NAME = f"-SETTINGSWIDGET{unique_id}-"
self.ALGO_SETTINGS_NAME = f"-ALGOSETTINGSWIDGET{unique_id}-"
self.VRCFT_MODULE_SETTINGS_NAME = f"-VRCFTSETTINGSWIDGET{unique_id}-"
self.LEFT_EYE_RADIO_NAME = f"-LEFTEYERADIO{unique_id}-"
self.RIGHT_EYE_RADIO_NAME = f"-RIGHTEYERADIO{unique_id}-"
self.BOTH_EYE_RADIO_NAME = f"-BOTHEYERADIO{unique_id}-"
self.SETTINGS_RADIO_NAME = f"-SETTINGSRADIO{unique_id}-"
self.ALGO_SETTINGS_RADIO_NAME = f"-ALGOSETTINGSRADIO{unique_id}-"
self.VRCFT_MODULE_SETTINGS_RADIO_NAME = f"-VRCFTSETTINGSRADIO{unique_id}-"
self.GUIOFF_RADIO_NAME = f"-GUIOFF{unique_id}-"
# Create an instance of the KeyManager
key_manager = KeyManager()
def create_window(config, settings, eyes):
key_manager.update_keys()
for eye in eyes:
eye.update_layouts()
layout = [
[
sg.Radio(
"Left Eye",
"EYESELECTRADIO",
background_color="#292929",
default=(config.eye_display_id == EyeId.LEFT),
key=key_manager.LEFT_EYE_RADIO_NAME,
),
sg.Radio(
"Right Eye",
"EYESELECTRADIO",
background_color="#292929",
default=(config.eye_display_id == EyeId.RIGHT),
key=key_manager.RIGHT_EYE_RADIO_NAME,
),
sg.Radio(
"Both Eyes",
"EYESELECTRADIO",
background_color="#292929",
default=(config.eye_display_id == EyeId.BOTH),
key=key_manager.BOTH_EYE_RADIO_NAME,
),
sg.Radio(
"Settings",
"EYESELECTRADIO",
background_color="#292929",
default=(config.eye_display_id == EyeId.SETTINGS),
key=key_manager.SETTINGS_RADIO_NAME,
),
sg.Radio(
"Algo Settings",
"EYESELECTRADIO",
background_color="#292929",
default=(config.eye_display_id == EyeId.ALGOSETTINGS),
key=key_manager.ALGO_SETTINGS_RADIO_NAME,
),
],
[
sg.Radio(
"VRCFT Module Settings",
"EYESELECTRADIO",
background_color="#292929",
default=(config.eye_display_id == EyeId.VRCFTMODULESETTINGS),
key=key_manager.VRCFT_MODULE_SETTINGS_RADIO_NAME,
),
],
[
sg.Column(
eyes[1].widget_layout,
vertical_alignment="top",
key=key_manager.LEFT_EYE_NAME,
visible=(config.eye_display_id in [EyeId.LEFT, EyeId.BOTH]),
background_color="#424042",
),
sg.Column(
eyes[0].widget_layout,
vertical_alignment="top",
key=key_manager.RIGHT_EYE_NAME,
visible=(config.eye_display_id in [EyeId.RIGHT, EyeId.BOTH]),
background_color="#424042",
),
sg.Column(
settings[0].get_layout(),
vertical_alignment="top",
key=key_manager.SETTINGS_NAME,
visible=(config.eye_display_id in [EyeId.SETTINGS]),
background_color="#424042",
),
sg.Column(
settings[1].get_layout(),
vertical_alignment="top",
key=key_manager.ALGO_SETTINGS_NAME,
visible=(config.eye_display_id in [EyeId.ALGOSETTINGS]),
background_color="#424042",
),
sg.Column(
settings[2].get_layout(),
vertical_alignment="top",
key=key_manager.VRCFT_MODULE_SETTINGS_NAME,
visible=(config.eye_display_id in [EyeId.VRCFTMODULESETTINGS]),
background_color="#424042",
),
],
[
sg.Button(
"GUI OFF",
key=key_manager.GUIOFF_RADIO_NAME,
button_color="#6f4ca1",
),
],
# Keep at bottom!
[sg.Text("- - - Interface Paused - - -", key="-WINFOCUS-", background_color="#292929", text_color="#F0F0F0", justification="center", expand_x=True, visible=False)],
]
if config.eye_display_id in [EyeId.LEFT, EyeId.BOTH]:
eyes[1].start()
if config.eye_display_id in [EyeId.RIGHT, EyeId.BOTH]:
eyes[0].start()
if config.eye_display_id in [EyeId.SETTINGS]:
settings[0].start()
if config.eye_display_id in [EyeId.ALGOSETTINGS]:
settings[1].start()
if config.eye_display_id in [EyeId.VRCFTMODULESETTINGS]:
settings[2].start()
# the eye's needs to be running before it is passed to the OSC
# Create the window
return sg.Window(
f"{appversion}",
layout,
icon=resource_path("Images/logo.ico"),
background_color="#292929")
def timerResolution(toggle):
if winmm != None:
if toggle:
rc = c_int(winmm.timeBeginPeriod(1))
if rc.value != 0:
# TIMEERR_NOCANDO = 97
print(f"\033[93m[WARN] Failed to set timer resolution: {rc.value}\033[0m")
else:
winmm.timeEndPeriod(1)
def main():
# Get Configuration
config: EyeTrackConfig = EyeTrackConfig.load()
config.save()
cancellation_event = threading.Event()
# Check to see if we can connect to our video source first. If not, bring up camera finding
# dialog.
try:
if config.settings.gui_update_check:
response = requests.get("https://api.github.com/repos/EyeTrackVR/EyeTrackVR/releases/latest")
latestversion = response.json()["name"]
appversion = "0.1.7.2"
url = "https://raw.githubusercontent.com/RedHawk989/EyeTrackVR-Installer/master/Version-Data/Version_Num.txt"
html = urlopen(url).read()
soup = BeautifulSoup(html, features="html.parser")
for script in soup(["script", "style"]):
script.extract()
text = soup.get_text()
# break into lines and remove leading and trailing space on each
lines = (line.strip() for line in text.splitlines())
# break multi-headlines into a line each
chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
# drop blank lines
latestversion = '\n'.join(chunk for chunk in chunks if chunk)
if appversion == latestversion: # If what we scraped and hardcoded versions are same, assume we are up to date.
print(f"[INFO] App is up to date! {latestversion}")
else:
print(f"[INFO] You have app version {appversion} installed. Please update to {latestversion} for the newest fixes.")
if sys.platform.startswith("win"):
toaster = ToastNotifier()
toaster.show_toast( #show windows toast
"EyeTrackVR has an update.",
"Click to go to the latest version.",
icon_path= "Images/logo.ico",
duration=5,
threaded=True,
callback_on_click=open_url
if (
appversion == latestversion
): # If what we scraped and hardcoded versions are same, assume we are up to date.
print(f"\033[92m[INFO] App is the latest version! [{latestversion}]\033[0m")
else:
print(
f"\033[93m[INFO] You have app version [{appversion}] installed. Please update to [{latestversion}] for the newest features.\033[0m"
)
try:
if is_nt:
# icon = cwd + "\Images\logo.ico"
icon = resource_path("Images/logo.ico")
toast = Notification(
app_id="EyeTrackApp",
title="New Update Available!",
msg=f"Please update to {latestversion}",
icon=r"{}".format(icon),
)
toast.add_actions(
label="Download Page",
launch="https://github.com/EyeTrackVR/EyeTrackVR/releases/latest",
)
toast.show()
except Exception as e:
print("[INFO] Toast notifications not supported")
except:
print("\033[91m[INFO] Could not check for updates. Please try again later.\033[0m")
# Check to see if we have an ROI. If not, bring up ROI finder GUI.
timerResolution(True)
# Spawn worker threads
osc_queue: queue.Queue[tuple[bool, int, int]] = queue.Queue()
osc = VRChatOSC(cancellation_event, osc_queue, config)
osc_thread = threading.Thread(target=osc.run)
# start worker threads
osc_thread.start()
osc_queue: queue.Queue[OSCMessage] = queue.Queue(maxsize=10)
eyes = [
CameraWidget(EyeId.RIGHT, config, osc_queue),
@ -96,152 +271,221 @@ def main():
]
settings = [
SettingsWidget(EyeId.SETTINGS, config, osc_queue),
SettingsWidget(EyeId.SETTINGS, config),
AlgoSettingsWidget(EyeId.ALGOSETTINGS, config),
VRCFTSettingsWidget(EyeId.VRCFTMODULESETTINGS, config, osc_queue),
]
layout = [
osc_manager = OSCManager(
osc_message_in_queue=osc_queue,
config=config,
)
config.register_listener_callback(osc_manager.update)
config.register_listener_callback(eyes[0].on_config_update)
config.register_listener_callback(eyes[1].on_config_update)
osc_manager.register_listeners(
config.settings.gui_osc_recenter_address,
[
sg.Radio(
"Right Eye",
"EYESELECTRADIO",
background_color='#292929',
default=(config.eye_display_id == EyeId.RIGHT),
key=RIGHT_EYE_RADIO_NAME,
),
sg.Radio(
"Left Eye",
"EYESELECTRADIO",
background_color='#292929',
default=(config.eye_display_id == EyeId.LEFT),
key=LEFT_EYE_RADIO_NAME,
),
sg.Radio(
"Both Eyes",
"EYESELECTRADIO",
background_color='#292929',
default=(config.eye_display_id == EyeId.BOTH),
key=BOTH_EYE_RADIO_NAME,
),
sg.Radio(
"Settings",
"EYESELECTRADIO",
background_color='#292929',
default=(config.eye_display_id == EyeId.SETTINGS),
key=SETTINGS_RADIO_NAME,
),
eyes[0].osc_recenter_eyes,
eyes[1].osc_recenter_eyes,
],
)
osc_manager.register_listeners(
config.settings.gui_osc_recalibrate_address,
[
sg.Column(
eyes[1].widget_layout,
vertical_alignment="top",
key=LEFT_EYE_NAME,
visible=(config.eye_display_id in [EyeId.LEFT, EyeId.BOTH]),
background_color='#424042',
),
sg.Column(
eyes[0].widget_layout,
vertical_alignment="top",
key=RIGHT_EYE_NAME,
visible=(config.eye_display_id in [EyeId.RIGHT, EyeId.BOTH]),
background_color='#424042',
),
sg.Column(
settings[0].widget_layout,
vertical_alignment="top",
key=SETTINGS_NAME,
visible=(config.eye_display_id in [EyeId.SETTINGS]),
background_color='#424042',
),
eyes[0].osc_recalibrate_eyes,
eyes[1].osc_recalibrate_eyes,
],
]
)
if config.eye_display_id in [EyeId.LEFT, EyeId.BOTH]:
eyes[1].start()
if config.eye_display_id in [EyeId.RIGHT, EyeId.BOTH]:
eyes[0].start()
osc_manager.start()
if config.eye_display_id in [EyeId.SETTINGS, EyeId.BOTH]:
settings[0].start()
# the eye's needs to be running before it is passed to the OSC
osc_receiver = VRChatOSCReceiver(cancellation_event, config, eyes)
osc_receiver_thread = threading.Thread(target=osc_receiver.run)
osc_receiver_thread.start()
# Create the window
window = sg.Window(f"EyeTrackVR {appversion}" , layout, icon='Images/logo.ico', background_color='#292929')
# GUI Render loop
while True:
tint = 33
fs = False
if config.settings.gui_disable_gui:
layoutg = [
[sg.Text("GUI Disabled!", background_color="#242224")],
[sg.Button('Enable GUI', button_color="#6f4ca1")]
]
# Create the window
windowg = sg.Window('ETVR', layoutg, background_color="#242224", size=(200, 80)) #icon=resource_path("Images/logo.ico") adds cpu usage.....
# Event loop
while True:
eventg, valuesg = windowg.read(timeout=tint)
if eventg == sg.WINDOW_CLOSED:
config.settings.gui_disable_gui = False
config.save()
break
elif eventg == 'Enable GUI':
config.settings.gui_disable_gui = False
config.save()
print('GUI Enabled')
break
windowg.close()
# First off, check for any events from the GUI
event, values = window.read(timeout=1)
window = create_window(config, settings, eyes)
# If we're in either mode and someone hits q, quit immediately
if event == "Exit" or event == sg.WIN_CLOSED:
for eye in eyes:
eye.stop()
cancellation_event.set()
# shut down worker threads
osc_thread.join()
# TODO: find a way to have this function run on join maybe??
# threading.Event() wont work because pythonosc spawns its own thread.
# only way i can see to get around this is an ugly while loop that only checks if a threading event is triggered
# and then call the pythonosc shutdown function
osc_receiver.shutdown()
osc_receiver_thread.join()
print("Exiting EyeTrackApp")
return
while True:
event, values = window.read(timeout=tint) # this higher timeout saves some cpu usage
if values[RIGHT_EYE_RADIO_NAME] and config.eye_display_id != EyeId.RIGHT:
eyes[0].start()
eyes[1].stop()
settings[0].stop()
window[RIGHT_EYE_NAME].update(visible=True)
window[LEFT_EYE_NAME].update(visible=False)
window[SETTINGS_NAME].update(visible=False)
config.eye_display_id = EyeId.RIGHT
config.settings.tracker_single_eye = 2
config.save()
elif values[LEFT_EYE_RADIO_NAME] and config.eye_display_id != EyeId.LEFT:
settings[0].stop()
eyes[0].stop()
eyes[1].start()
window[RIGHT_EYE_NAME].update(visible=False)
window[LEFT_EYE_NAME].update(visible=True)
window[SETTINGS_NAME].update(visible=False)
config.eye_display_id = EyeId.LEFT
config.settings.tracker_single_eye = 1
config.save()
elif values[BOTH_EYE_RADIO_NAME] and config.eye_display_id != EyeId.BOTH:
settings[0].stop()
eyes[0].stop()
eyes[1].start()
eyes[0].start()
# If we're in either mode and someone hits q, quit immediately
if event in ("Exit", sg.WIN_CLOSED) and not config.settings.gui_disable_gui:
for eye in eyes:
eye.stop()
cancellation_event.set()
osc_manager.shutdown()
timerResolution(False)
print("\033[94m[INFO] Exiting EyeTrackApp\033[0m")
window.close()
os._exit(0) # I do not like this, but for now this fixes app hang on close
return
window[LEFT_EYE_NAME].update(visible=True)
window[RIGHT_EYE_NAME].update(visible=True)
window[SETTINGS_NAME].update(visible=False)
config.eye_display_id = EyeId.BOTH
config.settings.tracker_single_eye = 0
config.save()
try:
# If window isn't in focus increase timeout and stop loop early
if window.TKroot.focus_get():
if fs:
fs = False
tint = 33
window["-WINFOCUS-"].update(visible=False)
window["-WINFOCUS-"].hide_row()
window.refresh()
else:
if not fs:
fs = True
tint = 100
window["-WINFOCUS-"].update(visible=True)
window["-WINFOCUS-"].unhide_row()
continue
except KeyError:
pass
elif values[SETTINGS_RADIO_NAME] and config.eye_display_id != EyeId.SETTINGS:
eyes[0].stop()
eyes[1].stop()
settings[0].start()
window[RIGHT_EYE_NAME].update(visible=False)
window[LEFT_EYE_NAME].update(visible=False)
window[SETTINGS_NAME].update(visible=True)
config.eye_display_id = EyeId.SETTINGS
config.save()
if values[key_manager.RIGHT_EYE_RADIO_NAME] and config.eye_display_id != EyeId.RIGHT:
config.settings.gui_disable_gui = False
eyes[0].start()
eyes[1].stop()
settings[0].stop()
settings[1].stop()
settings[2].stop()
window[key_manager.RIGHT_EYE_NAME].update(visible=True)
window[key_manager.LEFT_EYE_NAME].update(visible=False)
window[key_manager.SETTINGS_NAME].update(visible=False)
window[key_manager.VRCFT_MODULE_SETTINGS_NAME].update(visible=False)
window[key_manager.ALGO_SETTINGS_NAME].update(visible=False)
config.eye_display_id = EyeId.RIGHT
config.settings.tracker_single_eye = 2
config.save()
# Otherwise, render all of our cameras
for eye in eyes:
if eye.started():
eye.render(window, event, values)
settings[0].render(window, event, values)
elif values[key_manager.LEFT_EYE_RADIO_NAME] and config.eye_display_id != EyeId.LEFT:
config.settings.gui_disable_gui = False
settings[0].stop()
settings[1].stop()
settings[2].stop()
eyes[0].stop()
eyes[1].start()
window[key_manager.RIGHT_EYE_NAME].update(visible=False)
window[key_manager.LEFT_EYE_NAME].update(visible=True)
window[key_manager.SETTINGS_NAME].update(visible=False)
window[key_manager.VRCFT_MODULE_SETTINGS_NAME].update(visible=False)
window[key_manager.ALGO_SETTINGS_NAME].update(visible=False)
config.eye_display_id = EyeId.LEFT
config.settings.tracker_single_eye = 1
config.save()
elif values[key_manager.BOTH_EYE_RADIO_NAME] and config.eye_display_id != EyeId.BOTH:
config.settings.gui_disable_gui = False
settings[0].stop()
settings[1].stop()
settings[2].stop()
eyes[1].start()
eyes[0].start()
window[key_manager.LEFT_EYE_NAME].update(visible=True)
window[key_manager.RIGHT_EYE_NAME].update(visible=True)
window[key_manager.SETTINGS_NAME].update(visible=False)
window[key_manager.VRCFT_MODULE_SETTINGS_NAME].update(visible=False)
window[key_manager.ALGO_SETTINGS_NAME].update(visible=False)
config.eye_display_id = EyeId.BOTH
config.settings.tracker_single_eye = 0
config.save()
elif values[key_manager.SETTINGS_RADIO_NAME] and config.eye_display_id != EyeId.SETTINGS:
config.settings.gui_disable_gui = False
eyes[0].stop()
eyes[1].stop()
settings[1].stop()
settings[0].start()
settings[2].stop()
window[key_manager.RIGHT_EYE_NAME].update(visible=False)
window[key_manager.LEFT_EYE_NAME].update(visible=False)
window[key_manager.SETTINGS_NAME].update(visible=True)
window[key_manager.VRCFT_MODULE_SETTINGS_NAME].update(visible=False)
window[key_manager.ALGO_SETTINGS_NAME].update(visible=False)
config.eye_display_id = EyeId.SETTINGS
config.save()
elif values[key_manager.ALGO_SETTINGS_RADIO_NAME] and config.eye_display_id != EyeId.ALGOSETTINGS:
config.settings.gui_disable_gui = False
eyes[0].stop()
eyes[1].stop()
settings[0].stop()
settings[1].start()
settings[2].stop()
window[key_manager.RIGHT_EYE_NAME].update(visible=False)
window[key_manager.LEFT_EYE_NAME].update(visible=False)
window[key_manager.SETTINGS_NAME].update(visible=False)
window[key_manager.VRCFT_MODULE_SETTINGS_NAME].update(visible=False)
window[key_manager.ALGO_SETTINGS_NAME].update(visible=True)
config.eye_display_id = EyeId.ALGOSETTINGS
config.save()
elif values[key_manager.VRCFT_MODULE_SETTINGS_RADIO_NAME] and config.eye_display_id != EyeId.VRCFTMODULESETTINGS:
config.settings.gui_disable_gui = False
eyes[0].stop()
eyes[1].stop()
settings[0].stop()
settings[1].stop()
settings[2].start()
window[key_manager.RIGHT_EYE_NAME].update(visible=False)
window[key_manager.LEFT_EYE_NAME].update(visible=False)
window[key_manager.SETTINGS_NAME].update(visible=False)
window[key_manager.VRCFT_MODULE_SETTINGS_NAME].update(visible=True)
window[key_manager.ALGO_SETTINGS_NAME].update(visible=False)
config.eye_display_id = EyeId.VRCFTMODULESETTINGS
config.save()
else:
# Otherwise, render all
for eye in eyes:
if eye.started():
eye.render(window, event, values)
for setting in settings:
if setting.started():
setting.render(window, event, values)
if event == key_manager.GUIOFF_RADIO_NAME:
config.settings.gui_disable_gui = True
# eyes[0].stop()
# eyes[1].stop()
settings[0].stop()
settings[1].stop()
settings[2].stop()
window[key_manager.RIGHT_EYE_NAME].update(visible=False)
window[key_manager.LEFT_EYE_NAME].update(visible=False)
window[key_manager.SETTINGS_NAME].update(visible=False)
window[key_manager.VRCFT_MODULE_SETTINGS_NAME].update(visible=False)
window[key_manager.ALGO_SETTINGS_NAME].update(visible=False)
#config.eye_display_id = EyeId.GUIOFF
config.save()
window.close()
break
if __name__ == "__main__":
main()
main()

View File

@ -1,44 +1,47 @@
# -*- mode: py -3.11 ; coding: utf-8 -*-
# -*- mode: python ; coding: utf-8 -*-
import sys ; sys.setrecursionlimit(sys.getrecursionlimit() * 5)
block_cipher = None
resources=[("Audio/*", "Audio"), ("Images/*", "Images/"), ("pye3d/refraction_models/*", "pye3d/refraction_models/"), ("Models/*", "Models/"),("Tools/*", "Tools/")]
a = Analysis(['eyetrackapp.py'],
pathex=[],
binaries=[],
datas=[("Audio/*", "Audio"), ("Images/*", "Images/"), ("pye3d/refraction_models/*", "pye3d/refraction_models/")],
hiddenimports=['cv2', 'numpy', 'PySimpleGui'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
a = Analysis(
['eyetrackapp.py'],
pathex=[],
binaries=[],
datas=resources,
hiddenimports=['cv2', 'numpy', 'PySimpleGui', 'pkg_resources.extern'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False
)
exe = EXE(pyz,
a.scripts,
[],
exclude_binaries=True,
name='eyetrackapp',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon="Images/logo.ico" )
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='EyeTrackApp')
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='eyetrackapp',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon="Images/logo.ico",
)

View File

@ -0,0 +1,852 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Haar Surround Feature: Summer, PallasNeko (Optimization)
Algorithm App Implementations and tweaks By: Prohurtz
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Summer Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
import timeit
from functools import lru_cache
import cv2
import numpy as np
from utils.img_utils import safe_crop
import psutil
import sys
import os
process = psutil.Process(os.getpid()) # set process priority to low
try: # medium chance this does absolutely nothing but eh
sys.getwindowsversion()
except AttributeError:
process.nice(0) # UNIX: 0 low 10 high
process.nice()
else:
process.nice(psutil.BELOW_NORMAL_PRIORITY_CLASS) # Windows
process.nice()
# from line_profiler_pycharm import profile
video_path = "ezgif.com-gif-maker.avi"
imshow_enable = False
calc_print_enable = False
save_video = False
skip_autoradius = False
skip_blink_detect = False
# cache param
lru_maxsize_vvs = 16
lru_maxsize_vs = 64
lru_maxsize_s = 128
# CV param
default_radius = 20
auto_radius_range = (default_radius - 18, default_radius + 15) # (10,30)
auto_radius_step = 1
blink_init_frames = 60 * 3 # 60fps*3sec,Number of blink statistical frames
# step==(x,y)
default_step = (
5,
5,
) # bigger the steps,lower the processing time! ofc acc also takes an impact
class CvParameters:
# It may be a little slower because a dict named "self" is read for each function call.
def __init__(self, radius, step):
# self.prev_radius=radius
self._radius = radius
self.pad = 2 * radius
# self.prev_step=step
self._step = step
self._hsf = HaarSurroundFeature(radius)
def get_rpsh(self):
return self._radius, self.pad, self._step, self._hsf
# Essentially, the following would be preferable, but it would take twice as long to call.
# return self.radius, self.pad, self.step, self.hsf
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, now_radius):
# self.prev_radius=self._radius
self._radius = now_radius
self.pad = 2 * now_radius
self.hsf = now_radius
@property
def step(self):
return self._step
@step.setter
def step(self, now_step):
# self.prev_step=self.step
self._step = now_step
@property
def hsf(self):
return self._hsf
@hsf.setter
def hsf(self, now_radius):
self._hsf = HaarSurroundFeature(now_radius)
class HaarSurroundFeature:
def __init__(self, r_inner, r_outer=None, val=None):
if r_outer is None:
r_outer = r_inner * 3
# print(r_outer)
r_inner2 = r_inner * r_inner
count_inner = r_inner2
count_outer = r_outer * r_outer - r_inner2
if val is None:
val_inner = 1.0 / r_inner2
val_outer = -val_inner * count_inner / count_outer
else:
val_inner = val[0]
val_outer = val[1]
self.val_in = float(val_inner) # np.array(val_inner, dtype=np.float64)
self.val_out = float(val_outer) # np.array(val_outer, dtype=np.float64)
self.r_in = r_inner
self.r_out = r_outer
def get_kernel(self):
# Defined here, but not yet used?
# Create a kernel filled with the value of self.val_out
kernel = np.ones(shape=(2 * self.r_out - 1, 2 * self.r_out - 1), dtype=np.float64) * self.val_out
# Set the values of the inner area of the kernel using array slicing
start = self.r_out - self.r_in
end = self.r_out + self.r_in - 1
kernel[start:end, start:end] = self.val_in
return kernel
def to_gray(frame):
# Faster by quitting checking if the input image is already grayscale
# Perhaps it would be faster with less overhead to call cv2.cvtColor directly instead of using this function
return cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
@lru_cache(maxsize=lru_maxsize_vvs)
def get_frameint_empty_array(frame_shape, pad, x_step, y_step, r_in, r_out):
frame_int_dtype = np.intc
frame_pad = np.empty((frame_shape[0] + (pad * 2), frame_shape[1] + (pad * 2)), dtype=np.uint8)
row, col = frame_pad.shape
frame_int = np.empty((row + 1, col + 1), dtype=frame_int_dtype)
y_steps_arr = np.arange(pad, row - pad, y_step, dtype=np.int16)
x_steps_arr = np.arange(pad, col - pad, x_step, dtype=np.int16)
len_sx, len_sy = len(x_steps_arr), len(y_steps_arr)
len_syx = (len_sy, len_sx)
y_end = pad + (y_step * (len_sy - 1))
x_end = pad + (x_step * (len_sx - 1))
y_rin_m = slice(pad - r_in, y_end - r_in + 1, y_step)
y_rin_p = slice(pad + r_in, y_end + r_in + 1, y_step)
x_rin_m = slice(pad - r_in, x_end - r_in + 1, x_step)
x_rin_p = slice(pad + r_in, x_end + r_in + 1, x_step)
in_p00 = frame_int[y_rin_m, x_rin_m]
in_p11 = frame_int[y_rin_p, x_rin_p]
in_p01 = frame_int[y_rin_m, x_rin_p]
in_p10 = frame_int[y_rin_p, x_rin_m]
y_ro_m = np.maximum(y_steps_arr - r_out, 0) # [:,np.newaxis]
x_ro_m = np.maximum(x_steps_arr - r_out, 0) # [np.newaxis,:]
y_ro_p = np.minimum(row, y_steps_arr + r_out) # [:,np.newaxis]
x_ro_p = np.minimum(col, x_steps_arr + r_out) # [np.newaxis,:]
inner_sum = np.empty(len_syx, dtype=frame_int_dtype)
outer_sum = np.empty(len_syx, dtype=frame_int_dtype)
out_p_temp = np.empty((len_sy, col + 1), dtype=frame_int_dtype)
out_p00 = np.empty(len_syx, dtype=frame_int_dtype)
out_p11 = np.empty(len_syx, dtype=frame_int_dtype)
out_p01 = np.empty(len_syx, dtype=frame_int_dtype)
out_p10 = np.empty(len_syx, dtype=frame_int_dtype)
response_list = np.empty(len_syx, dtype=np.float64) # or np.int32
frame_conv = np.zeros(shape=(row - 2 * pad, col - 2 * pad), dtype=np.uint8) # or np.float64
frame_conv_stride = frame_conv[::y_step, ::x_step]
return (
frame_pad,
frame_int,
inner_sum,
in_p00,
in_p11,
in_p01,
in_p10,
y_ro_m,
x_ro_m,
y_ro_p,
x_ro_p,
outer_sum,
out_p_temp,
out_p00,
out_p11,
out_p01,
out_p10,
response_list,
frame_conv,
frame_conv_stride,
)
def conv_int(
frame_int,
kernel,
inner_sum,
in_p00,
in_p11,
in_p01,
in_p10,
y_ro_m,
x_ro_m,
y_ro_p,
x_ro_p,
outer_sum,
out_p_temp,
out_p00,
out_p11,
out_p01,
out_p10,
response_list,
frame_conv_stride,
):
# inner_sum[:, :] = in_p00 + in_p11 - in_p01 - in_p10
cv2.add(in_p00, in_p11, dst=inner_sum)
cv2.subtract(inner_sum, in_p01, dst=inner_sum)
cv2.subtract(inner_sum, in_p10, dst=inner_sum)
# p00 calc
frame_int.take(y_ro_m, axis=0, mode="clip", out=out_p_temp)
out_p_temp.take(x_ro_m, axis=1, mode="clip", out=out_p00)
# p01 calc
out_p_temp.take(x_ro_p, axis=1, mode="clip", out=out_p01)
# p11 calc
frame_int.take(y_ro_p, axis=0, mode="clip", out=out_p_temp)
out_p_temp.take(x_ro_p, axis=1, mode="clip", out=out_p11)
# p10 calc
out_p_temp.take(x_ro_m, axis=1, mode="clip", out=out_p10)
# outer_sum[:, :] = out_p00 + out_p11 - out_p01 - out_p10 - inner_sum
cv2.add(out_p00, out_p11, dst=outer_sum)
cv2.subtract(outer_sum, out_p01, dst=outer_sum)
cv2.subtract(outer_sum, out_p10, dst=outer_sum)
cv2.subtract(outer_sum, inner_sum, dst=outer_sum)
# cv2.transform(np.asarray([p00, p11, -p01, -p10, -inner_sum]).transpose((1, 2, 0)), np.ones((1, 5)),
# dst=outer_sum) # https://answers.opencv.org/question/3120/how-to-sum-a-3-channel-matrix-to-a-one-channel-matrix/
# np.multiply(kernel.val_in, inner_sum, dtype=np.float64, out=response_list)
# response_list += kernel.val_out * outer_sum
cv2.addWeighted(
inner_sum,
kernel.val_in,
outer_sum, # or p00 + p11 - p01 - p10 - inner_sum
kernel.val_out,
0.0,
dtype=cv2.CV_64F, # or cv2.CV_32S
dst=response_list,
)
min_response, _, min_loc, _ = cv2.minMaxLoc(response_list)
frame_conv_stride[:, :] = response_list
# or
# frame_conv_stride[:, :] = response_list.astype(np.uint8)
return min_response, min_loc
@lru_cache(maxsize=lru_maxsize_s)
def get_hsf_center(padding, x_step, y_step, min_loc): # min_x,min_y):
return (
padding + (x_step * min_loc[0]) - padding,
padding + (y_step * min_loc[1]) - padding,
)
class AutoRadiusCalc(object):
def __init__(self):
self.response_list = []
self.radius_cand_list = []
self.adj_comp_flag = False
self.radius_middle_index = None
self.left_item = None
self.right_item = None
self.left_index = None
self.right_index = None
def get_radius(self):
prev_res_len = len(self.response_list)
# adjustment of radius
if prev_res_len == 1:
# len==1==response_list==[default_radius]
self.adj_comp_flag = False
return auto_radius_range[0]
elif prev_res_len == 2:
# len==2==response_list==[default_radius, auto_radius_range[0]]
self.adj_comp_flag = False
return auto_radius_range[1]
elif prev_res_len == 3:
# len==3==response_list==[default_radius,auto_radius_range[0],auto_radius_range[1]]
if self.response_list[1][1] < self.response_list[2][1]:
self.left_item = self.response_list[1]
self.right_item = self.response_list[0]
else:
self.left_item = self.response_list[0]
self.right_item = self.response_list[2]
self.radius_cand_list = [
i
for i in range(
self.left_item[0],
self.right_item[0] + auto_radius_step,
auto_radius_step,
)
]
self.left_index = 0
self.right_index = len(self.radius_cand_list) - 1
self.radius_middle_index = (self.left_index + self.right_index) // 2
self.adj_comp_flag = False
return self.radius_cand_list[self.radius_middle_index]
else:
if self.left_index <= self.right_index and self.left_index != self.radius_middle_index:
if (self.left_item[1] + self.response_list[-1][1]) < (self.right_item[1] + self.response_list[-1][1]):
self.right_item = self.response_list[-1]
self.right_index = self.radius_middle_index - 1
self.radius_middle_index = (self.left_index + self.right_index) // 2
self.adj_comp_flag = False
return self.radius_cand_list[self.radius_middle_index]
if (self.left_item[1] + self.response_list[-1][1]) > (self.right_item[1] + self.response_list[-1][1]):
self.left_item = self.response_list[-1]
self.left_index = self.radius_middle_index + 1
self.radius_middle_index = (self.left_index + self.right_index) // 2
self.adj_comp_flag = False
return self.radius_cand_list[self.radius_middle_index]
self.adj_comp_flag = True
return self.radius_cand_list[self.radius_middle_index]
def get_radius_base(self):
"""
Use it when the new version doesn't work well.
:return:
"""
prev_res_len = len(self.response_list)
# adjustment of radius
if prev_res_len == 1:
# len==1==response_list==[default_radius]
self.adj_comp_flag = False
return auto_radius_range[0]
elif prev_res_len == 2:
# len==2==response_list==[default_radius, auto_radius_range[0]]
self.adj_comp_flag = False
return auto_radius_range[1]
elif prev_res_len == 3:
# len==3==response_list==[default_radius,auto_radius_range[0],auto_radius_range[1]]
sort_res = sorted(self.response_list, key=lambda x: x[1])[0]
# Extract the radius with the lowest response value
if sort_res[0] == default_radius:
# If the default value is best, change now_mode to init after setting radius to the default value.
self.adj_comp_flag = True
return default_radius
elif sort_res[0] == auto_radius_range[0]:
self.radius_cand_list = [i for i in range(auto_radius_range[0], default_radius, auto_radius_step)][1:]
self.adj_comp_flag = False
return self.radius_cand_list.pop()
else:
self.radius_cand_list = [i for i in range(default_radius, auto_radius_range[1], auto_radius_step)][1:]
self.adj_comp_flag = False
return self.radius_cand_list.pop()
else:
# Try the contents of the radius_cand_list in order until the radius_cand_list runs out
# Better make it a binary search.
if len(self.radius_cand_list) == 0:
sort_res = sorted(self.response_list, key=lambda x: x[1])[0]
self.adj_comp_flag = True
return sort_res[0]
else:
self.adj_comp_flag = False
return self.radius_cand_list.pop()
def add_response(self, radius, response):
self.response_list.append((radius, response))
return None
class BlinkDetector(object):
def __init__(self):
self.response_list = []
self.response_max = None
self.enable_detect_flg = False
self.quartile_1 = None
def calc_thresh(self):
# Calculate response_max by computing interquartile range, IQR
# self.response_listo = np.array(self.response_listo)
# 25%,75%
# This value may need to be adjusted depending on the environment.
# quartile_1, quartile_3 = np.percentile(self.response_listo, [25, 75])
# iqr = quartile_3 - quartile_1
# self.response_maxo = quartile_3 + (iqr * 1.5)
# quartile_1, quartile_3 = np.percentile(self.response_list, [25, 75])
# or
quartile_1, quartile_3 = np.percentile(np.array(self.response_list), [25, 75])
self.quartile_1 = quartile_1
iqr = quartile_3 - quartile_1
# response_min = quartile_1 - (iqr * 1.5)
self.response_max = float(quartile_3 + (iqr * 1.5))
# or
# self.response_max = quartile_3 + (iqr * 1.5)
self.enable_detect_flg = True
return None
def detect(self, now_response):
return now_response > self.response_max
def add_response(self, response):
self.response_list.append(response)
return None
def response_len(self):
return len(self.response_list)
class CenterCorrection(object):
def __init__(self):
# Tunable parameters
kernel_size = 7 # 3 or 5 or 7
self.hist_thr = float(4) # 4%
self.center_q1_radius = 20
self.setup_comp = False
self.quartile_1 = None
self.radius = None
self.frame_shape = None
self.frame_mask = None
self.frame_bin = None
self.frame_final = None
self.morph_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (kernel_size, kernel_size))
self.morph_kernel2 = np.ones((3, 3))
self.hist_index = np.arange(256)
self.hist = np.empty((256, 1))
self.hist_norm = np.empty((256, 1))
def init_array(self, gray_shape, quartile_1, radius):
self.frame_shape = gray_shape
self.frame_mask = np.empty(gray_shape, dtype=np.uint8)
self.frame_bin = np.empty(gray_shape, dtype=np.uint8)
self.frame_final = np.empty(gray_shape, dtype=np.uint8)
self.quartile_1 = quartile_1
self.radius = radius
self.setup_comp = True
# def reset_array(self):
# self.frame_mask.fill(0)
def correction(self, gray_frame, orig_x, orig_y):
center_x, center_y = orig_x, orig_y
self.frame_mask.fill(0)
# cv2.circle(self.frame_mask, center=(center_x, center_y), radius=int(self.radius * 2), color=255, thickness=-1)
# bottleneck
cv2.calcHist([gray_frame], [0], None, [256], [0, 256], hist=self.hist)
cv2.normalize(self.hist, self.hist_norm, alpha=100.0, norm_type=cv2.NORM_L1)
hist_per = self.hist_norm.cumsum()
hist_index_list = self.hist_index[hist_per >= self.hist_thr]
frame_thr = (
hist_index_list[0]
if len(hist_index_list)
else np.percentile(cv2.bitwise_or(255 - self.frame_mask, gray_frame), 4)
)
# bottleneck
self.frame_bin = cv2.threshold(gray_frame, frame_thr, 1, cv2.THRESH_BINARY_INV)[1]
cropped_x, cropped_y, cropped_w, cropped_h = cv2.boundingRect(self.frame_bin)
self.frame_final = cv2.bitwise_and(self.frame_bin, self.frame_mask)
# bottleneck
self.frame_final = cv2.morphologyEx(self.frame_final, cv2.MORPH_CLOSE, self.morph_kernel)
self.frame_final = cv2.morphologyEx(self.frame_final, cv2.MORPH_OPEN, self.morph_kernel)
if (cropped_h, cropped_w) == self.frame_shape:
# Not detected.
base_x, base_y = center_x, center_y
else:
base_x = cropped_x + cropped_w // 2
base_y = cropped_y + cropped_h // 2
if self.frame_final[base_y, base_x] != 1:
if self.frame_final[center_y, center_x] != 1:
self.frame_final = cv2.morphologyEx(
self.frame_final,
cv2.MORPH_DILATE,
self.morph_kernel2,
iterations=3,
)
else:
base_x, base_y = center_x, center_y
contours, _ = cv2.findContours(self.frame_final, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
contours_box = [cv2.boundingRect(cnt) for cnt in contours]
contours_dist = np.array(
[
abs(base_x - (cnt_x + cnt_w / 2)) + abs(base_y - (cnt_y + cnt_h / 2))
for cnt_x, cnt_y, cnt_w, cnt_h in contours_box
]
)
if len(contours_box):
cropped_x2, cropped_y2, cropped_w2, cropped_h2 = contours_box[contours_dist.argmin()]
x = cropped_x2 + cropped_w2 // 2
y = cropped_y2 + cropped_h2 // 2
else:
x = center_x
y = center_y
# if imshow_enable:
# cv2.circle(frame, (orig_x, orig_y), 10, (255, 0, 0), -1)
# cv2.circle(frame, (x, y), 7, (0, 0, 255), -1)
#
# out_x = center_x if abs(x - center_x) > radius else x
# out_y = center_y if abs(y - center_y) > radius else y
out_x, out_y = orig_x, orig_y
if (
gray_frame[
int(max(y - 5, 0)) : int(min(y + 5, self.frame_shape[0])),
int(max(x - 5, 0)) : int(min(x + 5, self.frame_shape[1])),
].min()
< self.quartile_1
):
out_x = x
out_y = y
# if imshow_enable:
# cv2.circle(frame, (out_x, out_y), 5, (0, 255, 0), -1)
#
# cv2.imshow("frame_bin", self.frame_bin * 255)
# cv2.imshow("frame_final", self.frame_final * 255)
return out_x, out_y
class HSF_cls(object):
def __init__(self):
# I'd like to take into account things like print, end_time - start_time processing time, etc., but it's too much trouble.
# For measuring total processing time
self.main_start_time = timeit.default_timer()
self.rng = np.random.default_rng()
self.cvparam = CvParameters(default_radius, default_step)
self.cv_modeo = ["first_frame", "radius_adjust", "blink_adjust", "normal"]
self.now_modeo = self.cv_modeo[0]
self.auto_radius_calc = AutoRadiusCalc()
self.blink_detector = BlinkDetector()
self.center_q1 = BlinkDetector()
self.center_correct = CenterCorrection()
self.cap = None
self.timedict = {
"to_gray": [],
"int_img": [],
"conv_int": [],
"crop": [],
"total_cv": [],
}
def open_video(self, video_path):
# Temporary implementation to run
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise IOError("Error opening video stream or file")
self.cap = cap
return True
def read_frame(self):
# Temporary implementation to run
if not self.cap.isOpened():
return False
ret, frame = self.cap.read()
if ret:
# I have set it to grayscale (1ch) just in case, but if the frame is 1ch, this line can be commented out.
self.current_image_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
return True
return False
cct = 300
ransac_lower_x = 100
ransac_lower_y = 100
cx = 0
cy = 0
def single_run(self):
# Temporary implementation to run
## default_radius = 14
# cropbox=[] # debug code
frame = self.current_image_gray
if self.now_modeo == self.cv_modeo[1]:
# adjustment of radius
# debug print
# if calc_print_enable:
# temp_radius = self.auto_radius_calc.get_radius()
# print('Now radius:', temp_radius)
# self.cvparam.radius = temp_radius
self.cvparam.radius = self.auto_radius_calc.get_radius()
if self.auto_radius_calc.adj_comp_flag:
self.now_modeo = self.cv_modeo[2] if not skip_blink_detect else self.cv_modeo[3]
radius, pad, step, hsf = self.cvparam.get_rpsh()
gray_frame = frame
# Calculate the integral image of the frame
(
frame_pad,
frame_int,
inner_sum,
in_p00,
in_p11,
in_p01,
in_p10,
y_ro_m,
x_ro_m,
y_ro_p,
x_ro_p,
outer_sum,
out_p_temp,
out_p00,
out_p11,
out_p01,
out_p10,
response_list,
frame_conv,
frame_conv_stride,
) = get_frameint_empty_array(gray_frame.shape, pad, step[0], step[1], hsf.r_in, hsf.r_out)
# BORDER_CONSTANT is faster than BORDER_REPLICATE There seems to be almost no negative impact when BORDER_CONSTANT is used.
cv2.copyMakeBorder(gray_frame, pad, pad, pad, pad, cv2.BORDER_CONSTANT, dst=frame_pad)
cv2.integral(frame_pad, sum=frame_int, sdepth=cv2.CV_32S)
# Convolve the feature with the integral image
conv_int_start_time = timeit.default_timer()
response, hsf_min_loc = conv_int(
frame_int,
hsf,
inner_sum,
in_p00,
in_p11,
in_p01,
in_p10,
y_ro_m,
x_ro_m,
y_ro_p,
x_ro_p,
outer_sum,
out_p_temp,
out_p00,
out_p11,
out_p01,
out_p10,
response_list,
frame_conv_stride,
)
center_xy = get_hsf_center(pad, step[0], step[1], hsf_min_loc)
# Pseudo-visualization of HSF
# cv2.normalize(cv2.filter2D(cv2.filter2D(frame_pad, cv2.CV_64F, hsf.get_kernel()[hsf.get_kernel().shape[0]//2,:].reshape(1,-1), borderType=cv2.BORDER_CONSTANT), cv2.CV_64F, hsf.get_kernel()[:,hsf.get_kernel().shape[1]//2].reshape(-1,1), borderType=cv2.BORDER_CONSTANT),None,0,255,cv2.NORM_MINMAX,dtype=cv2.CV_8U))
# self.timedict["conv_int"].append(timeit.default_timer() - conv_int_start_time)
crop_start_time = timeit.default_timer()
# Define the center point and radius
center_x, center_y = center_xy
upper_x = center_x + radius
lower_x = center_x - radius
upper_y = center_y + radius
lower_y = center_y - radius
# Crop the image using the calculated bounds
cropped_image = safe_crop(gray_frame, lower_x, lower_y, upper_x, upper_y)
# cropbox = [clamp(val, 0, gray_frame.shape[i]) for i, val in
# zip([1, 0, 1, 0], [lower_x, lower_y, upper_x, upper_y])] # debug code
if self.now_modeo == self.cv_modeo[0] or self.now_modeo == self.cv_modeo[1]:
# If mode is first_frame or radius_adjust, record current radius and response
self.auto_radius_calc.add_response(radius, response)
elif self.now_modeo == self.cv_modeo[2]:
# Statistics for blink detection
if self.blink_detector.response_len() < blink_init_frames:
self.blink_detector.add_response(cv2.mean(cropped_image)[0])
upper_x = center_x + max(20, radius) # self.center_correct.center_q1_radius
lower_x = center_x - max(20, radius) # self.center_correct.center_q1_radius
upper_y = center_y + max(20, radius) # self.center_correct.center_q1_radius
lower_y = center_y - max(20, radius) # self.center_correct.center_q1_radius
self.center_q1.add_response(
cv2.mean(
safe_crop(
gray_frame,
lower_x,
lower_y,
upper_x,
upper_y,
keepsize=False,
)
)[0]
)
else:
self.blink_detector.calc_thresh()
self.center_q1.calc_thresh()
self.now_modeo = self.cv_modeo[3]
else:
if 0 in cropped_image.shape:
# If shape contains 0, it is not detected well.
print("Something's wrong.")
else:
orig_x, orig_y = center_x, center_y
if self.blink_detector.enable_detect_flg:
# If the average value of cropped_image is greater than response_max
# (i.e., if the cropimage is whitish
if self.blink_detector.detect(cv2.mean(cropped_image)[0]):
# blink
pass
else:
# pass
if not self.center_correct.setup_comp:
self.center_correct.init_array(gray_frame.shape, self.center_q1.quartile_1, radius)
elif self.center_correct.frame_shape != gray_frame.shape:
"""The resolution should have changed and the statistics should have changed, so essentially the statistics
need to be reworked, but implementation will be postponed as viability is the highest priority."""
self.center_correct.init_array(gray_frame.shape, self.center_q1.quartile_1, radius)
center_x, center_y = self.center_correct.correction(gray_frame, center_x, center_y)
# Define the center point and radius
center_xy = (center_x, center_y)
upper_x = center_x + radius
lower_x = center_x - radius
upper_y = center_y + radius
lower_y = center_y - radius
# Crop the image using the calculated bounds
cropped_image = safe_crop(gray_frame, lower_x, lower_y, upper_x, upper_y)
# cropbox = [clamp(val, 0, gray_frame.shape[i]) for i, val in
# zip([1, 0, 1, 0], [lower_x, lower_y, upper_x, upper_y])] # debug code
# if imshow_enable or save_video:
cv2.circle(frame, (orig_x, orig_y), 6, (0, 0, 255), -1)
cv2.circle(frame, (center_x, center_y), 3, (255, 0, 0), -1)
# If you want to update response_max. it may be more cost-effective to rewrite response_list in the following way
# https://stackoverflow.com/questions/42771110/fastest-way-to-left-cycle-a-numpy-array-like-pop-push-for-a-queue
cv_end_time = timeit.default_timer()
# self.timedict["crop"].append(cv_end_time - crop_start_time)
# self.timedict["total_cv"].append(cv_end_time - cv_start_time)
# if calc_print_enable:
# the lower the response the better the likelyhood of there being a pupil. you can adujst the radius and steps accordingly
# print('Kernel response:', response)
# print('Pixel position:', center_xy)
if imshow_enable:
if self.now_modeo != self.cv_modeo[0] and self.now_modeo != self.cv_modeo[1]:
if 0 in cropped_image.shape:
# If shape contains 0, it is not detected well.
pass
else:
cv2.imshow("crop", cropped_image)
cv2.imshow("frame", frame)
if cv2.waitKey(1) & 0xFF == ord("q"):
pass
if self.now_modeo == self.cv_modeo[0]:
# Moving from first_frame to the next mode
if skip_autoradius and skip_blink_detect:
self.now_modeo = self.cv_modeo[3]
elif skip_autoradius:
self.now_modeo = self.cv_modeo[2]
else:
self.now_modeo = self.cv_modeo[1]
# debug code
# return center_x,center_y,cropbox,frame
return center_x, center_y, frame, radius
class External_Run_HSF(object):
def __init__(self, skip_autoradius_flg=False, radius=20):
# temporary code
global skip_autoradius, default_radius
skip_autoradius = skip_autoradius_flg
if skip_autoradius:
default_radius = radius
self.algo = HSF_cls()
def run(self, current_image_gray):
self.algo.current_image_gray = current_image_gray
# debug code
# center_x, center_y,cropbox, frame = self.algo.single_run()
# return center_x, center_y,cropbox, frame
center_x, center_y, frame, radius = self.algo.single_run()
return center_x, center_y, frame, radius
if __name__ == "__main__":
hsf = HSF_cls()
hsf.open_video(video_path)
while hsf.read_frame():
_ = hsf.single_run()

View File

@ -0,0 +1,344 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Intensity Based Openess By: Prohurtz, PallasNeko (Optimization)
Algorithm App Implementations By: Prohurtz
Copyright (c) 2025 EyeTrackVR <3
LICENSE: LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
import numpy as np
import time
import os
import cv2
from eye import EyeId
from one_euro_filter import OneEuroFilter
import psutil
import sys
process = psutil.Process(os.getpid()) # set process priority to low
try: # medium chance this does absolutely nothing but eh
sys.getwindowsversion()
except AttributeError:
process.nice(0) # UNIX: 0 low 10 high
process.nice()
else:
process.nice(psutil.BELOW_NORMAL_PRIORITY_CLASS) # Windows
process.nice()
# higher intensity means more closed/ more white/less pupil
# Hm I need an acronym for this, any ideas?
# IBO Intensity Based Openess
# HOW THIS WORKS:
# we get the intensity of pupil area from HSF crop, When the eyelid starts to close, the pupil starts being obstructed by skin which is generally lighter than the pupil.
# This causes the intensity to increase. We save all of the darkest intensities of each pupil position to calculate for pupil movement.
# ex. when you look up there is less pupil visible, which results in an uncalculated change in intensity even though the eyelid has not moved in a meaningful way.
# We compare the darkest intensity of that area, to the lightest (global) intensity to find the appropriate openness state via a float.
# Note.
# OpenCV on Windows will generate an error if the file path contains non-ASCII characters when using cv2.imread(), cv2.imwrite(), etc.
# https://stackoverflow.com/questions/43185605/how-do-i-read-an-image-from-a-path-with-unicode-characters
# https://github.com/opencv/opencv/issues/18305
def csv2data(frameshape, filepath):
# For data checking
frameshape = (frameshape[0], frameshape[1] + 1)
out = np.zeros(frameshape, dtype=np.uint32)
xy_list = []
val_list = []
with open(filepath, mode="r", encoding="utf-8") as in_f:
# Skip header.
_ = in_f.readline()
for s in in_f:
xyval = [int(val) for val in s.strip().split(",")]
xy_list.append((xyval[0], xyval[1]))
val_list.append(xyval[2])
xy_list = np.array(xy_list)
val_list = np.array(val_list)
out[xy_list[:, 1], xy_list[:, 0]] = val_list[:]
return out
def data2csv(data_u32, filepath):
# For data checking
nonzero_index = np.nonzero(data_u32) # (row,col)
data_list = data_u32[nonzero_index].tolist()
datalines = ["{},{},{}\n".format(x, y, val) for y, x, val in zip(*nonzero_index, data_list)]
with open(filepath, "w", encoding="utf-8") as out_f:
out_f.write("x,y,intensity\n")
out_f.writelines(datalines)
return
def u32_1ch_to_u16_3ch(img):
out = np.zeros((*img.shape[:2], 3), dtype=np.uint16)
# https://github.com/numpy/numpy/issues/2524
# https://stackoverflow.com/questions/52782511/why-is-numpy-slower-than-python-for-left-bit-shifts
out[:, :, 0] = img & np.uint32(65535)
out[:, :, 1] = (img >> np.uint32(16)) & np.uint32(65535)
return out
def u16_3ch_to_u32_1ch(img):
# The image format with the most bits that can be displayed on Windows without additional software and that opencv can handle is PNG's uint16
out = img[:, :, 0].astype(np.float64) # float64 = max 2^53
cv2.add(out, img[:, :, 1].astype(np.float64) * np.float64(65536), dst=out) # opencv did not have uint32 type
return out.astype(np.uint32) # cast
def newdata(frameshape):
print("\033[94m[INFO] Initialise data for blinking.\033[0m")
return np.zeros(frameshape, dtype=np.uint32)
class IntensityBasedOpeness:
def __init__(self, eye_id):
# todo: It is necessary to consider whether the filename can be changed in the configuration file, etc.
if eye_id in [EyeId.LEFT]:
self.imgfile = "IBO_LEFT.png"
else:
pass
if eye_id in [EyeId.RIGHT]:
self.imgfile = "IBO_RIGHT.png"
else:
pass
# self.imgfile = "IBO_LEFT.png" if eyeside is EyeLR.LEFT else "IBO_RIGHT.png"
# self.data[0, -1] = maxval, [1, -1] = rotation, [2, -1] = x, [3, -1] = y
self.data = None
self.lct = None
self.maxval = 0
# self.img_roi = self.now_roi == {"rotation": 0, "x": 0, "y": 0}
self.img_roi = np.zeros(3, dtype=np.int32)
self.now_roi = np.zeros(3, dtype=np.int32)
self.prev_val = 0.5
self.avg_intensity = 0.0
self.old = []
self.color = []
self.x = []
self.fc = 0
self.filterlist = []
self.averageList = []
self.openlist = []
self.eye_id = eye_id
self.maxinten = 0
self.tri_filter = []
min_cutoff = 0.0004
beta = 0.9
noisy_point = np.array([1, 1])
self.one_euro_filter = OneEuroFilter(noisy_point, min_cutoff=min_cutoff, beta=beta)
def check(self, frameshape):
# 0 in data is used as the initial value.
# When assigning a value, +1 is added to the value to be assigned.
self.load(frameshape)
# self.maxval = self.data[0, -1]
if self.lct is None:
self.lct = time.time()
def load(self, frameshape):
req_newdata = False
# Not very clever, but increase the width by 1px to save the maximum value.
frameshape = (frameshape[0], frameshape[1] + 1)
if self.data is None:
print(f"\033[92m[INFO] Loaded data for blinking: {self.imgfile}\033[0m")
if os.path.isfile(self.imgfile):
try:
img = cv2.imread(self.imgfile, flags=cv2.IMREAD_UNCHANGED)
# check code: cv2.absdiff(img,u32_1ch_to_u16_3ch(u16_3ch_to_u32_1ch(img)))
if img.shape[:2] != frameshape:
print("[WARN] Size does not match the input frame.")
req_newdata = True
else:
self.data = u16_3ch_to_u32_1ch(img)
self.img_roi[:] = self.data[1:4, -1]
if not np.array_equal(self.img_roi, self.now_roi):
# If the ROI recorded in the image file differs from the current ROI
req_newdata = True
else:
self.maxval = self.data[0, -1]
except:
print("[ERROR] File read error: {}".format(self.imgfile))
req_newdata = True
else:
print("\033[94m[INFO] File does not exist.\033[0m")
req_newdata = True
else:
if self.data.shape != frameshape or not np.array_equal(self.img_roi, self.now_roi):
# If the ROI recorded in the image file differs from the current ROI
# todo: Using the previous and current frame sizes and centre positions from the original, etc., the data can be ported to some extent, but there may be many areas where code changes are required.
print("[INFO] \033[94mFrame size changed.\033[0m")
req_newdata = True
if req_newdata:
self.data = newdata(frameshape)
self.maxval = 0
self.img_roi = self.now_roi.copy()
# data2csv(self.data, "a.csv")
# csv2data(frameshape,"a.csv")
def save(self):
self.data[0, -1] = self.maxval
self.data[1:4, -1] = self.now_roi
cv2.imwrite(self.imgfile, u32_1ch_to_u16_3ch(self.data))
# print("SAVED: {}".format(self.imgfile))
def change_roi(self, roiinfo: dict):
self.now_roi[:] = [v for v in roiinfo.values()]
def clear_filter(self):
self.data = None
self.filterlist.clear()
self.averageList.clear()
if os.path.exists(self.imgfile):
os.remove(self.imgfile)
def intense(self, x, y, frame, filterSamples, outputSamples):
# x,y = 0~(frame.shape[1 or 0]-1), frame = 1-channel frame cropped by ROI
self.check(frame.shape)
int_x, int_y = int(x), int(y)
if int_x < 0 or int_y < 0:
return self.prev_val
upper_x = min(int_x + 25, frame.shape[1] - 1) # TODO make this a setting
lower_x = max(int_x - 25, 0)
upper_y = min(int_y + 25, frame.shape[0] - 1)
lower_y = max(int_y - 25, 0)
# frame_crop = frame[lower_y:upper_y, lower_x:upper_x]
# frame = safe_crop(frame, lower_x, lower_y, upper_x, upper_y, False)
# ret_, th = cv2.threshold(frame_crop, 80, 1.0, cv2.THRESH_BINARY_INV, dst=frame_crop)
frame_crop = frame
# ret, f = cv2.threshold(frame, 80, 255, cv2.THRESH_BINARY)
# ret, frame_crop = cv2.threshold(frame_crop, 80, 255, cv2.THRESH_BINARY)
# The same can be done with cv2.integral, but since there is only one area of the rectangle for which we want to know the total value, there is no advantage in terms of computational complexity.
intensity = frame_crop.sum() + 1
if len(self.filterlist) < filterSamples:
self.filterlist.append(intensity)
else:
self.filterlist.pop(0)
self.filterlist.append(intensity)
try:
if intensity >= np.percentile(self.filterlist, 99): # filter abnormally high values
intensity = self.maxval
except:
pass
# numpy:np.sum(),ndarray.sum()
# opencv:cv2.sumElems()
# I don't know which is faster.
changed = False
newval_flg = False
oob = False
if int_x >= frame.shape[1]:
int_x = frame.shape[1] - 1
oob = True
# print('CAUGHT X OUT OF BOUNDS')
if int_x < 0:
int_x = True
oob = True
# print('CAUGHT X UNDER BOUNDS')
if int_y >= frame.shape[0]:
int_y = frame.shape[0] - 1
oob = True
# print('CAUGHT Y OUT OF BOUNDS')
if int_y < 0:
int_y = 1
oob = True
# print('CAUGHT Y UNDER BOUNDS')
if oob != True and self.data.any():
data_val = self.data[int_y, int_x]
else:
data_val = 0
# max pupil per cord
if data_val == 0:
# The value of the specified coordinates has not yet been recorded.
self.data[int_y, int_x] = intensity
changed = True
newval_flg = True
else:
if intensity < data_val: # if current intensity value is less (more pupil), save that
self.data[int_y, int_x] = intensity # set value
changed = True
else:
intensitya = max(
data_val + 5000, 1
) # if current intensity value is not less use this is an agressive adjust, test
self.data[int_y, int_x] = intensitya # set value
changed = True
# min pupil global
if self.maxval == 0: # that value is not yet saved
self.maxval = intensity # set value at 0 index
else:
if intensity > self.maxval: # if current intensity value is more (less pupil), save that NOTE: we have the
self.maxval = intensity - 5 # set value at 0 index
else:
intensityd = max(
(self.maxval - 5), 1
) # continuously adjust closed intensity, will be set when user blink, used to allow eyes to close when lighting changes
self.maxval = intensityd # set value at 0 index
# print(intensityd, intensity)
if newval_flg:
# Do the same thing as in the original version.
eyeopen = self.prev_val # 0.9
else:
maxp = float(self.data[int_y, int_x])
minp = float(self.maxval)
eyeopen = (intensity - maxp) / (
minp - maxp
) # for whatever reason when input and maxp are too close it outputs high
eyeopen = 1 - eyeopen
if outputSamples > 0:
if len(self.averageList) < outputSamples:
self.averageList.append(eyeopen)
else:
self.averageList.pop(0)
self.averageList.append(eyeopen)
eyeopen = np.average(self.averageList)
eyeopen = np.clip(eyeopen, 0.0, 1.0)
if changed and ((time.time() - self.lct) > 11): # save every 5 seconds if something changed to save disk usage
self.save()
self.lct = time.time()
self.prev_val = eyeopen
return eyeopen

193
EyeTrackApp/leap.py Normal file
View File

@ -0,0 +1,193 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Algorithm App Implementations By: Prohurtz
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
import os
import onnxruntime
import numpy as np
import cv2
import time
import math
from queue import Queue
import threading
from config import EyeTrackCameraConfig, EyeTrackConfig
from one_euro_filter import OneEuroFilter
import psutil
from utils.misc_utils import resource_path
from pathlib import Path
os.environ["OMP_NUM_THREADS"] = "1"
frames = 0
models = Path("Models")
def run_model(input_queue, output_queue, session):
while True:
frame = input_queue.get()
if frame is None:
break
img_np = np.array(frame, dtype=np.float32) / 255.0
gray_img = 0.299 * img_np[:, :, 0] + 0.587 * img_np[:, :, 1] + 0.114 * img_np[:, :, 2]
gray_img = np.expand_dims(np.expand_dims(gray_img, axis=0), axis=0)
ort_inputs = {session.get_inputs()[0].name: gray_img}
pre_landmark = session.run(None, ort_inputs)
pre_landmark = np.reshape(pre_landmark, (-1, 2))
output_queue.put((frame, pre_landmark))
def run_onnx_model(queues, session, frame):
for queue in queues:
if not queue.full():
queue.put(frame)
break
class LEAP_C:
def __init__(self, eye_config: EyeTrackCameraConfig, config: EyeTrackConfig):
self.last_lid = None
self.current_image_gray = None
self.current_image_gray_clean = None
onnxruntime.disable_telemetry_events()
self.num_threads = 1
self.queue_max_size = 1
self.model_path = resource_path(models / "pfld-sim.onnx")
self.print_fps = False
self.frames = 0
self.queues = [Queue(maxsize=self.queue_max_size) for _ in range(self.num_threads)]
self.threads = []
self.model_output = np.zeros((12, 2))
self.output_queue = Queue(maxsize=self.queue_max_size)
self.start_time = time.time()
opts = onnxruntime.SessionOptions()
opts.inter_op_num_threads = 1
opts.intra_op_num_threads = 1
opts.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
opts.enable_mem_pattern = False
self.one_euro_filter_float = OneEuroFilter(np.random.rand(1, 2), min_cutoff=0.0004, beta=0.9)
self.dmax = 0
self.dmin = 0
self.openlist = []
self.maxlist = []
self.previous_time = None
self.old_matrix = None
self.total_velocity_new = 0
self.total_velocity_avg = 0
self.total_velocity_old = 0
self.old_per = 0.0
self.delta_per_neg = 0.0
self.ort_session1 = onnxruntime.InferenceSession(self.model_path, opts, providers=["CPUExecutionProvider"])
self.eye_config: EyeTrackCameraConfig = eye_config
self.config: EyeTrackConfig = config
for i in range(self.num_threads):
thread = threading.Thread(
target=run_model,
args=(self.queues[i], self.output_queue, self.ort_session1),
name=f"Thread {i}",
)
self.threads.append(thread)
thread.start()
def leap_run(self):
img = self.current_image_gray_clean.copy()
img = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB)
img_height, img_width = img.shape[:2]
frame = cv2.resize(img, (112, 112))
imgvis = self.current_image_gray.copy()
run_onnx_model(self.queues, self.ort_session1, frame)
if not self.output_queue.empty():
frame, pre_landmark = self.output_queue.get()
for point in pre_landmark:
x, y = point
x = int(x * img_width)
y = int(y * img_height)
cv2.circle(imgvis, (x, y), 3, (255, 255, 0), -1)
cv2.circle(imgvis, (x, y), 1, (0, 0, 255), -1)
d1 = math.dist(pre_landmark[1], pre_landmark[3])
d2 = math.dist(pre_landmark[2], pre_landmark[4])
d = (d1 + d2) / 2
if self.calib == 0:
self.openlist = []
self.eye_config.leap_calibrated = False
if not self.eye_config.leap_calibrated:
self.openlist.append(d)
self.eye_config.leap_calibration_percentile_90 = np.percentile(self.openlist, 90) if len(self.openlist) >= 10 else 0.8
self.eye_config.leap_calibration_percentile_2 = np.percentile(self.openlist, 2) - self.eye_config.leap_calibration_percentile_90
if len(self.openlist) >= self.config.settings.leap_calibration_samples:
self.eye_config.leap_calibrated = True
self.config.save()
print(f"[INFO] {'Left' if self.eye_config is self.config.left_eye else 'Right'} eye calibrated")
try:
if len(self.openlist) > 0 or self.eye_config.leap_calibrated:
per = (d - self.eye_config.leap_calibration_percentile_90) / self.eye_config.leap_calibration_percentile_2
per = 1 - per
per = np.clip(per, 0.0, 1.0)
else:
per = 0.8
except:
per = 0.8
x = pre_landmark[6][0]
y = pre_landmark[6][1]
self.last_lid = per
calib_array = np.array([per, per]).reshape(1, 2)
per = self.one_euro_filter_float(calib_array)[0][0]
if per <= 0.25:
per = 0.0
return imgvis, float(x*img_width), float(y*img_height), per
imgvis = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
return imgvis, 0, 0, 0
class External_Run_LEAP:
def __init__(self, eye_config: EyeTrackCameraConfig, config: EyeTrackConfig):
self.algo = LEAP_C(eye_config, config)
def run(self, current_image_gray, current_image_gray_clean, calib):
self.algo.current_image_gray = current_image_gray
self.algo.current_image_gray_clean = current_image_gray_clean
self.algo.calib = calib
img, x, y, per = self.algo.leap_run()
return img, x, y, per

View File

@ -1,3 +1,6 @@
# https://github.com/jaantollander/OneEuroFilter
# LICENSE: MIT
import numpy as np
from time import time
@ -20,17 +23,17 @@ class OneEuroFilter:
self.beta = np.full(x0.shape, beta)
self.d_cutoff = np.full(x0.shape, d_cutoff)
# Previous values.
self.x_prev = x0.astype(np.float)
self.x_prev = x0
self.dx_prev = np.full(x0.shape, dx0)
self.t_prev = time()
def __call__(self, x):
"""Compute the filtered signal."""
assert x.shape == self.data_shape
t = time()
t_e = t - self.t_prev
if t_e != 0.0: #occasionally when switching to algos this becomes zero causing divide by zero errors crashing the filter.
try:
t = time()
t_e = t - self.t_prev
if t_e != 0.0: #occasionally when switching to HSF this becomes zero causing divide by zero errors crashing the filter.
t_e = np.full(x.shape, t_e)
# The filtered derivative of the signal.
@ -49,3 +52,8 @@ class OneEuroFilter:
self.t_prev = t
return x_hat
else:
self.x_prev = x
return x
except:
print("\033[91m[ERROR] One Euro Filter Error. Is your system clock running properly?\033[0m")

View File

@ -1,197 +0,0 @@
from pythonosc import udp_client
from pythonosc import osc_server
from pythonosc import dispatcher
from winsound import PlaySound, SND_FILENAME, SND_ASYNC
import queue
import threading
from enum import IntEnum
import time
class EyeId(IntEnum):
RIGHT = 0
LEFT = 1
BOTH = 2
SETTINGS = 3
from config import EyeTrackConfig
class VRChatOSC:
# Use a tuple of blink (true, blinking, false, not), x, y for now. Probably clearer as a class but
# we're stuck in python 3.6 so still no dataclasses. God I hate python.
def __init__(self, cancellation_event: threading.Event, msg_queue: queue.Queue[tuple[bool, int, int]], main_config: EyeTrackConfig,):
self.main_config = main_config
self.config = main_config.settings
self.client = udp_client.SimpleUDPClient(self.config.gui_osc_address, int(self.config.gui_osc_port)) # use OSC port and address that was set in the config
self.cancellation_event = cancellation_event
self.msg_queue = msg_queue
def run(self):
start = time.time()
last_blink = time.time()
yl = 621
yr = 621
sx = 0
sy = 0
se = 0
lec = 0
rec = 0
rb = False
lb = False
while True:
if self.cancellation_event.is_set():
print("Exiting OSC Queue")
return
try:
(eye_id, eye_info) = self.msg_queue.get(block=True, timeout=0.1)
except:
continue
if not eye_info.blink:
if self.config.tracker_single_eye == 1 or self.config.tracker_single_eye == 2:
self.client.send_message("/avatar/parameters/LeftEyeX", eye_info.x) # only one eye is detected or there is an error. Send mirrored data to both eyes.
self.client.send_message("/avatar/parameters/RightEyeX", eye_info.x)
self.client.send_message("/avatar/parameters/EyesY", eye_info.y)
self.client.send_message("/avatar/parameters/RightEyeLid", float(0))# old param open right
self.client.send_message("/avatar/parameters/RightEyeLidExpandedSqueeze", float(0.8)) # open r
self.client.send_message("/avatar/parameters/LeftEyeLid", float(0))# old param open left
self.client.send_message("/avatar/parameters/LeftEyeLidExpandedSqueeze", float(0.8)) # open left eye
if self.config.gui_blink_sync and not rb and not lb:
self.client.send_message("/avatar/parameters/RightEyeLid", float(0))# old param open right
self.client.send_message("/avatar/parameters/RightEyeLidExpandedSqueeze", float(0.8)) # open r
self.client.send_message("/avatar/parameters/LeftEyeLid", float(0))# old param open left
self.client.send_message("/avatar/parameters/LeftEyeLidExpandedSqueeze", float(0.8)) # open left eye
else:
if eye_id in [EyeId.RIGHT]:
yr = eye_info.y
sx = eye_info.x
sy = eye_info.y
rb = False
self.client.send_message("/avatar/parameters/RightEyeX", eye_info.x)
if not self.config.gui_blink_sync or self.config.gui_blink_sync and not lb:
self.client.send_message("/avatar/parameters/RightEyeLid", float(0))# old param open right
self.client.send_message("/avatar/parameters/RightEyeLidExpandedSqueeze", float(0.8)) # open right eye
if eye_id in [EyeId.LEFT]:
yl = eye_info.y
sx = eye_info.x
sy = eye_info.y
lb = False
self.client.send_message("/avatar/parameters/LeftEyeX", eye_info.x)
if not self.config.gui_blink_sync or self.config.gui_blink_sync and not rb:
self.client.send_message("/avatar/parameters/LeftEyeLid", float(0))# old param open left
self.client.send_message("/avatar/parameters/LeftEyeLidExpandedSqueeze", float(0.8)) # open left eye
if (yr != 621 and yl != 621) and (lb == False and rb == False):
y = (yr + yl) / 2
self.client.send_message("/avatar/parameters/EyesY", y)
else:
print(last_blink)
if self.config.gui_blink_sync:
if eye_id in [EyeId.LEFT]:
lb = True
if eye_id in [EyeId.RIGHT]:
rb = True
if rb == True and lb == True : # If both eyes are closed, blink
if last_blink > 0.5:
for i in range(4):
self.client.send_message("/avatar/parameters/RightEyeLid", float(1)) #close eye
self.client.send_message("/avatar/parameters/LeftEyeLid", float(1))
self.client.send_message("/avatar/parameters/RightEyeLidExpandedSqueeze", float(0)) # close eye
self.client.send_message("/avatar/parameters/LeftEyeLidExpandedSqueeze", float(0))
last_blink = time.time() - last_blink
else:
if self.config.tracker_single_eye == 1 or self.config.tracker_single_eye == 2:
if last_blink > 0.5:
for i in range(4):
self.client.send_message("/avatar/parameters/RightEyeLid", float(1)) #close eye
self.client.send_message("/avatar/parameters/LeftEyeLid", float(1))
self.client.send_message("/avatar/parameters/RightEyeLidExpandedSqueeze", float(0)) # close eye
self.client.send_message("/avatar/parameters/LeftEyeLidExpandedSqueeze", float(0))
last_blink = time.time() - last_blink
if not self.config.gui_eye_falloff:
if eye_id in [EyeId.LEFT]:
lb = True
if last_blink > 0.7:
for i in range(5):
self.client.send_message("/avatar/parameters/LeftEyeLid", float(1))
self.client.send_message("/avatar/parameters/LeftEyeLidExpandedSqueeze", float(0))
last_blink = time.time() - last_blink
if eye_id in [EyeId.RIGHT]:
rb = True
if last_blink > 0.7:
for i in range(5):
self.client.send_message("/avatar/parameters/RightEyeLid", float(1))
self.client.send_message("/avatar/parameters/RightEyeLidExpandedSqueeze", float(0)) # close eye
last_blink = time.time() - last_blink
else:
if eye_id in [EyeId.LEFT]:
lb = True
if eye_id in [EyeId.RIGHT]:
rb = True
if rb or lb: # If one eye closed and fall off is enabled, mirror data
self.client.send_message("/avatar/parameters/LeftEyeX", sx) #Send mirrored data to both eyes.
self.client.send_message("/avatar/parameters/RightEyeX", sx)
self.client.send_message("/avatar/parameters/EyesY", sy)
self.client.send_message("/avatar/parameters/RightEyeLid", float(0))# old param open right
self.client.send_message("/avatar/parameters/RightEyeLidExpandedSqueeze", float(0.8)) # open r
self.client.send_message("/avatar/parameters/LeftEyeLid", float(0))# old param open left
self.client.send_message("/avatar/parameters/LeftEyeLidExpandedSqueeze", float(0.8)) # open left eye
if rb and lb: # If both eyes are closed, blink
if last_blink > 0.5:
for i in range(4):
self.client.send_message("/avatar/parameters/RightEyeLid", float(1)) #close eye
self.client.send_message("/avatar/parameters/LeftEyeLid", float(1))
self.client.send_message("/avatar/parameters/RightEyeLidExpandedSqueeze", float(0)) # close eye
self.client.send_message("/avatar/parameters/LeftEyeLidExpandedSqueeze", float(0))
last_blink = time.time() - last_blink
class VRChatOSCReceiver:
def __init__(self, cancellation_event: threading.Event, main_config: EyeTrackConfig, eyes: []):
self.config = main_config.settings
self.cancellation_event = cancellation_event
self.dispatcher = dispatcher.Dispatcher()
self.eyes = eyes # we cant import CameraWidget so any type it is
try:
self.server = osc_server.OSCUDPServer((self.config.gui_osc_address, int(self.config.gui_osc_receiver_port)), self.dispatcher)
except:
print(f"[ERROR] OSC Recieve port: {self.config.gui_osc_receiver_port} occupied. ")
def shutdown(self):
print("Shutting down OSC receiver")
try:
self.server.shutdown()
except:
pass
def recenter_eyes(self, address, osc_value):
if type(osc_value) != bool: return # just incase we get anything other than bool
if osc_value:
for eye in self.eyes:
eye.settings.gui_recenter_eyes = True
def recalibrate_eyes(self, address, osc_value):
if type(osc_value) != bool: return # just incase we get anything other than bool
if osc_value:
for eye in self.eyes:
eye.ransac.calibration_frame_counter = 300
PlaySound('Audio/start.wav', SND_FILENAME | SND_ASYNC)
def run(self):
# bind what function to run when specified OSC message is received
try:
self.dispatcher.map(self.config.gui_osc_recalibrate_address, self.recalibrate_eyes)
self.dispatcher.map(self.config.gui_osc_recenter_address, self.recenter_eyes)
# start the server
print("VRChatOSCReceiver serving on {}".format(self.server.server_address))
self.server.serve_forever()
except:
print(f"[ERROR] OSC Recieve port: {self.config.gui_osc_receiver_port} occupied. ")

View File

@ -0,0 +1,13 @@
import dataclasses
from enum import IntEnum
class OSCMessageType(IntEnum):
EYE_INFO = 1
VRCFT_MODULE_INFO = 2
@dataclasses.dataclass
class OSCMessage:
type: OSCMessageType
data: any

View File

@ -0,0 +1,17 @@
from pythonosc.udp_client import SimpleUDPClient
from osc.OSCMessage import OSCMessage
class VRCFTModuleSender:
set_command_pattern = "/command/{}/{}/"
def send(self, osc_message: OSCMessage, client: SimpleUDPClient):
command = osc_message.data.get("command", None)
field_to_send = osc_message.data.get("field", None)
value_to_send = osc_message.data.get("value", None)
if not command or not all([field_to_send, value_to_send is not None]):
print("[ERROR] Misconfiguration in received OSC message for the VRCFT Module")
return
client.send_message(self.set_command_pattern.format(command, field_to_send), value_to_send)

View File

@ -0,0 +1,358 @@
from pythonosc.udp_client import SimpleUDPClient
from eye import EyeId
from osc.OSCMessage import OSCMessage
from config import EyeTrackConfig, EyeTrackSettingsConfig
from enum import IntEnum
import time
def _eyelid_transformer(config, eye_blink):
if config.osc_invert_eye_close:
return float(1 - eye_blink)
else:
return float(eye_blink)
class OutputType(IntEnum):
V1_PARAMS = 1
V2_PARAMS = 2
NATIVE_PARAMS = 3
class VRChatOSCSender:
def __init__(self):
self.is_single_eye = False
self.falloff_enabled = False
self.left_y = 621
self.right_y = 621
self.r_eye_x = 0
self.l_eye_x = 0
self.r_eye_blink = 0.7
self.l_eye_blink = 0.7
self.l_eye_velocity = 0
self.r_eye_velocity = 1
self.left_last_blink = time.time()
self.right_last_blink = time.time()
self.r_dilation = 0
self.l_dilation = 0
def output_osc_info(
self,
osc_message: OSCMessage,
client: SimpleUDPClient,
main_config: EyeTrackConfig,
config: EyeTrackSettingsConfig,
):
eye_id, eye_info = osc_message.data
self.is_single_eye = self.get_is_single_eye(main_config.eye_display_id)
output_method = None
if config.gui_vrc_native:
output_method = self.output_native
if config.gui_osc_vrcft_v1:
output_method = self.output_v1_params
if config.gui_osc_vrcft_v2:
output_method = self.output_v2_params
if output_method:
output_method(
main_config=main_config,
config=config,
client=client,
eye_x=eye_info.x,
eye_y=eye_info.y,
eye_blink=eye_info.blink,
avg_velocity=eye_info.avg_velocity,
eye_id=eye_id,
pupil_dilation=eye_info.pupil_dilation,
)
@staticmethod
def get_is_single_eye(eye_display_id):
return eye_display_id in [EyeId.RIGHT, EyeId.LEFT, 0, 1]
def update_eye_state(self, eye_id, eye_x, eye_y, eye_blink, avg_velocity, pupil_dilation):
if eye_id == EyeId.LEFT:
self.l_eye_x = eye_x
self.l_eye_blink = eye_blink
self.left_y = eye_y
self.l_eye_velocity = avg_velocity
self.l_dilation = pupil_dilation
if eye_id == EyeId.RIGHT:
self.r_eye_x = eye_x
self.r_eye_blink = eye_blink
self.right_y = eye_y
self.r_eye_velocity = avg_velocity
self.r_dilation = pupil_dilation
def output_native(self, main_config, config, client, eye_x, eye_y, eye_blink, avg_velocity, eye_id, pupil_dilation):
default_eye_blink_params = {
"eye_id": eye_id,
"client": client,
"config": config,
}
self.update_eye_state(
eye_id=eye_id,
eye_x=eye_x,
eye_y=eye_y,
eye_blink=eye_blink,
avg_velocity=avg_velocity,
pupil_dilation=pupil_dilation,
)
if self.is_single_eye:
self.output_osc_native_blink(
**default_eye_blink_params,
)
client.send_message(
"/tracking/eye/LeftRightVec",
[float(eye_x), float(eye_y), 1.0, float(eye_x), float(eye_y), 1.0],
)
if eye_id in [EyeId.LEFT, EyeId.RIGHT, EyeId.BOTH] and not self.is_single_eye:
self.output_osc_native_blink(**default_eye_blink_params, single_eye_mode=False)
if not self.is_single_eye:
# vrc native ET (z values may need tweaking, they act like a scalar)
client.send_message(
"/tracking/eye/LeftRightVec",
[
float(self.l_eye_x),
float(self.left_y),
1.0,
float(self.r_eye_x),
float(self.right_y),
1.0,
],
)
def output_v1_params(
self,
main_config,
config,
client,
eye_x,
eye_y,
eye_blink,
avg_velocity,
eye_id,
pupil_dilation,
):
default_eye_blink_params = {
"eye_id": eye_id,
"client": client,
"config": config,
"left_eye_blink_address": config.osc_left_eye_close_address,
"right_eye_blink_address": config.osc_right_eye_close_address,
}
self.update_eye_state(
eye_id=eye_id,
eye_x=eye_x,
eye_y=eye_y,
eye_blink=eye_blink,
avg_velocity=avg_velocity,
pupil_dilation=pupil_dilation,
)
if self.is_single_eye:
client.send_message(config.osc_left_eye_x_address, eye_x)
client.send_message(config.osc_right_eye_x_address, eye_x)
client.send_message(config.osc_eyes_y_address, eye_y)
client.send_message(config.osc_eyes_pupil_dilation_address, pupil_dilation)
self.output_vrcft_blink_data(**default_eye_blink_params)
if eye_id in [EyeId.LEFT, EyeId.RIGHT] and not self.is_single_eye:
self.output_vrcft_blink_data(**default_eye_blink_params, single_eye_mode=False)
if eye_id == EyeId.LEFT:
client.send_message(config.osc_left_eye_x_address, self.l_eye_x)
self.left_y = eye_y
self.l_dilation = pupil_dilation
client.send_message(
config.osc_left_eye_close_address,
_eyelid_transformer(config, self.l_eye_blink),
)
if eye_id == EyeId.RIGHT:
client.send_message(config.osc_right_eye_x_address, self.r_eye_x)
self.right_y = eye_y
self.r_dilation = pupil_dilation
client.send_message(
config.osc_right_eye_close_address,
_eyelid_transformer(config, self.r_eye_blink),
)
if main_config.eye_display_id == EyeId.BOTH and self.right_y != 621 and self.left_y != 621:
y = (self.right_y + self.left_y) / 2
client.send_message(config.osc_eyes_y_address, y)
avg_dilation = (self.r_dilation + self.l_dilation) / 2 # i am unsure of this tbh.
client.send_message(config.osc_eyes_pupil_dilation_address, avg_dilation) # single param for both eyes.
def output_v2_params(
self,
main_config,
config,
client,
eye_x,
eye_y,
eye_blink,
avg_velocity,
eye_id,
pupil_dilation,
):
default_eye_blink_params = {
"eye_id": eye_id,
"client": client,
"config": config,
}
self.update_eye_state(
eye_id=eye_id,
eye_x=eye_x,
eye_y=eye_y,
eye_blink=eye_blink,
avg_velocity=avg_velocity,
pupil_dilation=pupil_dilation,
)
if self.is_single_eye:
client.send_message("/avatar/parameters/v2/EyeX", eye_x)
client.send_message("/avatar/parameters/v2/EyeY", eye_y)
client.send_message("/avatar/parameters/v2/PupilDilation", pupil_dilation)
self.output_vrcft_blink_data(
**default_eye_blink_params,
left_eye_blink_address="/avatar/parameters/v2/EyeLid",
right_eye_blink_address="/avatar/parameters/v2/EyeLid",
)
if eye_id in [EyeId.LEFT, EyeId.RIGHT] and not self.is_single_eye:
self.output_vrcft_blink_data(
**default_eye_blink_params,
left_eye_blink_address="/avatar/parameters/v2/EyeLidLeft",
right_eye_blink_address="/avatar/parameters/v2/EyeLidRight",
single_eye_mode=False,
)
if eye_id == EyeId.LEFT:
self.l_dilation = pupil_dilation
client.send_message("/avatar/parameters/v2/EyeLeftX", self.l_eye_x)
if self.left_y != 621:
client.send_message("/avatar/parameters/v2/EyeLeftY", eye_y)
client.send_message(
"/avatar/parameters/v2/EyeLidLeft",
_eyelid_transformer(config, self.l_eye_blink),
)
if eye_id == EyeId.RIGHT:
self.r_dilation = pupil_dilation
client.send_message("/avatar/parameters/v2/EyeRightX", self.r_eye_x)
if eye_y != 621:
client.send_message("/avatar/parameters/v2/EyeRightY", eye_y)
client.send_message(
"/avatar/parameters/v2/EyeLidRight",
_eyelid_transformer(config, self.r_eye_blink),
)
avg_pupil_dilation = (self.l_dilation + self.r_dilation) / 2
client.send_message("/avatar/parameters/v2/PupilDilation", avg_pupil_dilation)
def output_vrcft_blink_data(
self,
eye_id: EyeId,
client: SimpleUDPClient,
config,
left_eye_blink_address,
right_eye_blink_address,
single_eye_mode=True,
):
active_eye_blink = self.r_eye_blink if eye_id == EyeId.RIGHT else self.l_eye_blink
falloff_blink = self.r_eye_blink if eye_id == EyeId.LEFT else self.l_eye_blink
blink_address = right_eye_blink_address if eye_id == EyeId.RIGHT else left_eye_blink_address
side_name = "left" if eye_id == EyeId.RIGHT else "right"
last_side_blink = getattr(self, f"{side_name}_last_blink")
if single_eye_mode:
# in case of v1 params, we have to send the same data do each eye separately.
# so in case of v2 params, we will be generating one unnecessary call more
client.send_message(left_eye_blink_address, _eyelid_transformer(config, active_eye_blink))
client.send_message(right_eye_blink_address, _eyelid_transformer(config, active_eye_blink))
elif eye_id in [EyeId.RIGHT, EyeId.LEFT] and not single_eye_mode:
if active_eye_blink == 0.0:
if last_side_blink > 0.20:
for _ in range(5):
client.send_message(blink_address, _eyelid_transformer(config, active_eye_blink))
setattr(self, f"{side_name}_last_blink", time.time() - last_side_blink)
if config.gui_outer_side_falloff:
if falloff_blink == 0.0:
client.send_message(left_eye_blink_address, _eyelid_transformer(config, self.l_eye_blink))
client.send_message(right_eye_blink_address, _eyelid_transformer(config, self.r_eye_blink))
client.send_message(blink_address, _eyelid_transformer(config, active_eye_blink))
def output_osc_native_blink(
self,
eye_id: EyeId,
client,
config,
single_eye_mode=True,
):
blink_address = "/tracking/eye/EyesClosedAmount"
active_eye_blink = self.r_eye_blink if eye_id == EyeId.RIGHT else self.l_eye_blink
falloff_blink = self.r_eye_blink if eye_id == EyeId.LEFT else self.l_eye_blink
side_name = "left" if eye_id == EyeId.RIGHT else "right"
last_side_blink = getattr(self, f"{side_name}_last_blink")
def send_native_binary_blink(address: str, blink_value):
if last_side_blink > 0.2:
for _ in range(5):
client.send_message(address, float(1 - blink_value))
setattr(self, f"{side_name}_last_blink", time.time() - last_side_blink)
if single_eye_mode:
if active_eye_blink == 0.0:
send_native_binary_blink(blink_address, active_eye_blink)
else:
client.send_message(blink_address, float(1 - active_eye_blink))
if eye_id in [EyeId.RIGHT, EyeId.LEFT] and not single_eye_mode:
# in dual eye mode we need to average the blink to prevent flickering.
# VRC also **currently** doesn't support separate eyelids, so it's fine
if self.r_eye_blink or self.l_eye_blink:
averaged_eye_blink = (self.r_eye_blink + self.l_eye_blink) / 2
else:
averaged_eye_blink = 0
client.send_message(
blink_address,
_eyelid_transformer(config, 1 - averaged_eye_blink),
)
if averaged_eye_blink == 0.0:
send_native_binary_blink(blink_address, averaged_eye_blink)
if config.gui_outer_side_falloff:
if falloff_blink == 0.0:
client.send_message(blink_address, float(1 - averaged_eye_blink))
if eye_id == EyeId.BOTH and self.r_eye_blink != 621 and self.r_eye_blink != 621:
if self.r_eye_blink == 0.0 or self.l_eye_blink == 0.0:
send_native_binary_blink(blink_address, active_eye_blink)
# this has a nasty habit of permanent-squint FIXME
averaged_eye_blink = (self.r_eye_blink + self.l_eye_blink) / 2
client.send_message(blink_address, float(1 - averaged_eye_blink))

View File

215
EyeTrackApp/osc/osc.py Normal file
View File

@ -0,0 +1,215 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
from time import sleep
from typing import Dict, Optional, Iterable, Callable
from pythonosc import udp_client
from pythonosc import osc_server
from pythonosc import dispatcher
from config import EyeTrackConfig
from osc.OSCMessage import OSCMessage, OSCMessageType
from osc.VRCFTModuleMessenger import VRCFTModuleSender
from osc.VRChatOSCSender import VRChatOSCSender
import queue
import threading
class OSCManager:
def __init__(
self,
osc_message_in_queue: queue.Queue[OSCMessage],
config: EyeTrackConfig,
):
self.sender_cancellation_event = threading.Event()
self.receiver_cancellation_event = threading.Event()
self.listeners = {}
self.osc_message_in_queue = osc_message_in_queue
self.config = config
self.settings = config.settings
self.osc_sender: Optional[OSCSender] = None
self.osc_receiver = None
self.osc_sender_thread: Optional[threading.Thread] = None
self.osc_receiver_thread: Optional[threading.Thread] = None
def start(self):
self.setup_sender()
self.setup_receiver()
def setup_sender(self):
print(f"\033[92m[INFO] Setting up OSC sender\033[0m")
self.sender_cancellation_event.clear()
self.osc_sender = OSCSender(self.sender_cancellation_event, self.osc_message_in_queue, self.config)
self.osc_sender_thread = threading.Thread(target=self.osc_sender.run)
self.osc_sender_thread.start()
def setup_receiver(self):
if self.settings.gui_ROSC:
self.receiver_cancellation_event.clear()
print(f"\033[92m[INFO] Setting up OSC receiver\033[0m")
self.osc_receiver = OSCReceiver(self.receiver_cancellation_event, self.config, self.listeners)
self.osc_receiver_thread = threading.Thread(target=self.osc_receiver.run)
self.osc_receiver_thread.start()
def register_listeners(self, osc_address: str, callbacks: Iterable[Callable]):
if not self.listeners.get(osc_address):
self.listeners[osc_address] = []
self.listeners[osc_address].extend(callbacks)
def update(self, data: dict):
keys = set(data.keys())
sender_trigger_keys = {
"gui_osc_port",
"gui_VRCFTModulePort",
"gui_VRCFTModuleIPAddress",
"gui_use_module",
}
if sender_trigger_keys.intersection(keys):
self.stop_sender()
self.setup_sender()
receiver_trigger_keys = {
"gui_ROSC",
"gui_osc_receiver_port",
}
if receiver_trigger_keys.intersection(keys):
self.stop_receiver()
self.setup_receiver()
def shutdown(self):
self.stop_sender()
self.stop_receiver()
def stop_sender(self):
self.sender_cancellation_event.set()
self.osc_sender_thread.join()
def stop_receiver(self):
if self.osc_receiver_thread:
self.receiver_cancellation_event.set()
self.osc_receiver_thread.join()
class OSCSender:
def __init__(
self,
cancellation_event: threading.Event,
msg_queue: queue.Queue[OSCMessage],
main_config: EyeTrackConfig,
):
self.cancellation_event = cancellation_event
self.msg_queue = msg_queue
self.main_config = main_config
self.config = main_config.settings
self.vrc_sender = VRChatOSCSender()
self.module_sender = VRCFTModuleSender()
self.vrc_client = None
self.vrcft_client = None
def run(self):
self.vrc_client = udp_client.SimpleUDPClient(self.config.gui_osc_address, int(self.config.gui_osc_port))
self.vrcft_client = udp_client.SimpleUDPClient(
self.config.gui_VRCFTModuleIPAddress,
int(self.config.gui_VRCFTModulePort),
)
vrc_osc_output_client = self.vrc_client
if self.config.gui_use_module:
vrc_osc_output_client = self.vrcft_client
while not self.cancellation_event.is_set():
try:
osc_message: OSCMessage = self.msg_queue.get(block=True, timeout=0.1)
match osc_message.type:
case OSCMessageType.EYE_INFO:
self.vrc_sender.output_osc_info(
osc_message=osc_message,
client=vrc_osc_output_client,
main_config=self.main_config,
config=self.config,
)
case OSCMessageType.VRCFT_MODULE_INFO:
self.module_sender.send(osc_message=osc_message, client=self.vrcft_client)
case _:
raise Exception("Encountered message without a handler %s", osc_message.type)
except TypeError:
continue
except queue.Empty:
continue
class OSCReceiver:
def __init__(
self,
cancellation_event: threading.Event,
main_config: EyeTrackConfig,
listeners: Dict[str, Callable[[OSCMessage], None]],
):
self.config = main_config.settings
self.cancellation_event = cancellation_event
self.dispatcher = dispatcher.Dispatcher()
self.listeners = listeners
self.server_thread = None
try:
# this thing sucks ass god fucking damn it.
# like, there is no way of shutting it down UNLESS you run it in a thread
# which is kinda dumb, but oh well.
# Also, it doesn't shutdown properly. It's STILL connected to the port
self.server = osc_server.OSCUDPServer(
(self.config.gui_osc_address, int(self.config.gui_osc_receiver_port)),
self.dispatcher,
)
except Exception: # noqa
print(f"\033[91m[ERROR] OSC Receive port: {self.config.gui_osc_receiver_port} occupied.\033[0m")
def shutdown(self):
print("\033[94m[INFO] Exiting OSC Receiver\033[0m")
try:
self.server.shutdown()
self.server_thread.join()
except Exception: # noqa
pass
def handle_osc_message(self, address, value):
for listener in self.listeners.get(address, []):
listener(OSCMessage(type=OSCMessageType.EYE_INFO, data=value))
def run(self):
try:
self.dispatcher.set_default_handler(self.handle_osc_message)
print("\033[92m[INFO] OSC Listening on {}\033[0m".format(self.server.server_address))
self.server_thread = threading.Thread(target=self.server.serve_forever)
self.server_thread.start()
while not self.cancellation_event.is_set():
sleep(10)
self.shutdown()
except Exception: # noqa:
print(f"\033[91m[ERROR] OSC Receive port: {self.config.gui_osc_receiver_port} occupied.\033[0m")

View File

@ -0,0 +1,355 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Copyright (c) 2023 EyeTrackVR <3
LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
import numpy as np
import time
from enum import IntEnum
from utils.misc_utils import PlaySound, SND_FILENAME, SND_ASYNC, resource_path
from utils.eye_falloff import velocity_falloff
import socket
import struct
import threading
import os
import subprocess
import math
from utils.calibration_3d import receive_calibration_data, converge_3d
from utils.misc_utils import resource_path
from pathlib import Path
tool = Path("Tools")
class TimeoutError(RuntimeError):
pass
class AsyncCall(object):
def __init__(self, fnc, callback=None):
self.Callable = fnc
self.Callback = callback
def __call__(self, *args, **kwargs):
self.Thread = threading.Thread(target=self.run, name=self.Callable.__name__, args=args, kwargs=kwargs)
self.Thread.start()
return self
def wait(self, timeout=None):
self.Thread.join(timeout)
if self.Thread.isAlive():
raise TimeoutError()
else:
return self.Result
def run(self, *args, **kwargs):
self.Result = self.Callable(*args, **kwargs)
if self.Callback:
self.Callback(self.Result)
class AsyncMethod(object):
def __init__(self, fnc, callback=None):
self.Callable = fnc
self.Callback = callback
def __call__(self, *args, **kwargs):
return AsyncCall(self.Callable, self.Callback)(*args, **kwargs)
def Async(fnc=None, callback=None):
if fnc == None:
def AddAsyncCallback(fnc):
return AsyncMethod(fnc, callback)
return AddAsyncCallback
else:
return AsyncMethod(fnc, callback)
class EyeId(IntEnum):
RIGHT = 0
LEFT = 1
BOTH = 2
SETTINGS = 3
class var:
average_velocity = 0
velocity_rolling_list = []
past_x = 0
past_y = 0
start_time = time.time()
r_eye_x = 0.0
l_eye_x = 0.0
left_y = 0.0
right_y = 0.0
l_eye_velocity = 0.0
r_eye_velocity = 0.0
overlay_active = False
falloff_latch = False
single_eye = True
left_enb = 0
right_enb = 0
eye_wait = 10
left_calib = False
right_calib = False
completed_3d_calib = 0
@Async
def center_overlay_calibrate(self):
tools = Path("Tools")
# try:
if var.overlay_active != True:
overlay_path = resource_path("tools/ETVR_SteamVR_Calibration_Overlay.exe")
os.startfile(overlay_path, arguments="center")
var.overlay_active = True
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_address = ("localhost", 2112)
sock.bind(server_address)
data, address = sock.recvfrom(4096)
received_int = struct.unpack("!l", data)[0]
message = received_int
self.settings.gui_recenter_eyes = False
self.calibration_frame_counter = 0
var.overlay_active = False
# except:
# print("[WARN] Calibration overlay error. Make sure SteamVR is Running.")
# self.settings.gui_recenter_eyes = False
# var.overlay_active = False
@Async
def overlay_calibrate_3d(self):
try:
if var.overlay_active != True:
overlay_path = resource_path("tools/EyeTrackVR-Overlay.exe")
os.startfile(overlay_path)
var.overlay_active = True
while var.overlay_active:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_address = ("localhost", 2112)
sock.bind(server_address)
data, address = sock.recvfrom(4096)
received_int = struct.unpack("!l", data)[0]
message = received_int
self.settings.gui_recenter_eyes = False
self.settings.grab_3d_point = True
print(message)
if message == 9:
var.overlay_active = False
except:
print("[WARN] Calibration overlay error. Make sure SteamVR is Running.")
self.settings.gui_recenter_eyes = False
var.overlay_active = False
class cal:
def cal_osc(self, cx, cy, angle):
# print(self.eye_id)
if cx == None or cy == None:
return 0, 0
if cx == 0:
cx = 1
if cy == 0:
cy = 1
if self.eye_id == EyeId.RIGHT:
flipx = self.settings.gui_flip_x_axis_right
else:
flipx = self.settings.gui_flip_x_axis_left
if self.calibration_3d_frame_counter == -621: # or self.settings.gui_3d_calibration:
self.calibration_3d_frame_counter = self.calibration_3d_frame_counter - 1
overlay_calibrate_3d(self)
self.config.calibration_points_3d = []
# print(self.eye_id, cx, cy)
# self.settings.gui_3d_calibration = False
if self.settings.grab_3d_point:
# Check if both calibrations are done
if var.left_calib and var.right_calib:
self.settings.grab_3d_point = False
var.left_calib = False
var.right_calib = False
print("end", len(self.config.calibration_points_3d), self.config.calibration_points_3d)
else:
# Check if it's the left eye and left calibration is not done yet
if self.eye_id == EyeId.LEFT and not var.left_calib:
var.left_calib = True
self.config.calibration_points_3d.append((cx, cy, 1))
# Check if it's the right eye and right calibration is not done yet
elif self.eye_id == EyeId.RIGHT and not var.right_calib:
var.right_calib = True
self.config.calibration_points_3d.append((cx, cy, 0))
if self.eye_id == EyeId.LEFT and len(self.config.calibration_points_3d) == 9 and var.left_calib == False:
var.left_calib = True
receive_calibration_data(self.config.calibration_points_3d, self.eye_id)
print("SENT LEFT EYE POINTS")
var.completed_3d_calib += 1
if self.eye_id == EyeId.RIGHT and len(self.config.calibration_points_3d) == 9 and var.right_calib == False:
var.right_calib = True
receive_calibration_data(self.config.calibration_points_3d, self.eye_id)
print("SENT RIGHT EYE POINTS")
var.completed_3d_calib += 1
# print(len(self.config.calibration_points), self.eye_id)
if var.completed_3d_calib >= 2:
converge_3d()
# pass
if self.calibration_frame_counter == 0:
self.calibration_frame_counter = None
self.config.calib_XOFF = cx
self.config.calib_YOFF = cy
self.baseconfig.save()
PlaySound(resource_path("Audio/completed.wav"), SND_FILENAME | SND_ASYNC)
if self.calibration_frame_counter == self.settings.calibration_samples:
self.config.calib_XMAX = -69420
self.config.calib_XMIN = 69420
self.config.calib_YMAX = -69420
self.config.calib_YMIN = 69420
self.blink_clear = True
self.calibration_frame_counter -= 1
elif self.calibration_frame_counter != None:
self.blink_clear = False
self.settings.gui_recenter_eyes = False
if cx > self.config.calib_XMAX:
self.config.calib_XMAX = cx
if cx < self.config.calib_XMIN:
self.config.calib_XMIN = cx
if cy > self.config.calib_YMAX:
self.config.calib_YMAX = cy
if cy < self.config.calib_YMIN:
self.config.calib_YMIN = cy
self.calibration_frame_counter -= 1
if self.settings.gui_recenter_eyes == True:
self.config.calib_XOFF = cx
self.config.calib_YOFF = cy
if self.ts == 0:
center_overlay_calibrate(self) # TODO, only call on windows machines?
self.settings.gui_recenter_eyes = False
PlaySound(resource_path("Audio/completed.wav"), SND_FILENAME | SND_ASYNC)
else:
self.ts = self.ts - 1
else:
self.ts = 10
out_x = 0.5
out_y = 0.5
if self.config.calib_XMAX != None and self.config.calib_XOFF != None:
calib_diff_x_MAX = self.config.calib_XMAX - self.config.calib_XOFF
if calib_diff_x_MAX == 0:
calib_diff_x_MAX = 1
calib_diff_x_MIN = self.config.calib_XMIN - self.config.calib_XOFF
if calib_diff_x_MIN == 0:
calib_diff_x_MIN = 1
calib_diff_y_MAX = self.config.calib_YMAX - self.config.calib_YOFF
if calib_diff_y_MAX == 0:
calib_diff_y_MAX = 1
calib_diff_y_MIN = self.config.calib_YMIN - self.config.calib_YOFF
if calib_diff_y_MIN == 0:
calib_diff_y_MIN = 1
xl = float((cx - self.config.calib_XOFF) / calib_diff_x_MAX)
xr = float((cx - self.config.calib_XOFF) / calib_diff_x_MIN)
yu = float((cy - self.config.calib_YOFF) / calib_diff_y_MIN)
yd = float((cy - self.config.calib_YOFF) / calib_diff_y_MAX)
if self.settings.gui_flip_y_axis: # check config on flipped values settings and apply accordingly
if yd >= 0:
out_y = max(0.0, min(1.0, yd))
if yu > 0:
out_y = -abs(max(0.0, min(1.0, yu)))
else:
if yd >= 0:
out_y = -abs(max(0.0, min(1.0, yd)))
if yu > 0:
out_y = max(0.0, min(1.0, yu))
if flipx:
if xr >= 0:
out_x = -abs(max(0.0, min(1.0, xr)))
if xl > 0:
out_x = max(0.0, min(1.0, xl))
else:
if xr >= 0:
out_x = max(0.0, min(1.0, xr))
if xl > 0:
out_x = -abs(max(0.0, min(1.0, xl)))
if self.settings.gui_outer_side_falloff:
run_time = time.time()
out_x_mult = out_x * 100
out_y_mult = out_y * 100
velocity = abs(
np.sqrt(abs(np.square(out_x_mult - var.past_x) - np.square(out_y_mult - var.past_y)))
/ ((var.start_time - run_time) * 10)
)
if len(var.velocity_rolling_list) < 15:
var.velocity_rolling_list.append(float(velocity))
else:
var.velocity_rolling_list.pop(0)
var.velocity_rolling_list.append(float(velocity))
var.average_velocity = sum(var.velocity_rolling_list) / len(var.velocity_rolling_list)
var.past_x = out_x_mult
var.past_y = out_y_mult
out_x, out_y = velocity_falloff(self, var, out_x, out_y)
try:
noisy_point = np.array([float(out_x), float(out_y)]) # fliter our values with a One Euro Filter
point_hat = self.one_euro_filter(noisy_point)
out_x = point_hat[0]
out_y = point_hat[1]
except:
pass
return out_x, out_y, var.average_velocity
else:
if self.printcal:
print("\033[91m[ERROR] Please Calibrate Eye(s).\033[0m")
self.printcal = False
return 0, 0, 0

483
EyeTrackApp/ransac.py Normal file
View File

@ -0,0 +1,483 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
RANSAC 3D By: Summer#2406 (Main Algorithm Engineer), Pupil Labs (pye3d), PallasNeko (Optimization)
Algorithm App Implementations By: Prohurtz, qdot (Initial App Creator)
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Summer Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
import cv2
import numpy as np
from eye import EyeId
from utils.img_utils import safe_crop
from utils.misc_utils import clamp
import os
import psutil
import sys
process = psutil.Process(os.getpid()) # set process priority to low
try: # medium chance this does absolutely nothing but eh
sys.getwindowsversion()
except AttributeError:
process.nice(0) # UNIX: 0 low 10 high
process.nice()
else:
process.nice(psutil.BELOW_NORMAL_PRIORITY_CLASS) # Windows
process.nice()
def ellipse_model(data, y, f):
"""
There is no need to make this process a function, since making the process a function will slow it down a little by calling it.
The results may be slightly different from the lambda version due to calculation errors derived from float types, but the calculation results are virtually the same.
a = 1.0,b = P[0],c = P[1],d = P[2],e = P[3],f = P[4]
:param data:
:param y: np.c_[d, e, a, c, b]
:param f: f == P[4, 0]
:return: this_return == np.array([ellipse_model(x, y) for (x, y) in data ])
"""
return data.dot(y) + f
# @profile
def fit_rotated_ellipse_ransac(
data: np.ndarray,
rng: np.random.Generator,
iter=45,
sample_num=10,
offset=80, # 80.0, 10, 80
): # before changing these values, please read up on the ransac algorithm
# However if you want to change any value just know that higher iterations will make processing frames slower
effective_sample = None
# The array contents do not change during the loop, so only one call is needed.
# They say len is faster than shape.
# Reference url: https://stackoverflow.com/questions/35547853/what-is-faster-python3s-len-or-numpys-shape
len_data = len(data)
if len_data < sample_num:
return None
# Type of calculation result
ret_dtype = np.float64
# Sorts a random number array of size (iter,len_data). After sorting, returns the index of sample_num random numbers before sorting.
# If the array size is less than about 100, this is faster than rng.choice.
rng_sample = rng.random((iter, len_data)).argsort()[:, :sample_num]
# or
# I don't see any advantage to doing this.
# rng_sample = np.asarray(rng.random((iter, len_data)).argsort()[:, :sample_num], dtype=np.int32)
# I don't think it looks beautiful.
# x,y,x**2,y**2,x*y,1,-1*x**2
datamod = np.concatenate(
[
data,
data**2,
(data[:, 0] * data[:, 1])[:, np.newaxis],
np.ones((len_data, 1), dtype=ret_dtype),
(-1 * data[:, 0] ** 2)[:, np.newaxis],
],
axis=1,
dtype=ret_dtype,
)
datamod_slim = np.array(datamod[:, :5], dtype=ret_dtype)
datamod_rng = datamod[rng_sample]
datamod_rng6 = datamod_rng[:, :, 6]
datamod_rng_swap = datamod_rng[:, :, [4, 3, 0, 1, 5]]
datamod_rng_swap_trans = datamod_rng_swap.transpose((0, 2, 1))
# These two lines are one of the bottlenecks
datamod_rng_5x5 = np.matmul(datamod_rng_swap_trans, datamod_rng_swap)
datamod_rng_p5smp = np.matmul(np.linalg.inv(datamod_rng_5x5), datamod_rng_swap_trans)
datamod_rng_p = np.matmul(datamod_rng_p5smp, datamod_rng6[:, :, np.newaxis]).reshape((-1, 5))
# I don't think it looks beautiful.
ellipse_y_arr = np.asarray(
[
datamod_rng_p[:, 2],
datamod_rng_p[:, 3],
np.ones(len(datamod_rng_p)),
datamod_rng_p[:, 1],
datamod_rng_p[:, 0],
],
dtype=ret_dtype,
)
ellipse_data_arr = ellipse_model(datamod_slim, ellipse_y_arr, np.asarray(datamod_rng_p[:, 4])).transpose((1, 0))
ellipse_data_abs = np.abs(ellipse_data_arr)
ellipse_data_index = np.argmax(np.sum(ellipse_data_abs < offset, axis=1), axis=0)
effective_data_arr = ellipse_data_arr[ellipse_data_index]
effective_sample_p_arr = datamod_rng_p[ellipse_data_index]
return fit_rotated_ellipse(effective_data_arr, effective_sample_p_arr)
# @profile
def fit_rotated_ellipse(data, P):
a = 1.0
b = P[0]
c = P[1]
d = P[2]
e = P[3]
f = P[4]
# The cost of trigonometric functions is high.
theta = 0.5 * np.arctan(b / (a - c), dtype=np.float64)
theta_sin = np.sin(theta, dtype=np.float64)
theta_cos = np.cos(theta, dtype=np.float64)
tc2 = theta_cos**2
ts2 = theta_sin**2
b_tcs = b * theta_cos * theta_sin
# Do the calculation only once
cxy = b**2 - 4 * a * c
cx = (2 * c * d - b * e) / cxy
cy = (2 * a * e - b * d) / cxy
# I just want to clear things up around here.
cu = a * cx**2 + b * cx * cy + c * cy**2 - f
cu_r = np.array([(a * tc2 + b_tcs + c * ts2), (a * ts2 - b_tcs + c * tc2)])
if cu > 1: # negatives can get thrown which cause errors, just ignore them
wh = np.sqrt(cu / cu_r)
else:
pass
w, h = wh[0], wh[1]
error_sum = np.sum(data)
# print("fitting error = %.3f" % (error_sum))
return (cx, cy, w, h, theta)
def get_center_noclamp(center_xy, radius):
center_x, center_y = center_xy
upper_x = center_x + radius
lower_x = center_x - radius
upper_y = center_y + radius
lower_y = center_y - radius
ransac_upper_x = center_x + max(20, radius)
ransac_lower_x = center_x - max(20, radius)
ransac_upper_y = center_y + max(20, radius)
ransac_lower_y = center_y - max(20, radius)
ransac_xy_offset = (ransac_lower_x, ransac_lower_y)
return (
center_x,
center_y,
upper_x,
lower_x,
upper_y,
lower_y,
ransac_lower_x,
ransac_lower_y,
ransac_upper_x,
ransac_upper_y,
ransac_xy_offset,
)
cct = 300
def RANSAC3D(self, hsrac_en):
f = False
ranf = False
blink = 0.8
angle = 0
if hsrac_en:
(
center_x,
center_y,
upper_x,
lower_x,
upper_y,
lower_y,
ransac_lower_x,
ransac_lower_y,
ransac_upper_x,
ransac_upper_y,
ransac_xy_offset,
) = get_center_noclamp((self.rawx, self.rawy), self.radius)
frame = safe_crop(
self.current_image_gray_clean,
int(ransac_lower_x),
int(ransac_lower_y),
int(ransac_upper_x),
int(ransac_upper_y),
1,
)
else:
frame = self.current_image_gray_clean
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
rng = np.random.default_rng()
newFrame2 = self.current_image_gray.copy()
# Convert the image to grayscale, and set up thresholding. Thresholds here are basically a
# low-pass filter that will set any pixel < the threshold value to 0. Thresholding is user
# configurable in this utility as we're dealing with variable lighting amounts/placement, as
# well as camera positioning and lensing. Therefore, everyone's cutoff may be different.
#
# The goal of thresholding settings is to make sure we can ONLY see the pupil. This is why we
# crop the image earlier; it gives us less possible dark area to get confused about in the
# next step.
# Crop first to reduce the amount of data to process.
# frame = self.current_image_gray
# For measuring processing time of image processing
# Crop first to reduce the amount of data to process.
# frame = frame[0:len(frame) - 5, :]
# To reduce the processing data, blur.
if frame is None:
print("[WARN] Frame is empty")
self.failed = self.failed + 1 # we have failed, move onto next algo
return 0, 0, 0, frame, blink, 0, 0
else:
frame_gray = cv2.GaussianBlur(frame, (9, 9), 10)
# this will need to be adjusted everytime hardware is changed (brightness of IR, Camera postion, etc)m
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(frame_gray)
maxloc0_hf, maxloc1_hf = int(0.5 * max_loc[0]), int(0.5 * max_loc[1])
# crop 15% sqare around min_loc
# frame_gray = frame_gray[max_loc[1] - maxloc1_hf:max_loc[1] + maxloc1_hf,
# max_loc[0] - maxloc0_hf:max_loc[0] + maxloc0_hf]
if self.settings.gui_legacy_ransac:
if self.eye_id in [EyeId.LEFT]:
threshold_value = self.settings.gui_legacy_ransac_thresh_left
else:
threshold_value = self.settings.gui_legacy_ransac_thresh_right
else:
threshold_value = min_val + self.settings.gui_thresh_add
_, thresh = cv2.threshold(frame_gray, threshold_value, 255, cv2.THRESH_BINARY)
try:
opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel)
closing = cv2.morphologyEx(opening, cv2.MORPH_CLOSE, kernel)
th_frame = 255 - closing
except:
# I want to eliminate try here because try tends to be slow in execution.
th_frame = 255 - frame_gray
contours, _ = cv2.findContours(th_frame, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)
hull = []
# print(contours)
# This way is faster than contours[i]
# But maybe this one is faster. hull = [cv2.convexHull(cnt, False) for cnt in contours]
for cnt in contours:
hull.append(cv2.convexHull(cnt, False))
if not hull:
# If empty, go to next loop
pass
try:
cnt = sorted(hull, key=cv2.contourArea)
maxcnt = cnt[-1]
# ellipse = cv2.fitEllipse(maxcnt)
ransac_data = fit_rotated_ellipse_ransac(maxcnt.reshape(-1, 2), rng)
if ransac_data is None:
# ransac_data is None==maxcnt.shape[0]<sample_num
# go to next loop
pass
cx, cy, w, h, theta = ransac_data
# print(cx, cy)
# cxi, cyi, wi, hi = int(cx), int(cy), int(w), int(h)
# cv2.drawContours(self.current_image_gray, contours, -1, (255, 0, 0), 1)
# cv2.circle(self.current_image_gray, (cx, cy), 2, (0, 0, 255), -1)
# cx1, cy1, w1, h1, theta1 = fit_rotated_ellipse(maxcnt.reshape(-1, 2))
# cv2.ellipse(self.current_image_gray, (cx, cy), (w, h), theta * 180.0 / np.pi, 0.0, 360.0, (50, 250, 200), 1, )
# img = newImage2[y1:y2, x1:x2]
except:
ranf = True
pass
self.current_image_gray = frame
cv2.circle(self.current_image_gray, min_loc, 2, (0, 0, 255), -1) # the point of the darkest area in the image
# However eyes are annoyingly three dimensional, so we need to take this ellipse and turn it
# into a curve patch on the surface of a sphere (the eye itself). If it's not a sphere, see your
# ophthalmologist about possible issues with astigmatism.
try:
# Get axis and angle of the ellipse, using pupil labs 2d algos. The next bit of code ranges
# from somewhat to completely magic, as most of it happens in native libraries (hence passing
# via dicts).
result_2d = {}
result_2d_final = {}
result_2d["center"] = (cx, cy)
result_2d["axes"] = (w, h)
result_2d["angle"] = theta * 180.0 / np.pi
angle = result_2d["angle"]
result_2d_final["ellipse"] = result_2d
result_2d_final["diameter"] = w
result_2d_final["location"] = (cx, cy)
result_2d_final["confidence"] = 0.99
result_2d_final["timestamp"] = self.current_frame_number / self.current_fps
# Black magic happens here, but after this we have our reprojected pupil/eye, and all we had
# to do was sell our soul to satan and/or C++.
result_3d = self.detector_3d.update_and_detect(result_2d_final, self.current_image_gray)
# Now we have our pupil
ellipse_3d = result_3d["ellipse"]
# And our eyeball that the pupil is on the surface of
self.lkg_projected_sphere = result_3d["projected_sphere"]
# Record our pupil center
exm = ellipse_3d["center"][0]
eym = ellipse_3d["center"][1]
# print(result_2d["angle"])
d = result_3d["diameter_3d"]
self.cc_radius = int(float(self.lkg_projected_sphere["axes"][0]))
self.xc = int(float(self.lkg_projected_sphere["center"][0]))
self.yc = int(float(self.lkg_projected_sphere["center"][1]))
except:
f = True
csy = newFrame2.shape[0]
csx = newFrame2.shape[1]
if hsrac_en:
if ranf:
cx = self.rawx
cy = self.rawy
else:
# print(int(cx), int(clamp(cx + ransac_lower_x, 0, csx)), ransac_lower_x, csx, "y", int(cy), int(clamp(cy + ransac_lower_y, 0, csy)), ransac_lower_y, csy)
cx = int(clamp(cx + ransac_lower_x, 0, csx)) # dunno why this is being weird
cy = int(clamp(cy + ransac_lower_y, 0, csy))
# print(contours)
for cnt in contours:
(x, y, w, h) = cv2.boundingRect(cnt)
perscalarw = w / csx
perscalarh = h / csy
# print(abs(perscalarw-perscalarh))
# if abs(perscalarw-perscalarh) >= 0.2: # TODO setting
# blink = 0.0
if self.settings.gui_RANSACBLINK:
if self.ran_blink_check_for_file:
if self.eye_id in [EyeId.LEFT]:
file_path = "RANSAC_blink_LEFT.cfg"
if self.eye_id in [EyeId.RIGHT]:
file_path = "RANSAC_blink_RIGHT.cfg"
else:
file_path = "RANSAC_blink_RIGHT.cfg"
if os.path.exists(file_path):
with open(file_path, "r") as file:
self.blink_list = [float(line.strip()) for line in file]
else:
print(
f"\033[93m[INFO] RANSAC Blink Config '{file_path}' not found. Waiting for calibration.\033[0m"
)
self.ran_blink_check_for_file = False
if len(self.blink_list) == 10000: # self calibrate ransac blink IN TESTING
if self.eye_id in [EyeId.LEFT]:
with open("RANSAC_BLINK_LEFT.cfg", "w") as file:
for item in self.blink_list:
file.write(str(item) + "\n")
if self.eye_id in [EyeId.RIGHT]:
with open("RANSAC_BLINK_RIGHT.cfg", "w") as file:
for item in self.blink_list:
file.write(str(item) + "\n")
# print("SAVE")
# self.blink_list.pop(0)
self.blink_list.append(abs(perscalarw - perscalarh))
elif len(self.blink_list) < 10000:
self.blink_list.append(abs(perscalarw - perscalarh))
if abs(perscalarw - perscalarh) >= np.percentile(self.blink_list, 92):
blink = 0.0
try:
cv2.drawContours(self.current_image_gray, contours, -1, (255, 0, 0), 1) # TODO: fix visualizations with HSRAC
cv2.circle(self.current_image_gray, (int(cx), int(cy)), 2, (0, 0, 255), -1)
except:
pass
# try: #for some reason the pye3d visualizations are wack, im going to just not visualize it for now..
# cv2.ellipse(
# self.current_image_gray,
# tuple(int(v) for v in ellipse_3d["center"]),
# tuple(int(v) for v in ellipse_3d["axes"]),
# ellipse_3d["angle"],
# 0,
# 360, # start/end angle for drawing
# (0, 255, 0), # color (BGR): red
# )
# except Exception:
# Sometimes we get bogus axes and trying to draw this throws. Ideally we should check for
# validity beforehand, but for now just pass. It usually fixes itself on the next frame.
# pass
try:
# print(self.lkg_projected_sphere["angle"], self.lkg_projected_sphere["axes"], self.lkg_projected_sphere["center"])
cv2.ellipse(
newFrame2,
tuple(int(v) for v in self.lkg_projected_sphere["center"]),
tuple(int(v) for v in self.lkg_projected_sphere["axes"]),
self.lkg_projected_sphere["angle"],
0,
360, # start/end angle for drawing
(0, 255, 0), # color (BGR): red
)
# draw line from center of eyeball to center of pupil
cv2.line(
self.current_image_gray,
tuple(int(v) for v in self.lkg_projected_sphere["center"]),
tuple(int(v) for v in ellipse_3d["center"]),
(0, 255, 0), # color (BGR): red
)
except:
pass
self.current_image_gray = newFrame2
y, x = self.current_image_gray.shape
thresh = cv2.resize(thresh, (x, y))
try:
self.failed = 0 # we have succeded, continue with this
return cx, cy, angle, thresh, blink, w, h
except:
self.failed = self.failed + 1 # we have failed, move onto next algo
return 0, 0, 0, thresh, blink, 0, 0

View File

@ -0,0 +1,94 @@
from datetime import datetime, timedelta
from typing import Iterable
import PySimpleGUI as sg
from colorama import Fore
from threading import Event
from eye import EyeId
from config import EyeTrackConfig, EyeTrackSettingsConfig
class BaseSettingsWidget:
def __init__(self, widget_id: EyeId, main_config: EyeTrackConfig, settings_modules: Iterable):
self.widget_id = widget_id
self.main_config = main_config
self.config = main_config.settings
self.last_error_printout = datetime.now() - timedelta(seconds=20)
self.error_printout_timeout = 2
self.reset_button_key = f"RESET_SETTINGS{widget_id}"
self.is_saving = False
self.initialized_modules = self._initialize_modules(settings_modules=settings_modules, widget_id=widget_id)
self.cancellation_event = Event()
self.cancellation_event.set()
def started(self):
return not self.cancellation_event.is_set()
def start(self):
if not self.cancellation_event.is_set():
return
self.cancellation_event.clear()
def stop(self):
if self.cancellation_event.is_set():
return
self.cancellation_event.set()
def _update_and_save_config(self, validated_data: dict):
self.main_config.update(validated_data, save=True)
self.is_saving = False
def _handle_errors(self, errors):
now = datetime.now()
elapsed_seconds = (datetime.now() - self.last_error_printout).seconds
if elapsed_seconds > self.error_printout_timeout:
self.last_error_printout = now
messages = [f"{Fore.RED}[ERROR]{Fore.RESET} {error['msg']} \n" for module_errors in errors for error in module_errors]
print("".join(messages))
def render(self, window, event, values):
validated_data, errors = {}, []
for module in self.initialized_modules:
module_validated_data = module.validate(values)
if module_validated_data.changes:
validated_data.update(module_validated_data.changes)
if module_validated_data.errors:
errors.append(module_validated_data.errors)
if not errors and validated_data and not self.is_saving:
self.is_saving = True
self._update_and_save_config(validated_data)
if errors:
self._handle_errors(errors)
self.handle_events(event, window)
def _initialize_modules(self, settings_modules, widget_id):
return [module(config=self.config, settings=self.main_config, widget_id=widget_id) for module in settings_modules]
def get_layout(self) -> Iterable:
general_settings_layout = []
for module in self.initialized_modules:
general_settings_layout.extend(module.get_layout())
widget_layout = [
[sg.Column(general_settings_layout, key=f"-GENERALSETTINGSLAYOUT{self.widget_id}-", background_color="#424042")],
[sg.Text("", background_color="#424042")],
[sg.Button("Reset settings to default", key=self.reset_button_key, button_color="#c40e23")]
]
return widget_layout
def handle_events(self, event, window):
if event == "__TIMEOUT__":
return
if event == self.reset_button_key:
self.reset_config(window)
def reset_config(self, window):
default_values = {}
base_settings = EyeTrackSettingsConfig()
print(f"\033[92m[INFO] Resetting config to defaults\033[0m")
for module in self.initialized_modules:
for key in module.get_key_for_panel_defaults():
default_val = getattr(base_settings, key)
widget_key = getattr(module, key)
default_values[key] = default_val
window[widget_key].update(default_val)
print(f"\033[92m[INFO] Config reset, saving\033[0m")
self._update_and_save_config(default_values)

View File

@ -0,0 +1,34 @@
from queue import Queue
from config import EyeTrackConfig
from eye import EyeId
from osc.OSCMessage import OSCMessage, OSCMessageType
from settings.BaseSettings import BaseSettingsWidget
from settings.modules.VRCFTSettingsModule import VRCFTSettingsModule
class VRCFTSettingsWidget(BaseSettingsWidget):
def __init__(self, widget_id: EyeId, main_config: EyeTrackConfig, osc_queue_in: Queue[OSCMessage]):
self.osc_queue = osc_queue_in
settings_modules = [
VRCFTSettingsModule,
]
super().__init__(widget_id, main_config, settings_modules)
def _update_and_save_config(self, validated_data: dict):
self.main_config.update(validated_data, save=True)
for field, value in validated_data.items():
self.osc_queue.put(
OSCMessage(
type=OSCMessageType.VRCFT_MODULE_INFO,
data={
"command": "set",
"field": field,
"value": value,
},
)
)
self.is_saving = False

View File

@ -0,0 +1,45 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
from config import EyeTrackConfig
from eye import EyeId
from settings.BaseSettings import BaseSettingsWidget
from settings.modules.AdvancedTrackingAlgoSettingsModule import (
AdvancedTrackingAlgoSettingsModule,
)
from settings.modules.BlinkAlgoModule import BlinkAlgoSettingsModule
from settings.modules.TrackingAlgorithmModule import TrackingAlgorithmModule
class AlgoSettingsWidget(BaseSettingsWidget):
def __init__(self, widget_id: EyeId, main_config: EyeTrackConfig):
settings_modules = [
TrackingAlgorithmModule,
BlinkAlgoSettingsModule,
AdvancedTrackingAlgoSettingsModule,
]
super().__init__(widget_id, main_config, settings_modules)

View File

@ -0,0 +1 @@
BACKGROUND_COLOR = "#424042"

View File

@ -0,0 +1,43 @@
"""
------------------------------------------------------------------------------------------------------
,@@@@@@
@@@@@@@@@@@ @@@
@@@@@@@@@@@@ @@@@@@@@@@@
@@@@@@@@@@@@@ @@@@@@@@@@@@@@
@@@@@@@/ ,@@@@@@@@@@@@@
/@@@@@@@@@@@@@@@ @@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@
@@@@@@@@ @@@@@
,@@@ @@@@&
@@@@@@. @@@@
@@@ @@@@@@@@@/ @@@@@
,@@@. @@@@@@((@ @@@@(
//@@@ ,, @@@@ @@@@@
@@@( @@@@@@@
@@@ @ @@@@@@@@#
@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@(
Copyright (c) 2025 EyeTrackVR <3
LICENSE: Babble Software Distribution License 1.0
------------------------------------------------------------------------------------------------------
"""
from config import EyeTrackConfig
from eye import EyeId
from settings.BaseSettings import BaseSettingsWidget
from settings.modules.GeneralSettingsModule import GeneralSettingsModule
from settings.modules.OneEuroSettingsModule import OneEuroSettingsModule
from settings.modules.OSCSettingsModule import OSCSettingsModule
class SettingsWidget(BaseSettingsWidget):
def __init__(self, widget_id: EyeId, main_config: EyeTrackConfig):
settings_modules = [
GeneralSettingsModule,
OneEuroSettingsModule,
OSCSettingsModule,
]
super().__init__(widget_id, main_config, settings_modules)

View File

@ -0,0 +1,137 @@
from settings.modules.BaseModule import BaseSettingsModule, BaseValidationModel
import PySimpleGUI as sg
class AdvancedTrackingAlgoSettingsValidationModel(BaseValidationModel):
gui_HSF_radius_left: int
gui_HSF_radius_right: int
gui_blob_maxsize: int
gui_blob_minsize: int
gui_legacy_ransac_thresh_left: int
gui_legacy_ransac_thresh_right: int
gui_skip_autoradius: bool
gui_thresh_add: int
gui_threshold: int
gui_pupil_dilation: bool
class AdvancedTrackingAlgoSettingsModule(BaseSettingsModule):
def __init__(self, config, widget_id, **kwargs):
super().__init__(config=config, widget_id=widget_id, **kwargs)
self.validation_model = AdvancedTrackingAlgoSettingsValidationModel
self.gui_blob_maxsize = f"-BLOBMAXSIZE{widget_id}-"
self.gui_blob_minsize = f"-BLOBMINSIZE{widget_id}-"
self.gui_skip_autoradius = f"-SKIPAUTORADIUS{widget_id}-"
self.gui_thresh_add = f"-THRESHADD{widget_id}-"
self.gui_threshold = f"-BLOBTHRESHOLD{widget_id}-"
self.gui_HSF_radius_left = f"-HSFRADIUSLEFT{widget_id}-"
self.gui_HSF_radius_right = f"-HSFRADIUSRIGHT{widget_id}-"
self.gui_legacy_ransac_thresh_right = f"-THRESHRIGHT{widget_id}-"
self.gui_legacy_ransac_thresh_left = f"-THRESHLEFT{widget_id}-"
self.gui_pupil_dilation = f"-EBPD{widget_id}-"
def get_layout(self):
return [
[sg.Text("Pupil Dilation Algo Settings:", background_color="#242224")],
[
sg.Checkbox(
"Ellipse Based Pupil Dilation",
default=self.config.gui_pupil_dilation,
key=self.gui_pupil_dilation,
background_color="#424042",
)
],
[sg.Text("Advanced Tracking Algorithm Settings:", background_color="#242224")],
[
sg.Checkbox(
"HSF: Skip Auto Radius",
default=self.config.gui_skip_autoradius,
key=self.gui_skip_autoradius,
background_color="#424042",
tooltip="To gain more control and possibly better tracking quality of HSF, please disable auto radius to enable manual adjustment.",
),
],
[
sg.Text("Left HSF Radius:", background_color="#424042"),
sg.Slider(
range=(1, 50),
default_value=self.config.gui_HSF_radius_left,
orientation="h",
key=self.gui_HSF_radius_left,
background_color="#424042",
tooltip="Adjusts the radius parameter for HSF. Only adjust if you are having tracking issues.",
),
sg.Text("Right HSF Radius:", background_color="#424042"),
sg.Slider(
range=(1, 50),
default_value=self.config.gui_HSF_radius_right,
orientation="h",
key=self.gui_HSF_radius_right,
background_color="#424042",
tooltip="Adjusts the radius parameter for HSF. Only adjust if you are having tracking issues.",
),
],
[
sg.Text("RANSAC Thresh Add", background_color="#424042"),
sg.Slider(
range=(1, 50),
default_value=self.config.gui_thresh_add,
orientation="h",
key=self.gui_thresh_add,
background_color="#424042",
tooltip="Adjusts the amount of threshold to add to RANSAC. Useful for fine tuning your setup.",
),
sg.Text("Blob Threshold", background_color="#424042"),
# TODO make this for right and left eyes? I dont know how vital that is..
sg.Slider(
range=(0, 110),
default_value=self.config.gui_threshold,
orientation="h",
key=self.gui_threshold,
background_color="#424042",
tooltip="Adjusts the threshold for blob tracking.",
),
],
[
sg.Text("Min Blob Size:", background_color="#424042"),
sg.Slider(
range=(1, 50),
default_value=self.config.gui_blob_minsize,
orientation="h",
key=self.gui_blob_minsize,
background_color="#424042",
tooltip="Minimum size a blob has to be for blob tracking.",
),
sg.Text("Max Blob Size:", background_color="#424042"),
sg.Slider(
range=(1, 50),
default_value=self.config.gui_blob_maxsize,
orientation="h",
key=self.gui_blob_maxsize,
background_color="#424042",
tooltip="Maximum size a blob can be for blob tracking.",
),
],
[
sg.Text("Right Eye Thresh:", background_color="#424042"),
sg.Slider(
range=(1, 120),
default_value=self.config.gui_legacy_ransac_thresh_right,
orientation="h",
key=self.gui_legacy_ransac_thresh_right,
background_color="#424042",
tooltip="Threshold for right eye, legacy RANSAC only",
),
sg.Text("Left Eye Thresh:", background_color="#424042"),
sg.Slider(
range=(1, 120),
default_value=self.config.gui_legacy_ransac_thresh_left,
orientation="h",
key=self.gui_legacy_ransac_thresh_left,
background_color="#424042",
tooltip="Threshold for left eye, legacy RANSAC only",
),
],
]

View File

@ -0,0 +1,68 @@
from typing import Iterable, Optional, NamedTuple, Any
import pydantic
from pydantic import BaseModel
from config import EyeTrackSettingsConfig
class MissingValidationModelException(Exception):
pass
class MissingValidationConfigException(Exception):
pass
class BaseValidationModel(BaseModel):
pass
class ValidationResult(NamedTuple):
changes: Optional[dict[str, str]]
errors: list[Any] # ErrorDict but we cannot import it, it's not exposed
class BaseSettingsModule:
def __init__(self, config: EyeTrackSettingsConfig, widget_id, **kwargs):
self.validation_model: BaseValidationModel = BaseValidationModel # noqa
self.config = config
self.widget_id = widget_id
def get_validation_model(self):
"""Return validation model, can be overridden for custom behaviour"""
return self.validation_model
def initialize_validation_model(self, values):
validation_model = self.get_validation_model()
if not validation_model:
raise MissingValidationModelException()
field_mapping = {}
for field in self.validation_model.model_fields.keys():
field_mapping[field] = values[getattr(self, field)]
return validation_model(**field_mapping)
def validate(self, values, raise_exception=False) -> Optional[ValidationResult]:
"""Return """
if not self.config:
raise MissingValidationConfigException()
try:
changes = {}
validated_model = self.initialize_validation_model(values)
for field, value in validated_model.model_dump().items():
if getattr(self.config, field) != value:
changes[field] = value
return ValidationResult(changes, [])
except pydantic.ValidationError as e:
if not raise_exception:
return ValidationResult(None, e.errors())
raise
def get_layout(self) -> Iterable:
raise NotImplementedError
def get_key_for_panel_defaults(self) -> dict:
return self.validation_model.schema().get("properties").keys()

View File

@ -0,0 +1,110 @@
from pydantic import AfterValidator
from typing_extensions import Annotated
from settings.modules.BaseModule import BaseSettingsModule, BaseValidationModel
import PySimpleGUI as sg
from settings.modules.CommonFieldValidators import check_is_float_convertible
class BlinkAlgoSettingsValidationModel(BaseValidationModel):
gui_IBO: bool
gui_RANSACBLINK: bool
gui_BLINK: bool
gui_LEAP_lid: bool
ibo_filter_samples: int
calibration_samples: int
ibo_fully_close_eye_threshold: Annotated[str, AfterValidator(check_is_float_convertible)]
gui_circular_crop_left: bool
gui_circular_crop_right: bool
leap_calibration_samples: int
class BlinkAlgoSettingsModule(BaseSettingsModule):
def __init__(self, config, widget_id, **kwargs):
super().__init__(config=config, widget_id=widget_id, **kwargs)
self.validation_model = BlinkAlgoSettingsValidationModel
self.gui_IBO = f"-IBO{widget_id}-"
self.gui_RANSACBLINK = f"-RANSACBLINK{widget_id}-"
self.gui_BLINK = f"-BLINK{widget_id}-"
self.gui_LEAP_lid = f"-LEAPLID{widget_id}-"
self.ibo_filter_samples = f"-IBOFILTERSAMPLE{widget_id}-"
self.calibration_samples = f"-CALIBRATIONSAMPLES{widget_id}-"
self.ibo_fully_close_eye_threshold = f"-CLOSETHRESH{widget_id}-"
self.gui_circular_crop_left = f"-CIRCLECROPLEFT{widget_id}-"
self.gui_circular_crop_right = f"-CIRCLECROPRIGHT{widget_id}-"
self.leap_calibration_samples = f"-LEAPCALIBRATION{widget_id}-"
def get_layout(self):
return [
[sg.Text("Blink Algo Settings:", background_color="#242224")],
[
sg.Checkbox(
"Intensity Based Openness",
default=self.config.gui_IBO,
key=self.gui_IBO,
background_color="#424042",
),
sg.Checkbox(
"RANSAC Quick Blink Algo",
default=self.config.gui_RANSACBLINK,
key=self.gui_RANSACBLINK,
background_color="#424042",
),
sg.Checkbox(
"Binary Blink Algo",
default=self.config.gui_BLINK,
key=self.gui_BLINK,
background_color="#424042",
),
sg.Checkbox(
"LEAP Lid",
default=self.config.gui_LEAP_lid,
key=self.gui_LEAP_lid,
background_color="#424042",
),
],
[
sg.Text("LEAP Calibration Samples", background_color="#424042"),
sg.InputText(
self.config.leap_calibration_samples,
key=self.leap_calibration_samples,
size=(0, 10),
),
],
[
sg.Text("IBO Filter Sample Size", background_color="#424042"),
sg.InputText(
self.config.ibo_filter_samples,
key=self.ibo_filter_samples,
size=(0, 10),
),
sg.Text("Calibration Samples", background_color="#424042"),
sg.InputText(
self.config.calibration_samples,
key=self.calibration_samples,
size=(0, 10),
),
sg.Text("IBO Close Threshold", background_color="#424042"),
sg.InputText(
self.config.ibo_fully_close_eye_threshold,
key=self.ibo_fully_close_eye_threshold,
size=(0, 10),
),
],
[
sg.Checkbox(
"Left Eye Circle crop",
default=self.config.gui_circular_crop_left,
key=self.gui_circular_crop_left,
background_color="#424042",
),
sg.Checkbox(
"Right Eye Circle crop",
default=self.config.gui_circular_crop_right,
key=self.gui_circular_crop_right,
background_color="#424042",
),
],
]

View File

@ -0,0 +1,34 @@
import ipaddress
def check_is_float_convertible(v: str):
"""
Check if value provided can be converted to a float or double.
PySimpleGUI does not support floats or doubles in UI, so we have to make sure
that what the user typed in is correct
"""
try:
float(v)
return v
except ValueError:
raise ValueError("Please provide a proper number")
def try_convert_to_float(v: str):
"""
Check if value provided can be converted to a float and return converted result
Basically what `check_is_float_convertible` does but returns a float
"""
try:
return float(check_is_float_convertible(v))
except ValueError:
raise
def check_is_ip_address(v: str):
try:
ipaddress.IPv4Address(v)
return v
except ValueError:
raise ValueError("Please provide a valid IP Address")

View File

@ -0,0 +1,106 @@
from config import EyeTrackSettingsConfig
from settings.modules.BaseModule import BaseSettingsModule, BaseValidationModel
import PySimpleGUI as sg
class GeneralSettingsValidationModel(BaseValidationModel):
gui_flip_x_axis_left: bool
gui_flip_x_axis_right: bool
gui_flip_y_axis: bool
gui_outer_side_falloff: bool
gui_update_check: bool
gui_right_eye_dominant: bool
gui_left_eye_dominant: bool
gui_eye_dominant_diff_thresh: float
class GeneralSettingsModule(BaseSettingsModule):
def __init__(self, config, widget_id, **kwargs):
super().__init__(config=config, widget_id=widget_id, **kwargs)
self.validation_model = GeneralSettingsValidationModel
self.gui_flip_x_axis_left = f"-FLIPXAXISLEFT{widget_id}-"
self.gui_flip_x_axis_right = f"-FLIPXAXISRIGHT{widget_id}-"
self.gui_flip_y_axis = f"-FLIPYAXIS{widget_id}-"
self.gui_outer_side_falloff = f"-EYEFALLOFF{widget_id}-"
self.gui_eye_dominant_diff_thresh = f"-DIFFTHRESH{widget_id}-"
self.gui_left_eye_dominant = f"-LEFTEYEDOMINANT{widget_id}-"
self.gui_right_eye_dominant = f"-RIGHTEYEDOMINANT{widget_id}-"
self.gui_update_check = f"-UPDATECHECK{widget_id}-"
# gui_right_eye_dominant: bool = False
# gui_left_eye_dominant: bool = False
# gui_outer_side_falloff: bool = True
# gui_eye_dominant_diff_thresh: float = 0.3
def get_layout(self):
return [
[
sg.Text("General Settings:", background_color="#242224"),
],
[
sg.Checkbox(
"Flip Left Eye X Axis",
default=self.config.gui_flip_x_axis_left,
key=self.gui_flip_x_axis_left,
background_color="#424042",
tooltip="Flips the left eye's X axis.",
),
sg.Checkbox(
"Flip Right Eye X Axis",
default=self.config.gui_flip_x_axis_right,
key=self.gui_flip_x_axis_right,
background_color="#424042",
tooltip="Flips the right eye's X axis.",
),
sg.Checkbox(
"Flip Y Axis",
default=self.config.gui_flip_y_axis,
key=self.gui_flip_y_axis,
background_color="#424042",
tooltip="Flips the eye's Y axis.",
),
],
[
sg.Checkbox(
"Check For Updates",
default=self.config.gui_update_check,
key=self.gui_update_check,
background_color="#424042",
tooltip="Toggle update check on launch.",
),
],
[
sg.Text("Eye Falloff Settings:", background_color="#242224"),
],
[
sg.Checkbox(
"Outer Eye Falloff",
default=self.config.gui_outer_side_falloff,
key=self.gui_outer_side_falloff,
background_color="#424042",
tooltip="If one eye's tracking is past a threshold of difference, we assume the eye looking most outward with lowest average velocity in the past x seconds is correct.",
),
sg.Text("Eye Difference Threshold", background_color="#424042"),
sg.InputText(
self.config.gui_eye_dominant_diff_thresh,
key=self.gui_eye_dominant_diff_thresh,
size=(0, 10),
),
],
[
sg.Checkbox(
"Force Left Eye Dominant",
default=self.config.gui_left_eye_dominant,
key=self.gui_left_eye_dominant,
background_color="#424042",
tooltip="If one eye is too different than the other, use left eye data",
),
sg.Checkbox(
"Force Right Eye Dominant",
default=self.config.gui_right_eye_dominant,
key=self.gui_right_eye_dominant,
background_color="#424042",
tooltip="If one eye is too different than the other, use right eye data",
),
],
]

View File

@ -0,0 +1,137 @@
from pydantic import model_validator
from settings.modules.BaseModule import BaseSettingsModule, BaseValidationModel
from settings.constants import BACKGROUND_COLOR
import PySimpleGUI as sg
class OSCValidationModel(BaseValidationModel):
gui_osc_port: int
gui_osc_address: str
gui_ROSC: bool
gui_osc_receiver_port: int
gui_osc_recenter_address: str
gui_osc_recalibrate_address: str
gui_vrc_native: bool
gui_osc_vrcft_v1: bool
gui_osc_vrcft_v2: bool
gui_use_module: bool
@model_validator(mode="after")
def check_osc_vrcft_versions(self):
if self.gui_osc_vrcft_v1 and self.gui_osc_vrcft_v2:
raise ValueError("Only one version of VRCFT params can be turned on")
return self
@model_validator(mode="after")
def check_osc_output_mode(self):
if self.gui_vrc_native and any([self.gui_osc_vrcft_v1, self.gui_osc_vrcft_v2]):
raise ValueError("Either VRCNative or VRCFT output can be active at a time")
return self
class OSCSettingsModule(BaseSettingsModule):
def __init__(self, config, widget_id, **kwargs):
super().__init__(config=config, widget_id=widget_id, **kwargs)
self.validation_model = OSCValidationModel
self.gui_osc_address = f"-OSCADDRESS{widget_id}-"
self.gui_osc_port = f"-OSCPORT{widget_id}-"
self.gui_ROSC = f"-ROSC{widget_id}-"
self.gui_osc_receiver_port = f"OSCRECEIVERPORT{widget_id}-"
self.gui_osc_recenter_address = f"OSCRECENTERADDRESS{widget_id}-"
self.gui_osc_recalibrate_address = f"OSCRECALIBRATEADDRESS{widget_id}-"
self.gui_vrc_native = f"-VRCNATIVE{widget_id}-"
self.gui_osc_vrcft_v1 = f"-OSCVRCFTV1{widget_id}-"
self.gui_osc_vrcft_v2 = f"-OSCVRCFTV2{widget_id}-"
self.gui_use_module = f"-OSCUSEMODULE{widget_id}-"
def get_layout(self):
return [
[
sg.Text("OSC Settings:", background_color="#242224"),
],
[
sg.Checkbox(
"Use ETVR VRCFT Module",
default=self.config.gui_use_module,
key=self.gui_use_module,
background_color="#424042",
tooltip="Toggle output to VRCFT Module or just regular OSC port",
),
],
[
sg.Checkbox(
"VRC Native Eyetracking",
default=self.config.gui_vrc_native,
key=self.gui_vrc_native,
background_color="#424042",
tooltip="Toggle VRCFT output or VRC native",
),
sg.Checkbox(
"VRCFT v1",
default=self.config.gui_osc_vrcft_v1,
key=self.gui_osc_vrcft_v1,
background_color="#424042",
tooltip="Toggle VRCFT's v1 Eyetracking format.",
),
sg.Checkbox(
"VRCFT v2 (UE)",
default=self.config.gui_osc_vrcft_v2,
key=self.gui_osc_vrcft_v2,
background_color="#424042",
tooltip="Toggle VRCFT's v2 (UE) Eyetracking format.",
),
],
[
sg.Text("Address:", background_color=BACKGROUND_COLOR),
sg.InputText(
self.config.gui_osc_address,
key=self.gui_osc_address,
size=(0, 20),
tooltip="IP address we send OSC data to.",
),
sg.Text("Port:", background_color=BACKGROUND_COLOR),
sg.InputText(
self.config.gui_osc_port,
key=self.gui_osc_port,
size=(0, 10),
tooltip="OSC port we send data to.",
),
],
[
sg.Text("Receive functions", background_color=BACKGROUND_COLOR),
sg.Checkbox(
"",
default=self.config.gui_ROSC,
key=self.gui_ROSC,
background_color=BACKGROUND_COLOR,
size=(0, 10),
tooltip="Toggle OSC receive functions.",
),
],
[
sg.Text("Receiver Port:", background_color=BACKGROUND_COLOR),
sg.InputText(
self.config.gui_osc_receiver_port,
key=self.gui_osc_receiver_port,
size=(0, 10),
tooltip="Port we receive OSC data from (used to recalibrate or recenter app from within VRChat.",
),
sg.Text("Recenter Address:", background_color=BACKGROUND_COLOR),
sg.InputText(
self.config.gui_osc_recenter_address,
key=self.gui_osc_recenter_address,
size=(0, 10),
tooltip="OSC Address used for recentering your eye.",
),
],
[
sg.Text("Recalibrate Address:", background_color=BACKGROUND_COLOR),
sg.InputText(
self.config.gui_osc_recalibrate_address,
key=self.gui_osc_recalibrate_address,
size=(0, 10),
tooltip="OSC address we use for recalibrating your eye",
),
],
]

View File

@ -0,0 +1,42 @@
from pydantic import AfterValidator
from typing_extensions import Annotated
from settings.modules.BaseModule import BaseSettingsModule, BaseValidationModel
from settings.constants import BACKGROUND_COLOR
import PySimpleGUI as sg
from settings.modules.CommonFieldValidators import check_is_float_convertible
class OneEuroFilterValidationModel(BaseValidationModel):
gui_speed_coefficient: Annotated[str, AfterValidator(check_is_float_convertible)]
gui_min_cutoff: Annotated[str, AfterValidator(check_is_float_convertible)]
class OneEuroSettingsModule(BaseSettingsModule):
def __init__(self, config, widget_id, **kwargs):
super().__init__(config=config, widget_id=widget_id, **kwargs)
self.gui_speed_coefficient = f"-SPEEDCOEFFICIENT{widget_id}-"
self.gui_min_cutoff = f"-MINCUTOFF{widget_id}-"
self.validation_model = OneEuroFilterValidationModel
def get_layout(self):
return [
[
sg.Text("One Euro Filter Paramaters:", background_color='#242224'),
],
[
sg.Text("Min Frequency Cutoff", background_color=BACKGROUND_COLOR),
sg.InputText(
self.config.gui_min_cutoff,
key=self.gui_min_cutoff,
size=(0, 10),
),
sg.Text("Speed Coefficient", background_color=BACKGROUND_COLOR),
sg.InputText(
self.config.gui_speed_coefficient,
key=self.gui_speed_coefficient,
size=(0, 10),
),
],
]

View File

@ -0,0 +1,238 @@
from pydantic import model_validator
from settings.modules.BaseModule import BaseSettingsModule, BaseValidationModel
import PySimpleGUI as sg
class TrackingAlgorithmValidationModel(BaseValidationModel):
gui_BLOB: bool
gui_DADDY: bool
gui_HSF: bool
gui_HSRAC: bool
gui_AHSF: bool
gui_LEAP: bool
gui_RANSAC3D: bool
gui_AHSFRAC: bool
gui_legacy_ransac: bool
gui_BLOBP: int
gui_DADDYP: int
gui_AHSFRACP: int
gui_HSFP: int
gui_AHSFP: int
gui_HSRACP: int
gui_LEAPP: int
gui_RANSAC3DP: int
@model_validator(mode="after")
def check_algorith_order(self):
algos_list = [
self.gui_AHSFP,
self.gui_BLOBP,
self.gui_DADDYP,
self.gui_HSFP,
self.gui_HSRACP,
self.gui_LEAPP,
self.gui_RANSAC3DP,
self.gui_AHSFRACP,
]
algos_set = set(algos_list)
if len(algos_set) != len(algos_list):
raise ValueError("Please fixup the algorithm order, some algos are doubled")
return self
class TrackingAlgorithmModule(BaseSettingsModule):
def __init__(self, config, widget_id, **kwargs):
super().__init__(config=config, widget_id=widget_id, **kwargs)
self.algo_count = ["1", "2", "3", "4", "5", "6", "7", "8"]
self.validation_model = TrackingAlgorithmValidationModel
self.gui_BLOB = f"-BLOBFALLBACK{widget_id}-"
self.gui_DADDY = f"-DADDY{widget_id}-"
self.gui_HSF = f"-HSF{widget_id}-"
self.gui_HSRAC = f"-HSRAC{widget_id}-"
self.gui_LEAP = f"-LEAP{widget_id}-"
self.gui_AHSF = f"-AHSF{widget_id}-"
self.gui_AHSFRAC = f"-gui_AHSFRAC{widget_id}-"
self.gui_RANSAC3D = f"-RANSAC3D{widget_id}-"
self.gui_legacy_ransac = f"-LEGACYRANSACTHRESH{widget_id}-"
self.gui_BLOBP = f"-BLOBP{widget_id}-"
self.gui_DADDYP = f"-DADDYP{widget_id}-"
self.gui_HSFP = f"-HSFP{widget_id}-"
self.gui_HSRACP = f"-HSRACP{widget_id}-"
self.gui_LEAPP = f"-LEAPP{widget_id}-"
self.gui_AHSFP = f"-AHSFP{widget_id}-"
self.gui_RANSAC3DP = f"-RANSAC3DP{widget_id}-"
self.gui_AHSFRACP = f"-gui_AHSFRACP{widget_id}-"
# TODO custom validation, make a set of values, count if there's less than overall, if yeah we have a problem
def get_layout(self):
return [
[
sg.Text(
"Tracking Algorithm Order Settings:",
background_color="#242224",
)
],
[
sg.Checkbox(
"",
default=self.config.gui_AHSFRAC,
key=self.gui_AHSFRAC,
background_color="#424042",
tooltip="Flagship hybrid algo",
),
sg.Combo(
self.algo_count,
default_value=self.config.gui_AHSFRACP,
key=self.gui_AHSFRACP,
background_color="#424042",
text_color="white",
button_arrow_color="black",
button_background_color="#6f4ca1",
tooltip="Select the priority of eyetracking algorithms.",
),
sg.Text("ASHSFRAC", background_color="#424042"),
sg.Checkbox(
"",
default=self.config.gui_AHSF,
key=self.gui_AHSF,
background_color="#424042",
tooltip="Newer version of HSF",
),
sg.Combo(
self.algo_count,
default_value=self.config.gui_AHSFP,
key=self.gui_AHSFP,
background_color="#424042",
text_color="white",
button_arrow_color="black",
button_background_color="#6f4ca1",
tooltip="Select the priority of eyetracking algorithms.",
),
sg.Text("ASHSF", background_color="#424042"),
],
[
sg.Checkbox(
"",
default=self.config.gui_HSRAC,
key=self.gui_HSRAC,
background_color="#424042",
tooltip="Our flagship algorithm, utilizing both HSF and RANSAC for best tracking quality and lighting resistance.",
),
sg.Combo(
self.algo_count,
default_value=self.config.gui_HSRACP,
key=self.gui_HSRACP,
background_color="#424042",
text_color="white",
button_arrow_color="black",
button_background_color="#6f4ca1",
tooltip="Select the priority of eyetracking algorithms.",
),
sg.Text("HSRAC", background_color="#424042"),
sg.Checkbox(
"",
default=self.config.gui_HSF,
key=self.gui_HSF,
background_color="#424042",
tooltip="HSF Is a new, lower resolution tracking algorithim that provides excelent resilancy to lighting conditions and great speed.",
),
sg.Combo(
self.algo_count,
default_value=self.config.gui_HSFP,
key=self.gui_HSFP,
background_color="#424042",
text_color="white",
button_arrow_color="black",
button_background_color="#6f4ca1",
tooltip="Select the priority of eyetracking algorithims.",
),
sg.Text("Haar Surround Feature", background_color="#424042"),
],
[
sg.Checkbox(
"",
default=self.config.gui_DADDY,
key=self.gui_DADDY,
background_color="#424042",
tooltip="DADDY Uses a Deep learning algorithm. This has a big CPU usage impact.",
),
sg.Combo(
self.algo_count,
default_value=self.config.gui_DADDYP,
key=self.gui_DADDYP,
background_color="#424042",
text_color="white",
button_arrow_color="black",
button_background_color="#6f4ca1",
tooltip="Select the priority of eyetracking algorithms.",
),
sg.Text("DADDY", background_color="#424042"),
# ],
# [
sg.Checkbox(
"",
default=self.config.gui_RANSAC3D,
key=self.gui_RANSAC3D,
background_color="#424042",
tooltip="RANSAC3D provides good tracking quality, however does not do well in bad lighting conditions.",
),
sg.Combo(
self.algo_count,
default_value=self.config.gui_RANSAC3DP,
key=self.gui_RANSAC3DP,
background_color="#424042",
text_color="white",
button_arrow_color="black",
button_background_color="#6f4ca1",
tooltip="Select the priority of eyetracking algorithms.",
),
sg.Text("RANSAC 3D", background_color="#424042"),
sg.Checkbox(
"Legacy RANSAC Thresh",
default=self.config.gui_legacy_ransac,
key=self.gui_legacy_ransac,
background_color="#424042",
),
],
[
sg.Checkbox(
"",
default=self.config.gui_LEAP,
key=self.gui_LEAP,
background_color="#424042",
tooltip="LEAP Uses a lightweight deep learning algorithm.",
),
sg.Combo(
self.algo_count,
default_value=self.config.gui_LEAPP,
key=self.gui_LEAPP,
background_color="#424042",
text_color="white",
button_arrow_color="black",
button_background_color="#6f4ca1",
tooltip="Select the priority of eyetracking algorithms.",
),
sg.Text("LEAP", background_color="#424042"),
sg.Checkbox(
"",
default=self.config.gui_BLOB,
key=self.gui_BLOB,
background_color="#424042",
tooltip="Blob tracking is the oldest and worst tracking algorithm, it provides fast, though sometimes inaccurate tracking.",
),
sg.Combo(
self.algo_count,
default_value=self.config.gui_BLOBP,
key=self.gui_BLOBP,
background_color="#424042",
text_color="white",
button_arrow_color="black",
button_background_color="#6f4ca1",
tooltip="Select the priority of eyetracking algorithms.",
),
sg.Text("Blob", background_color="#424042"),
],
]

View File

@ -0,0 +1,225 @@
from typing import Iterable
import PySimpleGUI as sg
from pydantic import AfterValidator
from typing_extensions import Annotated
from settings.modules.BaseModule import BaseSettingsModule, BaseValidationModel
from settings.modules.CommonFieldValidators import check_is_ip_address, try_convert_to_float
class VRCFTSettingsModuleValidationModel(BaseValidationModel):
gui_VRCFTModulePort: int
gui_VRCFTModuleIPAddress: Annotated[str, AfterValidator(check_is_ip_address)]
gui_ShouldEmulateEyeWiden: bool
gui_ShouldEmulateEyeSquint: bool
gui_ShouldEmulateEyebrows: bool
gui_WidenThresholdV1_min: float
gui_WidenThresholdV1_max: float
gui_WidenThresholdV2_min: float
gui_WidenThresholdV2_max: float
gui_SqueezeThresholdV1_min: float
gui_SqueezeThresholdV1_max: float
gui_SqueezeThresholdV2_min: float
gui_SqueezeThresholdV2_max: float
gui_EyebrowThresholdRising: float
gui_EyebrowThresholdLowering: float
# this is a hack. I don't like it, but that's what I gotta do to make both, Pydantic and PySimpleGUI happy
gui_OutputMultiplier: Annotated[float, AfterValidator(try_convert_to_float)]
class VRCFTSettingsModule(BaseSettingsModule):
def __init__(self, config, widget_id, **kwargs):
super().__init__(config=config, widget_id=widget_id, **kwargs)
self.validation_model = VRCFTSettingsModuleValidationModel
self.gui_VRCFTModulePort = f"-VRCFTSETTINGSPORTNUMBER{widget_id}"
self.gui_VRCFTModuleIPAddress = f"-VRCFTSETTINGSIPNUMBER{widget_id}"
self.gui_ShouldEmulateEyeWiden = f"-VRCFTSETTINGSEMULATEWIDEN{widget_id}"
self.gui_ShouldEmulateEyeSquint = f"-VRCFTSETTINGSEMULATEEYEWIDEN{widget_id}"
self.gui_ShouldEmulateEyebrows = f"-VRCFTSETTINGSEMULATEEYEBROWS{widget_id}"
self.gui_WidenThresholdV1_min = f"-VRCFTSETTINGSWIDENTHRESHOLDV1MIN{widget_id}"
self.gui_WidenThresholdV1_max = f"-VRCFTSETTINGSWIDENTHRESHOLDV1MAX{widget_id}"
self.gui_WidenThresholdV2_min = f"-VRCFTSETTINGSWIDENTHRESHOLDV2MIN{widget_id}"
self.gui_WidenThresholdV2_max = f"-VRCFTSETTINGSWIDENTHRESHOLDV2MAX{widget_id}"
self.gui_SqueezeThresholdV1_min = f"-VRCFTSETTINGSSQUEEZETHRESHOLDV1MIN{widget_id}"
self.gui_SqueezeThresholdV1_max = f"-VRCFTSETTINGSSQUEEZETHRESHOLDV1MAX{widget_id}"
self.gui_SqueezeThresholdV2_min = f"-VRCFTSETTINGSSQUEEZETHRESHOLDV2MIN{widget_id}"
self.gui_SqueezeThresholdV2_max = f"-VRCFTSETTINGSSQUEEZETHRESHOLDV2MAX{widget_id}"
self.gui_EyebrowThresholdRising = f"-VRCFTSETTINGSEYEBROWTHRESHOLDRISING{widget_id}"
self.gui_EyebrowThresholdLowering = f"-VRCFTSETTINGSEYEBROWTHRESHOLDLOWERING{widget_id}"
self.gui_OutputMultiplier = f"-VRCFTSETTINGSOUTPUTMULTIPLIER{widget_id}"
def get_layout(self) -> Iterable:
return [
[
sg.Text("Emulation selection:", background_color="#242224"),
],
[
sg.Checkbox(
"Emulate Eye Widen",
default=self.config.gui_ShouldEmulateEyeWiden,
key=self.gui_ShouldEmulateEyeWiden,
background_color="#424042",
),
sg.Checkbox(
"Emulate Eye Squint",
default=self.config.gui_ShouldEmulateEyeSquint,
key=self.gui_ShouldEmulateEyeSquint,
background_color="#424042",
),
sg.Checkbox(
"Emulate Eyebrows",
default=self.config.gui_ShouldEmulateEyebrows,
key=self.gui_ShouldEmulateEyebrows,
background_color="#424042",
),
],
[
sg.Text("General Module Settings:", background_color="#242224"),
],
[
sg.Text("VRCFT Module listening IP", background_color="#242224"),
sg.InputText(
self.config.gui_VRCFTModuleIPAddress,
key=self.gui_VRCFTModuleIPAddress,
size=(0, 10),
tooltip="Ip on which the module should listen.",
),
sg.Text("port", background_color="#242224"),
sg.InputText(
self.config.gui_VRCFTModulePort,
key=self.gui_VRCFTModulePort,
size=(0, 10),
tooltip="UDP port on which the module should listen.",
),
],
[
sg.Text("VRCFT Module output multiplier", background_color="#242224"),
sg.InputText(
self.config.gui_OutputMultiplier,
key=self.gui_OutputMultiplier,
size=(0, 10),
tooltip="Output multiplier adjusts the output by the given amount",
),
],
[
sg.Text("Eye Widen thresholds:", background_color="#424042"),
],
[
sg.Text("V1 Min:", background_color="#424042"),
sg.Slider(
range=(0, 1),
resolution=0.01,
default_value=self.config.gui_WidenThresholdV1_min,
orientation="h",
key=self.gui_WidenThresholdV1_min,
background_color="#424042",
tooltip="Controls the point at which the emulation should start for v1 params, reacts to openness",
),
sg.Text("V1 Max:", background_color="#424042"),
sg.Slider(
range=(0, 2),
resolution=0.01,
default_value=self.config.gui_WidenThresholdV1_max,
orientation="h",
key=self.gui_WidenThresholdV1_max,
background_color="#424042",
tooltip="Controls the maximum range of widen emulation",
),
],
[
sg.Text("V2 Min:", background_color="#424042"),
sg.Slider(
range=(0, 2),
resolution=0.01,
default_value=self.config.gui_WidenThresholdV2_min,
orientation="h",
key=self.gui_WidenThresholdV2_min,
background_color="#424042",
tooltip="Controls the point at which the emulation should start for v2 params, reacts to openness",
),
sg.Text("V2 Max:", background_color="#424042"),
sg.Slider(
range=(0, 2),
resolution=0.01,
default_value=self.config.gui_WidenThresholdV2_max,
orientation="h",
key=self.gui_WidenThresholdV2_max,
background_color="#424042",
tooltip="Controls the maximum range of widen emulation",
),
],
[
sg.Text("Eye Squeeze thresholds:", background_color="#424042"),
],
[
sg.Text("V1 Min:", background_color="#424042"),
sg.Slider(
range=(0, 1),
resolution=0.01,
default_value=self.config.gui_SqueezeThresholdV1_min,
orientation="h",
key=self.gui_SqueezeThresholdV1_min,
background_color="#424042",
tooltip="Controls the point at which the emulation should start for v1 params, reacts to openness",
),
sg.Text("V1 Max:", background_color="#424042"),
sg.Slider(
range=(0, 2),
resolution=0.01,
default_value=self.config.gui_SqueezeThresholdV1_max,
orientation="h",
key=self.gui_SqueezeThresholdV1_max,
background_color="#424042",
tooltip="Controls the maximum range of squeeze emulation",
),
],
[
sg.Text("V2 Min:", background_color="#424042"),
sg.Slider(
range=(0, 1),
resolution=0.01,
default_value=self.config.gui_SqueezeThresholdV2_min,
orientation="h",
key=self.gui_SqueezeThresholdV2_min,
background_color="#424042",
tooltip="Controls the point at which the emulation should start for v2 params, reacts to openness",
),
sg.Text("V2 Max:", background_color="#424042"),
sg.Slider(
range=(-2, 0),
resolution=0.01,
default_value=self.config.gui_SqueezeThresholdV2_max,
orientation="h",
key=self.gui_SqueezeThresholdV2_max,
background_color="#424042",
tooltip="Controls the maximum range of squeeze emulation",
),
],
[
sg.Text("Eyebrow emulation Thresholds:", background_color="#424042"),
],
[
sg.Text("Rising:", background_color="#424042"),
sg.Slider(
range=(0, 1),
resolution=0.01,
default_value=self.config.gui_EyebrowThresholdRising,
orientation="h",
key=self.gui_EyebrowThresholdRising,
background_color="#424042",
tooltip="Controls the point at which the emulation should start, reacts to openness",
),
sg.Text("Lowering:", background_color="#424042"),
sg.Slider(
range=(0, 2),
resolution=0.01,
default_value=self.config.gui_EyebrowThresholdLowering,
orientation="h",
key=self.gui_EyebrowThresholdLowering,
background_color="#424042",
tooltip="Controls the maximum range of eyebrows emulation",
),
],
]

View File

@ -1,257 +0,0 @@
import PySimpleGUI as sg
from config import EyeTrackSettingsConfig
from threading import Event, Thread
from eye_processor import EyeProcessor, InformationOrigin
from enum import Enum
from queue import Queue, Empty
from camera import Camera, CameraState
import cv2
from osc import EyeId
class SettingsWidget:
def __init__(self, widget_id: EyeId, main_config: EyeTrackSettingsConfig, osc_queue: Queue):
self.gui_flip_x_axis_left = f"-FLIPXAXISLEFT{widget_id}-"
self.gui_flip_x_axis_right = f"-FLIPXAXISRIGHT{widget_id}-"
self.gui_flip_y_axis = f"-FLIPYAXIS{widget_id}-"
self.gui_general_settings_layout = f"-GENERALSETTINGSLAYOUT{widget_id}-"
self.gui_osc_address = f"-OSCADDRESS{widget_id}-"
self.gui_osc_port = f"-OSCPORT{widget_id}-"
self.gui_osc_receiver_port = f"OSCRECEIVERPORT{widget_id}-"
self.gui_osc_recenter_address = f"OSCRECENTERADDRESS{widget_id}-"
self.gui_osc_recalibrate_address = f"OSCRECALIBRATEADDRESS{widget_id}-"
self.gui_blob_fallback = f"-BLOBFALLBACK{widget_id}-"
self.gui_blob_maxsize = f"-BLOBMAXSIZE{widget_id}-"
self.gui_blob_minsize = f"-BLOBMINSIZE{widget_id}-"
self.gui_speed_coefficient = f"-SPEEDCOEFFICIENT{widget_id}-"
self.gui_min_cutoff = f"-MINCUTOFF{widget_id}-"
self.gui_eye_falloff = f"-EYEFALLOFF{widget_id}-"
self.gui_blink_sync = f"-BLINKSYNC{widget_id}-"
self.main_config = main_config
self.config = main_config.settings
self.osc_queue = osc_queue
# Define the window's contents
self.general_settings_layout = [
[
sg.Checkbox(
"Flip Left Eye X Axis",
default=self.config.gui_flip_x_axis_left,
key=self.gui_flip_x_axis_left,
background_color='#424042',
),
sg.Checkbox(
"Flip Right Eye X Axis",
default=self.config.gui_flip_x_axis_right,
key=self.gui_flip_x_axis_right,
background_color='#424042',
),
],
[sg.Checkbox(
"Flip Y Axis",
default=self.config.gui_flip_y_axis,
key=self.gui_flip_y_axis,
background_color='#424042',
),
],
[sg.Checkbox(
"Dual Eye Falloff",
default=self.config.gui_eye_falloff,
key=self.gui_eye_falloff,
background_color='#424042',
),
],
[sg.Checkbox(
"Sync Blinks (disables winking)",
default=self.config.gui_blink_sync,
key=self.gui_blink_sync,
background_color='#424042',
),
],
[
sg.Text("Tracking Algorithim Settings:", background_color='#242224'),
],
[sg.Checkbox(
"Blob Fallback",
default=self.config.gui_blob_fallback,
key=self.gui_blob_fallback,
background_color='#424042',
),
],
[
sg.Text("Min blob size:", background_color='#424042'),
sg.Slider(
range=(1, 50),
default_value=self.config.gui_blob_minsize,
orientation="h",
key=self.gui_blob_minsize,
background_color='#424042'
),
sg.Text("Max blob size:", background_color='#424042'),
sg.Slider(
range=(1, 50),
default_value=self.config.gui_blob_maxsize,
orientation="h",
key=self.gui_blob_maxsize,
background_color='#424042'
),
],
[
sg.Text("Filter Paramaters:", background_color='#242224'),
],
[
sg.Text("Min Frequency Cutoff", background_color='#424042'),
sg.InputText(self.config.gui_min_cutoff, key=self.gui_min_cutoff),
],
[
sg.Text("Speed Coefficient", background_color='#424042'),
sg.InputText(self.config.gui_speed_coefficient, key=self.gui_speed_coefficient),
],
[
sg.Text("OSC Settings:", background_color='#242224'),
],
[
sg.Text("OSC Address:", background_color='#424042'),
sg.InputText(self.config.gui_osc_address, key=self.gui_osc_address),
],
[
sg.Text("OSC Port:", background_color='#424042'),
sg.InputText(self.config.gui_osc_port, key=self.gui_osc_port),
],
[
sg.Text("OSC Receiver Port:", background_color='#424042'),
sg.InputText(self.config.gui_osc_receiver_port, key=self.gui_osc_receiver_port),
],
[
sg.Text("OSC Recenter Address:", background_color='#424042'),
sg.InputText(self.config.gui_osc_recenter_address, key=self.gui_osc_recenter_address),
],
[
sg.Text("OSC Recalibrate Address:", background_color='#424042'),
sg.InputText(self.config.gui_osc_recalibrate_address, key=self.gui_osc_recalibrate_address),
]
]
self.widget_layout = [
[
sg.Text("General Settings:", background_color='#242224'),
],
[
sg.Column(self.general_settings_layout, key=self.gui_general_settings_layout, background_color='#424042' ),
],
# [
# sg.Button(
# "Save Settings", key=self.gui_save_button, button_color = '#6f4ca1'
# ),
#],
]
self.cancellation_event = Event() # Set the event until start is called, otherwise we can block if shutdown is called.
self.cancellation_event.set()
self.image_queue = Queue()
def started(self):
return not self.cancellation_event.is_set()
def start(self):
# If we're already running, bail
if not self.cancellation_event.is_set():
return
self.cancellation_event.clear()
def stop(self):
# If we're not running yet, bail
if self.cancellation_event.is_set():
return
self.cancellation_event.set()
def render(self, window, event, values):
# If anything has changed in our configuration settings, change/update those.
changed = False
if self.config.gui_osc_port != values[self.gui_osc_port]:
try:
int(values[self.gui_osc_port])
if len(values[self.gui_osc_port]) <= 5:
self.config.gui_osc_port = int(values[self.gui_osc_port])
changed = True
else:
print("[ERROR] OSC port value must be an integer 0-65535")
except:
print("[ERROR] OSC port value must be an integer 0-65535")
if self.config.gui_osc_receiver_port != values[self.gui_osc_receiver_port]:
try:
int(values[self.gui_osc_receiver_port])
if len(values[self.gui_osc_receiver_port]) <= 5:
self.config.gui_osc_receiver_port = int(values[self.gui_osc_receiver_port])
changed = True
else:
print("[ERROR] OSC receive port value must be an integer 0-65535")
except:
print("[ERROR] OSC receive port value must be an integer 0-65535")
if self.config.gui_osc_address != values[self.gui_osc_address]:
self.config.gui_osc_address = values[self.gui_osc_address]
changed = True
if self.config.gui_osc_recenter_address != values[self.gui_osc_recenter_address]:
self.config.gui_osc_recenter_address = values[self.gui_osc_recenter_address]
changed = True
if self.config.gui_osc_recalibrate_address != values[self.gui_osc_recalibrate_address]:
self.config.gui_osc_recalibrate_address = values[self.gui_osc_recalibrate_address]
changed = True
if self.config.gui_min_cutoff != values[self.gui_min_cutoff]:
self.config.gui_min_cutoff = values[self.gui_min_cutoff]
changed = True
if self.config.gui_speed_coefficient != values[self.gui_speed_coefficient]:
self.config.gui_speed_coefficient = values[self.gui_speed_coefficient]
changed = True
if self.config.gui_flip_x_axis_right != values[self.gui_flip_x_axis_right]:
self.config.gui_flip_x_axis_right = values[self.gui_flip_x_axis_right]
changed = True
if self.config.gui_flip_x_axis_left != values[self.gui_flip_x_axis_left]:
self.config.gui_flip_x_axis_left = values[self.gui_flip_x_axis_left]
changed = True
if self.config.gui_flip_y_axis != values[self.gui_flip_y_axis]:
self.config.gui_flip_y_axis = values[self.gui_flip_y_axis]
changed = True
if self.config.gui_blob_fallback != values[self.gui_blob_fallback]:
self.config.gui_blob_fallback = values[self.gui_blob_fallback]
changed = True
if self.config.gui_eye_falloff != values[self.gui_eye_falloff]:
self.config.gui_eye_falloff = values[self.gui_eye_falloff]
changed = True
if self.config.gui_blink_sync != values[self.gui_blink_sync]:
self.config.gui_blink_sync = values[self.gui_blink_sync]
changed = True
if self.config.gui_blob_maxsize != values[self.gui_blob_maxsize]:
self.config.gui_blob_maxsize = values[self.gui_blob_maxsize]
changed = True
if changed:
self.main_config.save()
self.osc_queue.put((EyeId.SETTINGS))

View File

View File

@ -0,0 +1,130 @@
# calibration_module.py
import numpy as np
class CalibrationProcessor:
def __init__(self):
self.left_eye_data = None
self.right_eye_data = None
self.P_left = None
self.P_right = None
self.gt_3d = np.array([
(0.8, 0.8, 1), (0, 0.8, 1), (-0.8, 0.8, 1), (0.8, 0, 1), (0, 0, 1),
(-0.8, 0, 1), (0.8, -0.8, 1), (0, -0.8, 1), (-0.8, -0.8, 1)
])
def estimate_projection_matrix(self, eye_data, gt_3d):
# Ensure the input data is a numpy array
eye_data = np.array(eye_data)
gt_3d = np.array(gt_3d)
# Append ones for homogeneous coordinates
gt_3d_h = np.hstack((gt_3d, np.ones((gt_3d.shape[0], 1))))
eye_data_h = np.hstack((eye_data, np.ones((eye_data.shape[0], 1))))
# Debug: Print the shapes of the matrices
print("Shape of gt_3d_h:", gt_3d_h.shape)
print("Shape of eye_data_h:", eye_data_h.shape)
# Solve for the projection matrix using least squares
P, _, _, _ = np.linalg.lstsq(gt_3d_h, eye_data_h, rcond=None)
return P
def receive_calibration_data(self, eye_id, data):
if eye_id == 1:
self.left_eye_data = data
elif eye_id == 0:
self.right_eye_data = data
# print('receive',len(self.left_eye_data), self.left_eye_data, self.right_eye_data, data, eye_id)
# Check if both sets of data have been received
if self.left_eye_data is not None and self.right_eye_data is not None:
if len(self.left_eye_data) == 8 and len(self.right_eye_data) == 8:
self.process_calibration_data()
def process_calibration_data(self):
# Ensure both data are present
if self.left_eye_data is None or self.right_eye_data is None:
raise ValueError("Calibration data for both eyes must be provided")
print("Processing calibration data for both eyes...")
print(f"Left Eye Data: {self.left_eye_data}")
print(f"Right Eye Data: {self.right_eye_data}")
self.left_eye_data = np.array(self.left_eye_data)
self.right_eye_data = np.array(self.right_eye_data)
if len(self.left_eye_data) != len(self.gt_3d):
raise ValueError(
f"Number of left eye points ({len(self.left_eye_data)}) does not match number of 3D points ({len(self.gt_3d)}).")
if len(self.right_eye_data) != len(self.gt_3d):
raise ValueError(
f"Number of right eye points ({len(self.right_eye_data)}) does not match number of 3D points ({len(self.gt_3d)}).")
# After processing, reset the data
# self.left_eye_data = None
# self.right_eye_data = None
# Function to compute the 3D gaze direction from 2D points
def compute_gaze_direction(self, P, point_2d):
print(P, point_2d)
# Convert 2D point to homogeneous coordinates
point_2d_h = np.append(point_2d, 1)
# Solve for 3D direction (Ax = b, where A is the projection matrix and b is the 2D point)
direction, _, _, _ = np.linalg.lstsq(P[:, :-1], point_2d_h, rcond=None)
direction /= np.linalg.norm(direction)
return direction
# Compute the convergence point given 2D points for both eyes
def compute_convergence_point(self, left_point_2d, right_point_2d, P_left, P_right, IPD):
left_eye_pos = np.array([-IPD / 2, 0, 0])
right_eye_pos = np.array([IPD / 2, 0, 0])
gaze_left = self.compute_gaze_direction(P_left, left_point_2d)
gaze_right = self.compute_gaze_direction(P_right, right_point_2d)
# Parameterize the gaze directions as lines
def line_parametric_form(point, direction, t):
return point + t * direction
# Find the closest point between two lines
t_values = np.linspace(-10, 10, 1000)
min_distance = float('inf')
best_point = None
for t1 in t_values:
for t2 in t_values:
point1 = line_parametric_form(left_eye_pos, gaze_left, t1)
point2 = line_parametric_form(right_eye_pos, gaze_right, t2)
distance = np.linalg.norm(point1 - point2)
if distance < min_distance:
min_distance = distance
best_point = (point1 + point2) / 2
return best_point
def set_P(self):
self.P_left = self.estimate_projection_matrix(self.left_eye_data, self.gt_3d)
self.P_right = self.estimate_projection_matrix(self.right_eye_data, self.gt_3d)
# Global instance of CalibrationProcessor
calibration_processor = CalibrationProcessor()
def receive_calibration_data(data, eye_id):
global calibration_processor
calibration_processor.receive_calibration_data(eye_id, data)
def converge_3d():
IPD = 0.058
left_point_2d = (120, 100)
right_point_2d = (118, 65)
# estimate_projection_matrix
calibration_processor.set_P()
convergence_point = calibration_processor.compute_convergence_point(left_point_2d, right_point_2d, calibration_processor.P_left, calibration_processor.P_right, IPD)
print(f"Convergence Point: {convergence_point}")

View File

@ -0,0 +1,45 @@
import numpy as np
from eye import EyeId
def velocity_falloff(self, var, out_x, out_y):
if (
self.settings.gui_right_eye_dominant
or self.settings.gui_left_eye_dominant
or self.settings.gui_outer_side_falloff
):
# Calculate the distance between the two eyes
dist = np.sqrt(np.square(var.l_eye_x - var.r_eye_x) + np.square(var.left_y - var.right_y))
if self.eye_id == EyeId.LEFT:
var.l_eye_x = out_x
var.left_y = out_y
if self.eye_id == EyeId.RIGHT:
var.r_eye_x = out_x
var.right_y = out_y
# Check if the distance is greater than the threshold
if dist > self.settings.gui_eye_dominant_diff_thresh:
if self.settings.gui_right_eye_dominant:
out_x, out_y = var.r_eye_x, var.right_y
elif self.settings.gui_left_eye_dominant:
out_x, out_y = var.l_eye_x, var.left_y
else:
# If the distance is too large, identify the eye with the lower velocity
if var.l_eye_velocity < var.r_eye_velocity:
# Mirror the position of the eye with lower velocity to the other eye
out_x, out_y = var.r_eye_x, var.right_y
else:
# Mirror the position of the eye with lower velocity to the other eye
out_x, out_y = var.l_eye_x, var.left_y
else:
# If the distance is within the threshold, do not mirror the eyes
pass
else:
pass
return out_x, out_y

View File

@ -0,0 +1,47 @@
import cv2
import numpy as np
def safe_crop(img, x, y, x2, y2, keepsize=False):
try:
# The order of the arguments can be reconsidered.
img_h, img_w = img.shape[:2]
outimg = img[max(0, y) : min(img_h, y2), max(0, x) : min(img_w, x2)].copy()
reqsize_x, reqsize_y = abs(x2 - x), abs(y2 - y)
if keepsize and outimg.shape[:2] != (reqsize_y, reqsize_x):
# If the size is different from the expected size (smaller by the amount that is out of range)
outimg = cv2.resize(outimg, (reqsize_x, reqsize_y))
return outimg
except cv2.error as e:
if '!ssize.empty()' in str(e):
print("Image is None or has zero dimensions. Skipping resizing.")
else:
raise
def circle_crop(img, xc, yc, radius, cct):
avg_color_per_row = np.average(img, axis=0)
avg_color = np.average(avg_color_per_row, axis=0)
if cct <= 0:
try:
ht, wd = img.shape[:2]
if radius < 10: #minimum size
radius = 10
# draw filled circle in white on black background as mask
mask = np.zeros((ht, wd), dtype=np.uint8)
mask = cv2.circle(mask, (xc, yc), radius, 255, -1)
# create white colored background
color = np.full_like(img, (avg_color))
# apply mask to image
masked_img = cv2.bitwise_and(img, img, mask=mask)
# apply inverse mask to colored image
masked_color = cv2.bitwise_and(color, color, mask=255 - mask)
# combine the two masked images
outimg = cv2.add(masked_img, masked_color)
return outimg, cct
except:
return img, cct
pass
else:
cct = cct - 1
return img, cct

View File

@ -0,0 +1,82 @@
import os
import typing
import sys
from pathlib import Path
from typing import Union
is_nt = True if os.name == "nt" else False
def PlaySound(*args, **kwargs):
pass
SND_FILENAME = SND_ASYNC = 1
if is_nt:
import winsound
PlaySound = winsound.PlaySound
SND_FILENAME = winsound.SND_FILENAME
SND_ASYNC = winsound.SND_ASYNC
def clamp(x, low, high):
return max(low, min(x, high))
def lst_median(lst, ordered=False):
# https://github.com/emilianavt/OpenSeeFace/blob/6f24efc4f58eb7cca47ec2146d934eabcc207e46/remedian.py
assert lst, "median needs a non-empty list"
n = len(lst)
p = q = n // 2
if n < 3:
p, q = 0, n - 1
else:
lst = lst if ordered else sorted(lst)
if not n % 2: # for even-length lists, use mean of mid 2 nums
q = p - 1
return lst[p] if p == q else (lst[p] + lst[q]) / 2
class FastMedian:
# https://github.com/emilianavt/OpenSeeFace/blob/6f24efc4f58eb7cca47ec2146d934eabcc207e46/remedian.py
# Initialization
def __init__(self, inits: typing.Optional[typing.Sequence] = [], k=64): # after some experimentation, 64 works ok
self.all, self.k = [], k
self.more, self.__median = None, None
if inits is not None:
[self + x for x in inits]
# When full, push the median of current values to next list, then reset.
def __add__(self, x):
self.__median = None
self.all.append(x) # It would be faster to pre-allocate an array and assign it by index.
if len(self.all) == self.k:
self.more = self.more or FastMedian(k=self.k)
self.more + self.__medianPrim(self.all)
# It's going to be slower because of the re-allocation.
self.all = [] # reset
# If there is a next list, ask its median. Else, work it out locally.
def median(self):
return self.more.median() if self.more else self.__medianPrim(self.all)
# Only recompute median if we do not know it already.
def __medianPrim(self, all):
if self.__median is None:
self.__median = lst_median(all, ordered=False)
return self.__median
def resource_path(relative_path: Union[str, Path]) -> str:
"""
Get absolute path to resource, works for dev and for PyInstaller
"""
try:
# PyInstaller creates a temp folder and stores path in _MEIPASS
base_path = Path(sys._MEIPASS)
except AttributeError:
base_path = Path(".")
return str(base_path / relative_path)

View File

@ -0,0 +1,171 @@
import functools
import math
import sys
import timeit
def TimeitWrapper(*args, **kwargs):
"""
This decorator @TimeitWrapper() prints the function name and execution time in seconds.
:param args:
:param kwargs:
:return:
"""
def decorator(function):
@functools.wraps(function)
def wrapper(*args, **kwargs):
start = timeit.default_timer()
results = function(*args, **kwargs)
end = timeit.default_timer()
print('{} execution time: {:.10f} s'.format(function.__name__, end - start))
return results
return wrapper
return decorator
class TimeitResult(object):
"""
from https://github.com/ipython/ipython/blob/339c0d510a1f3cb2158dd8c6e7f4ac89aa4c89d8/IPython/core/magics/execution.py#L55
Object returned by the timeit magic with info about the run.
Contains the following attributes :
loops: (int) number of loops done per measurement
repeat: (int) number of times the measurement has been repeated
best: (float) best execution time / number
all_runs: (list of float) execution time of each run (in s)
"""
def __init__(self, loops, repeat, best, worst, all_runs, precision):
self.loops = loops
self.repeat = repeat
self.best = best
self.worst = worst
self.all_runs = all_runs
self._precision = precision
self.timings = [dt / self.loops for dt in all_runs]
@property
def average(self):
return math.fsum(self.timings) / len(self.timings)
@property
def stdev(self):
mean = self.average
return (math.fsum([(x - mean) ** 2 for x in self.timings]) / len(self.timings)) ** 0.5
def __str__(self):
pm = '+-'
if hasattr(sys.stdout, 'encoding') and sys.stdout.encoding:
try:
u'\xb1'.encode(sys.stdout.encoding)
pm = u'\xb1'
except:
pass
return "min:{best} max:{worst} mean:{mean} {pm} {std} per loop (mean {pm} std. dev. of {runs} run{run_plural}, {loops:,} loop{loop_plural} each)".format(
pm=pm,
runs=self.repeat,
loops=self.loops,
loop_plural="" if self.loops == 1 else "s",
run_plural="" if self.repeat == 1 else "s",
mean=format_time(self.average, self._precision),
std=format_time(self.stdev, self._precision),
best=format_time(self.best, self._precision),
worst=format_time(self.worst, self._precision),
)
def _repr_pretty_(self, p, cycle):
unic = self.__str__()
p.text(u'<TimeitResult : ' + unic + u'>')
class FPSResult(object):
"""
base https://github.com/ipython/ipython/blob/339c0d510a1f3cb2158dd8c6e7f4ac89aa4c89d8/IPython/core/magics/execution.py#L55
"""
def __init__(self, loops, repeat, best, worst, all_runs, precision):
self.loops = loops
self.repeat = repeat
self.best = 1 / best
self.worst = 1 / worst
self.all_runs = all_runs
self._precision = precision
self.fps = [1 / dt for dt in all_runs]
self.unit = "fps"
@property
def average(self):
return math.fsum(self.fps) / len(self.fps)
@property
def stdev(self):
mean = self.average
return (math.fsum([(x - mean) ** 2 for x in self.fps]) / len(self.fps)) ** 0.5
def __str__(self):
pm = '+-'
if hasattr(sys.stdout, 'encoding') and sys.stdout.encoding:
try:
u'\xb1'.encode(sys.stdout.encoding)
pm = u'\xb1'
except:
pass
return "min:{best} max:{worst} mean:{mean} {pm} {std} per loop (mean {pm} std. dev. of {runs} run{run_plural}, {loops:,} loop{loop_plural} each)".format(
pm=pm,
runs=self.repeat,
loops=self.loops,
loop_plural="" if self.loops == 1 else "s",
run_plural="" if self.repeat == 1 else "s",
mean="%.*g%s" % (self._precision, self.average, self.unit),
std="%.*g%s" % (self._precision, self.stdev, self.unit),
best="%.*g%s" % (self._precision, self.best, self.unit),
worst="%.*g%s" % (self._precision, self.worst, self.unit),
)
def _repr_pretty_(self, p, cycle):
unic = self.__str__()
p.text(u'<FPSResult : ' + unic + u'>')
def format_time(timespan, precision=3):
"""
https://github.com/ipython/ipython/blob/339c0d510a1f3cb2158dd8c6e7f4ac89aa4c89d8/IPython/core/magics/execution.py#L1473
Formats the timespan in a human readable form
"""
if timespan >= 60.0:
# we have more than a minute, format that in a human readable form
# Idea from http://snipplr.com/view/5713/
parts = [("d", 60 * 60 * 24), ("h", 60 * 60), ("min", 60), ("s", 1)]
time = []
leftover = timespan
for suffix, length in parts:
value = int(leftover / length)
if value > 0:
leftover = leftover % length
time.append(u'%s%s' % (str(value), suffix))
if leftover < 1:
break
return " ".join(time)
# Unfortunately the unicode 'micro' symbol can cause problems in
# certain terminals.
# See bug: https://bugs.launchpad.net/ipython/+bug/348466
# Try to prevent crashes by being more secure than it needs to
# E.g. eclipse is able to print a µ, but has no sys.stdout.encoding set.
units = [u"s", u"ms", u'us', "ns"] # the save value
if hasattr(sys.stdout, 'encoding') and sys.stdout.encoding:
try:
u'\xb5'.encode(sys.stdout.encoding)
units = [u"s", u"ms", u'\xb5s', "ns"]
except:
pass
scaling = [1, 1e3, 1e6, 1e9]
if timespan > 0.0:
order = min(-int(math.floor(math.log10(timespan)) // 3), 3)
else:
order = 3
return u"%.*g %s" % (precision, timespan * scaling[order], units[order])

View File

@ -261,4 +261,4 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
(2) All disclaimers, limitations of liability, and indemnification
obligations survive termination.
END OF TERMS AND CONDITIONS
END OF TERMS AND CONDITIONS

19
Makefile Normal file
View File

@ -0,0 +1,19 @@
.DEFAULT_GOAL := run
install:
poetry install
run:
cd EyeTrackApp/ && poetry run python eyetrackapp.py
pyinstaller:
poetry run pyinstaller EyeTrackApp/eyetrackapp.spec EyeTrackApp/eyetrackapp.py
clean:
rm -rf build/
rm -rf dist/
rm -rf EyeTrackApp/__pycache__/
rm -rf EyeTrackApp/app/__pycache__/
rm -rf EyeTrackApp/app/algorithms/__pycache__/
rm EyeTrackApp/eyetrack_settings.backup
rm EyeTrackApp/eyetrack_settings.json

View File

@ -13,38 +13,30 @@ Picture courtesy of Wackalope#6737
- [Contact](#contact)
- [Licenses](#licenses)
Active Development: [V2 App Branch](https://github.com/EyeTrackVR/EyeTrackVR/tree/v2.0-beta-feature-branch)
### [Documentation](https://docs.eyetrackvr.dev/)
### [Documentation](https://redhawk989.github.io/EyeTrackVR/)
# EyeTrackVR
Source First and *affordable* VR eye tracker platform for [VRChat](https://hello.vrchat.com/) via `OSC` and `UDP` protocol.
> **Note**: This project is in development and is not fully finished
Open source and *affordable* VR eye tracker platform for [VRChat](https://hello.vrchat.com/) via `OSC` and `UDP` protocol.
> [!IMPORTANT]\
> This project is in active development and is not fully finished
## Documentation
Please check out our [documentation site.](https://docs.eyetrackvr.dev/)
Please check out our [documentation site](https://redhawk989.github.io/EyeTrackVR/)
## Hardware
3d files for mounting brackets can be found [here](https://github.com/RedHawk989/EyeTrackVR-Hardware)
IR emitter PCB files are also located there, along with pre-soldered PCBs on the [official store](https://store.eyetrackvr.dev/). For more info, please reference our [documentation site](https://docs.eyetrackvr.dev/how_to_build/parts_list)
3d files for mounting brackets will be found [here](https://github.com/RedHawk989/EyeTrackVR-Hardware)
IR emitter files are also located there. For more info please reference our parts list on our [documentation site](https://redhawk989.github.io/EyeTrackVR/parts-list/)
## ESP-CAM Firmware
## ESP32 Cam Firmware
Current work has been with our official firmware by `lorow` and `ZanzyTHEbar`, found [here](https://github.com/EyeTrackVR/OpenIris).
Current work has been with our official firmware by `lorow`, found [here](https://github.com/lorow/OpenIris).
## Headset support
Pretty much any headset that can fit the camera and LEDs can be supported. However, mounts may not have been made for it. Please reference our [parts list](https://docs.eyetrackvr.dev/how_to_build/parts_list#_3d-printed-mounts) for linked mounts and [create your own mount page](https://docs.eyetrackvr.dev/how_to_build/creating_your_own_mount) for details.
Any headset that can fit the camera and LEDs can likely be supported, however, mounts may not have been made for it. Please reference to our [parts list](https://redhawk989.github.io/EyeTrackVR/parts-list/#other-parts) for details.
## About IR Emitter Safety
@ -55,17 +47,14 @@ Please *exercise extreme caution* when messing around with IR emitters.
The safety measures were put in place to REDUCE the potential failure risk. All further safety responsibilities are on the user.
This includes visually checking with an IR camera that the brightness is correct and making sure you do not feel warmth.
> **Warning**: Make sure you are using **NON-focused** emitters and at power less than ***5mW cm^2 total per eye***.
Please read our LED safety page for a breakdown of math for our V3 and V4 LED kits [here](https://docs.eyetrackvr.dev/getting_started/led_safety)
[Effect of infrared radiation on the lens](https://docs.eyetrackvr.dev/safety/effect_of_ir_on_the_lens.pdf)
[Training-library Nir Stds](https://docs.eyetrackvr.dev/safety/training-library_nir_stds_20021011.pdf)
[AN002_Details on photobiological safety of LED light sources](https://docs.eyetrackvr.dev/safety/AN002_Details_on_photobiological_safety_of_LED_light_sources.pdf)
> [!WARNING]\
> Make sure you are using **NON-focused** emitters and at around ***5ma total power per eye***.
[Effect of infrared radiation on the lens](https://github.com/RedHawk989/EyeTrackVR/blob/docs/docs/Reference_Docs/saftey/effect_of_ir_on_the_lens.pdf)
[Training-library Nir Stds](https://github.com/RedHawk989/EyeTrackVR/blob/docs/docs/Reference_Docs/saftey/training-library_nir_stds_20021011.pdf)
[AN002_Details on photobiological safety of LED light sources](https://github.com/RedHawk989/EyeTrackVR/blob/docs/docs/Reference_Docs/saftey/AN002_Details_on_photobiological_safety_of_LED_light_sources.pdf)
## Contact
@ -75,9 +64,8 @@ Please join our Discord for updates and any questions.
## Licenses
[![GitHub license](https://img.shields.io/github/license/RedHawk989/EyeTrackVR?style=plastic)](https://github.com/RedHawk989/EyeTrackVR/blob/master/LICENSE)
***All software is licensed under the Babble Software Distribution License 1.0 unless marked otherwise.***
***All software is licensed under the Babble Software Distribution License 1.0 unless marked otherwise.
All documentation, including the [Wiki](https://github.com/RedHawk989/EyeTrackVR/wiki), is under the Creative Commons CC-BY-SA-4.0 license***.
<!-- <div align="center">

125
conftest.py Normal file
View File

@ -0,0 +1,125 @@
import pytest
from config import (
EyeTrackConfig,
EyeTrackCameraConfig,
EyeTrackSettingsConfig,
)
@pytest.fixture()
def eyetrack_settings_config():
return EyeTrackSettingsConfig(
gui_flip_x_axis_left=False,
gui_flip_x_axis_right=False,
gui_flip_y_axis=False,
gui_RANSAC3D=False,
gui_HSF=False,
gui_BLOB=False,
gui_BLINK=False,
gui_HSRAC=False,
gui_AHSFRAC=False,
gui_AHSF=False,
gui_DADDY=False,
gui_LEAP=True,
gui_HSF_radius=15,
gui_HSF_radius_left=10,
gui_HSF_radius_right=10,
gui_min_cutoff="0.0004",
gui_speed_coefficient="0.9",
gui_osc_address="127.0.0.1",
gui_osc_port=8889,
gui_osc_receiver_port=9001,
gui_osc_recenter_address="/avatar/parameters/etvr_recenter",
gui_osc_recalibrate_address="/avatar/parameters/etvr_recalibrate",
gui_blob_maxsize=25.0,
gui_blob_minsize=10.0,
gui_recenter_eyes=False,
tracker_single_eye=2,
gui_threshold=65,
gui_AHSFRACP=1,
gui_AHSFP=2,
gui_HSRACP=3,
gui_HSFP=4,
gui_DADDYP=5,
gui_RANSAC3DP=6,
gui_BLOBP=7,
gui_LEAPP=8,
gui_IBO=True,
gui_skip_autoradius=False,
gui_thresh_add=11,
gui_update_check=False,
gui_ROSC=False,
gui_circular_crop_right=False,
gui_circular_crop_left=False,
ibo_filter_samples=400,
ibo_average_output_samples=0,
ibo_fully_close_eye_threshold=0.3,
calibration_samples=600,
osc_right_eye_close_address="/avatar/parameters/RightEyeLidExpandedSqueeze",
osc_left_eye_close_address="/avatar/parameters/LeftEyeLidExpandedSqueeze",
osc_left_eye_x_address="/avatar/parameters/LeftEyeX",
osc_right_eye_x_address="/avatar/parameters/RightEyeX",
osc_eyes_y_address="/avatar/parameters/EyesY",
osc_invert_eye_close=False,
gui_RANSACBLINK=False,
gui_right_eye_dominant=False,
gui_left_eye_dominant=False,
gui_outer_side_falloff=False,
gui_eye_dominant_diff_thresh=0.3,
gui_legacy_ransac=False,
gui_legacy_ransac_thresh_right=80,
gui_legacy_ransac_thresh_left=80,
gui_LEAP_lid=False,
gui_osc_vrcft_v1=False,
gui_osc_vrcft_v2=False,
gui_vrc_native=False,
gui_pupil_dilation=True,
)
@pytest.fixture()
def eyetrack_camera_config():
return EyeTrackCameraConfig(
rotation_angle=250,
roi_window_x=67,
roi_window_y=27,
roi_window_w=96,
roi_window_h=117,
focal_length=30,
capture_source="http://192.168.0.31/",
calib_XMAX=122.5,
calib_XMIN=38.0,
calib_YMAX=118.0,
calib_YMIN=6.0,
calib_XOFF=40.0,
calib_YOFF=63.0,
calibration_points=[],
)
@pytest.fixture()
def main_config(eyetrack_camera_config, eyetrack_settings_config):
return EyeTrackConfig(
right_eye=eyetrack_camera_config,
left_eye=eyetrack_camera_config,
settings=eyetrack_settings_config,
eye_display_id=0,
)
@pytest.fixture()
def main_config_v1_params(main_config):
main_config.settings.gui_osc_vrcft_v1 = True
return main_config
@pytest.fixture()
def main_config_v2_params(main_config):
main_config.settings.gui_osc_vrcft_v2 = True
return main_config
@pytest.fixture()
def main_config_native_params(main_config):
main_config.settings.gui_vrc_native = True
return main_config

10
docker-compose.yml Normal file
View File

@ -0,0 +1,10 @@
version: '3.4'
services:
etvrtrackingbackend:
image: eyetrackvrapp
build:
context: .
dockerfile: ./Dockerfile
ports:
- 8000:8000

1778
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +1,49 @@
[tool.poetry]
name = "EyeTrackVR"
version = "0.1.7.2"
version = "0.2"
description = "Opensource, affordable VR eye tracker for VRChat"
authors = ["RedHawk989"]
license = "MIT"
repository = "https://github.com/RedHawk989/EyeTrackVR"
license = "LICENSE: Babble Software Distribution License 1.0"
repository = "https://github.com/EyeTrackVR/EyeTrackVR"
[tool.poetry.dependencies]
python = "~3.11.0"
python-osc = "^1.8.0"
opencv-python = "^4.6.0.66"
numpy = "^1.23.4"
pye3d = "^0.3.1.post1"
requests = "^2.28.1"
opencv-python = "^4.6.0.66"
numpy = "~1.23.5"
pye3d = "^0.3.2"
pysimplegui-4-foss = "^4.6.4.1"
pydantic = "^1.10.2"
win10toast_click = "^0.1.2"
beautifulsoup4 = "^4.11.1"
pydantic = "^2.4.2"
scikit-image = "*"
pyserial = "^3.5"
winotify = [
{ version = "^1.1.0", platform = 'win32' }
]
onnxruntime = "^1.13.1"
colorama = "^0.4.6"
taskipy = "^1.10.4"
pytest = "^8.0.0"
pytest-cov = "^4.1.0"
[tool.poetry.group.dev.dependencies]
black = "^22.10.0"
pyinstaller = "^5.6.2"
flake8 = "^5.0.4"
[tool.taskipy.tasks]
dev = "python eyetrackapp.py"
[tool.black]
line-length = 120
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.black]
line-length = 135
exclude = "(.git|.env|venv)"
target-version = ["py310","py311"]
[tool.pytest.ini_options]
addopts = "-ra -q"
pythonpath = "."
python_files = [
"test_*.py"
]

View File

@ -0,0 +1,53 @@
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "EyeTrackVR"
#define MyAppVersion "0.2 BETA 10"
#define MyAppPublisher "EyeTrackVR"
#define MyAppURL "https://redhawk989.github.io/EyeTrackVR/"
#define MyAppExeName "eyetrackapp.exe"
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={{60B32C3C-819B-4685-AF40-49AB93539405}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
DisableProgramGroupPage=yes
OutputDir=C:\Users\Prohurtz\Desktop\Output\
; Uncomment the following line to run in non administrative install mode (install for current user only.)
;PrivilegesRequired=lowest
OutputBaseFilename=EyeTrackVR-Setup
SetupIconFile=C:\Users\Prohurtz\PycharmProjects\EyeTrackVR\EyeTrackApp\Images\logo.ico
Compression=lzma/ultra64
SolidCompression=yes
WizardStyle=modern
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}";
[Files]
Source: "C:\Users\Prohurtz\PycharmProjects\EyeTrackVR\EyeTrackApp\dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "C:\Users\Prohurtz\PycharmProjects\EyeTrackVR\EyeTrackApp\dist\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Dirs]
Name: {app}; Permissions: users-full
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent

View File

@ -0,0 +1,14 @@
:: Example script to auto build app and make an installer
:: File paths will all need to be updated to your setup
cd C:\Users\beaul\PycharmProjects\EyeTrackVR\EyeTrackApp
pyinstaller eyetrackapp.spec --noconfirm
cd C:\Users\beaul\OneDrive\Desktop
cd C:\Program Files (x86)\Inno Setup 6
ISCC C:\Users\beaul\OneDrive\Desktop\ETVR_SETUP.iss
cls
@echo off
color 0A
echo -------------------------------
echo ############ DONE #############
echo -------------------------------
PAUSE

24
scripts/setup.sh Normal file
View File

@ -0,0 +1,24 @@
#!/bin/sh
# This script is used to setup the environment for the build process.
# check if make is installed and if not install it
if ! command -v make &> /dev/null
then
echo "make could not be found, installing it now"
apt-get update
apt-get install ffmpeg libsm6 libxext6 -y
apt install libgl1-mesa-glx
apt install build-essential -y --no-install-recommends
apt-get install make
fi
# check if poetry is installed and if not install it
if ! command -v poetry &> /dev/null
then
echo "poetry could not be found, installing it now"
apt-get install python3-pip -y --no-install-recommends
pip3 install poetry
fi

20
tests/__init__.py Normal file
View File

@ -0,0 +1,20 @@
import dataclasses
@dataclasses.dataclass
class EyeInfoMock:
x: int
y: int
blink: float
pupil_dilation: float
avg_velocity: float
class SimpleUDPClientMock:
def __init__(self, osc_address, port):
self.osc_address = osc_address
self.port = port
self.messages = []
def send_message(self, address, value):
self.messages.append((address, value))

View File

@ -0,0 +1,269 @@
from queue import Queue
from time import sleep
from unittest import mock
import pytest
from osc.osc import OSCManager, OSCMessage
from osc.OSCMessage import OSCMessageType
from tests import EyeInfoMock, SimpleUDPClientMock
@pytest.mark.parametrize(
"messages,expected_outcome",
[
(
[
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
0,
EyeInfoMock(
x=0,
y=0,
blink=1,
pupil_dilation=0,
avg_velocity=0,
),
),
),
],
[
("/tracking/eye/EyesClosedAmount", 0.0),
("/tracking/eye/LeftRightVec", [0.0, 0.0, 1.0, 0.0, 0.0, 1.0]),
],
),
(
[
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
1,
EyeInfoMock(
x=0,
y=0,
blink=1,
pupil_dilation=0,
avg_velocity=0,
),
),
),
],
[
("/tracking/eye/EyesClosedAmount", 0.0),
("/tracking/eye/LeftRightVec", [0.0, 0.0, 1.0, 0.0, 0.0, 1.0]),
],
),
],
)
def test_send_command_native_params_single_eye(main_config_native_params, messages, expected_outcome):
with mock.patch("EyeTrackApp.osc.osc.udp_client.SimpleUDPClient", SimpleUDPClientMock):
msg_queue = Queue()
client = OSCManager(
config=main_config_native_params,
osc_message_in_queue=msg_queue,
)
client.start()
for message in messages:
sleep(0.01)
msg_queue.put(message)
client.shutdown()
assert msg_queue.empty()
assert client.osc_sender.client.messages == expected_outcome
@pytest.mark.parametrize(
"eye_data,expected_outcome",
[
(
[
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
0,
EyeInfoMock(
x=0,
y=0,
blink=1,
pupil_dilation=1,
avg_velocity=0,
),
),
),
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
1,
EyeInfoMock(
x=10,
y=5,
blink=0.5,
pupil_dilation=1,
avg_velocity=0,
),
),
),
],
[
("/tracking/eye/EyesClosedAmount", 0.0),
("/tracking/eye/EyesClosedAmount", 0.0),
# we're expecting 621 as left_y here because that's the default value
# before the first state update with real data, but that's ok
# we're gonna be like 10 messages deep before anyone starts playing
# and if they already are, they won't be able to notice
("/tracking/eye/LeftRightVec", [0.0, 621.0, 1.0, 0.0, 0.0, 1.0]),
("/tracking/eye/EyesClosedAmount", 0.5),
("/tracking/eye/EyesClosedAmount", 0.5),
("/tracking/eye/LeftRightVec", [0.0, 5.0, 1.0, 0.0, 0.0, 1.0]),
],
),
# binary blink
(
[
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
0,
EyeInfoMock(
x=0,
y=0,
blink=0,
pupil_dilation=1,
avg_velocity=0,
),
),
),
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
1,
EyeInfoMock(
x=10,
y=5,
blink=0,
pupil_dilation=1,
avg_velocity=0,
),
),
),
],
[
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/LeftRightVec", [0.0, 621.0, 1.0, 0.0, 0.0, 1.0]),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/LeftRightVec", [0.0, 5.0, 1.0, 0.0, 0.0, 1.0]),
],
),
],
)
def test_send_command_native_params_dual_eye(main_config_native_params, eye_data, expected_outcome):
main_config_native_params.eye_display_id = 2
with mock.patch("EyeTrackApp.osc.osc.udp_client.SimpleUDPClient", SimpleUDPClientMock):
msg_queue = Queue()
client = OSCManager(
config=main_config_native_params,
osc_message_in_queue=msg_queue,
)
client.start()
for message in eye_data:
sleep(0.01)
msg_queue.put(message)
client.shutdown()
assert msg_queue.empty()
assert client.osc_sender.client.messages == expected_outcome
@pytest.mark.parametrize(
"eye_data,expected_outcome",
[
(
[
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
0,
EyeInfoMock(
x=0,
y=0,
blink=0,
pupil_dilation=1,
avg_velocity=0,
),
),
),
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
1,
EyeInfoMock(
x=10,
y=5,
blink=0,
pupil_dilation=1,
avg_velocity=0,
),
),
),
],
[
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/LeftRightVec", [0.0, 621.0, 1.0, 0.0, 0.0, 1.0]),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/EyesClosedAmount", 1.0),
("/tracking/eye/LeftRightVec", [0.0, 5.0, 1.0, 0.0, 0.0, 1.0]),
],
),
],
)
def test_send_command_native_params_eye_outer_side_falloff(main_config_native_params, eye_data, expected_outcome):
main_config_native_params.eye_display_id = 2
main_config_native_params.settings.gui_outer_side_falloff = True
with mock.patch("EyeTrackApp.osc.osc.udp_client.SimpleUDPClient", SimpleUDPClientMock):
msg_queue = Queue()
client = OSCManager(
config=main_config_native_params,
osc_message_in_queue=msg_queue,
)
client.start()
for message in eye_data:
msg_queue.put(message)
sleep(1)
client.shutdown()
assert msg_queue.empty()
assert client.osc_sender.client.messages == expected_outcome

274
tests/test_osc_v1_params.py Normal file
View File

@ -0,0 +1,274 @@
from queue import Queue
from time import sleep
from unittest import mock
import pytest
from osc.osc import OSCManager, OSCMessage
from osc.OSCMessage import OSCMessageType
from tests import EyeInfoMock, SimpleUDPClientMock
@pytest.mark.parametrize(
"messages,expected_outcome",
[
(
[
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
0,
EyeInfoMock(
x=0,
y=0,
blink=1,
pupil_dilation=0,
avg_velocity=0,
),
),
),
],
[
("/avatar/parameters/LeftEyeX", 0),
("/avatar/parameters/RightEyeX", 0),
("/avatar/parameters/EyesY", 0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 1.0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 1.0),
],
),
(
[
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
1,
EyeInfoMock(
x=0,
y=0,
blink=1,
pupil_dilation=0,
avg_velocity=0,
),
),
),
],
[
("/avatar/parameters/LeftEyeX", 0),
("/avatar/parameters/RightEyeX", 0),
("/avatar/parameters/EyesY", 0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 1.0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 1.0),
],
),
],
)
def test_send_command_v1_params_single_eye(main_config_v1_params, messages, expected_outcome):
with mock.patch("EyeTrackApp.osc.osc.udp_client.SimpleUDPClient", SimpleUDPClientMock):
msg_queue = Queue()
client = OSCManager(
config=main_config_v1_params,
osc_message_in_queue=msg_queue,
)
client.start()
for message in messages:
sleep(0.01)
msg_queue.put(message)
client.shutdown()
assert msg_queue.empty()
assert client.osc_sender.client.messages == expected_outcome
@pytest.mark.parametrize(
"eye_data,expected_outcome",
[
(
[
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
0,
EyeInfoMock(
x=0,
y=0,
blink=1,
pupil_dilation=1,
avg_velocity=0,
),
),
),
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
1,
EyeInfoMock(
x=10,
y=5,
blink=0.5,
pupil_dilation=1,
avg_velocity=0,
),
),
),
],
[
("/avatar/parameters/RightEyeLidExpandedSqueeze", 1.0),
("/avatar/parameters/RightEyeX", 0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 1.0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.5),
("/avatar/parameters/LeftEyeX", 0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.5),
("/avatar/parameters/EyesY", 2.5),
],
),
# binary blink
(
[
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
0,
EyeInfoMock(
x=0,
y=0,
blink=0,
pupil_dilation=1,
avg_velocity=0,
),
),
),
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
1,
EyeInfoMock(
x=10,
y=5,
blink=0,
pupil_dilation=1,
avg_velocity=0,
),
),
),
],
[
("/avatar/parameters/RightEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/RightEyeX", 0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/LeftEyeX", 0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/EyesY", 2.5),
],
),
],
)
def test_send_command_v1_params_dual_eye(main_config_v1_params, eye_data, expected_outcome):
main_config_v1_params.eye_display_id = 2
with mock.patch("EyeTrackApp.osc.osc.udp_client.SimpleUDPClient", SimpleUDPClientMock):
msg_queue = Queue()
client = OSCManager(
config=main_config_v1_params,
osc_message_in_queue=msg_queue,
)
client.start()
for message in eye_data:
sleep(0.01)
msg_queue.put(message)
client.shutdown()
assert msg_queue.empty()
assert client.osc_sender.client.messages == expected_outcome
@pytest.mark.parametrize(
"eye_data,expected_outcome",
[
(
[
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
0,
EyeInfoMock(
x=0,
y=0,
blink=0,
pupil_dilation=1,
avg_velocity=0,
),
),
),
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
1,
EyeInfoMock(
x=10,
y=5,
blink=0,
pupil_dilation=1,
avg_velocity=0,
),
),
),
],
[
("/avatar/parameters/RightEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/RightEyeX", 0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/RightEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/LeftEyeX", 0),
("/avatar/parameters/LeftEyeLidExpandedSqueeze", 0.0),
("/avatar/parameters/EyesY", 2.5),
],
),
],
)
def test_send_command_v1_params_eye_outer_side_falloff(main_config_v1_params, eye_data, expected_outcome):
main_config_v1_params.eye_display_id = 2
main_config_v1_params.settings.gui_outer_side_falloff = True
with mock.patch("EyeTrackApp.osc.osc.udp_client.SimpleUDPClient", SimpleUDPClientMock):
msg_queue = Queue()
client = OSCManager(
config=main_config_v1_params,
osc_message_in_queue=msg_queue,
)
client.start()
for message in eye_data:
msg_queue.put(message)
sleep(1)
client.shutdown()
assert msg_queue.empty()
assert client.osc_sender.client.messages == expected_outcome

275
tests/test_osc_v2_params.py Normal file
View File

@ -0,0 +1,275 @@
from queue import Queue
from time import sleep
from unittest import mock
import pytest
from osc.osc import OSCManager, OSCMessage
from osc.OSCMessage import OSCMessageType
from tests import EyeInfoMock, SimpleUDPClientMock
@pytest.mark.parametrize(
"messages,expected_outcome",
[
(
[
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
0,
EyeInfoMock(
x=0,
y=0,
blink=1,
pupil_dilation=0,
avg_velocity=0,
),
),
),
],
[
("/avatar/parameters/v2/EyeX", 0),
("/avatar/parameters/v2/EyeY", 0),
("/avatar/parameters/v2/EyeLid", 1.0),
("/avatar/parameters/v2/EyeLid", 1.0),
],
),
(
[
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
1,
EyeInfoMock(
x=0,
y=0,
blink=1,
pupil_dilation=0,
avg_velocity=0,
),
),
),
],
[
("/avatar/parameters/v2/EyeX", 0),
("/avatar/parameters/v2/EyeY", 0),
("/avatar/parameters/v2/EyeLid", 1.0),
("/avatar/parameters/v2/EyeLid", 1.0),
],
),
],
)
def test_send_command_v2_params_single_eye(main_config_v2_params, messages, expected_outcome):
with mock.patch("EyeTrackApp.osc.osc.udp_client.SimpleUDPClient", SimpleUDPClientMock):
msg_queue = Queue()
client = OSCManager(
config=main_config_v2_params,
osc_message_in_queue=msg_queue,
)
client.start()
for message in messages:
sleep(0.01)
msg_queue.put(message)
client.shutdown()
assert msg_queue.empty()
assert client.osc_sender.client.messages == expected_outcome
@pytest.mark.parametrize(
"eye_data,expected_outcome",
[
(
[
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
0,
EyeInfoMock(
x=0,
y=0,
blink=1,
pupil_dilation=1,
avg_velocity=0,
),
),
),
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
1,
EyeInfoMock(
x=10,
y=5,
blink=0.5,
pupil_dilation=1,
avg_velocity=0,
),
),
),
],
[
("/avatar/parameters/v2/EyeLidRight", 1.0),
("/avatar/parameters/v2/EyeRightX", 0),
("/avatar/parameters/v2/EyeRightY", 0),
("/avatar/parameters/v2/EyeLidRight", 1.0),
("/avatar/parameters/v2/EyeLidLeft", 0.5),
("/avatar/parameters/v2/EyeLeftX", 10),
("/avatar/parameters/v2/EyeLeftY", 5),
("/avatar/parameters/v2/EyeLidLeft", 0.5),
],
),
# binary blink
(
[
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
0,
EyeInfoMock(
x=0,
y=0,
blink=0,
pupil_dilation=1,
avg_velocity=0,
),
),
),
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
1,
EyeInfoMock(
x=10,
y=5,
blink=0,
pupil_dilation=1,
avg_velocity=0,
),
),
),
],
[
("/avatar/parameters/v2/EyeLidRight", 0.0),
("/avatar/parameters/v2/EyeLidRight", 0.0),
("/avatar/parameters/v2/EyeLidRight", 0.0),
("/avatar/parameters/v2/EyeLidRight", 0.0),
("/avatar/parameters/v2/EyeLidRight", 0.0),
("/avatar/parameters/v2/EyeLidRight", 0.0),
("/avatar/parameters/v2/EyeRightX", 0),
("/avatar/parameters/v2/EyeRightY", 0),
("/avatar/parameters/v2/EyeLidRight", 0.0),
("/avatar/parameters/v2/EyeLidLeft", 0.0),
("/avatar/parameters/v2/EyeLidLeft", 0.0),
("/avatar/parameters/v2/EyeLidLeft", 0.0),
("/avatar/parameters/v2/EyeLidLeft", 0.0),
("/avatar/parameters/v2/EyeLidLeft", 0.0),
("/avatar/parameters/v2/EyeLidLeft", 0.0),
("/avatar/parameters/v2/EyeLeftX", 10),
("/avatar/parameters/v2/EyeLeftY", 5),
("/avatar/parameters/v2/EyeLidLeft", 0.0),
],
),
],
)
def test_send_command_v2_params_dual_eye(main_config_v2_params, eye_data, expected_outcome):
main_config_v2_params.eye_display_id = 2
with mock.patch("EyeTrackApp.osc.osc.udp_client.SimpleUDPClient", SimpleUDPClientMock):
msg_queue = Queue()
client = OSCManager(
config=main_config_v2_params,
osc_message_in_queue=msg_queue,
)
client.start()
for message in eye_data:
sleep(0.01)
msg_queue.put(message)
client.shutdown()
assert msg_queue.empty()
assert client.osc_sender.client.messages == expected_outcome
@pytest.mark.parametrize(
"eye_data,expected_outcome",
[
(
[
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
0,
EyeInfoMock(
x=0,
y=0,
blink=0,
pupil_dilation=1,
avg_velocity=0,
),
),
),
OSCMessage(
type=OSCMessageType.EYE_INFO,
data=(
1,
EyeInfoMock(
x=10,
y=5,
blink=0,
pupil_dilation=1,
avg_velocity=0,
),
),
),
],
[
("/avatar/parameters/v2/EyeLidRight", 0.0),
("/avatar/parameters/v2/EyeLidRight", 0.0),
("/avatar/parameters/v2/EyeLidRight", 0.0),
("/avatar/parameters/v2/EyeLidRight", 0.0),
("/avatar/parameters/v2/EyeLidRight", 0.0),
("/avatar/parameters/v2/EyeLidRight", 0.0),
("/avatar/parameters/v2/EyeRightX", 0),
("/avatar/parameters/v2/EyeRightY", 0),
("/avatar/parameters/v2/EyeLidRight", 0.0),
("/avatar/parameters/v2/EyeLidLeft", 0.0),
("/avatar/parameters/v2/EyeLidLeft", 0.0),
("/avatar/parameters/v2/EyeLidLeft", 0.0),
("/avatar/parameters/v2/EyeLidLeft", 0.0),
("/avatar/parameters/v2/EyeLidLeft", 0.0),
("/avatar/parameters/v2/EyeLidLeft", 0.0),
("/avatar/parameters/v2/EyeLidRight", 0.0),
("/avatar/parameters/v2/EyeLidLeft", 0.0),
("/avatar/parameters/v2/EyeLeftX", 10),
("/avatar/parameters/v2/EyeLeftY", 5),
("/avatar/parameters/v2/EyeLidLeft", 0.0),
],
),
],
)
def test_send_command_v2_params_eye_outer_side_falloff(main_config_v2_params, eye_data, expected_outcome):
main_config_v2_params.eye_display_id = 2
main_config_v2_params.settings.gui_outer_side_falloff = True
with mock.patch("EyeTrackApp.osc.osc.udp_client.SimpleUDPClient", SimpleUDPClientMock):
msg_queue = Queue()
client = OSCManager(
config=main_config_v2_params,
osc_message_in_queue=msg_queue,
)
client.start()
for message in eye_data:
msg_queue.put(message)
sleep(1)
client.shutdown()
assert msg_queue.empty()
assert client.osc_sender.client.messages == expected_outcome