From 07365c02ec6b701e1bad4be9ad218d08de45414d Mon Sep 17 00:00:00 2001 From: Tony Garnock-Jones Date: Tue, 7 Mar 2023 16:39:10 +0100 Subject: [PATCH] Experiments with opencv headtracking --- protocols/Makefile | 8 +++ protocols/house-schemas.bin | 6 +++ protocols/schemas/tracking.prs | 9 ++++ scene/lobby.pr | 11 +++++ serve.sh | 2 +- src/index.ts | 26 ++++++++++ tracking/.envrc | 9 ++++ tracking/.gitignore | 2 + tracking/README.md | 3 ++ tracking/calibrate.py | 47 ++++++++++++++++++ tracking/calibration.json | 28 +++++++++++ tracking/make_markers.py | 42 ++++++++++++++++ tracking/markers.pdf | Bin 0 -> 8457 bytes tracking/markers.png | Bin 0 -> 36983 bytes tracking/requirements.txt | 4 ++ tracking/track.py | 86 +++++++++++++++++++++++++++++++++ tracking/tracking.pr | 17 +++++++ 17 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 protocols/Makefile create mode 100644 protocols/house-schemas.bin create mode 100644 protocols/schemas/tracking.prs create mode 100644 tracking/.envrc create mode 100644 tracking/.gitignore create mode 100644 tracking/README.md create mode 100755 tracking/calibrate.py create mode 100644 tracking/calibration.json create mode 100755 tracking/make_markers.py create mode 100644 tracking/markers.pdf create mode 100644 tracking/markers.png create mode 100644 tracking/requirements.txt create mode 100755 tracking/track.py create mode 100644 tracking/tracking.pr diff --git a/protocols/Makefile b/protocols/Makefile new file mode 100644 index 0000000..d6c82b4 --- /dev/null +++ b/protocols/Makefile @@ -0,0 +1,8 @@ +all: house-schemas.bin + +clean: + rm -f house-schemas.bin + +house-schemas.bin: schemas/*.prs + preserves-schemac schemas > $@.tmp + mv $@.tmp $@ diff --git a/protocols/house-schemas.bin b/protocols/house-schemas.bin new file mode 100644 index 0000000..767dc12 --- /dev/null +++ b/protocols/house-schemas.bin @@ -0,0 +1,6 @@ +´³bundle·µ³scene„´³schema·³version‘³ definitions·³Touch´³rec´³lit³touch„´³tupleµ´³named³subject´³atom³String„„´³named³object´³atom³String„„„„„³Portal´³rec´³lit³portal„´³tupleµ´³named³name´³atom³String„„´³named³ destination´³refµ„³PortalDestination„„´³named³position´³refµ³shapes„³LiteralVector3„„„„„³Gravity´³rec´³lit³gravity„´³tupleµ´³named³ direction´³refµ³shapes„³LiteralVector3„„„„„³ AmbientSound´³rec´³lit³ ambient-sound„´³tupleµ´³named³name´³atom³String„„´³named³spec´³refµ³shapes„³ SoundSpec„„„„„³PortalDestination´³orµµ±local´³embedded³any„„µ±remote´³refµ³ +gatekeeper„³Route„„„„„³ embeddedType€„„µ³shapes„´³schema·³version‘³ definitions·³Box´³rec´³lit³box„´³tupleµ„„„³CSG´³rec´³lit³csg„´³tupleµ´³named³expr´³refµ„³CSGExpr„„„„„³Mesh´³orµµ±Sphere´³refµ„³Sphere„„µ±Box´³refµ„³Box„„µ±Ground´³refµ„³Ground„„µ±Plane´³refµ„³Plane„„µ±External´³refµ„³External„„µ±turtle´³refµ³turtle„³Shape„„„„³Move´³rec´³lit³move„´³tupleµ´³named³v´³refµ„³Vector3„„´³named³shape´³refµ„³Shape„„„„„³Name´³rec´³lit³name„´³tupleµ´³named³base´³atom³String„„´³named³shape´³refµ„³Shape„„„„„³Color´³orµµ±opaque´³rec´³lit³color„´³tupleµ´³named³r´³refµ„³ DoubleValue„„´³named³g´³refµ„³ DoubleValue„„´³named³b´³refµ„³ DoubleValue„„´³named³shape´³refµ„³Shape„„„„„„µ± transparent´³rec´³lit³color„´³tupleµ´³named³r´³refµ„³ DoubleValue„„´³named³g´³refµ„³ DoubleValue„„´³named³b´³refµ„³ DoubleValue„„´³named³alpha´³refµ„³ DoubleValue„„´³named³shape´³refµ„³Shape„„„„„„„„³Floor´³rec´³lit³floor„´³tupleµ´³named³shape´³refµ„³Shape„„„„„³Light´³rec´³lit³hemispheric-light„´³tupleµ´³named³v´³refµ„³Vector3„„„„„³Plane´³rec´³lit³plane„´³tupleµ„„„³Scale´³rec´³lit³scale„´³tupleµ´³named³v´³refµ„³Vector3„„´³named³shape´³refµ„³Shape„„„„„³Shape´³orµµ±Mesh´³refµ„³Mesh„„µ±Light´³refµ„³Light„„µ±Scale´³refµ„³Scale„„µ±Move´³refµ„³Move„„µ±Rotate´³refµ„³Rotate„„µ±many´³seqof´³refµ„³Shape„„„µ±Texture´³refµ„³Texture„„µ±Color´³refµ„³Color„„µ±Sound´³refµ„³Sound„„µ±Name´³refµ„³Name„„µ±Floor´³refµ„³Floor„„µ± Nonphysical´³refµ„³ Nonphysical„„µ± Touchable´³refµ„³ Touchable„„µ±CSG´³refµ„³CSG„„µ±Skybox´³refµ„³Skybox„„„„³Sound´³rec´³lit³sound„´³tupleµ´³named³spec´³refµ„³ SoundSpec„„´³named³shape´³refµ„³Shape„„„„„³Ground´³rec´³lit³ground„´³tupleµ„„„³Rotate´³orµµ±euler´³rec´³lit³rotate„´³tupleµ´³named³v´³refµ„³Vector3„„´³named³shape´³refµ„³Shape„„„„„„µ± +quaternion´³rec´³lit³rotate„´³tupleµ´³named³q´³refµ„³ +Quaternion„„´³named³shape´³refµ„³Shape„„„„„„„„³Skybox´³rec´³lit³skybox„´³tupleµ´³named³path´³atom³String„„„„„³Sphere´³rec´³lit³sphere„´³tupleµ„„„³Sprite´³rec´³lit³sprite„´³tupleµ´³named³name´³atom³String„„´³named³formals´³seqof´³atom³Symbol„„„´³named³shape´³refµ„³Shape„„„„„³CSGExpr´³orµµ±mesh´³rec´³lit³mesh„´³tupleµ´³named³shape´³refµ„³Mesh„„„„„„µ±scale´³rec´³lit³scale„´³tupleµ´³named³v´³refµ„³LiteralVector3„„´³named³shape´³refµ„³CSGExpr„„„„„„µ±move´³rec´³lit³move„´³tupleµ´³named³v´³refµ„³LiteralVector3„„´³named³shape´³refµ„³CSGExpr„„„„„„µ±rotate´³rec´³lit³rotate„´³tupleµ´³named³v´³refµ„³LiteralVector3„„´³named³shape´³refµ„³CSGExpr„„„„„„µ±subtract´³rec´³lit³subtract„´³tupleµ´³ tuplePrefixµ´³named³base´³refµ„³CSGExpr„„„´³named³more´³seqof´³refµ„³CSGExpr„„„„„„„„µ±union´³rec´³lit³union„´³tupleµ´³ tuplePrefixµ´³named³base´³refµ„³CSGExpr„„„´³named³more´³seqof´³refµ„³CSGExpr„„„„„„„„µ± intersect´³rec´³lit³ intersect„´³tupleµ´³ tuplePrefixµ´³named³base´³refµ„³CSGExpr„„„´³named³more´³seqof´³refµ„³CSGExpr„„„„„„„„µ±invert´³rec´³lit³invert„´³tupleµ´³named³shape´³refµ„³CSGExpr„„„„„„„„³Texture´³rec´³lit³texture„´³tupleµ´³named³spec´³refµ„³ TextureSpec„„´³named³shape´³refµ„³Shape„„„„„³Vector2´³orµµ± immediate´³refµ„³ImmediateVector2„„µ± reference´³atom³Symbol„„„„³Vector3´³orµµ± immediate´³refµ„³ImmediateVector3„„µ± reference´³atom³Symbol„„„„³External´³rec´³lit³external„´³tupleµ´³named³path´³atom³String„„„„„³Variable´³rec´³lit³variable„´³tupleµ´³named³ +spriteName´³atom³String„„´³named³variable´³atom³Symbol„„´³named³value³any„„„„³ SoundSpec´³orµµ±stream´³rec´³lit³stream„´³tupleµ´³named³url´³atom³String„„„„„„µ±loop´³rec´³lit³loop„´³tupleµ´³named³url´³atom³String„„„„„„„„³ Touchable´³rec´³lit³ touchable„´³tupleµ´³named³shape´³refµ„³Shape„„„„„³ +Quaternion´³orµµ± immediate´³refµ„³ImmediateQuaternion„„µ± reference´³atom³Symbol„„„„³ DoubleValue´³orµµ± immediate´³atom³Double„„µ± reference´³atom³Symbol„„„„³ Nonphysical´³rec´³lit³ nonphysical„´³tupleµ´³named³shape´³refµ„³Shape„„„„„³ TextureSpec´³orµµ±simple´³tupleµ´³named³path´³atom³String„„„„„µ±uv´³tupleµ´³named³path´³atom³String„„´³named³scale´³refµ„³Vector2„„´³named³offset´³refµ„³Vector2„„„„„µ±uvAlpha´³tupleµ´³named³path´³atom³String„„´³named³scale´³refµ„³Vector2„„´³named³offset´³refµ„³Vector2„„´³named³alpha´³refµ„³ DoubleValue„„„„„„„³LiteralVector3´³rec´³lit³v„´³tupleµ´³named³x´³atom³Double„„´³named³y´³atom³Double„„´³named³z´³atom³Double„„„„„³ImmediateVector2´³rec´³lit³v„´³tupleµ´³named³x´³refµ„³ DoubleValue„„´³named³y´³refµ„³ DoubleValue„„„„„³ImmediateVector3´³rec´³lit³v„´³tupleµ´³named³x´³refµ„³ DoubleValue„„´³named³y´³refµ„³ DoubleValue„„´³named³z´³refµ„³ DoubleValue„„„„„³ImmediateQuaternion´³rec´³lit³q„´³tupleµ´³named³a´³refµ„³ DoubleValue„„´³named³b´³refµ„³ DoubleValue„„´³named³c´³refµ„³ DoubleValue„„´³named³d´³refµ„³ DoubleValue„„„„„„³ embeddedType€„„µ³turtle„´³schema·³version‘³ definitions·³Block´³seqof´³refµ„³Token„„³Shape´³rec´³lit³turtle„´³tupleµ´³named³program´³refµ„³Program„„„„„³Token´³orµµ±i´³atom³ SignedInteger„„µ±d´³atom³Double„„µ±b´³atom³Boolean„„µ±s´³atom³String„„µ±v´³atom³Symbol„„µ±block´³refµ„³Block„„„„³Program´³refµ„³Block„„³ embeddedType€„„µ³tracking„´³schema·³version‘³ definitions·³Marker´³rec´³lit³marker„´³tupleµ´³named³camera³any„´³named³id´³atom³ SignedInteger„„´³named³rotation´³refµ³shapes„³LiteralVector3„„´³named³ translation´³refµ³shapes„³LiteralVector3„„´³named³time´³atom³Double„„„„„„³ embeddedType€„„„„ \ No newline at end of file diff --git a/protocols/schemas/tracking.prs b/protocols/schemas/tracking.prs new file mode 100644 index 0000000..cf02b19 --- /dev/null +++ b/protocols/schemas/tracking.prs @@ -0,0 +1,9 @@ +version 1 . + +Marker = . diff --git a/scene/lobby.pr b/scene/lobby.pr index e889b8c..95c6907 100644 --- a/scene/lobby.pr +++ b/scene/lobby.pr @@ -190,4 +190,15 @@ ]> >>>> +? [ + let ?name = stringify [track $cam $id] + + >>>>> + ? [ + + + ] +] + [] diff --git a/serve.sh b/serve.sh index 633b373..ab4c138 100755 --- a/serve.sh +++ b/serve.sh @@ -5,4 +5,4 @@ then openssl req -new -newkey rsa:2048 -days 365 -nodes -x509 -keyout dummykey.key -out dummykey.crt cat dummykey.key dummykey.crt > dummykey.pem fi -exec syndicate-server -c ./config +exec syndicate-server -c ./config -c ./tracking/tracking.pr diff --git a/src/index.ts b/src/index.ts index ccfde8f..7a97e40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import * as wsRelay from "@syndicate-lang/ws-relay"; import * as wakeDetector from './wake-detector.js'; import * as Shapes from './gen/shapes.js'; import * as SceneProtocol from './gen/scene.js'; +import * as Tracking from './gen/tracking.js'; import { md5 } from './md5.js'; import { setupLog, log } from './log.js'; import G = Schemas.gatekeeper; @@ -267,6 +268,31 @@ function bootApp(ds: Ref, runningEngine: RunningEngine) { at remoteDs { stop on asserted SceneHandle($sceneDs_e: Embedded) => { react { + at remoteDs { + const ms: { [key: number]: true } = {}; + on message $m0(Tracking.Marker({ "camera": "cam1", "id": _ })) => { + const m = Tracking.asMarker(m0); + if (!(m.id in ms)) { + console.log('Spawning marker', m.id); + ms[m.id] = true; + spawn linked named ['marker', m.id] { + field current: Tracking.Marker = m; + on stop { delete ms[m.id]; } + on message $m1(Tracking.Marker({ + "camera": m.camera, + "id": m.id, + "rotation": _, + })) => { + current.value = Tracking.asMarker(m1); + } + at sceneDs_e.embeddedValue { + assert fromJS(current.value); + } + } + } + } + } + enterScene(route, id, runningEngine, diff --git a/tracking/.envrc b/tracking/.envrc new file mode 100644 index 0000000..a753da9 --- /dev/null +++ b/tracking/.envrc @@ -0,0 +1,9 @@ +if ! [ -d .venv ] +then + python -m venv .venv + . .venv/bin/activate + pip install -U setuptools setuptools_scm wheel + pip install -r requirements.txt +else + . .venv/bin/activate +fi diff --git a/tracking/.gitignore b/tracking/.gitignore new file mode 100644 index 0000000..b7e718d --- /dev/null +++ b/tracking/.gitignore @@ -0,0 +1,2 @@ +/.venv/ +/__pycache__/ diff --git a/tracking/README.md b/tracking/README.md new file mode 100644 index 0000000..a6d7b63 --- /dev/null +++ b/tracking/README.md @@ -0,0 +1,3 @@ +# OpenCV for head tracking + + sudo apt install libopencv-dev opencv-data python3-opencv diff --git a/tracking/calibrate.py b/tracking/calibrate.py new file mode 100755 index 0000000..cdcd1b4 --- /dev/null +++ b/tracking/calibrate.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 + +import cv2 + +def main(): + imgSize = (640, 480) + + video = cv2.VideoCapture(0) + video.set(cv2.CAP_PROP_FRAME_WIDTH, imgSize[0]) + video.set(cv2.CAP_PROP_FRAME_HEIGHT, imgSize[1]) + + d = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50) + board = cv2.aruco.CharucoBoard.create(3, 3, 0.02, 0.012, d) + + allCorners = [] + allIds = [] + + while True: + (ok, frame) = video.read() + if not ok: + raise Error('video.read() yielded False') + + (corners, ids, rejectedImagePoints) = cv2.aruco.detectMarkers(frame, d) + + if ids is not None: + cv2.aruco.drawDetectedMarkers(frame, corners, ids) + (response, boardCorners, boardIds) = cv2.aruco.interpolateCornersCharuco(corners, ids, frame, board) + if response >= 4: + allCorners.append(boardCorners) + allIds.append(boardIds) + print(len(allCorners), len(allIds)) + + cv2.imshow('calibrate', frame) + if cv2.waitKey(1) != -1: + break + + (calibration, cameraMatrix, distCoeffs, _rvecs, _tvecs) = \ + cv2.aruco.calibrateCameraCharuco(allCorners, allIds, board, imgSize, None, None) + with open('calibration.json', 'wt') as f: + import json + json.dump({ + 'cameraMatrix': cameraMatrix.tolist(), + 'distCoeffs': distCoeffs.tolist(), + }, f, indent=4) + +if __name__ == '__main__': + main() diff --git a/tracking/calibration.json b/tracking/calibration.json new file mode 100644 index 0000000..2bf7efd --- /dev/null +++ b/tracking/calibration.json @@ -0,0 +1,28 @@ +{ + "cameraMatrix": [ + [ + 293.7923171378614, + 0.0, + 311.6628157931463 + ], + [ + 0.0, + 254.53720087509802, + 246.53051367347976 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "distCoeffs": [ + [ + 0.11935276438835996, + 0.040571025979352617, + -0.010671890343277932, + 0.00864586497856339, + -0.01991935448364431 + ] + ] +} \ No newline at end of file diff --git a/tracking/make_markers.py b/tracking/make_markers.py new file mode 100755 index 0000000..f2cb274 --- /dev/null +++ b/tracking/make_markers.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +import cv2 +import numpy as np + +def stamp(target, source, x, y): + target[y:y+source.shape[0], x:x+source.shape[1]] = source + +def main(): + page = np.zeros((2970, 2100), np.uint8) + page[:,:] = 255 + + cv2.line(page, (20, 20), (1020, 20), 0, 2) + for offset in range(20, 1120, 100): + cv2.line(page, (offset, 0), (offset, 40), 0, 2) + cv2.putText(page, '10cm', (1040, 40), 0, 1, 0, 2) + + cv2.line(page, (20, 20), (20, 1020), 0, 2) + for offset in range(20, 1120, 100): + cv2.line(page, (0, offset), (40, offset), 0, 2) + + d = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50) + + stamp(page, cv2.aruco.drawCharucoDiamond(d, (0, 1, 2, 3), 200, 120), 220, 220) + cv2.putText(page, 'front', (220, 170), 0, 2, 0, 2) + + stamp(page, cv2.aruco.drawCharucoDiamond(d, (4, 5, 6, 7), 160, 96), 220, 1220) + cv2.putText(page, 'back', (220, 1170), 0, 2, 0, 2) + + stamp(page, cv2.aruco.drawMarker(d, 8, 200), 1220, 220) + cv2.putText(page, 'top', (1220, 170), 0, 2, 0, 2) + + stamp(page, cv2.aruco.drawMarker(d, 9, 200), 1220, 620) + cv2.putText(page, 'left', (1220, 570), 0, 2, 0, 2) + + stamp(page, cv2.aruco.drawMarker(d, 10, 200), 1220, 1020) + cv2.putText(page, 'right', (1220, 970), 0, 2, 0, 2) + + cv2.imwrite('markers.png', page) + +if __name__ == '__main__': + main() diff --git a/tracking/markers.pdf b/tracking/markers.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6e18c6e376b558bb31e8dc38e4fac2f8903e70b6 GIT binary patch literal 8457 zcmeHMdsq`^x<4*Gy21cwm#zf4rE3*9RWd^i;U=`kO05=LutEiaA%uWH5|co?r5GfeRMV&|_lJ*7$ z^BG+1KN#jS_AuydKZd%!y*iK2Wxyfo;hB`=a?N-U*dsr1T;uZ}-)?bg_|2il-@f&q z@4k!5+~1SEtveO*X;b6pyUcz{%G!1>!?q({%$Pjz8yEhUURJwZo?R^4l-yD{G5r}` z@c||6!>{RE%|1_!72k)CU4Hyq?L3EepU8ElTlF8SGgy8LI~MDR=!=bq4YcnjYkp~- zFtDZd!!OR9%j|!T*}b*>{GFq_M!@|6?c0g{XZHOy{VdN*k-q-KjSmbi_Jnx9tEhi4 z+COa4Hvk6Z!U_$|PM4odXKG^7K1c z1qscA&&v{X615!(2o2bRPD?rCV?pE!MGQJKGAnB~2@cJuic>1ePzDja?=ab4w zORsVbq&kS*S;7l4ozh$gd4}Fl#oH36xh{!=z&e z92P>E2Ew>)sB~Nr-h)BMO71Xh|7KZ=5yL$hBRh-b9pud9(mTf|%T9z$nW9`P53MSv zVL`(PgOG~kBpO!q&gKESg1?C6LR+R)77{yKo8aTo3n8Q0KMDJq)0%x`cF$+5l1tH$ zsY8e>K)6sX?n`ikWmcMVE%F_SOBmjD;&!tp!CY`sdOEkX=vy#n7&JlpMv<^_QBx_e zV1m0>NId|E^PF*>V~$f|lIV}#r(k>G`>9F0EDTYn9A{had%b7+0x$QN`MR^>Xw6y1 z4ZRVk11~cRw;>bdgY4c530=wACF!XVf@wiBr}(*M$9N!gwg;T6kU@Rf4~!5A0&k!; zWYo;j4m1SBMGrEs^*orYD%uQsB1?uYp1gDilwD4+h(oeIUBUZ#cy>Mf@R&HL+4EwD z_(6A&JezFvs3pns;o1Bh-T@};G#HIhLzqKvp!Vm&3!Sn z5iy&`VftHW|EL6wU#GSnAb>u%=5H}o5v}U`xwbyC@qvQK?{@+pq4(DuuEh9j4Yp9oU1xmh6q!t zn>>|U;Kg!p+k#XhB`Ls$@`w*6j|ol4Hz~6qFgK#D;XR~-TZ*(5q{;8##!4sqyX+xc zZrRPq8_odk1gppkUnYIV+ygE8gYiq2^>}4TXMJlb;@(iM2ZMcEMxvH|R|Wx{Sa$w) zywu-BGPMs5@7B|11L(t#;aeQu#{{t5^AAW`#Xs^7yu$Z2gR&NbLk^kVZ5z=EBw4eM z>xO(ovE%-rU0p1#luFNn{NE!^-`?Lxu-Qm6fqUNuFy#H85(kaZ{%mJH8I57y+qIae zRNgNtux{L>XJbgqr}y%KtkhgF#J545MECm$uO=$gpmxt&ugdEEB1V9yHf2M*r|h=g zCVlNPyZ+vCy~E8_)T!*SAlay1HeV9I4y|5Cu=zLI2A|&eW?xz4w1OiWJl8y8SeGW{ zdOV#%MNBVLurmsI_!e@Rq=qC32#A!d#Js)l-;`d|B}*qwCQNC}$4Jy(!m;+I{_u(` zoukd^Q_4S8jgGf z6rfD6YHYuOK{fj3KkZGS-xuSRNhJ5~#9e&$v%k)(I;6_)-P2l5;eA zZ*5QJcqg>o@?uN_Q-0FE{QP5nP>TtoIi*R+zM%tkla8*`oBC_GLffo zv`q^YPGhp(Vd0#SYIed-DCg$#amKUMcYBdqTIwZliorr28#xWQW%Dx-q zbZE*;xp9|i$9*}5>Fj!|vaY1C< z3ju;xQ1u*Kre5kgv<(hFM>cv)K-%0+VL8xNIi^+pYUwA$bHd}8xDXb$@+HNND-0uFd)=os53>-vaL@%6EFxWS8+H;2 zA8Rr<43}C5*8s*^j)?XKtgPi1w@W;*3aap-bmqD2LFDDqDa|V56rjhkXILbxpGuSd z6z=D4tUa7BOr1)DA&bl%NX`Y}v%<@;Jy$HSOs%8I}7>mvONSR8F`DztQHLh79n(c#3pEKI;gn(%#Jto|Wfm+M?5 zGt0uW|A=&1V!Yh>`X@vy!r0rXmUAF=f{>;ts(>KtW+_TnyR28+cl%;0%QF zm@R)&Nk0EHCcFy2^q03%boI;g3uohh3}$QE`nS~lwQ|od5vGI6%c~+zE`{u8Q`|DR z<+YsH30L!^`Sb1hOA0$I+cqW>Dp_UM2L11FzLEZLeU-16udJbqsiM-HFKXd%DwIho zy?jcZp@Zh5ql334o6s2_Ym1AzZ7ttEuluufDj@;3vX$jmTg?UdS$PE0HKwRrkh7f+ zx$Zk0b|BsedIqkzb`^UU5awo`YXqosXIy?~VtJ4gP&+XiPIKQ;)AlWC3rcEhxvf_B z8EoaSnZ2HwG}`gq-4U^6*vPwNUzr8qh&FV3|3{Cg^F%%>ou?A2d}gc^f=VlIV2+xZ z&1P)S-bq3l(9yfaUQTH>wx>HN-pJ9|M<87}k`eJJOB#K=>HHFHq&aQm6MziMic>W0 z8l&~Ju3tWpn7#uYo~vlD(J~6S4w%ecX~FL*fw{1X6hpiF6$DmH2j@!rgsynZuVuRQ z@KrzAl`;0U9+V@uy_e-MV4;JQ3#rkM@Tc|y0^^w0E@$of&!qMAGtAnMV5y?}4yWf# z-%*F7WL?Cz!8KX%JKG>^VNOYFSHc;xZ<*YnyEq5%u8P^HS810#yYVAG|h6MA0;?&MuagQCB->5$94>99wP0^5I+BfyQk}lrT=7^DJf+ z=FiCJhmd^5**^$`QS`MQ?l%J*(~Lv>Ws~Q3OWc>Vz9cv@Q`+Xt)t4Ak;K0sy|&&NX>^|6@b}xVs=F88FE2y^17h}!vq#06-?tNXNQSc!R;|pJT?7m- zO8`{j1IaDfpm$+v0jNmQ3IHiFx9cv4AM*yX4xRWNVLj07=z|F|K7p0Zs}NhFkR;(@ ziD!)xxU1{6v}ljh!&v&MQL7!=zohl8cB*~`s3E>BdB08pvlhBm-IEtK==EexXs&sN zfY+Q~fd1DQeSI4L*YA(l>FITP`hT2m3l+xhZo4#A_KES95f{d2sp@P`-Rf6Hn}Ms4&<_lkU3;&4iZfR z_bCJaaaeX=bKWM8lfs{6IEq58lo+-D6_`T9839h;@i5J1LZR zK*$6H%~W(+tLU6Jng;XXU^Y`J4evtp7W&T1-Rf{I!(Sc5dgSWh(nYkSP{2B z9(PI_f3>LJxkscAOX0h`pcDs_=~}7`~w)E2m_oqjB5MHQEDdOP$Z{8=tKF_PrfF zxpjQ|Hmjb7RwsTPU@exnpWQd$dgq$c-AMc79n9z_1&T#YRQX~EBQyHx3>sLWJUnRK z*1ipvfDqTCS{O;cN`C~{}dZ3FRO{_zM* z<2%BPU92Zl2AA^loV}XsXS8~b;A#uCZ)HuI_|93YKKFpw7kJhgRdemeJTV!k5Y zdd-gYpFTYw`?^?Jj`Ed^Z3BxqfcK&^wJn12$z`rj&%r0BIqkwNj`^JSkR}UxYS5FZ zZBBQ`i~wP~a9~>7X$~vPPQH)R{%o^{*X&|t*@AuY4!>XCtwrC`npCidt$s4%R-a66 zOA}LmMe93)0eAW0XLY8gWptl=O^aFRvkk!P=jY81J>&U|;N+&qwj$wz5(W* z@yTn^KS+UhBQv?G^Uz%0q0VZ(?s=TD@lT3|!A8(GYaQ0?I0CIiK0WBkByp<~(mW4D zFe0L+o!;h@jAY6e&(Olj+~ftljK&n4jGe!;+p z#N|Ysmc{VVYFJ_30SEDCHu4it0IvJM3e`{|m$II`LtH$(rrn*e_(F@mABczgf%ou{(_J1!fgyJ2Sv<94aTC~kHPNwak=n1{X5&fCbYK4P_rVFVFt#Rx*m~A89jyE-xHI>ywIZlDy zLmNiBuMDu3+lsR(@)U6ZJccMNYW9s)jaOgCq#Z)wLHLHnwoYEJBod&@QG94BM(-V7xaCtKOW)$d-=+mNbpWMc_^&lO)V%PSe1s!Tp<{U z2B{MOs-PSgA?VOl~`p_L;k(B!t5b`1Z`&3N&F{CP>&1J5F!G4_YH(-KFa6 z5)USJtUL^4INSjvAK13HE$nHC5r3LCt92P9)#ajr5IIYEIG_an%SEXce{SD$C`f+d zK^s2Q6c1o0obk>>ikvLMGK9GR&@2I<;6O10AZ&nXpw90?qIe3*Fem;F7~+kqydoKx zxq>|v>{^{C1H$1D66g+2FeLWhociOcR;N994rnWl#up=l(9wXkq_fXQ^{QFxKY2H} zOx#+0cHEjB|0p;u=PxM}U#6wh^w8ITGX8EqVCnIxU)#8jF|*3)mpRGv6M;--q(P9k zU$|W6q<~p^(VXC&J(&=1+q2{3V_=3zo0OUv<-WlDZF7RZaJ|gA%Z901{|O9V0;~AJ zvh&%netI*|$f9G+#!p}dk37FNrz?Aa+CfHVUUFm_+D4rf>J#ZVc%(00V=(i#eVTy8#0=`F?UtX7t z({Q8gXc(w~rjpU|J+b7(o+~*;?j()%gnHGO@4eBL)PWLX3B@kaK32{mcdiCPrEKOc3rII`4DMHa2s5=?uNbazPcQ&LY{d@k8ST5^3!dH@8p>gBo^oF+Q zAMu#=k(%CUDB_1Z$~B$FP{fy?^VbmBP{fbtXJ~tzmdm<`ud#oum1Bk??)9)%(_Ia1 zoOg?>Y0ZYHUVpM$)7K4A{ngkwq+r9@*lXH@(ZjsNSi;631y|-Z)@cu0piUbI8;9a@ z%7&yLvR4u!4k@^C3NBAW=deiSDMG}d_Q=CCc|;Yxkq~iIdrA~_I+;(Dtx$Npdw7Pm zE>xY^j(WoadH8;Hs@lM2wNl)`PPO{oz&5p_-@qQVCxz#~!4KEV6`J1yfZ#-%Rq`Kv z`$iG{qBWgesA4X+`l?{m>YH1<-{Sq3sQh!O57WNxVTzOB%bW+a%NPDA@H-k@v+qK2 zci<#}M=w__&;BHcGU38l*^V;{=1iU^IF&cw!s=d7y4x!>EXn+aYun;`>~t3^iEV!^ zXCw13E`m4T&;4@y1Df2ump*^Dxhzi^U301;i6;5o(ctSrnUv-GHNre{6eI# z?qB16=eic=nD~A!XLg~7EN4C?@?JIT+LJNcY47ZwC>e`GGgccDwclV&b{MS4p5AcP zbMXO98*;U8n<#ot%@gQ(GtuYU30v-98aLb$Uf_H6RF;|=r0-@#g zT?PipCjl|)>e5y_2{?~|kvv|LAMpHCF0i3a{snNa%^$eL<8@t{P7yzJ1q#${NrCL& zXr-PGSX+|;l&!0~Q<_9$-3~TR{c8fEp!bfK?NArxy1IdUs68Hq-&0cF&t115VKT%w z7J?d(r!LEWz2hM+&F?u0y4m+;Nygo9EdyJKygh_C(yh_@vbux=0UDit=bCA=dFQ$X znR*y^rNgjlcf5STP9LuR=5-4W4nyp*&~K(tKaS15Wrd!@LYXDo5iu;9UDTBKE%gKX z+PXSZ$p1V-ogHLgJ(b7ax5PO@#x_D$F@=i+j9)Ja-uEq4KBek>AtTIHq+GPaSwjZ7 zRKi?zP@OwuO4tp?%@l%qnlqWqii6!(iac>1k%?j40w%dzwPd*=DBhyRU$k?dYwNC! zPH~w9;{ROx|1R>3*x(+aMH{TNIe2jT#nS28!0o*kp-tpc2|YzZ*Ki=I$WNPM?_0vu z#acSCFGJ@Ws<+Q)p*y9rAMPbe5}oSSOh;98rV!R(Ygt53VKvQg&D^C-lraSyUdzm< z+vF+kkwhg=~TVI(%LBD%358 zZ+=TMOVMtTWW(Y;k>bWMEDU|Eg6M_#Of_h1cheI}jH_u~(a!5Wcnvwh~nY zrN#9ZCUFtn!MWdjhMbE)jH4rVVffIgINC&Wj^?xjjvF17<;RzsNL;NfZqWJ3O+=yn0sS}wK-AVMTu5L?tG6Y83s zdnqEFa;I{U!m58y_iUEpO(L%lZ*b9q^h4g)%yt*F6!9(iuC&1guHlZ~5E}E%;NOWog&9XwQI7E^RD@ zoDc@l4yKqt3ox#NQAG0S1SyKxA_^(7bg~nXZx5}U7B$0(PS|S!0jLd+TX{dJK%1w50 z0wj*Jn&|ORJ6hm`M3l&A7xTa*6->GW-SMW7GG~i-SNGALHo?ipus88*IID((t-SfXnW(q@+CcK&$X9_s_z6MJ`<*a zdRAXeq3aL*dk4}TnDK&%cq3>;gNVj_3+5Dlg5DM7W}EEr!83XnnZ0zSNV!?>y7L}e z;o1LB{oBamZf@^ChTou8-@8K9-fY5U2Ri7Xn_lu1aKV9;2D;KEd4cX(Km*~s+lnHl zul5!d-K~J?!k4thCDa#EFCI{YlMTAXCvgeO^`Wzqq4?^KP58?Y61mWEKy!REz!@Z^ z)F)7UxgUA4!clwMj&!%?NNa$HSWc>ssOWnZQl21btvNzr;+gik5C}ILKpTyWwq{&oq?@pqAgEHe%^0tVmO!$ryEp)c-< zBni8}e68lU07GBmmszB!ueF}SHp3Gc2R}7EpBrG##e`o5;2@<8snzq6T^9+z4)8-4 zbNC_;`gOAGJnIisW;e78cZ!Lp|m7V0|xt=4h!km`$mnkBLJ?4n0I53DeQw^x@+Lo36I=o5`^jcB|Q zWKh((v5T6Ibgv36qGu(_>JjH#q0_?K`wVESbav5a#J3+%bKLIpUC}2Wn9G6-A2D}t zux1o}mD3&EztS_Y$SrGvr(=#Adjf?iYPVw~(im6M3bhuCb9zVTxMewFlX(~dM3ae6mIL$7csxCLQ-2Dkug6%-7X(!b*bYL2G zuKCDwAI6Rvw9aOz!uq2XV&(1)b__n1DM}O+yM2h%2K|1JX{WDD<;^sc@)Wg!m2a6z z6D5(I4CSB0PD2;8V{h3V=&Gz&PUU1Ag%ZJuKjAmW=bTC`Ze@W15t76i_7qRFmY}6%d*EEE2FfkFyMv(@Z|@eD36KHjGgwQDWRN6L3PaI)oU!nlU0F7o zFfS*-nqi&;L4yGIhgOym*4)}#8rUCOxni_5Xlrjhc&A&?q|+~_w=wV=OVRc~Sh`pn zMRN;IfGtC1DFS;6#FaQGus^y|X~(DlSM>*pB1cOjb9(b$;6W{>onD-=roe|KS}IPW za{}x~+O0RI!L-wbGtIPphyazD zu(~9718@hUcMKrU!-->VfzVs3z|jq=Twx}Ss|;%o8KR!F8rIHaFon)i{FyH}KMCiN z+W8a+`$9!L4px8YR<|rF&Q~iNMg=fXkodNG&)N2aORK)aozOoF`O+YX96hu(FXiE| z40Rq9iB!STYKc{+Z_Wl$Z=NVpXcc;M1$Je&4WsBGo*-*7lofSq7<~8`YK((A)prFJ zeuZn!qb#%hDxtbiOy+;qzkp}{2wnOuwu&Zxl#hb}1tO8Ax z|8^pjn^>%z3#feb4>E{;NllO7`DA(62A z=uraXRN1Oku09%OMmb3SDrM<{;{)eW;g{$Lh-exfNAdNh>cV)GmJs70VZo6R5#M{2 z>zPA7gn%0tewCp<+9Em;Uiip8>41(TUpq2V?HEOOCKG5T{n@m0r)lWb3_*#GFE(j~q}mc5GD!c~65egVq6}f^M;Lvhr{l$X;hz z@LWsL{%+{mqsZVXK`J{-eI>cQTm=@u)!kxHPeBR}{fUL%tWg>T)xBqf9SA9y-3Xzr zQ!6PXAw~SrQl=!=?S7;M85$7OFy0G%VtD8DoRdqKv2bjn#NgOS0L@WJKi!g$mDT)WT)W-o^ z&sdnDLOTQMzh=5lt=S0b=^6}C6V@#FLYNz+@KcoizbucH+>opdEqb)X)A;&7gF}Yw ziq~U83|_zY33I6_ojzqH8A)HevA0xiF*+(P%6L`dsl1V?2p(x zgngOR^ODq4180Ek>LKi_H&`CqV5y1L6+zq(_O)e_LuJ}%`6Yp}Mw~THkFb9#ILEq6 zG4B--Kx7S)mn11_7W)C~HL%a5R(N8{UlDP2G4jbgi1QJoHN8{AE2}W zKF_2Ar48`;5{jCp`Vjbh%s4%Q#Sl2JXa6$F8G~|03ky0f{8k#%xR&mSM#TIjJ+A$m zZbO;Eos`aw4By;{^h02c`Ya!j=v$F|n%~cKoT+&Gxx(4pEaPeIwdTfbx zYMdg1bUcQRgL^s1r{btP-dKdLmidv-%rBfnAldW~0y(XR5Xe0}gg}bxAp}xU4T(V1 zRua0I2jL(AnpT%> + +? [ + ? ?t> [ + $t { + mainDataspace: $ds + } + ] + ; $ds ?? [ + ; $log ! + ; ] + [] +]