From 2e4c6964b21c4d7354ef718d99d640f94badea61 Mon Sep 17 00:00:00 2001 From: Reza Adousi <91836545+RezaAdousi@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:04:47 +0330 Subject: [PATCH] Initial commit --- .vscode/settings.json | 3 + __pycache__/calibration.cpython-312.pyc | Bin 0 -> 41465 bytes __pycache__/camera.cpython-312.pyc | Bin 0 -> 9466 bytes __pycache__/config.cpython-312.pyc | Bin 0 -> 4788 bytes __pycache__/detection.cpython-312.pyc | Bin 0 -> 33165 bytes __pycache__/effects.cpython-312.pyc | Bin 0 -> 8659 bytes __pycache__/geometry.cpython-312.pyc | Bin 0 -> 6720 bytes __pycache__/imaging.cpython-312.pyc | Bin 0 -> 4719 bytes __pycache__/launcher.cpython-312.pyc | Bin 0 -> 5932 bytes __pycache__/main.cpython-312.pyc | Bin 0 -> 7233 bytes __pycache__/physics.cpython-312.pyc | Bin 0 -> 20022 bytes __pycache__/rendering.cpython-312.pyc | Bin 0 -> 29558 bytes __pycache__/vision.cpython-312.pyc | Bin 0 -> 6000 bytes calibration.py | 1580 +++++++++++++++++++++++ camera.py | 258 ++++ config.py | 599 +++++++++ detection.py | 1132 ++++++++++++++++ effects.py | 271 ++++ geometry.py | 255 ++++ imaging.py | 124 ++ launcher.py | 138 ++ main.py | 274 ++++ physics.py | 747 +++++++++++ rendering.py | 965 ++++++++++++++ vision.py | 228 ++++ 25 files changed, 6574 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 __pycache__/calibration.cpython-312.pyc create mode 100644 __pycache__/camera.cpython-312.pyc create mode 100644 __pycache__/config.cpython-312.pyc create mode 100644 __pycache__/detection.cpython-312.pyc create mode 100644 __pycache__/effects.cpython-312.pyc create mode 100644 __pycache__/geometry.cpython-312.pyc create mode 100644 __pycache__/imaging.cpython-312.pyc create mode 100644 __pycache__/launcher.cpython-312.pyc create mode 100644 __pycache__/main.cpython-312.pyc create mode 100644 __pycache__/physics.cpython-312.pyc create mode 100644 __pycache__/rendering.cpython-312.pyc create mode 100644 __pycache__/vision.cpython-312.pyc create mode 100644 calibration.py create mode 100644 camera.py create mode 100644 config.py create mode 100644 detection.py create mode 100644 effects.py create mode 100644 geometry.py create mode 100644 imaging.py create mode 100644 launcher.py create mode 100644 main.py create mode 100644 physics.py create mode 100644 rendering.py create mode 100644 vision.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c9ebf2d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:system" +} \ No newline at end of file diff --git a/__pycache__/calibration.cpython-312.pyc b/__pycache__/calibration.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..39ed637bb2eeb5c574da9cc3782d161969c5834a GIT binary patch literal 41465 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 M9KCCzly%Sl3ozhb{r~^~ literal 0 HcmV?d00001 diff --git a/__pycache__/camera.cpython-312.pyc b/__pycache__/camera.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..41bfe8bc07f2b89f31fa200e15896d8e74078732 GIT binary patch literal 9466 zcmbVRYit`=cD}>m@F9^RMNty<^hlO$(|Xy7?Z&TUWj$;sj%>)vrct*bD9%WxOwKSf zL(AgIYwy-SB*0tS?Jn$W5+v#Vh=VM;Zhy1|+CR!}_D74N;z|Lz69ZMT0gC)nWCISo z1=@4&JV+_DH|WT|oclWW-gD3S&Uf$po8MnW;L$(&_3VG_AmsO$u^y-S^6(72+#v!H zm=u{bewj&z<{gs``pZr_;n$Jk(ymEY+CAw`dnP>$agdWlV6PLw`LV;0HtC&bqTH95 z8H=)d>(Jn1G;M{o)(pO{I5Aes(5%_SK^tBsPJ(` z&PoEGxb$5AcawrB55+TDRuTDW8R{hd)WpQOfmdHXH*%6sPzk)MDPla`ONGoOwF`Wl zpBCqM@$!Y_R8mXIQlAi?=hduoDS0WLg1R`$Q;$^99)0|%0&@5nB|bHklxBD(E2+FJ z@ktGS<|KY9p13$;%t^ZdBMH2AA+GWGrt)!>3gP+bxT--8>O^xwydb8MmqY~`!E!E& zVg|*g=4q4jr^im78R65is+j^5F(FDCKaG>=|HkTfn{&xdPm2o95+*2#+MKLhH2WJ` zfM(O02$P?YWLP6VGKbq_qeUrpr$$ zNpTvcsix#P$iiZ!vQ_mOGgSFEvtm}{vl;kj3~kN>7mIS{`BYqk{-~!tQTPF+gJMA# z*7m7dJO#CMx%{3^RND-!+!Qh+g7VD?pg`lbGuqD&EAga+3V_*bNhvF5RhTQbfq4p0 zVM>s*T1nfII48pP7=xwb7eO&}50mMPtg6YWl-O?>!;maVVge-3@bQ!?gH$j|!ysrE z(*$rp$xv5HPF$q>ZjPLYr(k7AsLpUn{9IBJBr}62e(5GI2i3^IHvGp@Tx7N@k-6h!2 zq?CYNR#ARZ;PLkI zOe4s|v#JPdpkhkSZrKraC-hzzu1^)IjnSql^mxRfUViREGI8M>K5I%q3rfOPcj9`~ zPHPML`PX5K6#y6LF2WMf)nFd!_>4%68_>Yok%X27qnhUvVB<4jL})bPWw2dnug0e7 z62Zqn%ZBl(@GHT5r(tEL3^XPsix>p}D4H0Sl4UU6IKmV^1r9?^sh{@v5@4Ck7~x>! zC7sPGse~04uoSCV`2X;47z_~$%mRsFIei2IIVd+C4cpRZ)@8kwem^!}p@$VYNm&mX zXG^W|t*nPFkB;cJ+G(}c+ib{sEB%)Bx21pU5vwgZbVQ$>!}a^^oC9(-R<2BTyH!^D zT_Cf3DQCS2tl<0w_c3Z19*}0Q2-zCE+H~wIm4kf>c?PrDQ^{HEIn*kI~v{ zFmM>&=fr)&1#Em4 zn{9h$O)D)gwro$`xxfgH9C*eR#tU{lYhyvFwbhm(n(e^?`>{C}uwEzr?IfDV>eEG( z%oXslS=)cD8bS^dh6vn(<830i-etjaN*Li3;Ll>z43T_0$$p|Ta}I{Q0llDvz0AHr z=9sd1xEC0Z}P2 z%Twe&OXtjuf4=dvs4vce3MrT)y?%ufP?H8gG?0PT7?G3GXFdybDg~sMM@|Vel7=I@ zw_)cHARwxwAl8dQKR5RtbyukXCDOmKLTrkS_ zfoe?kf)a9U|NiNmQ|<5Xk9rlf0^J3%S_L%aVyJta%K(wrbnnRJgqT56qC}vT(t-(k ziSC?>C$%W6@R)I%qv{-mHx>6ESJr`*bukml4a!C5k!!YK=$myfLnrk=A-P62YDisp z(Y+pOD1UPynd=G;4D-Ats!QNu9 z;p+`Qsq6jvQ4I;VZIT*jRo_&o?_RC%UT@xazy7}a{+lb2!y7EAZ(0lQdd!lBT~M8` z?|$;Aiqtj~8wT#X@6B%z=5VAKX}dMI(%%1|@%j5hD-AFFlXHV})W7(Mb=HP9e5AI% z5ajbgz7Xur2fH7H_dRAEwV|&FB!Adwf{~wW1V~+D`4T=0^?a=&^!~hMkRi*A_rwu) zxypCqAiLb)8|>khJH7bc!wnwrF7F#8kon{w2aUFXAW{-k^}}0WJwS6R0^ko@D6~Pd z?a#BIu^jGZZD=b=s0?o(qZPvgz-GKa`qa^FLSi>1nhl4uHkg%a1*Qa#mDf630njV} zS0h@TB};e;Jikreby#co5i?H!)@|Acu+AbB-}tcweidt9wQ8g6n1Z|~*EJ+(Q*a5^gNi?abHhe1G`EQy-jKt?NV1u;F37wMAd>=BcGqH_t4cd8e+>v@hSZ?|#Rc z@4$wK1j9EkFI_GK_^u z6Lnt3U;n#cc!P!CKRgPNVACdHytTjc1)<1W`$XLdbN`pz*(sqdJ!3+47>*$xD4gJ6^&GR#)bA`Q0~wT9ZiZP&JF zGK)NQ+hdLQJs9VT;|jarP#g>Fwd+7roCZw+ySHh5sk2EdNUTIr@Jf%A`3uscV5x`o zcJX@v=r2HBrIp$KVzoxko(-3B)|=%s4A%cqJxVR#emucdl6_;@+=WJK+|utt86^Yh zPtnl=QZBo0fm~*0n@cjRH$&G6N7<1H5$)-^t*pIgkV(~Xi@D*ShocW*h)oj#VUAsK z3J!bspSli#U2q~+J!@7^Cy+qr$Q9=goG^#|##@QdZ6}@3rrn@=+iPwt80As7ad?Y5 zM#pk>2A%-ffJ+d{1&;6r3U-VdYy_Z>RvLF6a66DTp|F4E`{BPm`Cqx~FGXvVe%K4j zOO)OCMxoJ({U|P$xQHHkb?{GPaAyn`#jj1A{_aR@bnx_<*Wj*M>4A=V(>Pu)(+lMj zgF~;Jg_~&W!Z{qvW&}L=O)H|96X9S88Knt_Lz{?l2DJ$TK~cp-R)cGZ7&0onoz>lH zHh~vEI!D)`^g$05Hyl`x$$OMG$mXgofCVc07ue`BxCh{?2M#xU-ovkXI@fpbE1uSE z90v%(hQs4;D8l(F@&!N8R0#E~06lu-qS)_39rPM2FRik< z@1A(?^_9rMEsHhy+yDO0h8uc4dE5Y$4*A^UBQVZNZO0S!B<#R1+lG&ml*AjnyMP+^3yheDC2#4tPJ^=f?Nr9JKij$BhQuzX@@pE$%JB z1mT?qlYfAhrx7s(>B_)I&#pGn01@UxPmvWu#}!s!@UH~#SD9-8%?>D6*~^Ry=SHE7 z%q%ch9n+5U061Gum6m%)+1%l=@|$Qp%-~msq;QmnYi$*7_R1LrUiC_F%S&%(#&Vrw zvUx*_p9;WN1jyRHO&ftn(KCeYV#aCW5G*$8q|TxAEM8slv_Gzg)+_$DCu%i}^5gEo0J|LE1|wTd0^=z) z3FKLSj9&;0D&;(hI!r8OloL>({0SuGob52=ZAigI|0_0VfCL~VP-hUU)j%gWTA+Ky z)2-};s>+sn7i#`LmWs5ZoLH3IA6Bqbi_lzUauDW9cO(~wak<~H#ocB|VaC8%kbNi^ z>Xmv-aFq%gL)2j)b2YuHGjE+y-PVEAYhOF(f+qahCI1fXRMagf%(jl^_CoW%eDl7= zSKt^}(+=#`Uwd8TkdiVQz-`)qlj;}aJuoIZ6x04!6Y5Vy%C=|LrXi&YOUp{@Q~KD*Q0gNvzp&AP;*w26N^O4FO37=5n%N46 zH)|Gnqf3d6eT1`)PV=Pfa5Q6%^PMQ8yYZj`AH|{`Bj`U5bqZcb<9Q}VKLO~@S@@hn znSg=^XM+fAcMz<})tH-Z9;H5mGF64d47-uMCsOdV<~^-&VF|$sjw{EOjuoo;d^NvT z-HFE+IJy*kZFyhY`-v4_+nR6h`ZLZ$pMha;#K&6^tL(J$M8zBa<@V;mBV_rA_k@f6 zgkvDx^05Lphnh+i81WsV+5EW@Coik5U>tBxU^EbCJe59Zz^3C^@RYTyaK5)f%PeBt zwnwuWbfr_e#n-PmzTJloIOSA4Q<&ir-G1A1&9jxqOJf?|OVLNO4Rf~DDl7e#)mHl0 zfwuw`#zW|`R<-^2igUrK@zw|kYl?g8>=uxv+gjWXpXexC1M<9iwF>tKQOB1k`b#t- z@De}V$caiuhK~$zAs4Mx_CvYuNnFx~lcpk6a{_RQJw z*ol+l&z&3}d_#A^=Z*P{s5_*L&VjuBhsw~EA}${9DC`2aP04ZiOp+GklFr7bR0U1P zB*sRp=8P{TtR&CrP9Zrxy`2OfgC4mSiv(9--2V*?)K4L~M)2M(e8+d&_rMpp`SQ}s z5QmGw$bWo zK%wVo9{%k-y4redHH;h}61wFCoCrtOnh*W9?ohFB->u=b@ZR+uU5mqOzShT$5Ho;h zuky1UV6l zDMvtpaugDsh07tO6SMRz`XfREKD~BTFiy|}VOV_ylI!Fv zM=R&qI7ax66=!Ra-?`#!+i-@tw!1?Q3H)pxXy-zk-(l*wn$72)=k{(YjE~!~*~)Oc zHeD{Rb2I4U>K}Hsa0aoU(&6GNfFscuO%0NhvgypcG6AK!3r9Jfgq^vh6Jv780K>l{G3$(o>YBKLXVwJ#_<(_#79&_m_hxV4~w5O&s>&=fIgvFS)ee;ziO@FvBJGe}4aC z|HVHhC&vk%{LbIs{P`n7KIfa@8S!7f{1|V)A&gMMM(7ATL(i~LI?B${v+Nu_$IjF9 z>;?J)8>3_F0=>Y-={UPcFR}?b!6xY>i_i$0qEjqNqimW^gC4_ghQ(-{&Cmpk(7uXzKgxw`pq|5kUVe@pAEzmW# zNY~jCy~LL3Wzb*5?+RO?8*G(svNd{@t+CY!hTcoCe}lb9Zz7&s>@(SLr>rMN8}&Ewk%XVB569UZPcYgNp1XmDnv>W4CD?@(si;vpe)Y zdztRAyHr6eP4)^^**&VU5^b?E-DLvRS%n(NkqLgARp}lR={}R_0rK#G)hLDjH;|84 zS)G0pdH5EvTC7384IkgZ?=|pWhb&_16o?a)U|p+|Us6FT30ud&`d`sy4Z zN535<Zrs|~+St0ieP{dT_RY;79kGXx5B+MF{g+LNgL_{Hv@3m-iDW2rmMcOLI^ zjvHI@BoZv$UvW045wcY7PT|jup_{~8H;UzO+I@aq@*)2%aLLL?XK@T_uCc@QSAY4} z|E_&nI{F#%;2AvL2{IQ@%6};P=H3acob_dncR%s9c74hxdZ2zMkP(8HcLLUSxjVr@ z;)h2K$oGPmV)^Jlyx`@@QPSm6cW+|x^D$cPQcn6QU-F#(!812)0k~+ndlqE1>lTw2 zxXVXCw}oZ>EoZ{0>XM{d`?6>@oJoJzkmP#9bjGVfQ_=;itt(F6f3Iq)DpgHOmrVVE zWk}eIhLde-s%&byB}yi^vZh+HYD)T^pg1|LVwgfzkxpaIRRu+^=z^=#5=`i*me$6f zRn-(tcTN?Mfguo>9kk$SRy17%-;k-~46!}EL3UwK+Z6P=WLk|jhgwHo3}@|Y_@<^H zGitRjIX@`blvT^OW(a26i;w59AMAh#vecHG;viQLg_bGrNiG;k6)K7(IzwQGB*0ch zXXR82P2s>2b-8ASK@CCOZ^))(h1RrM&5+FDxIGaLSg079g6dcbZxB^73}<=JsV@nS zJbkJqYS7|oa!&E7OEpQCcn(j2?^)`L@F;w2L#8+?ziS0u5-c~>!``^+Ns)y?+}w)S zJATzpGXoi~fqZPdX`R&bW_H^sviaaS2+*yy6(wj2&-@SsUD|ESx+GdSEXapd({cEW z=gc6Is;2Yizu#`Q{9to~5R9g#nGIR3TY}PR2*dr~6pS6Ks%VB}?cktE{bOCzHOlKH z*J_s8&?TdxDWa3`553O~PJ6T>RCnsS)>cLTl#csptF;Vgstxm^teX!oB&w$2#NEhs zsU=`0gd-!lCmTqWCDm$N$_ko1UJ;Ndeg=6_f^#@PMGgXLI+QgUyumScDnNyHn|3*- zF35_7vr)%z%M6eVq1i%?gMd8gx}xoeFmkT6fod8Jlmz3h+6bBXL8j;*@6cLGZngxB zNG~!s45KCC{7&*tYzew4V;UMx3Q68aEPP_3?!lM@;{?r5bKxYzA7$w?q$;@Vyr8&!EcD5;vR<3V&Eg)YCJIHs$sciOsN$n*so1X*zaF6Av2~3 z=zs=}2=5<3tt*lfr#O)}+X zzsHhp7;>|%n1U*4ZNuWShBGr@1UCvMrgh-AuV=Z5TS-=(?10>!q-a%C#=0-_8#AzB zwyQfhc^Dx42>1#vkt4x;3zIbEH43kSf$4$s;!eQeqUMtnB;+b+O3Njs``h4t@@Mi~ z$Z@lN@|wtp0^wCtK@dCWA3=4NPI5)5CLmW94vCIc77Rn-I?Q>NO`wWh!u#F+_s zl32(6Bq2vmYAC9T9$s2AL$P^goZOH!pIzvbwt_=}Gmsc!;ue%ePR)uH9)x&`NBkm4wzZv2o(~Z`rra~(J^s&Nk6ek%nqyvtS z-!lB;s1VZ8ikLfarskpi=mJyHCvjF`==@J z$sV4XOeo>zTZ6ISuI#tpiF@=T@+H2f$yXO%fBgM7jt)28e$N@Z`@P5Phi^T+_v9Dg z@Ne!P#PIpu8yy)L=|xC1emr4EXO1U(F%sE49tWwo*dh5|niS?cBx@HIIwaS7ZG
yV;7?@Fv+ z?vNFDT3+E2o_vIG>M|sk*FlB;r423xJA7|*{dss?xzr&G;Vw7VAt`|LbfRPhKEd@Z z%mG~vp|~g^<%VRZa~+ZZH9p%R(WjG9L>nVBspBd5O6NNy4#{kxLz2A-lFnjpr}zqP z9@I22BvKtRZRdeL(>qTR8SFrHz1e)tLpE0gGB2fhZ}T(@SSfoR$tu9bD#Bd^jH&74 zi_p#hP2%ZP?05nxh{HkDjwOL9LZ;&=%NU8Jk0U_jdGTTxBYDJ|?q$iGTb>w6BDR># z5ygOv2W3wupm&~lnL1BqA(?@lS=5F9XAyA{cvH}vvAN@EoAa{0xKEo!Bv}BS;c)zL z?Ko00-P`t3VlOQ5c8L24UPYWs0A6qvBZQ}K1`*_tzLcFz^WC@?NDw&eayJ?5y`qhI z4a4O?&)F$x#Zf0W44Ws5gI+)h3gQ14DFR0xL3zQBkwq65Y$Bi>@MVA`0bi?VrbYN# Vf_{HRhy(D5OfQJVRpII+|0OAsiGu%dpfnX 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 literal 0 HcmV?d00001 diff --git a/__pycache__/effects.cpython-312.pyc b/__pycache__/effects.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c632c64285892c7ce0e70b25896b419b04fae122 GIT binary patch literal 8659 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 E0WXcEDgXcg literal 0 HcmV?d00001 diff --git a/__pycache__/geometry.cpython-312.pyc b/__pycache__/geometry.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a0f0b17bc4c884d6583e76f480998629e5889864 GIT binary patch literal 6720 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 Wp8RZV1jP|qWBL%rJg=+Ew*LU>;tp#7 literal 0 HcmV?d00001 diff --git a/__pycache__/imaging.cpython-312.pyc b/__pycache__/imaging.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5cb74b5808587c29fcc4761008f4f689c5d4524f GIT binary patch literal 4719 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 Lc%p2Um5KiX=P~xu literal 0 HcmV?d00001 diff --git a/__pycache__/launcher.cpython-312.pyc b/__pycache__/launcher.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..be0f9572e1a4cd5eacd107faf0b43c7c72f6e8dd GIT binary patch literal 5932 zcmd5=U2GKB6`r4+-Ps>|@jw0n1`-lhH6{UyDMG({$w~~+08Tc^Ndcfii31vvfRsr9$cMSX7C=lvgh2baBqD6bSB(8O|tKF_oQCWrDi{d}_|&Ipl+?oSo9A32uoQ(Pt-Q z+dK)Opqb?Kl&<8mqL!CsiQtBqQ7xak;z&$R)Rl~Ea@G)LlQ^4~Qo3x9kYLS#CaaSK z*NNcrX!uQnApAS3I=(xQFPOY2Dp^Gr#bS%=(gUuj4|{zC`_T4^YbPbdP@5gFgefavad*_2)&sMt+0@J**f$8l1 z^@VC@KQu&FdUpM$x!MD!-;b~CeB!~A&sBFmkEVN9dW8qOk5zl0LvH(O$97PO)SMR| z`-1N=_@2M=ee3XvRAV5j(DMHSBDfPXM$m&30TPE-ef0_YWP&9-;AL{Q7X@wiaW{sb zo13l9E;=5jDMh*1>ut@Wjn6HMTkhUs;4X$*6>RN z!Cru%J;!2v)1-e0IvN_dO4az-^~igX8#4wkd=LT7a|L^1UW5hhFg(`8{sB^)Si@;G zjI>4QYDe37x^XOSY^?|}(doyO7>Ris=1%w$*bu!XFHkz7+$s_)1xid2VuDY`fo(4a zyP6Qm9SfKP-1t@ zvX+lStCCHGY#)aVAldQQ45u=ARncc9*$n1oqU0o#%Vp%Ll*vO^S-hN5A-c~_=qhA^ z)MZ&i_keJX#R7%}o?@qmiVflZN1&|z4G4gvqr39f!dpwyC)rzBqvP;rtkM2lIlP5< zHQK&NzjwUWy{A0<{@Gfr_3I$j+Hqri@z7HD%@cRi%TFxpUksi#2G3Rp&KW)Dsx71C zV6CP7mwM&O!j**`OHUcw_LaFW+qx=87mhBDEDe8h_SRXWZ=l+Cpv>1gdlp|`$lVUy z)qgwp>D*`BJ@)hHJ?YOgf0!|jzEnN%va#=Fqx0o*bhTsWQup2Q<@VocpB=gvtqzV> z_n$X*pEo+rmjfR}9)&fyiw$eZf_43{(CEknE*${s5pDj45H3Fy|E?j4xM-VmwCt7U)?@hfqJPIY}XKDzjb%lS$9PQ!vB1i3uB&k6_H)HYMw#hMysL)1D4x=?0Ki>_Vy_peBZ7B04z_lUj^IYttQ*yCsL{^ zSI$ zW>`y2%A%xbaPx6i!mSiE42y1%4Iqi3WXueEQloLe}zbiinTcKHRPeGov?+F9ve=wIr%-EHhXXtW+I zhiZ?CR^y$EA<*IO$M@go_kRnZo`EBV|Az6s$vGg&6<$c?2)&&$@;Y2@3iel zzJvX(=ibOZXv6nMOMEF@ij<;L7*v?zc`pvQK?NhhVIiF(S(!ipP_nuv}9N7hFWksrA~@htZrr`FY39x zsLGSN8MW)g`u73M1pNE38PGxowKVi9HxS_44h9hn2KMZVZ5v{MEqoh#X_%z8NUF`P z*Qc*d-_U-Zfeh#8AfHl}LHpK$+enb$)kniF?@|w$R&K|`eJpq2VT6GiALqvChkKg26Ay(r zH?Y2E7k7%bZD7wt)IP=q)4A-VGG+Z7v;K$?JjGkTzpP(g)-Nt!cCv;ZM->5y^?QVW u-k#QR@(RenPwa16V`-XR@1f|}H-RwCd_w_QNAk_~98I61A5zxKGV~u~_`H$; literal 0 HcmV?d00001 diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ef4e3a4849b3acf663ff76921805825ebe28c89 GIT binary patch literal 7233 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 literal 0 HcmV?d00001 diff --git a/__pycache__/physics.cpython-312.pyc b/__pycache__/physics.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9c1a5fd49015fec3e64b5d9882bd8d050597b590 GIT binary patch literal 20022 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 Bqo@D? literal 0 HcmV?d00001 diff --git a/__pycache__/rendering.cpython-312.pyc b/__pycache__/rendering.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e0e465de56e3b0feb24dbeda0169e3a2db7c576d GIT binary patch literal 29558 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 literal 0 HcmV?d00001 diff --git a/__pycache__/vision.cpython-312.pyc b/__pycache__/vision.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b600bdb094e37ed3c8fd52973e964f62a1a45013 GIT binary patch literal 6000 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 literal 0 HcmV?d00001 diff --git a/calibration.py b/calibration.py new file mode 100644 index 0000000..52f77f0 --- /dev/null +++ b/calibration.py @@ -0,0 +1,1580 @@ +# calibration.py + +import math + +import cv2 +import numpy as np + +import imaging + +from config import ( + SCREEN_WIDTH, + SCREEN_HEIGHT, + CALIBRATION_TARGET_HUE_DEGREES, + CALIBRATION_TARGET_HUE_TOLERANCE_DEGREES, + CALIBRATION_MIN_TARGET_SATURATION, + CALIBRATION_MIN_TARGET_VALUE, + CALIBRATION_ADAPTIVE_HUE_ENABLED, + CALIBRATION_ADAPTIVE_HUE_BLEND, + CALIBRATION_ADAPTIVE_MAX_DRIFT_DEGREES, + CALIBRATION_WHITE_ADAPTIVE_OFFSET, + CALIBRATION_WHITE_MIN_ABSOLUTE_LIGHTNESS, + CALIBRATION_WHITE_MAX_GREEN_DOMINANCE, + CALIBRATION_WHITE_REFERENCE_BLEND, + CALIBRATION_WHITE_REFERENCE_MAX_OFFSET, + CALIBRATION_MIN_SCREEN_AREA_RATIO, + CALIBRATION_MAX_SCREEN_AREA_RATIO, + CALIBRATION_MIN_OUTER_TARGET_RATIO, + CALIBRATION_MIN_INNER_WHITE_RATIO, + CALIBRATION_MIN_FULL_TARGET_RATIO, + CALIBRATION_MIN_QUALITY, + CALIBRATION_REQUIRED_STABLE_FRAMES, + CALIBRATION_MAX_CORNER_JUMP_RATIO, + CALIBRATION_SMOOTHING_ALPHA, + CALIBRATION_MASK_CLOSE_KERNEL, + CALIBRATION_OUTER_BORDER_SIZE, + CALIBRATION_INNER_BORDER_SIZE, +) + + +class Calibration: + """ + Detects the monitor in two phases, matching what Renderer draws + in each: + + 1. NOT YET CALIBRATED: the whole screen is filled solid pink + (Renderer.draw_full_calibration_screen). We just look for + the single largest, best-shaped, (almost) entirely-pink + quad in the camera frame - the simplest, most unambiguous + target possible for the very first lock-on. + + 2. CALIBRATED (locked on): the screen instead shows a + saturated green outer ring with a white inner ring around + a black interior (Renderer.draw_calibration_border), drawn + every frame during gameplay so the system can keep quietly + re-verifying the monitor's position without interrupting + play, while leaving the black interior free for the ball + and the detected obstacles. + + Compared to the previous four-marker approach this is far more + robust in a busy, noisy exhibition environment: + - it's one large, high-contrast shape instead of four tiny + independent ones, so a person briefly walking in front of + part of the screen doesn't break detection the way losing + one out of four small markers would; + - requiring BOTH the green AND white ring to be present, in + the right relative position, makes it very unlikely a + random green/white object or a reflection in the room gets + mistaken for the screen; + - once locked, calibration is only re-verified/refined by + new stable detections and is never silently dropped just + because a frame or two failed - it only clears on an + explicit reset() call (bound to the "C" key in main.py). + + Color detection is NOT a single fixed HSV threshold (see + config.py's "Target-color detection" section for the full + reasoning). In short: a loose, wide hue pre-filter rules out + everything nowhere near green, and a per-frame Otsu threshold + on green-dominance (G compared to R and B) then finds the exact + cutoff that best separates "clearly green" from "not" IN THE + CURRENT frame - so it keeps adapting to the room's lighting + instead of needing a fixed brightness/saturation number that + only ever matched the lighting at setup time. On top of that, + once locked on, the system slowly learns the actual hue it is + seeing and re-centers the pre-filter on it, bounded so it can + never drift onto some unrelated color. + + Public API is intentionally identical to the previous + marker-based Calibration class, so main.py needs no changes + beyond how the pattern itself is drawn. + """ + + def __init__(self): + self.matrix = None + + self.history = [] + self.hue_history = [] + self.previous_points = None + self.reference_points = None + + # What hue (0-360 degrees) the camera is ACTUALLY showing + # for the calibration green right now, learned on the fly. + # None until the first successful lock, and never reset by + # reset() below - a "C" key reset is about re-finding the + # screen's position, not about the room's lighting having + # changed, so there is no reason to throw this away with it. + self._learned_hue_center = None + + # What the camera ACTUALLY records for the white inner ring + # right now, as a (a*, b*) offset from LAB neutral (128, + # 128) - i.e. exactly what a photographer gets by custom + # white-balancing off a gray card, except sampled + # continuously off the ring that is drawn every single + # frame anyway. detection.py uses this to judge how "white" + # a candidate sticker really is relative to what white + # currently looks like to this camera, instead of relative + # to an absolute assumption that colored ambient light can + # never actually hold true. Also never cleared by reset(). + self._white_reference_ab = None + + self.failed_frame_count = 0 + + # ======================================================== + # Public API + # ======================================================== + + def is_calibrated(self): + return ( + self.matrix is not None + and np.isfinite(self.matrix).all() + ) + + def reset(self): + self.matrix = None + + self.history = [] + self.hue_history = [] + self.previous_points = None + self.reference_points = None + + self.failed_frame_count = 0 + + def update(self, frame): + if frame is None: + return self.is_calibrated() + + if frame.ndim != 3: + return self.is_calibrated() + + # Before we're locked on, the screen is filled solid pink + # (Renderer.draw_full_calibration_screen) - look for one + # big pink quad. Once locked on, the screen instead shows + # the pink/white border ring around a black interior + # (Renderer.draw_calibration_border) - look for that + # specific pattern instead, which also lets us tell a real + # match from a random pink object/reflection in the room. + if self.is_calibrated(): + candidate = self._find_best_border_candidate(frame) + else: + candidate = self._find_best_full_screen_candidate( + frame + ) + + if candidate is None: + self._handle_failed_detection() + return self.is_calibrated() + + self.failed_frame_count = 0 + + self._update_white_reference(candidate.get("white_ab")) + + points = candidate["points"] + hue = candidate.get("hue_degrees") + + if self._stable_against_previous( + points, + frame.shape, + ): + self.history.append(points) + self.hue_history.append(hue) + + if ( + len(self.history) + > CALIBRATION_REQUIRED_STABLE_FRAMES + ): + self.history.pop(0) + self.hue_history.pop(0) + else: + self.history = [points] + self.hue_history = [hue] + + self.previous_points = points.copy() + + if ( + len(self.history) + >= CALIBRATION_REQUIRED_STABLE_FRAMES + ): + self._accept_or_refine(frame.shape) + + return self.is_calibrated() + + def transform_frame(self, frame, scale=1.0): + """ + Warp the camera frame into screen space. With scale < 1 the + result is a proportionally smaller (SCREEN_WIDTH*scale by + SCREEN_HEIGHT*scale) image of the same screen - the warp is + composed with the scaling, so it's a single cheap pass and + much faster than warping full size and shrinking after. + """ + if not self.is_calibrated(): + raise RuntimeError( + "Calibration has not been completed." + ) + + matrix = self.matrix + + if scale != 1.0: + matrix = ( + np.diag([scale, scale, 1.0]) @ matrix + ) + + return cv2.warpPerspective( + frame, + matrix, + ( + int(round(SCREEN_WIDTH * scale)), + int(round(SCREEN_HEIGHT * scale)), + ), + flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_CONSTANT, + borderValue=(0, 0, 0), + ) + + def transform_point(self, x, y): + if not self.is_calibrated(): + raise RuntimeError( + "Calibration has not been completed." + ) + + point = np.float32( + [[[x, y]]] + ) + + transformed = cv2.perspectiveTransform( + point, + self.matrix, + ) + + return tuple(transformed[0][0]) + + # ======================================================== + # Candidate detection + # ======================================================== + + def _find_best_border_candidate(self, frame): + hsv = cv2.cvtColor( + frame, + cv2.COLOR_BGR2HSV, + ) + + lightness, a_channel, b_channel = imaging.to_lab(frame) + + target_mask = self._make_target_mask(frame, hsv) + white_mask = self._make_white_mask(frame) + + contours, _ = cv2.findContours( + target_mask, + cv2.RETR_EXTERNAL, + cv2.CHAIN_APPROX_SIMPLE, + ) + + outer_depth = float(CALIBRATION_OUTER_BORDER_SIZE) + + inner_depth = float( + CALIBRATION_OUTER_BORDER_SIZE + + CALIBRATION_INNER_BORDER_SIZE + ) + + best = None + + for contour in contours: + quad = self._quad_from_contour( + contour, + frame.shape, + ) + + if quad is None: + continue + + quality = self._quad_quality( + quad, + frame.shape, + ) + + if quality <= 0: + continue + + target_ratio = self._band_ratio( + target_mask, + quad, + 0.0, + outer_depth, + ) + + if ( + target_ratio + < CALIBRATION_MIN_OUTER_TARGET_RATIO + ): + continue + + white_ratio = self._band_ratio( + white_mask, + quad, + outer_depth, + inner_depth, + ) + + if ( + white_ratio + < CALIBRATION_MIN_INNER_WHITE_RATIO + ): + continue + + score = ( + quality * 0.45 + + target_ratio * 0.30 + + white_ratio * 0.25 + ) + + if score < CALIBRATION_MIN_QUALITY: + continue + + if ( + best is None + or score > best["score"] + ): + best = { + "points": quad, + "score": float(score), + "quality": float(quality), + "target_ratio": float(target_ratio), + "white_ratio": float(white_ratio), + "hue_degrees": self._band_mean_hue( + hsv, + target_mask, + quad, + 0.0, + outer_depth, + ), + "white_ab": self._band_mean_ab( + a_channel, + b_channel, + white_mask, + quad, + outer_depth, + inner_depth, + ), + } + + return best + + def _find_best_full_screen_candidate(self, frame): + """ + Used only before we're locked on, while the monitor is + showing one solid green fill (no white ring, no black + interior). We just need the largest, best-shaped green quad + whose own interior is (almost) entirely green - there's no + separate inner-ring check needed since the "ring" is the + whole screen. + """ + hsv = cv2.cvtColor( + frame, + cv2.COLOR_BGR2HSV, + ) + + target_mask = self._make_target_mask(frame, hsv) + + contours, _ = cv2.findContours( + target_mask, + cv2.RETR_EXTERNAL, + cv2.CHAIN_APPROX_SIMPLE, + ) + + best = None + + for contour in contours: + quad = self._quad_from_contour( + contour, + frame.shape, + ) + + if quad is None: + continue + + quality = self._quad_quality( + quad, + frame.shape, + ) + + if quality <= 0: + continue + + fill_ratio = self._full_quad_ratio( + target_mask, + quad, + ) + + if ( + fill_ratio + < CALIBRATION_MIN_FULL_TARGET_RATIO + ): + continue + + score = ( + quality * 0.5 + + fill_ratio * 0.5 + ) + + if score < CALIBRATION_MIN_QUALITY: + continue + + if ( + best is None + or score > best["score"] + ): + best = { + "points": quad, + "score": float(score), + "quality": float(quality), + "target_ratio": float(fill_ratio), + "white_ratio": 1.0, + "hue_degrees": self._quad_mean_hue( + hsv, + target_mask, + quad, + ), + } + + return best + + def _quad_from_contour( + self, + contour, + frame_shape, + ): + frame_area = ( + frame_shape[0] + * frame_shape[1] + ) + + area = cv2.contourArea(contour) + + if area <= 0: + return None + + area_ratio = area / frame_area + + if area_ratio < CALIBRATION_MIN_SCREEN_AREA_RATIO: + return None + + if area_ratio > CALIBRATION_MAX_SCREEN_AREA_RATIO: + return None + + perimeter = cv2.arcLength( + contour, + True, + ) + + if perimeter <= 0: + return None + + for epsilon_ratio in ( + 0.005, + 0.008, + 0.012, + 0.018, + 0.025, + 0.035, + ): + approx = cv2.approxPolyDP( + contour, + epsilon_ratio * perimeter, + True, + ) + + if len(approx) != 4: + continue + + points = approx.reshape( + 4, 2 + ).astype(np.float32) + + if not cv2.isContourConvex( + points.reshape((-1, 1, 2)) + ): + continue + + return self._order_points(points) + + return None + + # ======================================================== + # Color masks + # ======================================================== + + def _make_target_mask(self, frame_bgr, hsv): + """ + Finds the calibration green robustly under unpredictable, + possibly-shifting exhibition lighting, by combining a loose + hue pre-filter with a per-frame auto-threshold on a + brightness-INVARIANT color signal, rather than one fixed + HSV box (see config.py's "Target-color detection" section + and the class docstring for the full reasoning). + """ + pre_filter = self._make_hue_prefilter_mask(hsv) + + if cv2.countNonZero(pre_filter) < 64: + # Nothing even loosely green-ish in view - there is no + # meaningful split for Otsu to find, so don't ask it to + # invent one out of noise. + return self._clean_mask(pre_filter) + + # The "how green is this pixel" signal is measured on LAB's + # a* axis (green-red), NOT by comparing raw R/G/B levels. + # LAB deliberately separates brightness (L) from true color + # (a, b), so a* barely moves when a glare highlight, a + # shadow, or the camera's own auto-exposure/white-balance + # changes how BRIGHT a pixel looks - which is most of what + # "the camera doesn't quite see what the monitor shows" + # actually is. Comparing raw channel levels (as a plain + # "G minus R/B" measure would) conflates color with + # brightness and is far more easily fooled by exactly that. + _lightness, a_channel, _b_channel = imaging.to_lab( + frame_bgr + ) + + dominance = imaging.green_dominance(a_channel) + + # Otsu needs to see BOTH classes to find a meaningful split + # point - "pattern green" and "everything else" - so it is + # run over the WHOLE frame's dominance histogram (that is + # genuinely bimodal: a bright green cluster from the + # pattern vs. a large, much less green-dominant cluster for + # the room/people/background). Restricting it to only the + # pixels the loose pre-filter already approved would feed + # Otsu an almost-uniform population with no real second + # class to split against, which produces a meaningless + # threshold instead of an adaptive one. + threshold, _ = cv2.threshold( + dominance, + 0, + 255, + cv2.THRESH_BINARY + cv2.THRESH_OTSU, + ) + + strong = cv2.inRange( + dominance, + int(threshold), + 255, + ) + + # The hue pre-filter is then applied on top as a precision + # pass: it rules out anything that passed the brightness- + # based Otsu split but isn't actually the right hue (e.g. a + # bright white highlight, which has near-zero dominance + # anyway, or a saturated cyan/yellow object, which does + # not). + mask = cv2.bitwise_and(pre_filter, strong) + + mask = self._clean_mask(mask) + + # A specular glare spot on the monitor's glass can punch a + # small hole clean through the middle of the pattern, or + # even a thin streak that splits it into two separate + # blobs. Filling any fully-enclosed hole (see + # imaging.fill_holes) repairs exactly that without touching + # a real gap caused by, say, a person actually standing in + # front of part of the screen (which stays open to the + # mask's edge and so is left alone). + return imaging.fill_holes(mask) + + def _make_hue_prefilter_mask(self, hsv): + center = self._effective_hue_center() + + mask = np.zeros( + hsv.shape[:2], + dtype=np.uint8, + ) + + for lower_h, upper_h in self._hue_ranges( + center, + CALIBRATION_TARGET_HUE_TOLERANCE_DEGREES, + ): + current = cv2.inRange( + hsv, + np.array( + [ + lower_h, + CALIBRATION_MIN_TARGET_SATURATION, + CALIBRATION_MIN_TARGET_VALUE, + ], + dtype=np.uint8, + ), + np.array( + [upper_h, 255, 255], + dtype=np.uint8, + ), + ) + + mask = cv2.bitwise_or(mask, current) + + return mask + + @staticmethod + def _hue_ranges(center_degrees, tolerance_degrees): + """ + A (center +/- tolerance) window in standard 0-360 degree + hue, converted to OpenCV's 0-179 H channel and split into + one or two (lower, upper) ranges so a window that wraps + past 0/360 (relevant for colors near red, not for the green + this is tuned for, but kept general) still works correctly. + """ + raw_low = center_degrees - tolerance_degrees + raw_high = center_degrees + tolerance_degrees + + if raw_low < 0.0: + spans = [ + (0.0, raw_high), + (raw_low + 360.0, 360.0), + ] + elif raw_high > 360.0: + spans = [ + (raw_low, 360.0), + (0.0, raw_high - 360.0), + ] + else: + spans = [(raw_low, raw_high)] + + return [ + ( + int(round(lo / 2.0)), + int(round(hi / 2.0)), + ) + for lo, hi in spans + ] + + def _make_white_mask(self, frame_bgr): + """ + Finds the white inner ring the same way detection.py finds + white stickers (see its module docstring): LOCALLY adaptive + brightness rather than one fixed V floor for the whole frame + (so a ring sitting in a dimmer part of the hall is still + found), and LAB chroma rather than HSV saturation for "is + this actually neutral" (stable across the whole brightness + range, unlike HSV's saturation ratio). A fixed absolute HSV + box here was exactly as fragile under real exhibition + lighting as the old fixed box was for stickers. + """ + lightness, a_channel, _b_channel = imaging.to_lab( + frame_bgr + ) + + block = self._white_ring_adaptive_block_size( + lightness.shape + ) + + local = cv2.adaptiveThreshold( + lightness, + 255, + cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY, + block, + -CALIBRATION_WHITE_ADAPTIVE_OFFSET, + ) + + mask = local & cv2.inRange( + lightness, + CALIBRATION_WHITE_MIN_ABSOLUTE_LIGHTNESS, + 255, + ) + + # The only other bright thing near the white ring is the + # outer green ring itself, bleeding in right at the band + # boundary - so it is enough to exclude anything clearly + # GREEN-dominant (one-sided), rather than pinning an exact + # "how white" chroma number. A strong ambient color cast can + # push even genuinely white paper's overall chroma quite + # far (see config.py's comment), but it does not make white + # look GREEN - a one-sided test stays reliable under warm, + # cool, or any other cast a two-sided chroma ceiling cannot + # universally cover. + mask &= cv2.inRange( + imaging.green_dominance(a_channel), + 0, + CALIBRATION_WHITE_MAX_GREEN_DOMINANCE, + ) + + kernel = cv2.getStructuringElement( + cv2.MORPH_RECT, + (9, 9), + ) + + return cv2.morphologyEx( + mask, + cv2.MORPH_CLOSE, + kernel, + ) + + @staticmethod + def _white_ring_adaptive_block_size(shape): + # Tied to the frame's own size (calibration has no learned + # "ring thickness" the way detection.py learns sticker + # size), large enough that the sampled neighborhood is + # mostly the ring's dark/green surroundings rather than the + # ring's own pixels. + size = int(round(min(shape[:2]) * 0.05)) + + size = max(15, size) + + if size % 2 == 0: + size += 1 + + return size + + def _clean_mask(self, mask): + open_kernel = cv2.getStructuringElement( + cv2.MORPH_RECT, + (5, 5), + ) + + close_kernel = cv2.getStructuringElement( + cv2.MORPH_RECT, + ( + CALIBRATION_MASK_CLOSE_KERNEL, + CALIBRATION_MASK_CLOSE_KERNEL, + ), + ) + + mask = cv2.morphologyEx( + mask, + cv2.MORPH_OPEN, + open_kernel, + ) + + mask = cv2.morphologyEx( + mask, + cv2.MORPH_CLOSE, + close_kernel, + ) + + return mask + + # ======================================================== + # Full-quad sampling (used during the solid-pink full-screen + # calibration phase, before we're locked on) + # ======================================================== + + def _full_quad_ratio(self, mask, points): + polygon = points.astype(np.int32) + + fill = np.zeros_like(mask) + + cv2.fillPoly( + fill, + [polygon], + 255, + ) + + total = cv2.countNonZero(fill) + + if total == 0: + return 0.0 + + inside = cv2.bitwise_and( + mask, + fill, + ) + + return cv2.countNonZero(inside) / total + + # ======================================================== + # Band sampling (used to confirm the pink outer / white inner + # ring at the right relative depth, instead of trusting the + # outer pink contour alone) + # ======================================================== + + # points is always [top_left, top_right, bottom_right, + # bottom_left] (see _order_points), so edge 0 (top) and edge 2 + # (bottom) run the screen's WIDTH and edge 1 (right) and edge 3 + # (left) run its HEIGHT. An absolute screen-space pixel depth + # (e.g. CALIBRATION_OUTER_BORDER_SIZE) converts to that edge's + # own camera-space pixels via (edge's camera length / that + # edge's TRUE screen-space length) - using the same reference + # (SCREEN_WIDTH) for every edge, as if top/bottom and left/right + # were interchangeable, silently undersizes the left/right band + # whenever the screen isn't exactly square, which in practice is + # always: a 1920x1080 screen's left/right edges are only + # 1080/1920 as long as its top/bottom edges, so a band meant to + # be the same physical width on every side would come out + # visibly too shallow on the sides, easily shallow enough to + # land entirely short of where the white ring actually starts. + _EDGE_REFERENCE_LENGTH = ( + SCREEN_WIDTH, + SCREEN_HEIGHT, + SCREEN_WIDTH, + SCREEN_HEIGHT, + ) + + def _band_ratio( + self, + mask, + points, + depth_start_px, + depth_end_px, + ): + center = np.mean(points, axis=0) + + values = [] + + for i in range(4): + p1 = points[i] + p2 = points[(i + 1) % 4] + + edge = p2 - p1 + length = np.linalg.norm(edge) + + if length < 1: + continue + + reference = self._EDGE_REFERENCE_LENGTH[i] + + depth_start = ( + depth_start_px * length / reference + ) + depth_end = ( + depth_end_px * length / reference + ) + + if depth_end <= depth_start: + continue + + normal = np.array( + [-edge[1], edge[0]], + dtype=np.float32, + ) + + normal_length = np.linalg.norm(normal) + + if normal_length < 1e-6: + continue + + normal /= normal_length + + midpoint = (p1 + p2) * 0.5 + + if ( + np.dot(normal, center - midpoint) + < 0 + ): + normal = -normal + + a = p1 + normal * depth_start + b = p2 + normal * depth_start + c = p2 + normal * depth_end + d = p1 + normal * depth_end + + polygon = np.array( + [a, b, c, d], + dtype=np.int32, + ) + + band = np.zeros_like(mask) + + cv2.fillPoly( + band, + [polygon], + 255, + ) + + total = cv2.countNonZero(band) + + if total == 0: + continue + + inside = cv2.bitwise_and( + mask, + band, + ) + + values.append( + cv2.countNonZero(inside) / total + ) + + if not values: + return 0.0 + + return float(np.mean(values)) + + def _band_polygon_mask( + self, + points, + depth_start_px, + depth_end_px, + shape, + ): + """ + Same ring-band geometry as _band_ratio above (including the + same per-edge reference-length correction), but returned as + a single combined mask (union of the 4 edge trapezoids) + instead of an averaged ratio - used for pooling pixels + together (e.g. to measure their mean hue) rather than just + counting them. + """ + center = np.mean(points, axis=0) + + mask = np.zeros(shape, dtype=np.uint8) + + drew_any = False + + for i in range(4): + p1 = points[i] + p2 = points[(i + 1) % 4] + + edge = p2 - p1 + length = np.linalg.norm(edge) + + if length < 1: + continue + + reference = self._EDGE_REFERENCE_LENGTH[i] + + depth_start = ( + depth_start_px * length / reference + ) + depth_end = ( + depth_end_px * length / reference + ) + + if depth_end <= depth_start: + continue + + normal = np.array( + [-edge[1], edge[0]], + dtype=np.float32, + ) + + normal_length = np.linalg.norm(normal) + + if normal_length < 1e-6: + continue + + normal /= normal_length + + midpoint = (p1 + p2) * 0.5 + + if ( + np.dot(normal, center - midpoint) + < 0 + ): + normal = -normal + + a = p1 + normal * depth_start + b = p2 + normal * depth_start + c = p2 + normal * depth_end + d = p1 + normal * depth_end + + polygon = np.array( + [a, b, c, d], + dtype=np.int32, + ) + + cv2.fillPoly( + mask, + [polygon], + 255, + ) + + drew_any = True + + return mask if drew_any else None + + def _band_mean_hue( + self, + hsv, + color_mask, + points, + depth_start_px, + depth_end_px, + ): + """ + Mean hue (0-360 degrees) of the pixels that are BOTH inside + the given ring band AND already matched by color_mask - the + measurement used to learn what hue the camera is actually + recording for the calibration green right now (see + Calibration._update_learned_hue). + """ + band = self._band_polygon_mask( + points, + depth_start_px, + depth_end_px, + hsv.shape[:2], + ) + + if band is None: + return None + + return self._masked_mean_hue( + hsv, + cv2.bitwise_and(color_mask, band), + ) + + def _quad_mean_hue(self, hsv, color_mask, points): + polygon = points.astype(np.int32) + + fill = np.zeros(hsv.shape[:2], dtype=np.uint8) + + cv2.fillPoly(fill, [polygon], 255) + + return self._masked_mean_hue( + hsv, + cv2.bitwise_and(color_mask, fill), + ) + + def _band_mean_ab( + self, + a_channel, + b_channel, + color_mask, + points, + depth_start_px, + depth_end_px, + ): + """ + Mean LAB (a*, b*) of the pixels that are BOTH inside the + given ring band AND already matched by color_mask - used to + learn what the camera currently records for a KNOWN-white + surface (see white_reference / _update_white_reference). + """ + band = self._band_polygon_mask( + points, + depth_start_px, + depth_end_px, + a_channel.shape[:2], + ) + + if band is None: + return None + + combined = cv2.bitwise_and(color_mask, band) + + if cv2.countNonZero(combined) < 32: + return None + + mean_a = cv2.mean(a_channel, mask=combined)[0] + mean_b = cv2.mean(b_channel, mask=combined)[0] + + return float(mean_a), float(mean_b) + + @staticmethod + def _masked_mean_hue(hsv, mask): + # A minimum pixel count keeps a tiny sliver of matched + # pixels (e.g. a stray reflection) from producing a + # confident-looking but meaningless hue measurement. + if cv2.countNonZero(mask) < 32: + return None + + mean_hue_cv_units = cv2.mean( + hsv[:, :, 0], + mask=mask, + )[0] + + return float(mean_hue_cv_units) * 2.0 + + # ======================================================== + # Live white reference ("gray card" for the sticker detector) + # ======================================================== + + def white_reference(self): + """ + (a*, b*) in LAB's 0-255 encoding (128 = neutral) that the + camera is CURRENTLY recording for the calibration ring's + white band, or None before enough samples have come in. + detection.py measures how "white" a candidate sticker is + relative to this, instead of relative to absolute LAB + neutral - exactly like a photographer custom white-balancing + off a gray card, except this one is sampled continuously off + the ring that is drawn on the monitor every single frame + anyway, so it tracks the room's actual current lighting + automatically rather than needing to be re-tuned on-site. + """ + return self._white_reference_ab + + def _update_white_reference(self, measured_ab): + if measured_ab is None: + return + + measured_a, measured_b = measured_ab + + if self._white_reference_ab is None: + self._white_reference_ab = (measured_a, measured_b) + return + + current_a, current_b = self._white_reference_ab + + blended_a = ( + current_a + + CALIBRATION_WHITE_REFERENCE_BLEND + * (measured_a - current_a) + ) + blended_b = ( + current_b + + CALIBRATION_WHITE_REFERENCE_BLEND + * (measured_b - current_b) + ) + + # A real color cast from room lighting is a gentle, physical + # effect - it should never push "white" wildly far from + # actual neutral. Clamping the offset's magnitude keeps a + # run of bad samples (someone's bright shirt briefly + # overlapping the band, say) from teaching the sticker + # detector a wildly wrong idea of white. + offset_a = blended_a - 128.0 + offset_b = blended_b - 128.0 + + magnitude = math.hypot(offset_a, offset_b) + + limit = CALIBRATION_WHITE_REFERENCE_MAX_OFFSET + + if magnitude > limit: + scale = limit / magnitude + + offset_a *= scale + offset_b *= scale + + self._white_reference_ab = ( + 128.0 + offset_a, + 128.0 + offset_b, + ) + + # ======================================================== + # Hue learning + # ======================================================== + # + # Slowly re-centers the loose hue pre-filter on whatever hue + # the camera is ACTUALLY recording for the calibration green, + # instead of forever trusting the single fixed + # CALIBRATION_TARGET_HUE_DEGREES value from config.py. This is + # what lets the system track a slow lighting change over a long + # show without needing to be re-tuned on-site - while + # ADAPTIVE_MAX_DRIFT_DEGREES keeps it from ever wandering far + # enough to start matching some unrelated color. + + def _effective_hue_center(self): + if ( + CALIBRATION_ADAPTIVE_HUE_ENABLED + and self._learned_hue_center is not None + ): + return self._learned_hue_center + + return CALIBRATION_TARGET_HUE_DEGREES + + def _update_learned_hue(self, measured_hue_degrees): + if measured_hue_degrees is None: + return + + if not CALIBRATION_ADAPTIVE_HUE_ENABLED: + return + + current = ( + self._learned_hue_center + if self._learned_hue_center is not None + else CALIBRATION_TARGET_HUE_DEGREES + ) + + blended = self._circular_blend_degrees( + current, + measured_hue_degrees, + CALIBRATION_ADAPTIVE_HUE_BLEND, + period=360.0, + ) + + drift = self._circular_diff_degrees( + blended, + CALIBRATION_TARGET_HUE_DEGREES, + period=360.0, + ) + + max_drift = CALIBRATION_ADAPTIVE_MAX_DRIFT_DEGREES + + if abs(drift) > max_drift: + blended = ( + CALIBRATION_TARGET_HUE_DEGREES + + math.copysign(max_drift, drift) + ) % 360.0 + + self._learned_hue_center = blended + + @staticmethod + def _representative_hue(samples): + """ + Circular mean (vector average, not a plain arithmetic mean + - hue wraps around, so naively averaging e.g. 350 degrees + and 10 degrees must give 0, not 180) of a list of hue + samples in degrees, skipping any that are None. Returns + None if nothing usable is in the list. + """ + valid = [ + value + for value in samples + if value is not None + ] + + if not valid: + return None + + radians = np.radians(valid) + + mean_sin = float(np.mean(np.sin(radians))) + mean_cos = float(np.mean(np.cos(radians))) + + return float( + np.degrees( + np.arctan2(mean_sin, mean_cos) + ) + % 360.0 + ) + + @staticmethod + def _circular_diff_degrees(a, b, period): + """Shortest signed a-b distance on a circle of this period.""" + return ( + (a - b + period / 2.0) % period + ) - period / 2.0 + + @classmethod + def _circular_blend_degrees(cls, old, new, alpha, period): + diff = cls._circular_diff_degrees( + new, + old, + period, + ) + + return (old + alpha * diff) % period + + # ======================================================== + # Geometry / quality scoring + # ======================================================== + + def _quad_quality( + self, + points, + frame_shape, + ): + height, width = frame_shape[:2] + + frame_area = width * height + + area = self._polygon_area(points) + + area_ratio = area / frame_area + + if area_ratio < CALIBRATION_MIN_SCREEN_AREA_RATIO: + return 0.0 + + if area_ratio > CALIBRATION_MAX_SCREEN_AREA_RATIO: + return 0.0 + + contour = points.reshape( + (-1, 1, 2) + ).astype(np.float32) + + if not cv2.isContourConvex(contour): + return 0.0 + + sides = self._side_lengths(points) + + if min(sides) < 50: + return 0.0 + + top_bottom = ( + min(sides[0], sides[2]) + / max(sides[0], sides[2]) + ) + + left_right = ( + min(sides[1], sides[3]) + / max(sides[1], sides[3]) + ) + + side_quality = ( + top_bottom + left_right + ) / 2.0 + + angle_quality = 1.0 + + for i in range(4): + p1 = points[i - 1] + p2 = points[i] + p3 = points[(i + 1) % 4] + + v1 = p1 - p2 + v2 = p3 - p2 + + n1 = np.linalg.norm(v1) + n2 = np.linalg.norm(v2) + + if n1 < 1e-6 or n2 < 1e-6: + return 0.0 + + cos_angle = np.clip( + np.dot(v1, v2) / (n1 * n2), + -1.0, + 1.0, + ) + + angle = np.degrees( + np.arccos(cos_angle) + ) + + if angle < 45 or angle > 135: + angle_quality *= 0.3 + elif angle < 60 or angle > 120: + angle_quality *= 0.7 + + area_quality = min( + area_ratio / 0.40, + 1.0, + ) + + return float( + area_quality * 0.35 + + side_quality * 0.35 + + angle_quality * 0.30 + ) + + @staticmethod + def _polygon_area(points): + return abs( + cv2.contourArea( + points.astype(np.float32) + ) + ) + + @staticmethod + def _side_lengths(points): + return [ + np.linalg.norm(points[1] - points[0]), + np.linalg.norm(points[2] - points[1]), + np.linalg.norm(points[3] - points[2]), + np.linalg.norm(points[0] - points[3]), + ] + + @staticmethod + def _order_points(points): + points = np.asarray( + points, + dtype=np.float32, + ) + + s = points.sum(axis=1) + d = np.diff(points, axis=1).reshape(-1) + + top_left = points[np.argmin(s)] + top_right = points[np.argmin(d)] + bottom_right = points[np.argmax(s)] + bottom_left = points[np.argmax(d)] + + return np.array( + [top_left, top_right, bottom_right, bottom_left], + dtype=np.float32, + ) + + # ======================================================== + # Temporal stability + # ======================================================== + + def _stable_against_previous( + self, + points, + frame_shape, + ): + if self.previous_points is None: + return True + + height, width = frame_shape[:2] + + diagonal = np.sqrt( + width * width + height * height + ) + + distance = self._corners_distance( + points, + self.previous_points, + ) + + return ( + distance + <= diagonal * CALIBRATION_MAX_CORNER_JUMP_RATIO + ) + + @staticmethod + def _corners_distance(a, b): + return float( + np.mean( + np.linalg.norm(a - b, axis=1) + ) + ) + + # ======================================================== + # Accepting / refining calibration + # ======================================================== + + def _accept_or_refine(self, frame_shape): + final_points = self._order_points( + np.median( + np.stack(self.history, axis=0), + axis=0, + ) + ) + + quality = self._quad_quality( + final_points, + frame_shape, + ) + + if quality < CALIBRATION_MIN_QUALITY: + return + + area_ratio = self._polygon_area( + final_points + ) / (frame_shape[0] * frame_shape[1]) + + if area_ratio < CALIBRATION_MIN_SCREEN_AREA_RATIO: + return + + if self.reference_points is not None: + final_points = self._smooth( + self.reference_points, + final_points, + ) + + matrix = self._calculate_matrix( + final_points + ) + + if matrix is None: + return + + self.matrix = matrix + self.reference_points = final_points + + self._update_learned_hue( + self._representative_hue(self.hue_history) + ) + + @staticmethod + def _smooth(old_points, new_points): + alpha = CALIBRATION_SMOOTHING_ALPHA + + return ( + old_points * (1.0 - alpha) + + new_points * alpha + ).astype(np.float32) + + def _calculate_matrix(self, points): + destination = np.float32( + [ + [0, 0], + [SCREEN_WIDTH, 0], + [SCREEN_WIDTH, SCREEN_HEIGHT], + [0, SCREEN_HEIGHT], + ] + ) + + try: + matrix = cv2.getPerspectiveTransform( + points.astype(np.float32), + destination, + ) + except cv2.error: + return None + + if matrix is None: + return None + + if not np.isfinite(matrix).all(): + return None + + return matrix + + # ======================================================== + # Failure handling + # ======================================================== + + def _handle_failed_detection(self): + self.failed_frame_count += 1 + + self.history = [] + self.hue_history = [] + self.previous_points = None + + # ======================================================== + # Debugging + # ======================================================== + + def debug_draw(self, frame): + """ + Returns an annotated copy of `frame` showing the best + currently-detected candidate (if any), its quality/color + scores, the hue center currently being searched around + (handy for seeing the adaptive learning track the room's + actual lighting), and whether calibration is locked. Useful + for tuning config.py's hue/saturation/value floors on-site + against your real camera and lighting. + """ + output = frame.copy() + + if self.is_calibrated(): + candidate = self._find_best_border_candidate(frame) + else: + candidate = self._find_best_full_screen_candidate( + frame + ) + + if candidate is not None: + points = candidate["points"].astype( + np.int32 + ) + + cv2.polylines( + output, + [points.reshape((-1, 1, 2))], + True, + (0, 255, 0), + 3, + ) + + text = ( + f"quality={candidate['quality']*100:.0f}% " + f"green={candidate['target_ratio']*100:.0f}% " + f"white={candidate['white_ratio']*100:.0f}% " + f"stable={len(self.history)}/" + f"{CALIBRATION_REQUIRED_STABLE_FRAMES}" + ) + else: + text = "searching..." + + status = ( + "CALIBRATED" + if self.is_calibrated() + else "NOT CALIBRATED" + ) + + hue_line = ( + f"hue center ~{self._effective_hue_center():.0f} deg " + f"(anchor {CALIBRATION_TARGET_HUE_DEGREES} deg)" + ) + + cv2.putText( + output, + f"{status} | {text}", + (20, 30), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + (0, 255, 0), + 2, + cv2.LINE_AA, + ) + + cv2.putText( + output, + hue_line, + (20, 58), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + (0, 255, 0), + 2, + cv2.LINE_AA, + ) + + return output \ No newline at end of file diff --git a/camera.py b/camera.py new file mode 100644 index 0000000..4fb0135 --- /dev/null +++ b/camera.py @@ -0,0 +1,258 @@ +import threading +import time + +import cv2 +import numpy as np + +from config import ( + CAMERA_FROZEN_DIFF_THRESHOLD, + CAMERA_FROZEN_SECONDS, + CAMERA_CONNECT_RETRY_SECONDS, +) + + +class Camera: + """ + Wrapper around cv2.VideoCapture for an HTTP/MJPEG camera stream, + with a few exhibition-day survival features: + + - Frame grabbing runs on its own background thread that reads as + fast as the stream delivers and keeps only the SINGLE most + recent frame. cv2.VideoCapture's own buffering on network + streams often ignores CAP_PROP_BUFFERSIZE; if the consumer is + ever briefly slower than the stream, frames queue up upstream + and every later read() returns an ever-staler frame ("the game + gets slower and slower"). Draining continuously and handing + out only the newest frame makes that impossible. + + - Connecting also happens on that thread and never blocks the + caller: the game window comes up immediately (and stays + responsive to the keyboard) even if the phone/app isn't + streaming yet. It keeps retrying forever, both at startup and + after a mid-show drop. + + - Every frame gets an increasing id (see read_with_id), so the + vision thread can skip a frame it has already processed + instead of redoing the same work on it. + + - Frozen-frame detection: apps that pause in the background + often keep re-sending the last JPEG, which cv2.VideoCapture + happily "reads" forever. We track whether the image content + actually changes and expose is_frozen() so callers can refuse + to trust a feed that looks alive but isn't. + """ + + MAX_CONSECUTIVE_FAILURES = 5 + RECONNECT_BACKOFF_SECONDS = 1.0 + + def __init__(self, url: str): + self.url = url + self.cap = None + + self._stop_event = threading.Event() + + self._state_lock = threading.Lock() + self._last_thumb = None + self._last_change_time = time.monotonic() + + self._frame_lock = threading.Lock() + self._latest_frame = None + self._latest_id = 0 + self._latest_error = "Camera not connected yet." + + self._thread = threading.Thread( + target=self._run, + name="camera-reader", + daemon=True, + ) + self._thread.start() + + # ======================================================== + # Connection + # ======================================================== + + def _connect_with_retry(self): + """ + Keeps trying to open the stream until it works or the + camera is released. This runs unattended in a booth: the + phone/app may not be streaming yet, or wifi may take a + moment, and nobody may be there to relaunch anything. + """ + attempt = 0 + + while not self._stop_event.is_set(): + attempt += 1 + + try: + self._open() + print("[CAMERA] connected.") + return + except Exception as error: + with self._frame_lock: + self._latest_error = ( + f"Camera not connected ({error})" + ) + + print( + f"[CAMERA] connect attempt {attempt} failed " + f"({error}) - retrying in " + f"{CAMERA_CONNECT_RETRY_SECONDS:.1f}s..." + ) + + self._stop_event.wait( + CAMERA_CONNECT_RETRY_SECONDS + ) + + def _open(self): + if self.cap is not None: + try: + self.cap.release() + except Exception: + pass + + self.cap = cv2.VideoCapture(self.url) + + # Cheap extra defense on backends that honor it; the reader + # thread is what actually guarantees we never fall behind. + try: + self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + except Exception: + pass + + if not self.cap.isOpened(): + raise RuntimeError( + f"Could not connect to camera stream: {self.url}" + ) + + # ======================================================== + # Background thread + # ======================================================== + + def _run(self): + self._connect_with_retry() + + consecutive_failures = 0 + + while not self._stop_event.is_set(): + try: + success, frame = self.cap.read() + except Exception as error: + success, frame = False, None + + with self._frame_lock: + self._latest_error = ( + f"Camera read error: {error}" + ) + + if not success or frame is None: + consecutive_failures += 1 + + with self._frame_lock: + self._latest_error = ( + "Failed to read frame from camera." + ) + + if ( + consecutive_failures + >= self.MAX_CONSECUTIVE_FAILURES + ): + self._stop_event.wait( + self.RECONNECT_BACKOFF_SECONDS + ) + + self._connect_with_retry() + + consecutive_failures = 0 + else: + # A failed read on some backends returns + # instantly; don't spin a core on it. + self._stop_event.wait(0.01) + + continue + + consecutive_failures = 0 + + self._update_freeze_state(frame) + + # Publish only the newest frame; anything the consumer + # hasn't taken yet is simply replaced. + with self._frame_lock: + self._latest_frame = frame + self._latest_id += 1 + self._latest_error = None + + # ======================================================== + # Public API + # ======================================================== + + def read_with_id(self): + """Newest frame and its id; raises RuntimeError if none yet.""" + with self._frame_lock: + frame = self._latest_frame + frame_id = self._latest_id + error = self._latest_error + + if frame is None: + raise RuntimeError( + error or "No frame received from camera yet." + ) + + return frame, frame_id + + def read(self): + return self.read_with_id()[0] + + def is_frozen(self): + with self._state_lock: + last_change = self._last_change_time + + return ( + time.monotonic() - last_change + ) > CAMERA_FROZEN_SECONDS + + def release(self): + self._stop_event.set() + + # Let the reader thread notice and finish before the + # capture object is torn down underneath it. + if self._thread.is_alive(): + self._thread.join(timeout=2.0) + + if self.cap is not None: + try: + self.cap.release() + except Exception: + pass + + # ======================================================== + # Frozen-frame detection + # ======================================================== + + def _update_freeze_state(self, frame): + # Shrink FIRST (INTER_AREA averages, so it also suppresses + # pixel noise) and only then convert to gray - converting a + # full frame just to throw almost all of it away is wasted + # work on the capture thread. + thumb = cv2.cvtColor( + cv2.resize( + frame, + (32, 18), + interpolation=cv2.INTER_AREA, + ), + cv2.COLOR_BGR2GRAY, + ).astype(np.int16) + + now = time.monotonic() + + with self._state_lock: + if self._last_thumb is None: + self._last_change_time = now + else: + diff = float( + np.mean(np.abs(thumb - self._last_thumb)) + ) + + if diff > CAMERA_FROZEN_DIFF_THRESHOLD: + self._last_change_time = now + + self._last_thumb = thumb diff --git a/config.py b/config.py new file mode 100644 index 0000000..a35dfa7 --- /dev/null +++ b/config.py @@ -0,0 +1,599 @@ +# config.py + +# ============================================================ +# Screen +# ============================================================ + +SCREEN_WIDTH = 1530 +SCREEN_HEIGHT = 920 + + +# ============================================================ +# Camera +# ============================================================ + +# CAMERA_URL = "http://172.30.2.62:8080/video" +# CAMERA_URL = "http://172.30.1.216:4747/video" +CAMERA_URL = "http://192.168.1.194:4747/video" + +# How long to wait between attempts to (re)connect to the camera +# when it is unreachable - both on startup (phone app not open yet) +# and whenever a reconnect is needed mid-show. The game keeps +# retrying forever instead of crashing, since this runs unattended +# in a booth and nobody may be there to relaunch it. +CAMERA_CONNECT_RETRY_SECONDS = 2.0 + + +# ============================================================ +# Detection intervals +# ============================================================ + +# Both of these run on a background vision thread (see vision.py), +# never on the render loop, so they can no longer cause frame +# hitches. Obstacle detection works on a downscaled image (see +# DETECTION_SCALE) and is cheap enough to run 4x per second, which +# makes moving a sticker feel responsive. +MONITOR_DETECTION_INTERVAL = 1.0 +OBSTACLE_DETECTION_INTERVAL = 0.25 + + +# ============================================================ +# Calibration - screen border pattern +# ============================================================ +# +# Instead of four small corner markers, the monitor draws a +# magenta/pink outer ring with a white inner ring around the edge +# of the screen, permanently (during both calibration and normal +# gameplay - see Renderer.draw_calibration_border). This is much +# more robust in a busy, noisy exhibition hall than four small +# markers: +# - it's a single large, high-contrast shape instead of four +# tiny independent ones, so a person briefly walking in front +# of one corner doesn't break detection the way losing one out +# of four markers would; +# - requiring BOTH the pink AND the white ring to be present +# (in the right relative position) makes it very unlikely that +# a random pink or white object/reflection in the room could +# be mistaken for the screen. + +# Colors drawn on the monitor itself (RGB, used by pygame). +# +# Why GREEN instead of magenta/pink: the outer ring only has to be +# the single easiest possible color for a CAMERA (not a human eye) +# to pick out of a crowded, oddly-lit exhibition hall, and green is +# the best pick available for three concrete reasons: +# 1. Every consumer camera sensor uses a Bayer filter with TWICE +# as many green photosites as red or blue (the pattern a human +# eye is most sensitive to), so the green channel is always +# the least noisy, highest-resolution channel the camera has - +# this is exactly why film/broadcast green-screens are green +# and not some other color. +# 2. Pink/magenta sits close to skin tones and to a lot of common +# clothing under warm (incandescent/halogen) exhibition +# lighting, which white-balance algorithms can shift toward +# orange/red - pushing it out of a fixed HSV window entirely. +# Saturated green has no such close neighbour in a crowd of +# people, and survives white-balance shifts far better (most +# auto white balance implementations anchor their correction +# around making genuinely neutral/gray areas look neutral, +# which leaves a strongly green-dominant pixel green-dominant +# under almost any color temperature). +# 3. It pairs well with the detection method below: "is the green +# channel clearly stronger than red AND blue" is a RELATIVE +# test, so it survives brightness/exposure changes (someone's +# shadow, a spotlight) that would otherwise shift an absolute +# HSV threshold out of range. +CALIBRATION_PATTERN_OUTER_COLOR = (40, 255, 60) # saturated green +CALIBRATION_PATTERN_INNER_COLOR = (255, 255, 255) # white + +# Thickness of each ring, in screen pixels (at the SCREEN_WIDTH x +# SCREEN_HEIGHT design resolution above). +CALIBRATION_OUTER_BORDER_SIZE = 40 +CALIBRATION_INNER_BORDER_SIZE = 26 + +# ------------------------------------------------------------ +# Target-color detection +# ------------------------------------------------------------ +# +# The exact color the camera records for CALIBRATION_PATTERN_OUTER_ +# COLOR depends on the camera's white balance/exposure and the +# room's lighting, and can drift over the course of a show (the +# hall's lighting changing, the phone's camera re-adjusting +# exposure, etc.) - a single fixed HSV box, tuned once, is exactly +# what used to make detection "work great at setup time, then get +# flaky an hour into the exhibition". So the mask that finds the +# outer ring is now built from two cooperating, auto-adapting +# pieces instead of one fixed threshold (see Calibration. +# _make_target_mask for the implementation): +# +# 1. A wide, loose hue pre-filter (this section) that only rules +# out colors nowhere near green at all (reds, blues, skin, +# gray) - it does NOT try to pin down the exact shade. +# 2. Within whatever passes that pre-filter, a per-frame Otsu +# threshold on how green-dominant each pixel is (how far G +# exceeds R and B) - Otsu finds the split point that best +# separates "clearly green" from "not" IN THAT FRAME, so it +# re-adapts itself to the current lighting automatically, +# frame by frame, with no fixed brightness/saturation number +# to go stale. +# +# CALIBRATION_TARGET_HUE_DEGREES/TOLERANCE describe the loose +# pre-filter in standard 0-360 degree hue (green = 120 degrees). +CALIBRATION_TARGET_HUE_DEGREES = 120 +CALIBRATION_TARGET_HUE_TOLERANCE_DEGREES = 45 + +# Loose floor for saturation/brightness in the pre-filter - just +# enough to exclude flat gray/black/white areas, not to pin an +# exact shade (that's Otsu's job). +CALIBRATION_MIN_TARGET_SATURATION = 50 +CALIBRATION_MIN_TARGET_VALUE = 35 + +# Once locked on, the system also WATCHES the hue it is actually +# seeing inside the confirmed ring and slowly re-centers the +# pre-filter on it (see Calibration._update_learned_hue). This is +# what lets it track a slow lighting drift over the course of a +# multi-hour show instead of needing to be re-tuned on-site. +# ADAPTIVE_MAX_DRIFT_DEGREES bounds how far that learned center may +# ever wander from the true CALIBRATION_TARGET_HUE_DEGREES, so a +# run of bad detections can never "learn" its way onto some +# unrelated color. +CALIBRATION_ADAPTIVE_HUE_ENABLED = True +CALIBRATION_ADAPTIVE_HUE_BLEND = 0.08 +CALIBRATION_ADAPTIVE_MAX_DRIFT_DEGREES = 30 + +# The white inner ring is found the same way detection.py finds +# white stickers (see config's "White obstacle detection" section +# for the full reasoning): a LOCAL adaptive brightness test rather +# than one fixed floor for the whole frame, and LAB chroma rather +# than HSV saturation for "is this actually neutral". A fixed HSV +# box here was just as fragile under real exhibition lighting as the +# old fixed box was for stickers. +CALIBRATION_WHITE_ADAPTIVE_OFFSET = 8 +CALIBRATION_WHITE_MIN_ABSOLUTE_LIGHTNESS = 50 + +# One-sided: the only other bright thing nearby is the green ring +# itself, so it's enough to exclude clearly GREEN-dominant pixels +# rather than pin an exact "how white" number - see the comment in +# Calibration._make_white_mask for why a two-sided chroma ceiling +# can't reliably cover every possible lighting cast but this can. +CALIBRATION_WHITE_MAX_GREEN_DOMINANCE = 50 + +# The white inner ring also serves as a live "gray card": its mean +# LAB (a*, b*) as the camera currently records it is continuously +# learned (slow exponential blend) and handed to detection.py, which +# judges a candidate sticker's whiteness relative to THAT instead of +# absolute neutral - see Calibration.white_reference(). Colored +# ambient light shifts a genuinely white/neutral surface's recorded +# color for physical reasons no fixed threshold can see around; this +# is what lets the sticker detector stay correct anyway, and track +# the room's lighting changing over a long show without re-tuning. +CALIBRATION_WHITE_REFERENCE_BLEND = 0.05 + +# How far (LAB a/b units) the learned white point may ever drift +# from true neutral, so a run of bad samples can never teach the +# sticker detector a wildly wrong idea of white. +CALIBRATION_WHITE_REFERENCE_MAX_OFFSET = 40.0 + +# The detected quad's area, as a fraction of the full camera frame, +# must fall in this range. Filters out both tiny false positives +# (reflections, noise) and implausibly large ones. +CALIBRATION_MIN_SCREEN_AREA_RATIO = 0.10 +CALIBRATION_MAX_SCREEN_AREA_RATIO = 0.90 + +# How much of the sampled outer/inner ring band must actually match +# its expected color for a candidate to be accepted. +CALIBRATION_MIN_OUTER_TARGET_RATIO = 0.55 +CALIBRATION_MIN_INNER_WHITE_RATIO = 0.40 + +# Combined quality/color score (0-1) required before a detection is +# even considered as a calibration candidate. +CALIBRATION_MIN_QUALITY = 0.50 + +# While NOT yet calibrated, the whole screen is filled solid green +# (Renderer.draw_full_calibration_screen) instead of just the thin +# border ring. This gives the very first lock-on the simplest, +# most unambiguous target possible: one huge solid-color rectangle +# with nothing else on screen. This is the fraction of that +# candidate quad's own interior that must actually be green for it +# to count - it naturally stays low while someone is standing in +# front of part of the monitor, so a person walking through frame +# during calibration just delays the lock instead of causing a bad +# one. +CALIBRATION_MIN_FULL_TARGET_RATIO = 0.85 + +# Temporal stability: how many consecutive matching detections are +# required before (re)locking calibration, and how far (as a +# fraction of the camera frame's diagonal) the corners are allowed +# to drift between frames and still count as "the same" detection. +# This is what lets the system ignore a person briefly walking +# through frame instead of resetting calibration. +CALIBRATION_REQUIRED_STABLE_FRAMES = 5 +CALIBRATION_MAX_CORNER_JUMP_RATIO = 0.035 + +# Exponential smoothing applied to the accepted corner positions on +# every successful re-detection once already calibrated, so small +# frame-to-frame jitter doesn't make the transform wobble. +CALIBRATION_SMOOTHING_ALPHA = 0.25 + +# Morphological closing kernel (pixels) used to bridge small gaps +# in the color mask caused by glare/reflections on the monitor +# glass. Bigger = more tolerant of glare, but also more likely to +# merge in nearby unrelated green/white blobs - tune together with +# the hue settings above. +CALIBRATION_MASK_CLOSE_KERNEL = 17 + + +# ============================================================ +# Camera feed health +# ============================================================ +# +# If the raw camera feed barely changes for this long, treat it as +# a stuck/cached/frozen frame (phone screen locked, streaming app +# paused in the background, stale network buffer, dead wifi +# re-serving the last JPEG, lens covered, etc.) rather than a live +# view, and don't trust it for calibration or obstacle detection. +CAMERA_FROZEN_DIFF_THRESHOLD = 0.6 +CAMERA_FROZEN_SECONDS = 2.0 + + +# ============================================================ +# Rendering +# ============================================================ + +BACKGROUND_COLOR = (0, 0, 0) + +TARGET_FPS = 144 + +# Only repaint (and push to the display) the small screen regions +# that actually changed since the last frame, instead of redrawing +# and re-uploading all 1920x1080 pixels 144 times a second. On most +# machines this is the single biggest rendering speed-up. If you +# ever see stale "ghost" pixels on the monitor (a driver that +# ignores partial updates), set this to False - everything still +# works, just with more CPU/GPU load. +USE_DIRTY_RECTS = True + +# Safety net for USE_DIRTY_RECTS: force one complete repaint every +# this many seconds, so even a misbehaving driver can't leave a +# smudge on the screen for long. +FULL_REPAINT_SECONDS = 10.0 + + +# ============================================================ +# Visual effects (purely cosmetic - never affect physics) +# ============================================================ +# +# Master switch. Everything below is drawn ONLY with saturated +# colors (never white blobs) and only in small, dim glows, because +# the camera is looking at this same screen and must never mistake +# an effect for a white sticker. If detection ever misbehaves and +# you suspect the visuals, flip this to False first to find out. +VISUAL_EFFECTS_ENABLED = True + +# Each ball gets its own hue (degrees, 0-360) from this range. The +# default skips magenta/pink (roughly 280-350) on purpose: that is +# the calibration color, and it must stay unique to the calibration +# border so the camera never confuses the two. +BALL_HUE_MIN_DEGREES = 15 +BALL_HUE_MAX_DEGREES = 255 + +# Every time a ball bounces off a sticker its hue shifts by this +# many degrees (staying inside the range above) - a small, very +# visible "the sticker reacted to me" cue that people love. +BALL_HUE_SHIFT_ON_BOUNCE = 22 + +# Fading comet tail behind each ball: how many past positions it +# remembers, and how often (seconds) a new one is recorded. +BALL_TRAIL_LENGTH = 16 +BALL_TRAIL_SAMPLE_SECONDS = 1.0 / 60.0 + +# Soft colored glow around each ball (brighter the faster it moves). +BALL_GLOW_ENABLED = True + +# Springy squash-and-stretch: balls elongate along their direction +# of travel with speed, and squash flat for a moment on a hard hit. +BALL_SQUASH_STRETCH_ENABLED = True +BALL_SQUASH_DURATION_SECONDS = 0.16 + +# Sparks + expanding ring on a bounce whose impact speed (px/s) +# exceeds this; gentler taps and resting contact produce nothing, +# so a ball lying still on a sticker stays calm. +IMPACT_EFFECT_MIN_SPEED = 160.0 +MAX_PARTICLES = 260 + + +# ============================================================ +# Detected obstacles (what is painted back onto the monitor) +# ============================================================ +# +# When True, every obstacle the camera currently sees is also +# painted white on the game's own screen (i.e. on the physical +# monitor itself), where it was detected - as the real tilted +# rectangle, not an axis-aligned box. When False, nothing extra is +# drawn for it. Either way this is purely visual - balls always +# collide with every currently-detected obstacle regardless of this +# flag; it never affects physics. +SHOW_DETECTED_OBSTACLES = False + +DETECTED_OBSTACLE_COLOR = (255, 255, 255) +DETECTED_OBSTACLE_BORDER_WIDTH = 0 + +# The painted white is deliberately drawn a little SMALLER than the +# sticker (this fraction of the sticker's short side is trimmed off +# each edge). Reason: the camera looks at this very screen. If the +# painted white were exactly sticker-sized, then after someone +# removes the sticker the camera would keep seeing the painted +# white and "detect" the (now nonexistent) sticker forever. Painting +# inset keeps it hidden under the real sticker, and the detector +# additionally blanks out exactly the area that was painted before +# looking for stickers (see detection.py), so a removed sticker +# really disappears. +DETECTED_OBSTACLE_PAINT_INSET_RATIO = 0.22 + + +# ============================================================ +# Ball physics +# ============================================================ + +BALL_RADIUS = 15 + +# Each ball's radius is randomly scaled by up to +/- this fraction +# (bigger balls are also heavier in ball-vs-ball collisions). +BALL_RADIUS_VARIATION = 0.25 + +BALL_GRAVITY = 900 + +# Fraction of normal speed kept after bouncing off a sticker or a +# screen edge (1.0 = perfectly elastic: with gravity, a ball just +# keeps bouncing at the same energy forever and never settles down, +# which is the default here). Lower it (e.g. ~0.75) for a "real +# rubber ball" feel that loses a little height on every bounce +# instead. Very slow contact against a STICKER is still always +# treated as fully inelastic (BALL_REST_SPEED) so a ball resting on +# one doesn't buzz at the pixel level; screen edges always bounce at +# the full BALL_RESTITUTION regardless of speed. +BALL_RESTITUTION = 1.0 +BALL_REST_SPEED = 55.0 + +# Fraction of sliding (tangential) speed lost per bounce off a +# sticker - what makes a ball roll/slide along a tilted sticker +# instead of skating on ice. 0 = no energy lost this way either (the +# default, paired with BALL_RESTITUTION = 1.0 above); raise it for +# more of a "rolling to a stop on a ramp" feel. +BALL_FRICTION = 0.0 + +# Safety cap on speed (px/s), so a freak event can never launch a +# ball fast enough to tunnel through a sticker. +BALL_MAX_SPEED = 2000.0 + +# Balls collide with each other: overlapping balls push apart and +# exchange momentum (heavier balls push lighter ones around). 1.0 = +# no energy lost in the collision either, matching the walls/ +# stickers above. +BALL_BALL_COLLISION_ENABLED = True +BALL_BALL_RESTITUTION = 1.0 + +# Physics runs in equal slices of at most this many seconds, so +# nothing tunnels even on a slow frame. +PHYSICS_MAX_STEP_SECONDS = 1.0 / 240.0 + + +# ============================================================ +# Ball spawning and stuck balls +# ============================================================ +# +# The two settings that matter most, kept together and right up +# front so they're easy to find and tune: +# +# A new ball drops in every BALL_SPAWN_INTERVAL_SECONDS, on its own +# clock, completely independent of what earlier balls are doing or +# whether they've disappeared yet. +BALL_SPAWN_INTERVAL_SECONDS = 2.5 + +# ============================================================ +# Launcher configuration +# ============================================================ +# +# Attached to top-center of the screen. +# Angles are in degrees relative to straight down (0 = straight down, +# negative = left, positive = right). +LAUNCHER_MIN_ANGLE = -70.0 +LAUNCHER_MAX_ANGLE = 70.0 + +# Dimensions of the launcher tube in screen pixels. +LAUNCHER_LENGTH = 75.0 +LAUNCHER_WIDTH = 26.0 + +# Angular rotation speed (controls continuous smooth movement). +LAUNCHER_ROTATION_SPEED = 35.0 + +# Initial ball speed when exiting the nozzle (px/s). +LAUNCHER_BALL_SPAWN_SPEED = 280.0 + +# Every ball is removed (with a little pop effect, see effects.py) +# this many seconds after it was spawned, whatever else is +# happening to it. 0 = balls live forever (until/unless the stuck- +# timeout below removes them instead). +BALL_MAX_LIFETIME_SECONDS = 20.0 + +# --- Everything below is secondary detail for the two settings above --- + +# Hard ceiling on balls alive at once - only a safety net so trapped +# balls can never accumulate without limit over a long show. +MAX_SIMULTANEOUS_BALLS = 12 + +# Random horizontal drop position. With BALL_SPAWN_RANDOM_X = False +# every ball drops from the middle of the screen (the old +# behavior). Margin keeps drops away from the side walls. +BALL_SPAWN_RANDOM_X = True +BALL_SPAWN_MARGIN = 120 +BALL_SPAWN_VELOCITY_JITTER = 60.0 + +# A ball is "stuck" if it has stayed within BALL_STUCK_DISTANCE +# pixels of one spot for BALL_STUCK_TIMEOUT_SECONDS - usually a +# pocket between stickers, but with every screen edge now a solid +# wall (see physics.py) a ball resting on an empty floor with no +# obstacles nearby counts too. A stuck ball stays put for that long +# (so visitors can see it) and is then removed with a small pop, +# freeing its slot, same as the lifetime limit above. 0 = never +# remove stuck balls (BALL_MAX_LIFETIME_SECONDS still applies). +BALL_STUCK_DISTANCE = 30.0 +BALL_STUCK_TIMEOUT_SECONDS = 8.0 + + +# ============================================================ +# White obstacle detection +# ============================================================ + +# The camera image is warped to screen space at this fraction of +# the real screen resolution before looking for stickers. 0.5 makes +# detection ~4x cheaper with no meaningful loss of precision (a +# sticker is still tens of pixels wide). All the pixel values below +# stay in real screen pixels - the code converts. +DETECTION_SCALE = 0.5 + +# Real sticker size limits, in screen pixels squared. +MIN_OBSTACLE_AREA = 1500 +MAX_OBSTACLE_AREA = 200000 + +# --- Finding candidate blobs (deliberately loose) ------------- +# Pass 1 finds anything vaguely bright and unsaturated. It is meant +# to be generous; the strict checks below decide what is a sticker. +# +# Both "bright" and "unsaturated" are judged in CIELAB, not HSV (see +# imaging.py): L (lightness) and chroma (how colorful a pixel is) +# are far more independent of each other there than HSV's V and S, +# which is what makes the two tests below behave consistently +# whether a sticker sits in a shadowed corner or under a spotlight. +# +# "Bright" is no longer one fixed brightness floor for the whole +# frame, either. A single global number means a sticker in dimmer +# ambient light can fall below it and never even reach the per-blob +# refinement step below - which is exactly what made otherwise +# identical stickers seem to be "detected by force" some of the +# time. Instead, pass 1 asks a strictly LOCAL question - "is this +# pixel clearly brighter than the small neighborhood around it?" +# (cv2.adaptiveThreshold) - the same question a human eye asks when +# picking out a pale sticker on a dim part of a black screen, and it +# holds regardless of the overall exposure level that frame happens +# to have. OBSTACLE_ADAPTIVE_OFFSET is how much brighter than that +# local neighborhood a pixel must be; OBSTACLE_MIN_ABSOLUTE_LIGHTNESS +# is a sanity floor underneath it, so camera sensor noise sitting in +# a totally dark area can never be mistaken for "locally brighter". +OBSTACLE_ADAPTIVE_OFFSET = 14 +OBSTACLE_MIN_ABSOLUTE_LIGHTNESS = 55 + +# The neighborhood size (screen px) the adaptive threshold above +# looks at is tied to the sticker's own size - large enough that the +# neighborhood is mostly black screen around the sticker rather than +# the sticker's own bright pixels (which would otherwise drag the +# local average up and make the sticker fail to stand out against +# ITSELF). Used as a multiple of the short side once a size is +# known; OBSTACLE_ADAPTIVE_DEFAULT_SHORT_SIDE is only the starting +# guess used before any sticker has been seen yet. +OBSTACLE_ADAPTIVE_BLOCK_SPAN = 2.4 +OBSTACLE_ADAPTIVE_DEFAULT_SHORT_SIDE = 90 + +# Loose chroma ceiling for pass 1 (and for the per-blob edge +# refinement below) - just needs to exclude obviously colored +# objects (clothing, skin, colored lighting splashed on the floor), +# not pin an exact "how white" number (that is MAX_OBSTACLE_CHROMA +# below). +OBSTACLE_COARSE_CHROMA_MAX = 45 + +# Each blob's edge is then re-drawn at the "half-way" LIGHTNESS +# between the sticker's own paper level and the dark screen next to +# it. That makes a dim sticker in a shadowy corner and an identical +# sticker under a spotlight come out exactly the same size, which is +# what makes all stickers look and behave alike. +OBSTACLE_EDGE_LEVEL = 0.5 + +# --- Deciding whether a blob really is a sticker -------------- +# Average lightness/chroma INSIDE the blob (CIELAB - see above). +MIN_OBSTACLE_BRIGHTNESS = 130 +MAX_OBSTACLE_CHROMA = 28 + +# Fraction of the blob's own best-fit rotated rectangle it fills. +# A disc (a ball's glow, a bulb) scores 0.785, so anything above +# ~0.84 rejects round things outright. +MIN_OBSTACLE_RECTANGULARITY = 0.86 + +# Fraction of its convex hull the blob fills (rejects L-shapes, +# blobs with bites out of them). +MIN_OBSTACLE_SOLIDITY = 0.90 + +# Longest side / shortest side. Squares up to index-card shapes. +MAX_OBSTACLE_ASPECT_RATIO = 4.0 + +# Paper is a uniform, diffuse surface. Reflections and hot-spots +# vary a lot in brightness across their surface. +MAX_OBSTACLE_BRIGHTNESS_STD = 30.0 + +# A real sticker sits on a black screen: this fraction of the ring +# of pixels just outside it must be clearly darker than the sticker. +MIN_OBSTACLE_DARK_SURROUND_RATIO = 0.55 + +# Only look for stickers inside the calibration border, and never +# closer than this many extra screen pixels to it (a sticker +# touching the bright border would merge with it). +OBSTACLE_BORDER_MASK_MARGIN = 4 +OBSTACLE_EDGE_MARGIN = 5 + +# --- All stickers are identical: use that ------------------- +# Once a few stickers have been seen the system learns their size +# and rejects blobs that don't match (within this fraction) - this +# is what kills "forced" detections of things that merely look +# roughly rectangular. Set OBSTACLE_EXPECTED_SIZE = (long, short) +# in screen pixels to pin the size instead of learning it (the +# learned size is printed to the console once it has settled). +OBSTACLE_SIZE_TOLERANCE = 0.22 +OBSTACLE_EXPECTED_SIZE = None + +# Two or three identical stickers glued edge to edge form one +# bigger rectangle; accept whole multiples of the sticker size. +# (For physics a merged rectangle behaves exactly like the parts.) +OBSTACLE_ALLOW_MERGED_STICKERS = True + +# Draw every sticker at exactly the learned size when the measured +# size is within ~10% of it, so identical stickers give identical +# obstacles instead of wobbling by a pixel or two. +OBSTACLE_SNAP_TO_EXPECTED_SIZE = True + +# Forget the learned size after this many seconds with no stickers +# at all, so a different sticker size can be used later. +OBSTACLE_SIZE_FORGET_SECONDS = 30.0 + +# Tracks that are already established are kept with slightly +# relaxed rules (this fraction of the strict thresholds) so a real +# sticker never flickers in and out on borderline frames, while NEW +# stickers still have to pass the full strict tests. +OBSTACLE_RELAXED_FACTOR = 0.90 + +# --- Following stickers over time (each one tracked separately) --- +# Consecutive detections needed before a NEW sticker counts. +OBSTACLE_REQUIRED_STABLE_FRAMES = 2 + +# An established sticker survives this many passes without being +# seen (someone's hand in the way). At 0.25 s per pass, 12 = 3 s. +OBSTACLE_MAX_MISSING_FRAMES = 12 + +# After this many passes without seeing an established sticker, the +# game stops PAINTING its white patch (and, a moment later, stops +# blanking that area for the detector). Without this, a sticker that +# was moved would be hidden by its own old painted patch until it +# timed out. If it was moved, it is found at the new spot within a +# pass or two; if it was removed, it is dropped at +# OBSTACLE_MAX_MISSING_FRAMES as before. Physics keeps using it +# either way until then. +OBSTACLE_STALE_MISSES = 3 + +# Not-yet-confirmed candidates are dropped after this many misses. +OBSTACLE_PENDING_MAX_MISSING_FRAMES = 1 + +# Max distance (screen px) a sticker may move between two passes +# and still be "the same" sticker. +OBSTACLE_TEMPORAL_MATCH_DISTANCE = 80 + +# Kernel sizes (screen pixels) for cleaning up the blob mask. +OBSTACLE_MASK_OPEN_KERNEL = 3 +OBSTACLE_MASK_CLOSE_KERNEL = 5 diff --git a/detection.py b/detection.py new file mode 100644 index 0000000..13f3498 --- /dev/null +++ b/detection.py @@ -0,0 +1,1132 @@ +# detection.py +# +# Finds the white paper stickers stuck on the monitor, as ROTATED +# rectangles, in a stable, flicker-free way. +# +# Input: a BGR image of the monitor already warped into screen +# space (Calibration.transform_frame) at DETECTION_SCALE of the real +# screen resolution. Output: one dict per sticker, in real SCREEN +# pixels (see geometry.py for the fields/angle convention). +# +# How it decides, in order (each step exists because a real +# exhibition hall breaks the simpler version): +# +# 1. Find generous "maybe" blobs: anything LOCALLY brighter than +# its own immediate surroundings and not very colored, inside +# the calibration border (see imaging.py - brightness and color +# are measured in CIELAB, not HSV, specifically because that +# separates the two cleanly). "Locally brighter" rather than +# "brighter than one fixed number for the whole frame" is what +# lets a sticker sitting in a dimmer part of the hall still be +# found - a global floor would simply never see it, no matter +# how good every later step is. The area the game itself +# painted white for already-detected stickers is blanked out +# first, otherwise a removed sticker would be "seen" forever in +# its own painted white (see DETECTED_OBSTACLE_PAINT_*), and any +# small hole a glare reflection punches through a blob (or a +# thin glare streak that splits it in two) is patched before +# measuring it (see imaging.fill_holes). +# +# 2. Re-draw each blob's edge at the half-way lightness between +# that blob's own paper brightness and the dark screen beside +# it. A dim sticker in a shadowy corner and an identical one +# under a spotlight then come out the SAME size - a fixed +# brightness cutoff can't do that, and it is what made +# identical stickers seem to be "detected by force" some of the +# time and not others. +# +# 3. Measure the refined blob: best-fit rotated rectangle (true +# side lengths and angle), how well it fills that rectangle +# (rejects round glows), convexity, uniformity of brightness +# (rejects hot-spot reflections), average saturation (rejects +# skin/colored objects), and whether the ring of pixels just +# outside it is dark (a sticker sits on a black screen). +# +# 4. All stickers are identical, so once a couple have been seen +# the size is learned and a blob of a different size is +# rejected. Two or three stickers glued edge to edge form one +# bigger rectangle and are accepted as whole multiples. +# +# 5. Every sticker is tracked separately over time. NEW stickers +# must pass the strict tests on consecutive passes; stickers +# already established are kept under slightly relaxed tests, so +# a real sticker doesn't flicker on borderline frames, and they +# survive a hand in front of them for a few seconds. Motion is +# smoothed with a dead-band (no jitter when nothing moves) and +# catches up fast when a sticker really is moved. + +import math +import time +from collections import deque + +import cv2 +import numpy as np + +import imaging + +from geometry import ( + normalize_angle, + blend_angle, + obb_corners, + extract_obb, + paint_dimensions, +) + +from config import ( + SCREEN_WIDTH, + SCREEN_HEIGHT, + CALIBRATION_OUTER_BORDER_SIZE, + CALIBRATION_INNER_BORDER_SIZE, + DETECTION_SCALE, + MIN_OBSTACLE_AREA, + MAX_OBSTACLE_AREA, + OBSTACLE_ADAPTIVE_OFFSET, + OBSTACLE_MIN_ABSOLUTE_LIGHTNESS, + OBSTACLE_ADAPTIVE_BLOCK_SPAN, + OBSTACLE_ADAPTIVE_DEFAULT_SHORT_SIDE, + OBSTACLE_COARSE_CHROMA_MAX, + OBSTACLE_EDGE_LEVEL, + MIN_OBSTACLE_BRIGHTNESS, + MAX_OBSTACLE_CHROMA, + MIN_OBSTACLE_RECTANGULARITY, + MIN_OBSTACLE_SOLIDITY, + MAX_OBSTACLE_ASPECT_RATIO, + MAX_OBSTACLE_BRIGHTNESS_STD, + MIN_OBSTACLE_DARK_SURROUND_RATIO, + OBSTACLE_BORDER_MASK_MARGIN, + OBSTACLE_EDGE_MARGIN, + OBSTACLE_SIZE_TOLERANCE, + OBSTACLE_EXPECTED_SIZE, + OBSTACLE_ALLOW_MERGED_STICKERS, + OBSTACLE_SNAP_TO_EXPECTED_SIZE, + OBSTACLE_SIZE_FORGET_SECONDS, + OBSTACLE_RELAXED_FACTOR, + OBSTACLE_REQUIRED_STABLE_FRAMES, + OBSTACLE_MAX_MISSING_FRAMES, + OBSTACLE_PENDING_MAX_MISSING_FRAMES, + OBSTACLE_STALE_MISSES, + OBSTACLE_TEMPORAL_MATCH_DISTANCE, + OBSTACLE_MASK_OPEN_KERNEL, + OBSTACLE_MASK_CLOSE_KERNEL, + SHOW_DETECTED_OBSTACLES, + DETECTED_OBSTACLE_PAINT_INSET_RATIO, +) + + +# Extra margin (screen px) added around the painted patch when it is +# blanked out, to absorb the small misalignment between the pixels +# we painted and where the camera sees them. +_BLANK_MARGIN = 5.0 + +# Below this long/short ratio a sticker is "square enough" that its +# orientation only matters modulo 90 degrees. +_SQUARE_ASPECT = 1.15 + + +class _Track: + """One physical sticker followed over time.""" + + __slots__ = ( + "id", + "cx", + "cy", + "angle", + "long", + "short", + "hits", + "misses", + "confirmed", + ) + + def __init__(self, track_id, candidate): + self.id = track_id + self.cx = candidate["cx"] + self.cy = candidate["cy"] + self.angle = candidate["angle"] + self.long = candidate["long"] + self.short = candidate["short"] + self.hits = 1 + self.misses = 0 + self.confirmed = False + + +class Detector: + def __init__(self): + self.scale = float(DETECTION_SCALE) + + self.det_width = int(round(SCREEN_WIDTH * self.scale)) + self.det_height = int(round(SCREEN_HEIGHT * self.scale)) + + self._s2 = self.scale * self.scale + + # Kernels, sized in screen pixels then scaled (always odd). + self._open_kernel = self._kernel(OBSTACLE_MASK_OPEN_KERNEL) + self._close_kernel = self._kernel(OBSTACLE_MASK_CLOSE_KERNEL) + self._small_kernel = np.ones((3, 3), np.uint8) + + # Region where stickers are searched: everything inside the + # calibration border (which is bright by design and would + # merge with a sticker touching it). + border = int( + round( + ( + CALIBRATION_OUTER_BORDER_SIZE + + CALIBRATION_INNER_BORDER_SIZE + + OBSTACLE_BORDER_MASK_MARGIN + ) + * self.scale + ) + ) + + self._roi = np.zeros( + (self.det_height, self.det_width), np.uint8 + ) + self._roi[ + border : self.det_height - border, + border : self.det_width - border, + ] = 255 + + margin = int(round(OBSTACLE_EDGE_MARGIN * self.scale)) + + self._roi_bounds = ( + border + margin, + border + margin, + self.det_width - border - margin, + self.det_height - border - margin, + ) + + self._pinned_size = None + + if OBSTACLE_EXPECTED_SIZE: + long_side, short_side = OBSTACLE_EXPECTED_SIZE + self._pinned_size = ( + float(max(long_side, short_side)), + float(min(long_side, short_side)), + ) + + # What "white" currently looks like to the camera, as a LAB + # (a*, b*) point - see Calibration.white_reference(). Starts + # at neutral (128, 128) and is kept updated by whoever drives + # this Detector (vision.py); NOT reset by reset() below, + # since it describes the room's current lighting, not + # anything about where the stickers are. + self._white_a = 128.0 + self._white_b = 128.0 + + self.reset() + + # ======================================================== + # Public API + # ======================================================== + + def set_white_reference(self, reference_ab): + """ + Update what "white" currently looks like to the camera (see + Calibration.white_reference()). Falls back to LAB neutral + (128, 128) if None (not measured yet, or adaptive hue/white + learning is disabled) - the same as the old fixed behavior. + """ + if reference_ab is None: + self._white_a = 128.0 + self._white_b = 128.0 + else: + self._white_a, self._white_b = reference_ab + + def reset(self): + self.tracks = [] + self._next_id = 1 + + self._samples = deque(maxlen=30) + self._expected = self._pinned_size + self._established = self._pinned_size is not None + self._last_size_log = None + + self._last_seen_any = time.monotonic() + + self.obstacles = [] + self._signature = None + + # Last pass's raw candidates, for debugging/tuning. + self.last_candidates = [] + + def detect(self, frame): + """ + Run one detection pass on a screen-space frame at + DETECTION_SCALE and return the current list of confirmed + stickers (screen pixels). The SAME list object is returned + while nothing changed, so consumers can cheaply notice + changes with an identity check. + """ + if ( + frame is None + or frame.ndim != 3 + or frame.shape[0] != self.det_height + or frame.shape[1] != self.det_width + ): + return self.obstacles + + candidates = self._find_candidates(frame) + + self.last_candidates = candidates + + self._update_tracks(candidates) + + self._forget_stale_size() + + self._publish() + + return self.obstacles + + # ======================================================== + # 1. Coarse blobs + # ======================================================== + + def _find_candidates(self, frame): + lightness, a_channel, b_channel = imaging.to_lab(frame) + + lightness = cv2.GaussianBlur(lightness, (3, 3), 0) + + chroma = np.clip( + imaging.chroma( + a_channel, + b_channel, + self._white_a, + self._white_b, + ), + 0, + 255, + ).astype(np.uint8) + + # "Locally brighter than its own neighborhood" (not "above + # one fixed brightness for the whole frame") - see the + # module docstring and config.py's OBSTACLE_ADAPTIVE_* for + # why. A plain floor is still kept underneath it purely as a + # noise guard: without it, sensor noise inside a totally + # dark, sticker-free patch of screen can register as + # "locally brighter than its (equally dark) surroundings" + # even though nothing is actually there. + local = cv2.adaptiveThreshold( + lightness, + 255, + cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY, + self._adaptive_block_size(), + -OBSTACLE_ADAPTIVE_OFFSET, + ) + + mask = local & cv2.inRange( + lightness, + OBSTACLE_MIN_ABSOLUTE_LIGHTNESS, + 255, + ) + + mask &= cv2.inRange( + chroma, + 0, + OBSTACLE_COARSE_CHROMA_MAX, + ) + + mask &= self._roi + + blank = self._painted_blank_mask() + + if blank is not None: + mask[blank > 0] = 0 + + mask = cv2.morphologyEx( + mask, cv2.MORPH_OPEN, self._open_kernel + ) + mask = cv2.morphologyEx( + mask, cv2.MORPH_CLOSE, self._close_kernel + ) + + # Patches a glare highlight punched through the middle of a + # sticker, or a thin specular streak that splits one blob + # into two, without touching a real gap (e.g. a ball or a + # hand actually covering part of it), which stays open to + # the mask's edge and so is left alone. + mask = imaging.fill_holes(mask) + + contours, _ = cv2.findContours( + mask, + cv2.RETR_EXTERNAL, + cv2.CHAIN_APPROX_SIMPLE, + ) + + candidates = [] + + for contour in contours: + candidate = self._analyze( + contour, + lightness, + chroma, + blank, + ) + + if candidate is not None: + candidates.append(candidate) + + return candidates + + def _adaptive_block_size(self): + """ + Neighborhood size (detection-scale px, always odd) for the + local brightness threshold above. Tied to the sticker's own + short side (once known) so the neighborhood sampled around a + sticker is mostly black screen rather than the sticker's own + bright pixels, which would otherwise drag the local average + up and make the sticker fail to stand out against itself. + """ + if self._expected is not None: + short_side = min(self._expected) + else: + short_side = OBSTACLE_ADAPTIVE_DEFAULT_SHORT_SIDE + + size = int( + round( + short_side + * self.scale + * OBSTACLE_ADAPTIVE_BLOCK_SPAN + ) + ) + + size = max(15, size) + + if size % 2 == 0: + size += 1 + + return size + + def _painted_blank_mask(self): + """ + Mask of the exact patches the game painted white for the + stickers it is currently tracking. Those pixels are ignored + so that a REMOVED sticker's leftover painted white can never + be mistaken for a sticker (the camera is looking at the same + screen the game paints on). + """ + if not SHOW_DETECTED_OBSTACLES: + return None + + # Keep blanking for two passes after painting stops, to + # cover the camera's own latency (it still shows the old + # painted patch for a moment). + confirmed = [ + t + for t in self.tracks + if t.confirmed + and t.misses < OBSTACLE_STALE_MISSES + 2 + ] + + if not confirmed: + return None + + blank = np.zeros( + (self.det_height, self.det_width), np.uint8 + ) + + for track in confirmed: + long_side, short_side = paint_dimensions( + track.long, + track.short, + DETECTED_OBSTACLE_PAINT_INSET_RATIO, + ) + + corners = obb_corners( + track.cx * self.scale, + track.cy * self.scale, + (long_side + 2 * _BLANK_MARGIN) * self.scale, + (short_side + 2 * _BLANK_MARGIN) * self.scale, + track.angle, + ) + + cv2.fillConvexPoly( + blank, + np.round(corners).astype(np.int32), + 255, + ) + + return blank + + # ======================================================== + # 2 + 3. Refine one blob and measure it + # ======================================================== + + def _analyze(self, contour, lightness, chroma, blank): + area_px = cv2.contourArea(contour) + + # Loose pre-gate on the coarse blob (the refined blob is + # gated exactly below). + loose_area = area_px / self._s2 + + if ( + loose_area < MIN_OBSTACLE_AREA * 0.5 + or loose_area > MAX_OBSTACLE_AREA * 1.5 + ): + return None + + x, y, w, h = cv2.boundingRect(contour) + + ring = max(3, int(round(0.15 * min(w, h)))) + pad = ring + 3 + + x0 = max(0, x - pad) + y0 = max(0, y - pad) + x1 = min(self.det_width, x + w + pad) + y1 = min(self.det_height, y + h + pad) + + crop_light = lightness[y0:y1, x0:x1] + crop_chroma = chroma[y0:y1, x0:x1] + crop_roi = self._roi[y0:y1, x0:x1] + + crop_blank = ( + blank[y0:y1, x0:x1] if blank is not None else None + ) + + shifted = contour - np.array([[[x0, y0]]], np.int32) + + coarse_fill = np.zeros(crop_light.shape, np.uint8) + + cv2.drawContours(coarse_fill, [shifted], -1, 255, -1) + + inside = crop_light[coarse_fill > 0] + outside = crop_light[coarse_fill == 0] + + if inside.size == 0 or outside.size < 8: + return None + + plateau = float(np.percentile(inside, 75)) + background = float(np.percentile(outside, 10)) + + # 2. Half-way brightness between this blob's own paper level + # and the dark screen beside it. + level = max( + background + + OBSTACLE_EDGE_LEVEL * (plateau - background), + float(OBSTACLE_MIN_ABSOLUTE_LIGHTNESS), + ) + + refined = cv2.inRange(crop_light, level, 255) + refined &= cv2.inRange( + crop_chroma, 0, OBSTACLE_COARSE_CHROMA_MAX + ) + refined &= crop_roi + + if crop_blank is not None: + refined[crop_blank > 0] = 0 + + refined = cv2.morphologyEx( + refined, cv2.MORPH_OPEN, self._small_kernel + ) + refined = cv2.morphologyEx( + refined, cv2.MORPH_CLOSE, self._small_kernel + ) + + found, _ = cv2.findContours( + refined, + cv2.RETR_EXTERNAL, + cv2.CHAIN_APPROX_SIMPLE, + ) + + if not found: + return None + + contour2 = max(found, key=cv2.contourArea) + + area2_px = cv2.contourArea(contour2) + area = area2_px / self._s2 + + if area < MIN_OBSTACLE_AREA or area > MAX_OBSTACLE_AREA: + return None + + # ---- 3. measurements --------------------------------- + rect = cv2.minAreaRect(contour2) + + (rect_cx, rect_cy), (rect_w, rect_h), _raw_angle = rect + + if rect_w <= 0 or rect_h <= 0: + return None + + rectangularity = area2_px / (rect_w * rect_h) + + # minAreaRect's own angle convention differs between + # OpenCV versions and jumps for near-square shapes, so the + # angle is measured from the box's actual long edge. + box = cv2.boxPoints(rect) + + edge_a = box[1] - box[0] + edge_b = box[2] - box[1] + + length_a = float(np.hypot(edge_a[0], edge_a[1])) + length_b = float(np.hypot(edge_b[0], edge_b[1])) + + if length_a <= 0 or length_b <= 0: + return None + + if length_a >= length_b: + long_edge, long_px, short_px = edge_a, length_a, length_b + else: + long_edge, long_px, short_px = edge_b, length_b, length_a + + aspect = long_px / short_px + + angle = math.degrees( + math.atan2(long_edge[1], long_edge[0]) + ) + + angle = normalize_angle( + angle, + 90.0 if aspect < _SQUARE_ASPECT else 180.0, + ) + + hull_area = cv2.contourArea(cv2.convexHull(contour2)) + + if hull_area <= 0: + return None + + solidity = area2_px / hull_area + + fill = np.zeros(crop_light.shape, np.uint8) + + cv2.drawContours(fill, [contour2], -1, 255, -1) + + inner = cv2.erode(fill, self._small_kernel) + + if not inner.any(): + inner = fill + + mean_light = float(cv2.mean(crop_light, mask=inner)[0]) + mean_chroma = float(cv2.mean(crop_chroma, mask=inner)[0]) + + _, std = cv2.meanStdDev(crop_light, mask=inner) + + light_std = float(std[0][0]) + + # Ring just outside the blob must be dark (black screen). + ring_kernel = np.ones( + (2 * ring + 1, 2 * ring + 1), np.uint8 + ) + + ring_mask = cv2.dilate(fill, ring_kernel) & ~fill + + ring_mask &= crop_roi + + if crop_blank is not None: + ring_mask[crop_blank > 0] = 0 + + ring_count = int(np.count_nonzero(ring_mask)) + + if ring_count >= 12: + dark = crop_light[ring_mask > 0] < (mean_light * 0.5) + + dark_ratio = float(np.count_nonzero(dark)) / ring_count + else: + dark_ratio = 1.0 + + # Touching the edge of the search area means the sticker is + # (or may be) cut off - its true size is unknown. + bx, by, bw, bh = cv2.boundingRect(contour2) + + left, top, right, bottom = self._roi_bounds + + clipped = ( + bx + x0 <= left + or by + y0 <= top + or bx + x0 + bw >= right + or by + y0 + bh >= bottom + ) + + # ---- back to real screen pixels ---------------------- + inverse = 1.0 / self.scale + + candidate = { + "cx": (rect_cx + x0) * inverse, + "cy": (rect_cy + y0) * inverse, + "angle": float(angle), + "long": long_px * inverse, + "short": short_px * inverse, + "area": float(area), + "aspect": float(aspect), + "rectangularity": float(rectangularity), + "solidity": float(solidity), + "brightness": mean_light, + "chroma": mean_chroma, + "std": light_std, + "dark_ratio": dark_ratio, + "clipped": clipped, + } + + candidate["strict_ok"], candidate["snap"] = self._passes( + candidate, 1.0 + ) + + candidate["relaxed_ok"], relaxed_snap = self._passes( + candidate, OBSTACLE_RELAXED_FACTOR + ) + + candidate["snap"] = candidate["snap"] or relaxed_snap + + return candidate + + # ======================================================== + # Sticker tests (strict for new stickers, relaxed to keep + # established ones) + # ======================================================== + + def _passes(self, c, factor): + if c["clipped"]: + return False, False + + if c["rectangularity"] < MIN_OBSTACLE_RECTANGULARITY * factor: + return False, False + + if c["solidity"] < MIN_OBSTACLE_SOLIDITY * factor: + return False, False + + if c["aspect"] > MAX_OBSTACLE_ASPECT_RATIO / factor: + return False, False + + if c["brightness"] < MIN_OBSTACLE_BRIGHTNESS * factor: + return False, False + + if c["chroma"] > MAX_OBSTACLE_CHROMA / factor: + return False, False + + if c["std"] > MAX_OBSTACLE_BRIGHTNESS_STD / factor: + return False, False + + if ( + c["dark_ratio"] + < MIN_OBSTACLE_DARK_SURROUND_RATIO * factor + ): + return False, False + + return self._size_matches( + c["long"], + c["short"], + OBSTACLE_SIZE_TOLERANCE / factor, + ) + + def _size_matches(self, long_side, short_side, tolerance): + """ + (matches, is_single_sticker_size). With no learned size yet + anything passes; with a size learned from just one sighting + the check is deliberately loose (that one sighting could + itself have been a false positive). + """ + if self._expected is None: + return True, False + + expected_long, expected_short = self._expected + + if not self._established: + tolerance = max(tolerance, 0.45) + + limit = 3 if OBSTACLE_ALLOW_MERGED_STICKERS else 1 + + for along in range(1, limit + 1): + for across in range(1, limit + 1): + if along * across > 6: + continue + + target_a = along * expected_long + target_b = across * expected_short + + target_long = max(target_a, target_b) + target_short = min(target_a, target_b) + + if ( + abs(long_side - target_long) / target_long + <= tolerance + and abs(short_side - target_short) + / target_short + <= tolerance + ): + single = along == 1 and across == 1 + + return True, single + + return False, False + + # ======================================================== + # Learned sticker size + # ======================================================== + + def _learn_size(self, long_side, short_side): + if self._pinned_size is not None: + return + + self._samples.append((long_side, short_side)) + + best = [] + + for a_long, a_short in self._samples: + members = [ + (b_long, b_short) + for b_long, b_short in self._samples + if abs(b_long - a_long) / a_long <= 0.15 + and abs(b_short - a_short) / a_short <= 0.15 + ] + + if len(members) > len(best): + best = members + + self._expected = ( + float(np.median([m[0] for m in best])), + float(np.median([m[1] for m in best])), + ) + + self._established = len(best) >= 2 + + if self._established: + rounded = ( + round(self._expected[0]), + round(self._expected[1]), + ) + + if ( + self._last_size_log is None + or abs(rounded[0] - self._last_size_log[0]) > 4 + or abs(rounded[1] - self._last_size_log[1]) > 4 + ): + self._last_size_log = rounded + + print( + "[DETECT] sticker size learned: about " + f"{rounded[0]} x {rounded[1]} px. To pin it, set " + f"OBSTACLE_EXPECTED_SIZE = ({rounded[0]}, " + f"{rounded[1]}) in config.py." + ) + + def _forget_stale_size(self): + if self._pinned_size is not None: + return + + now = time.monotonic() + + if any(t.confirmed for t in self.tracks): + self._last_seen_any = now + return + + if ( + self._expected is not None + and now - self._last_seen_any + > OBSTACLE_SIZE_FORGET_SECONDS + ): + self._samples.clear() + self._expected = None + self._established = False + self._last_size_log = None + + # ======================================================== + # 5. Tracking + # ======================================================== + + def _update_tracks(self, candidates): + pairs = [] + + for track_index, track in enumerate(self.tracks): + gate = min( + OBSTACLE_TEMPORAL_MATCH_DISTANCE, + max(25.0, 0.75 * track.short), + ) + + for cand_index, candidate in enumerate(candidates): + if not candidate["relaxed_ok"]: + continue + + distance = math.hypot( + candidate["cx"] - track.cx, + candidate["cy"] - track.cy, + ) + + if distance <= gate: + pairs.append((distance, track_index, cand_index)) + + pairs.sort() + + used_tracks = set() + used_candidates = set() + + for _distance, track_index, cand_index in pairs: + if ( + track_index in used_tracks + or cand_index in used_candidates + ): + continue + + used_tracks.add(track_index) + used_candidates.add(cand_index) + + self._apply_hit( + self.tracks[track_index], + candidates[cand_index], + ) + + survivors = [] + + for track_index, track in enumerate(self.tracks): + if track_index not in used_tracks: + track.misses += 1 + + limit = ( + OBSTACLE_MAX_MISSING_FRAMES + if track.confirmed + else OBSTACLE_PENDING_MAX_MISSING_FRAMES + ) + + if track.misses > limit: + continue + + survivors.append(track) + + self.tracks = survivors + + # New stickers: strict tests only, and not on top of a + # sticker we already follow. + for cand_index, candidate in enumerate(candidates): + if ( + cand_index in used_candidates + or not candidate["strict_ok"] + ): + continue + + overlaps = any( + math.hypot( + candidate["cx"] - t.cx, + candidate["cy"] - t.cy, + ) + < 0.5 * min(t.short, candidate["short"]) + for t in self.tracks + ) + + if overlaps: + continue + + track = _Track(self._next_id, candidate) + + self._next_id += 1 + + self.tracks.append(track) + + self._maybe_confirm(track, candidate) + + def _apply_hit(self, track, candidate): + track.misses = 0 + track.hits += 1 + + long_side = candidate["long"] + short_side = candidate["short"] + + if ( + OBSTACLE_SNAP_TO_EXPECTED_SIZE + and self._established + and self._expected is not None + and candidate["snap"] + ): + expected_long, expected_short = self._expected + + if ( + abs(long_side - expected_long) / expected_long <= 0.10 + and abs(short_side - expected_short) + / expected_short + <= 0.10 + ): + long_side, short_side = expected_long, expected_short + + # Position: dead-band while nothing moves (no jitter for a + # ball resting on the sticker), fast catch-up when it does. + distance = math.hypot( + candidate["cx"] - track.cx, + candidate["cy"] - track.cy, + ) + + if distance > 0.8: + alpha = min(1.0, max(0.25, distance / 8.0)) + + track.cx += (candidate["cx"] - track.cx) * alpha + track.cy += (candidate["cy"] - track.cy) * alpha + + period = ( + 90.0 + if track.long / max(track.short, 1e-6) < _SQUARE_ASPECT + else 180.0 + ) + + half = period / 2.0 + + angle_diff = abs( + ((candidate["angle"] - track.angle + half) % period) + - half + ) + + if angle_diff > 0.6: + alpha = min(1.0, max(0.25, angle_diff / 4.0)) + + track.angle = blend_angle( + track.angle, + candidate["angle"], + alpha, + period, + ) + + track.long += (long_side - track.long) * 0.35 + track.short += (short_side - track.short) * 0.35 + + self._maybe_confirm(track, candidate) + + def _maybe_confirm(self, track, candidate): + if ( + track.confirmed + or track.hits < OBSTACLE_REQUIRED_STABLE_FRAMES + ): + return + + track.confirmed = True + + # Only single-sticker sightings teach the size (a merged + # pair would otherwise be learned as "the" sticker size). + if candidate["snap"] or self._expected is None: + self._learn_size(candidate["long"], candidate["short"]) + + # ======================================================== + # Output + # ======================================================== + + def _publish(self): + confirmed = [t for t in self.tracks if t.confirmed] + + signature = tuple( + ( + t.id, + round(t.cx, 1), + round(t.cy, 1), + round(t.angle, 1), + round(t.long, 1), + round(t.short, 1), + t.misses < OBSTACLE_STALE_MISSES, + ) + for t in confirmed + ) + + if signature == self._signature: + return + + self._signature = signature + + obstacles = [] + + for track in confirmed: + corners = obb_corners( + track.cx, + track.cy, + track.long, + track.short, + track.angle, + ) + + xs = [p[0] for p in corners] + ys = [p[1] for p in corners] + + obstacles.append( + { + "id": track.id, + "center": (track.cx, track.cy), + "angle": track.angle, + "rect_long": track.long, + "rect_short": track.short, + # False once the sticker hasn't been seen for a + # few passes: rendering then stops painting it. + "painted": track.misses < OBSTACLE_STALE_MISSES, + "x": min(xs), + "y": min(ys), + "width": max(xs) - min(xs), + "height": max(ys) - min(ys), + } + ) + + self.obstacles = obstacles + + # ======================================================== + # Debug drawing + # ======================================================== + + def draw(self, frame, obstacles=None): + """ + Annotated copy of a screen-space detection frame: confirmed + stickers in green, rejected candidates in red (with the + first failing reason's numbers), for tuning on site. + """ + if obstacles is None: + obstacles = self.obstacles + + output = frame.copy() + + for candidate in self.last_candidates: + if candidate["relaxed_ok"]: + continue + + corners = obb_corners( + candidate["cx"], + candidate["cy"], + candidate["long"], + candidate["short"], + candidate["angle"], + ) + + points = ( + np.array(corners) * self.scale + ).astype(np.int32).reshape((-1, 1, 2)) + + cv2.polylines(output, [points], True, (0, 0, 255), 1) + + cv2.putText( + output, + f"r{candidate['rectangularity']:.2f} " + f"s{candidate['solidity']:.2f} " + f"d{candidate['dark_ratio']:.2f}", + (int(points[0][0][0]), int(points[0][0][1])), + cv2.FONT_HERSHEY_SIMPLEX, + 0.4, + (0, 0, 255), + 1, + cv2.LINE_AA, + ) + + for obstacle in obstacles: + obb = extract_obb(obstacle) + + if obb is None: + continue + + cx, cy, long_side, short_side, angle = obb + + corners = obb_corners(cx, cy, long_side, short_side, angle) + + points = ( + np.array(corners) * self.scale + ).astype(np.int32).reshape((-1, 1, 2)) + + cv2.polylines(output, [points], True, (0, 255, 0), 2) + + cv2.circle( + output, + (int(cx * self.scale), int(cy * self.scale)), + 4, + (0, 255, 0), + -1, + ) + + return output + + # ======================================================== + # Helpers + # ======================================================== + + def _kernel(self, size_screen_px): + size = max(1, int(round(size_screen_px * self.scale))) + + if size % 2 == 0: + size += 1 + + return np.ones((size, size), np.uint8) diff --git a/effects.py b/effects.py new file mode 100644 index 0000000..81dab97 --- /dev/null +++ b/effects.py @@ -0,0 +1,271 @@ +# effects.py +# +# Everything cosmetic that reacts to the simulation: comet tails, +# colour changes on bounce, squash-and-stretch, sparks and rings. +# Pure state + math (no pygame, no drawing) - rendering.py draws it. +# None of it ever feeds back into physics. +# +# Driven by two things only: each ball's own state (position, +# velocity) and the events physics.py emits (kind, x, y, normal_x, +# normal_y, speed, ball) with kind "obstacle", "ball" or "pop". +# +# Camera safety: rendering only ever draws these with saturated +# colours in small dim glows, so the camera looking at this same +# monitor never mistakes an effect for a white sticker. Set +# VISUAL_EFFECTS_ENABLED = False in config.py to switch it all off. + +import math +import random + +from config import ( + VISUAL_EFFECTS_ENABLED, + BALL_HUE_MIN_DEGREES, + BALL_HUE_MAX_DEGREES, + BALL_HUE_SHIFT_ON_BOUNCE, + BALL_TRAIL_LENGTH, + BALL_TRAIL_SAMPLE_SECONDS, + BALL_SQUASH_DURATION_SECONDS, + IMPACT_EFFECT_MIN_SPEED, + MAX_PARTICLES, +) + + +_PARTICLE_GRAVITY = 520.0 +_PARTICLE_DRAG = 2.2 +_MAX_RINGS = 24 + +# Impact speed (px/s) at which effects reach full strength. +_FULL_STRENGTH_SPEED = 1400.0 + + +class Effects: + def __init__(self): + # Sparks: [x, y, vx, vy, age, life, hue, size] + self.particles = [] + + # Expanding rings: [x, y, age, life, hue, max_radius] + self.rings = [] + + self._random = random.Random() + + def clear(self): + self.particles = [] + self.rings = [] + + # ======================================================== + # Update + # ======================================================== + + def update(self, delta_time, balls, events): + if not VISUAL_EFFECTS_ENABLED: + return + + delta_time = max(0.0, min(delta_time, 0.05)) + + for event in events: + self._on_event(event) + + for ball in balls: + self._update_ball(ball, delta_time) + + self._update_particles(delta_time) + self._update_rings(delta_time) + + # ======================================================== + # Per-ball state + # ======================================================== + + def _update_ball(self, ball, dt): + if ball.squash > 0.0: + ball.squash = max( + 0.0, + ball.squash + - dt / max(BALL_SQUASH_DURATION_SECONDS, 1e-3), + ) + + if BALL_TRAIL_LENGTH <= 0: + ball.trail = [] + return + + ball.trail_timer += dt + + # More than one sample can be due on a slow frame; keep the + # tail evenly spaced in TIME regardless of frame rate. + while ball.trail_timer >= BALL_TRAIL_SAMPLE_SECONDS: + ball.trail_timer -= BALL_TRAIL_SAMPLE_SECONDS + + if ball.dying: + # A popping ball's tail drains away. + if ball.trail: + ball.trail.pop(0) + else: + ball.trail.append((ball.x, ball.y)) + + excess = len(ball.trail) - BALL_TRAIL_LENGTH + + if excess > 0: + del ball.trail[:excess] + + @staticmethod + def _shift_hue(hue, amount): + span = BALL_HUE_MAX_DEGREES - BALL_HUE_MIN_DEGREES + + return ( + BALL_HUE_MIN_DEGREES + + (hue - BALL_HUE_MIN_DEGREES + amount) % span + ) + + # ======================================================== + # Events -> sparks, rings, squash, colour change + # ======================================================== + + def _on_event(self, event): + kind, x, y, nx, ny, speed, ball = event + + if kind == "pop": + self._burst(x, y, ball.hue, 16, 80.0, 260.0) + self._ring(x, y, ball.hue, ball.radius * 3.0, 0.45) + return + + if kind == "launch": + self._sparks(x, y, nx, ny, ball.hue, 12, 0.8) + self._ring(x, y, ball.hue, ball.radius * 2.2, 0.25) + return + + strength = min(1.0, speed / _FULL_STRENGTH_SPEED) + + ball.squash = 1.0 + ball.squash_nx = nx + ball.squash_ny = ny + + if kind == "obstacle": + ball.hue = self._shift_hue( + ball.hue, BALL_HUE_SHIFT_ON_BOUNCE + ) + + self._sparks( + x, + y, + nx, + ny, + ball.hue, + int(4 + 10 * strength), + strength, + ) + + self._ring( + x, + y, + ball.hue, + ball.radius * (1.6 + 1.6 * strength), + 0.32, + ) + else: + self._burst( + x, + y, + ball.hue, + int(3 + 6 * strength), + 60.0, + 140.0 + 200.0 * strength, + ) + + self._ring( + x, + y, + ball.hue, + ball.radius * (1.3 + 1.0 * strength), + 0.25, + ) + + def _sparks(self, x, y, nx, ny, hue, count, strength): + rng = self._random + + base = math.atan2(ny, nx) + + for _ in range(count): + angle = base + rng.uniform(-1.25, 1.25) + + speed = rng.uniform( + 140.0, + 260.0 + 420.0 * strength, + ) + + self._add_particle( + x, + y, + math.cos(angle) * speed, + math.sin(angle) * speed, + rng.uniform(0.35, 0.8), + hue + rng.uniform(-14.0, 14.0), + rng.uniform(2.5, 5.5), + ) + + def _burst(self, x, y, hue, count, speed_min, speed_max): + rng = self._random + + for _ in range(count): + angle = rng.uniform(0.0, 2.0 * math.pi) + speed = rng.uniform(speed_min, speed_max) + + self._add_particle( + x, + y, + math.cos(angle) * speed, + math.sin(angle) * speed, + rng.uniform(0.3, 0.7), + hue + rng.uniform(-18.0, 18.0), + rng.uniform(2.5, 5.0), + ) + + def _add_particle(self, x, y, vx, vy, life, hue, size): + if len(self.particles) >= MAX_PARTICLES: + # Drop the oldest so a busy moment never grows without + # bound. + del self.particles[0] + + self.particles.append( + [x, y, vx, vy, 0.0, life, hue, size] + ) + + def _ring(self, x, y, hue, max_radius, life): + if len(self.rings) >= _MAX_RINGS: + del self.rings[0] + + self.rings.append([x, y, 0.0, life, hue, max_radius]) + + # ======================================================== + # Ageing + # ======================================================== + + def _update_particles(self, dt): + alive = [] + + drag = max(0.0, 1.0 - _PARTICLE_DRAG * dt) + + for particle in self.particles: + particle[4] += dt + + if particle[4] >= particle[5]: + continue + + particle[3] += _PARTICLE_GRAVITY * dt + particle[2] *= drag + particle[3] *= drag + particle[0] += particle[2] * dt + particle[1] += particle[3] * dt + + alive.append(particle) + + self.particles = alive + + def _update_rings(self, dt): + alive = [] + + for ring in self.rings: + ring[2] += dt + + if ring[2] < ring[3]: + alive.append(ring) + + self.rings = alive diff --git a/geometry.py b/geometry.py new file mode 100644 index 0000000..39a139a --- /dev/null +++ b/geometry.py @@ -0,0 +1,255 @@ +# geometry.py +# +# Small, shared helpers for working with ROTATED rectangles (an +# "OBB" - oriented bounding box) in screen space: a rectangle +# stickers can be glued on crooked, so an obstacle's position is no +# longer just an axis-aligned (x, y, width, height) - it also has +# an angle. This module is the single place that knows how to: +# +# - turn a raw cv2.minAreaRect() result into a STABLE, canonical +# (center, long side, short side, angle) representation that +# doesn't jitter or jump between frames (see normalize_angle +# and detection.py for why that's needed), +# - blend/interpolate that angle smoothly across frames +# (blend_angle), +# - convert between world space and the rectangle's own local, +# axis-aligned frame (world_to_local / local_to_world) - this +# is what lets both collision detection AND collision response +# be written as plain axis-aligned math that "just works" for +# any rotation, by doing the rotation once at the boundary +# instead of re-deriving rotated formulas everywhere, +# - compute the 4 corners of a rectangle for drawing +# (obb_corners), +# - pull all of the above out of a detected-obstacle dict in one +# place, with one shared definition of what counts as a valid +# obstacle (extract_obb), so detection/physics/rendering can +# never disagree about it. +# +# Angle convention: an un-rotated rectangle (long side horizontal) +# has angle 0. Positive angles rotate the long side counter- +# clockwise in standard math coordinates - visually CLOCKWISE on +# screen, since screen y grows downward. Angles are always kept in +# the canonical range (-90, 90], because a rectangle looks +# identical after a 180-degree rotation (it has no "arrow" printed +# on it), so anything outside that range is a redundant duplicate +# of an angle already inside it. Collapsing to one canonical range +# is what keeps blend_angle's shortest-path interpolation well +# defined instead of it occasionally spinning the "wrong" way. + +import math + + +# ============================================================ +# Angle canonicalization +# ============================================================ + +def normalize_angle(angle_degrees, period=180.0): + """ + Fold an orientation angle into the canonical (-period/2, + period/2] range. + + A rectangle has 180-degree rotational symmetry, so any angle is + equivalent to one inside a 180-degree-wide window (period=180). + A (nearly) SQUARE sticker is even more symmetric - it looks the + same after every 90 degrees - so for those callers pass + period=90. Always folding into the SAME window is what makes + two angles describing the same physical orientation compare and + blend as equal instead of looking like they are far apart. + """ + half = period / 2.0 + + angle = angle_degrees % period + + if angle > half: + angle -= period + + return angle + + +def blend_angle(old_angle, new_angle, alpha, period=180.0): + """ + Move old_angle toward new_angle by fraction alpha along the + SHORTEST path, for orientations that repeat every `period` + degrees. + + A naive `old + alpha * (new - old)` breaks near the wrap-around + point: 89 degrees and -89 degrees are really only 2 degrees + apart for a rectangle, but the naive subtraction sees 178 and + blends the wrong way (and for a near-square sticker, averaging + across its 90-degree ambiguity would even produce a completely + wrong in-between angle). Wrapping the raw difference into + (-period/2, period/2] first avoids both. + """ + half = period / 2.0 + + diff = ((new_angle - old_angle + half) % period) - half + + return normalize_angle(old_angle + alpha * diff, period) + + +def paint_dimensions(rect_long, rect_short, inset_ratio): + """ + Size of the white patch painted on the monitor for a detected + sticker: the sticker trimmed by inset_ratio * short side on every + edge. Shared by rendering (to draw it) and detection (to blank + exactly that area out before looking for stickers) so the two can + never disagree about what was painted. + """ + inset = inset_ratio * rect_short + + return ( + max(1.0, rect_long - 2.0 * inset), + max(1.0, rect_short - 2.0 * inset), + ) + + +# ============================================================ +# World <-> local (rectangle-aligned) frame conversion +# ============================================================ + +def world_to_local(x, y, angle_degrees): + """ + Rotate a world-space vector (x, y) by -angle_degrees, i.e. into + the rectangle's own local frame, where its long side lies along + the local x-axis and its short side along the local y-axis. + + This takes a VECTOR, not a point - to transform a point, first + subtract the rectangle's center, call this, and the result is + the point's position relative to the rectangle in its own + frame. The same function (with no subtraction) also correctly + rotates direction-only quantities like velocity, since rotation + doesn't care about position. + """ + theta = math.radians(angle_degrees) + + cos_t = math.cos(theta) + sin_t = math.sin(theta) + + return ( + x * cos_t + y * sin_t, + -x * sin_t + y * cos_t, + ) + + +def local_to_world(x, y, angle_degrees): + """ + The exact inverse of world_to_local: rotate a local-frame + vector back by +angle_degrees into world space. + """ + theta = math.radians(angle_degrees) + + cos_t = math.cos(theta) + sin_t = math.sin(theta) + + return ( + x * cos_t - y * sin_t, + x * sin_t + y * cos_t, + ) + + +# ============================================================ +# Corners (for drawing / debugging) +# ============================================================ + +def obb_corners(center_x, center_y, rect_long, rect_short, angle_degrees): + """ + Return the 4 corners of a rotated rectangle, in order around + its perimeter, as world-space (x, y) tuples. Safe to hand + straight to pygame.draw.polygon or cv2.polylines. + """ + half_long = rect_long / 2.0 + half_short = rect_short / 2.0 + + local_corners = ( + (half_long, half_short), + (half_long, -half_short), + (-half_long, -half_short), + (-half_long, half_short), + ) + + corners = [] + + for local_x, local_y in local_corners: + world_dx, world_dy = local_to_world( + local_x, + local_y, + angle_degrees, + ) + + corners.append( + ( + center_x + world_dx, + center_y + world_dy, + ) + ) + + return corners + + +# ============================================================ +# Obstacle dict -> OBB extraction +# ============================================================ + +def extract_obb(obstacle): + """ + Pull (center_x, center_y, rect_long, rect_short, angle) out of + a detected-obstacle dict, or return None if the obstacle is + missing/malformed/degenerate. This is the ONE place that + decides what counts as a usable oriented rectangle, so physics, + rendering, and validity-checking can never disagree with each + other about it. + + Falls back to treating the obstacle as an axis-aligned + (angle=0) rectangle built from x/y/width/height if it has no + "center"/"rect_long"/"rect_short"/"angle" fields, so any code + that still hands in a plain axis-aligned obstacle dict keeps + working exactly as before. + """ + if not isinstance(obstacle, dict): + return None + + try: + if "center" in obstacle: + center_x, center_y = obstacle["center"] + else: + center_x = ( + obstacle["x"] + obstacle["width"] / 2.0 + ) + center_y = ( + obstacle["y"] + obstacle["height"] / 2.0 + ) + + rect_long = obstacle.get( + "rect_long", + obstacle.get("width"), + ) + rect_short = obstacle.get( + "rect_short", + obstacle.get("height"), + ) + angle = obstacle.get("angle", 0.0) + + center_x = float(center_x) + center_y = float(center_y) + rect_long = float(rect_long) + rect_short = float(rect_short) + angle = float(angle) + except (KeyError, TypeError, ValueError): + return None + + if not all( + math.isfinite(value) + for value in ( + center_x, + center_y, + rect_long, + rect_short, + angle, + ) + ): + return None + + if rect_long <= 0 or rect_short <= 0: + return None + + return center_x, center_y, rect_long, rect_short, angle diff --git a/imaging.py b/imaging.py new file mode 100644 index 0000000..37b9910 --- /dev/null +++ b/imaging.py @@ -0,0 +1,124 @@ +# imaging.py +# +# Small, shared OpenCV helpers for illumination-robust color work, +# used by both calibration.py (finding the monitor) and +# detection.py (finding the white stickers). +# +# Both work in CIELAB rather than HSV, for one concrete reason: LAB +# separates brightness (L) from true color (a, b) far more cleanly +# than HSV does. A glare highlight, a shadow, a dimmer corner of the +# hall, or the camera's own auto white-balance drifting under +# exhibition lighting - all of these move L a lot while barely +# moving a/b. That is exactly the property needed to answer "is +# this green?" or "is this a neutral white/gray?" in a way that +# stays true regardless of how bright, dim, or unevenly lit that +# particular pixel happens to be right now - which is the real +# source of "the camera doesn't quite see what the monitor is +# actually showing": most of that gap is a brightness/exposure +# effect, not a pure color-chemistry one, and LAB is built to shrug +# that off. + +import cv2 +import numpy as np + + +def to_lab(frame_bgr): + """ + (L, a, b), each a 2D uint8 array in OpenCV's 0-255 LAB + encoding: L is lightness (0=black, 255=white), a is the + green(low)-red(high) axis centered at 128, b is the + blue(low)-yellow(high) axis centered at 128. + """ + lab = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2LAB) + + return lab[:, :, 0], lab[:, :, 1], lab[:, :, 2] + + +def chroma(a_channel, b_channel, reference_a=128.0, reference_b=128.0): + """ + How colorful a pixel is, independent of how bright or dark it + is - near 0 at the reference point, larger for anything further + away from it in LAB's roughly perceptually-even a/b plane. + Unlike HSV saturation (a ratio that gets noisy and unstable for + dark pixels, since it is normalized by brightness itself), this + stays meaningful whether a pixel is in a bright spot or a + shadowed corner. + + The reference defaults to (128, 128) - LAB's own neutral gray - + but callers that have a measured "what does a KNOWN-white + surface look like to this camera right now" point (see + Calibration.white_reference) should pass it in instead. Under + colored ambient light even genuinely white paper is NOT neutral + gray to the camera - that is unavoidable physics, not a flaw in + the camera - so testing distance from true neutral would wrongly + reject it. Testing distance from "whatever white looks like + right now" sidesteps that entirely, the same way a photographer + custom white-balances off a gray card instead of trusting a + fixed assumption about what "white" means. + """ + da = a_channel.astype(np.float32) - reference_a + db = b_channel.astype(np.float32) - reference_b + + return np.sqrt(da ** 2 + db ** 2) + + +def green_dominance(a_channel): + """ + How far into "green" territory a pixel sits on LAB's red-green + axis, clipped to [0, 255]. Green is the LOW end of the a + channel (a < 128), so this is just how far below 128 the pixel + is. Using this one, roughly illumination-independent channel + instead of comparing raw R/G/B brightness is what lets the + calibration pattern still be found once the camera's own white + balance has shifted its recorded color away from what the + monitor is actually displaying. + """ + return np.clip( + 128 - a_channel.astype(np.int16), + 0, + 255, + ).astype(np.uint8) + + +def fill_holes(mask): + """ + Fills any fully-enclosed hole in a binary mask (0/255) - e.g. a + glare highlight punching a small gap in the middle of an + otherwise solid colored region, or a reflection splitting a + sticker's blob in two. Standard flood-fill-from-the-border + trick: anything NOT reachable from the mask's own border by + walking through zero pixels must be an enclosed hole, so it + gets filled in. A real gap that reaches all the way out to the + mask's edge (e.g. a person actually standing in front of part + of the pattern) stays untouched, since it IS reachable from the + border. + """ + height, width = mask.shape[:2] + + # Padding with a guaranteed-zero 1px border first means the + # flood-fill seed point is always background, even if the real + # mask happens to already be foreground right at image corner + # (0, 0) - otherwise floodFill would treat that corner's own + # color as the "background" to flood, filling nothing at all. + padded = cv2.copyMakeBorder( + mask, + 1, + 1, + 1, + 1, + cv2.BORDER_CONSTANT, + value=0, + ) + + flood = padded.copy() + + flood_fill_mask = np.zeros( + (height + 4, width + 4), + np.uint8, + ) + + cv2.floodFill(flood, flood_fill_mask, (0, 0), 255) + + flood = flood[1:-1, 1:-1] + + return mask | cv2.bitwise_not(flood) diff --git a/launcher.py b/launcher.py new file mode 100644 index 0000000..ae0fb19 --- /dev/null +++ b/launcher.py @@ -0,0 +1,138 @@ +# launcher.py +# +# Represents the top-center launcher tube. +# +# Features: +# - Pivot point attached at top-center of the screen (x = width / 2, y = -15), +# extending above the screen top so it is clipped by the screen boundary. +# - Consists ONLY of a short, elongated solid tube/barrel (no base/tank/turret). +# - Continuous smooth rotation between LAUNCHER_MIN_ANGLE and LAUNCHER_MAX_ANGLE. +# - Provides collision parameters for PhysicsWorld (_Solid OBB). +# - Provides ball spawn transform (position + velocity) outward from nozzle exit. + +import math + +from config import ( + LAUNCHER_MIN_ANGLE, + LAUNCHER_MAX_ANGLE, + LAUNCHER_LENGTH, + LAUNCHER_WIDTH, + LAUNCHER_ROTATION_SPEED, + LAUNCHER_BALL_SPAWN_SPEED, + BALL_RADIUS, + BALL_RADIUS_VARIATION, +) + + +class Launcher: + def __init__(self, screen_width, screen_height): + self.screen_width = float(screen_width) + self.screen_height = float(screen_height) + + # Pivot point at top center. Placed slightly above y=0 (-15px) so the upper part + # extends beyond the top of the screen and is clipped by the screen boundary. + self.pivot_x = self.screen_width / 2.0 + self.pivot_y = -15.0 + + self.min_angle = float(LAUNCHER_MIN_ANGLE) + self.max_angle = float(LAUNCHER_MAX_ANGLE) + self.length = float(LAUNCHER_LENGTH) + self.tube_width = float(LAUNCHER_WIDTH) + self.rotation_speed = float(LAUNCHER_ROTATION_SPEED) + self.spawn_speed = float(LAUNCHER_BALL_SPAWN_SPEED) + + self._time = 0.0 + self.angle = 0.0 # degrees from straight down (0=down, negative=left, positive=right) + self._update_angle() + + def update_screen_size(self, width, height): + self.screen_width = float(width) + self.screen_height = float(height) + self.pivot_x = self.screen_width / 2.0 + + def update(self, delta_time): + self._time += delta_time + self._update_angle() + + def _update_angle(self): + mid_angle = (self.min_angle + self.max_angle) / 2.0 + amplitude = (self.max_angle - self.min_angle) / 2.0 + + if amplitude <= 0: + self.angle = mid_angle + return + + # Smooth harmonic oscillation within [min_angle, max_angle] + period = max(0.1, (4.0 * amplitude) / max(1.0, self.rotation_speed)) + omega = (2.0 * math.pi) / period + + sine_val = math.sin(omega * self._time) + subtle_wave = 0.85 * sine_val + 0.15 * math.sin(omega * 0.5 * self._time) + + self.angle = mid_angle + amplitude * max(-1.0, min(1.0, subtle_wave)) + + def get_solid_params(self): + """ + Returns (cx, cy, rect_long, rect_short, angle) for creating a _Solid in physics. + """ + rad = math.radians(self.angle) + dir_x = math.sin(rad) + dir_y = math.cos(rad) + + cx = self.pivot_x + dir_x * (self.length / 2.0) + cy = self.pivot_y + dir_y * (self.length / 2.0) + + # OBB angle in geometry convention: 90 degrees minus launcher angle + obb_angle = 90.0 - self.angle + + return cx, cy, self.length, self.tube_width, obb_angle + + def get_spawn_transform(self): + """ + Returns (spawn_x, spawn_y, dir_x, dir_y) for spawning a ball outward + along the nozzle direction, positioned slightly outside the nozzle tip. + """ + rad = math.radians(self.angle) + dir_x = math.sin(rad) + dir_y = math.cos(rad) + + max_ball_radius = BALL_RADIUS * (1.0 + BALL_RADIUS_VARIATION) + safe_dist = self.length + max_ball_radius + 3.0 + + spawn_x = self.pivot_x + dir_x * safe_dist + spawn_y = self.pivot_y + dir_y * safe_dist + + return spawn_x, spawn_y, dir_x, dir_y + + def get_points(self): + """ + Returns the 4 corner points of the launcher tube rectangle. + """ + rad = math.radians(self.angle) + u_x = math.sin(rad) + u_y = math.cos(rad) + + v_x = -u_y + v_y = u_x + + half_w = self.tube_width / 2.0 + length = self.length + + p_top_left = ( + self.pivot_x - v_x * half_w, + self.pivot_y - v_y * half_w, + ) + p_top_right = ( + self.pivot_x + v_x * half_w, + self.pivot_y + v_y * half_w, + ) + p_bot_right = ( + self.pivot_x + u_x * length + v_x * half_w, + self.pivot_y + u_y * length + v_y * half_w, + ) + p_bot_left = ( + self.pivot_x + u_x * length - v_x * half_w, + self.pivot_y + u_y * length - v_y * half_w, + ) + + return [p_top_left, p_top_right, p_bot_right, p_bot_left] diff --git a/main.py b/main.py new file mode 100644 index 0000000..c16bcbe --- /dev/null +++ b/main.py @@ -0,0 +1,274 @@ +# main.py + +import time + +import cv2 +import pygame + +from camera import Camera +from calibration import Calibration +from detection import Detector +from vision import Vision +from physics import PhysicsWorld +from effects import Effects +from rendering import Renderer + +from config import ( + CAMERA_URL, + SCREEN_WIDTH, + SCREEN_HEIGHT, + BALL_SPAWN_INTERVAL_SECONDS, +) + + +# If a spawn attempt fails (too many balls in play, or another ball +# is sitting right at the drop point), try again after this short +# delay instead of waiting a whole spawn interval. +_SPAWN_RETRY_SECONDS = 0.25 + +_WARNING_INTERVAL_SECONDS = 5.0 + + +class Game: + # ======================================================== + # Initialization + # ======================================================== + + def __init__(self): + # Connecting happens on the camera's own thread, so the + # window below comes up (and stays responsive to the + # keyboard) even if the phone isn't streaming yet. + self.camera = Camera(CAMERA_URL) + + self.calibration = Calibration() + self.detector = Detector() + + # Camera -> calibration -> sticker detection, all on a + # background thread; the loop below only ever reads its + # latest published result. + self.vision = Vision( + self.camera, + self.calibration, + self.detector, + ) + + self.physics = PhysicsWorld( + SCREEN_WIDTH, + SCREEN_HEIGHT, + ) + + self.effects = Effects() + + self.renderer = Renderer() + + self.running = True + + self.was_calibrated = False + + # Very negative so the first ball drops the moment + # calibration locks on. + self.last_ball_spawn = -1e9 + + self.last_warning = 0.0 + + self.vision.start() + + # ======================================================== + # Main loop + # ======================================================== + + def run(self): + try: + while self.running: + self._handle_events() + + if not self.running: + break + + # The per-frame pipeline is wrapped defensively: + # this runs unattended in a busy exhibition hall, + # and one unexpected error must never take the + # installation down. Worst case we skip a frame. + try: + self._run_one_frame() + except Exception as error: + print( + "[ERROR] unexpected failure in main " + f"loop, continuing: {error!r}" + ) + + time.sleep(0.05) + + finally: + self.close() + + def _run_one_frame(self): + ( + calibrated, + frozen, + obstacles, + message, + ) = self.vision.snapshot() + + current_time = time.monotonic() + + self._maybe_warn(current_time, frozen, message) + + if not calibrated: + if self.was_calibrated: + print( + "[CALIBRATION] lost/reset - " + "waiting for the screen to be recognized..." + ) + + self.was_calibrated = False + + self.renderer.render_calibration() + + # Nothing moves while calibrating; no need to spin. + self.renderer.clock.tick(30) + + return + + if not self.was_calibrated: + print("[CALIBRATION] locked on - starting game.") + + self.was_calibrated = True + + delta_time = self.renderer.get_delta_time() + + self.physics.update(delta_time, obstacles) + + self._spawn_ball(current_time) + + self.effects.update( + delta_time, + self.physics.balls, + self.physics.events, + ) + + self.renderer.render( + self.physics.balls, + obstacles, + self.effects, + self.physics.launcher, + ) + + # ======================================================== + # Warnings + # ======================================================== + + def _maybe_warn(self, current_time, frozen, message): + if ( + current_time - self.last_warning + < _WARNING_INTERVAL_SECONDS + ): + return + + if frozen: + print( + "[WARNING] Camera feed looks FROZEN " + "(no motion detected for a while) - " + "check the phone/app/connection. " + "Calibration and obstacle detection are " + "paused until a live frame is seen again." + ) + elif message: + print(f"[WARNING] {message}") + else: + return + + self.last_warning = current_time + + # ======================================================== + # Ball spawning + # ======================================================== + + def _spawn_ball(self, current_time): + # Balls arrive on their OWN clock, one every + # BALL_SPAWN_INTERVAL_SECONDS, whether or not earlier balls + # have left. A ball wedged between stickers just stays + # (until the physics removes it as stuck) and the next one + # still comes on schedule. + if ( + current_time - self.last_ball_spawn + < BALL_SPAWN_INTERVAL_SECONDS + ): + return + + if self.physics.spawn_ball() is not None: + self.last_ball_spawn = current_time + else: + self.last_ball_spawn = ( + current_time + - BALL_SPAWN_INTERVAL_SECONDS + + _SPAWN_RETRY_SECONDS + ) + + # ======================================================== + # Events + # ======================================================== + + def _handle_events(self): + for event in pygame.event.get(): + if event.type == pygame.QUIT: + self.running = False + + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_q: + self.running = False + + elif event.key in ( + pygame.K_c, + pygame.K_ESCAPE, + ): + self._reset_calibration() + + # ======================================================== + # Calibration reset + # ======================================================== + + def _reset_calibration(self): + self.vision.request_reset() + + self.physics.clear() + + self.effects.clear() + + self.renderer.force_full_repaint() + + self.last_ball_spawn = -1e9 + + # ======================================================== + # Shutdown + # ======================================================== + + def close(self): + try: + self.vision.stop() + except Exception: + pass + + try: + self.camera.release() + except Exception: + pass + + try: + cv2.destroyAllWindows() + except Exception: + pass + + try: + self.renderer.close() + except Exception: + pass + + +def main(): + game = Game() + game.run() + + +if __name__ == "__main__": + main() diff --git a/physics.py b/physics.py new file mode 100644 index 0000000..d51bf91 --- /dev/null +++ b/physics.py @@ -0,0 +1,747 @@ +# physics.py +# +# Pure simulation - no pygame, no OpenCV, no drawing. Everything the +# viewer sees (trails, glow, sparks, squash) is derived from this +# module's state and the events it emits, in effects.py. +# +# What is simulated: +# - balls falling under gravity, with slightly different sizes +# (bigger = heavier), +# - bouncing off detected stickers, which are ROTATED rectangles: +# the bounce uses the real surface normal (flat edge or corner) +# of the tilted sticker, with restitution (how bouncy) and +# friction (so balls roll along slopes instead of skating), +# - balls bouncing off each other, +# - ALL FOUR screen edges as solid walls - a ball never leaves the +# screen, it bounces off the top/bottom/left/right forever, +# - by default none of the above bounces lose any energy +# (BALL_RESTITUTION = BALL_BALL_RESTITUTION = 1.0, BALL_FRICTION +# = 0 in config.py), so with gravity a ball just keeps bouncing +# indefinitely like an ideal rubber ball, instead of gradually +# settling - lower these if a more "real rubber ball" feel that +# loses a little height each bounce is wanted instead, +# - a ball is only ever removed in one of two ways, each +# independently configurable in config.py and each with its own +# little pop effect: it reaches BALL_MAX_LIFETIME_SECONDS after +# being spawned, or it is "stuck" (parked in one spot, usually a +# pocket between stickers) for BALL_STUCK_TIMEOUT_SECONDS. +# +# Performance notes (this runs 144 times a second): +# - obstacle dicts are converted ONCE per detection update into +# lightweight _Solid records with cos/sin already computed, +# - each ball first does a cheap circle-vs-circle reject against +# each solid's bounding circle before any real collision math, +# - time is split into equal slices no longer than +# PHYSICS_MAX_STEP_SECONDS (and short enough that a fast ball +# can't skip over a sticker), so motion stays smooth and +# proportional to real elapsed time. + +import math +import random + +from geometry import extract_obb +from launcher import Launcher + +from config import ( + BALL_RADIUS, + BALL_RADIUS_VARIATION, + BALL_GRAVITY, + BALL_RESTITUTION, + BALL_REST_SPEED, + BALL_FRICTION, + BALL_MAX_SPEED, + BALL_BALL_COLLISION_ENABLED, + BALL_BALL_RESTITUTION, + PHYSICS_MAX_STEP_SECONDS, + MAX_SIMULTANEOUS_BALLS, + BALL_STUCK_DISTANCE, + BALL_STUCK_TIMEOUT_SECONDS, + BALL_MAX_LIFETIME_SECONDS, + BALL_HUE_MIN_DEGREES, + BALL_HUE_MAX_DEGREES, + IMPACT_EFFECT_MIN_SPEED, +) + + +# Continuous slow drag (per second) applied to the sliding speed of +# a ball that is resting/rolling ON a surface, so it doesn't glide +# forever on a slope. Impact friction (BALL_FRICTION) is separate. +_ROLLING_DRAG = 0.6 + +# How long a removed ("popped") ball takes to shrink away. +_DYING_SECONDS = 0.45 + +_MAX_EVENTS_PER_FRAME = 64 + + +class Ball: + def __init__( + self, + x, + y, + radius=15, + velocity_x=0.0, + velocity_y=0.0, + gravity=900.0, + hue=200.0, + ): + self.x = float(x) + self.y = float(y) + + self.radius = float(radius) + + # Mass grows with area, so a big ball shoves a small one + # around instead of bouncing off it like an equal. + self.mass = self.radius * self.radius + + self.velocity_x = float(velocity_x) + self.velocity_y = float(velocity_y) + + self.gravity = float(gravity) + + self.alive = True + + # True once the ball has fallen down far enough to actually + # be on screen (y - radius >= 0). Until then the top wall is + # not enforced, so a ball can still drop in from just above + # the visible screen the way it always has; from then on, + # the top wall is a solid ceiling like every other edge. + self.entered = False + + # Dying balls are on their way out (shrinking away): they + # no longer collide with anything. + self.dying = False + self.fade = 1.0 + + self.age = 0.0 + + # Purely cosmetic state, owned/updated by effects.py. + self.hue = float(hue) + self.trail = [] + self.trail_timer = 0.0 + self.squash = 0.0 + self.squash_nx = 0.0 + self.squash_ny = -1.0 + + # Stuck detection. + self._anchor_x = self.x + self._anchor_y = self.y + self._stuck_time = 0.0 + + @property + def speed(self): + return math.hypot(self.velocity_x, self.velocity_y) + + +class _Solid: + """One detected sticker, pre-digested for fast collision tests.""" + + __slots__ = ( + "cx", + "cy", + "half_long", + "half_short", + "cos", + "sin", + "bound", + ) + + def __init__(self, cx, cy, rect_long, rect_short, angle): + theta = math.radians(angle) + + self.cx = cx + self.cy = cy + self.half_long = rect_long / 2.0 + self.half_short = rect_short / 2.0 + self.cos = math.cos(theta) + self.sin = math.sin(theta) + + # Radius of the smallest circle containing the whole + # rectangle: anything farther away than this (plus a + # ball's radius) can't possibly touch it. + self.bound = math.hypot( + self.half_long, + self.half_short, + ) + + +class PhysicsWorld: + def __init__(self, width, height): + self.width = float(width) + self.height = float(height) + + self.launcher = Launcher(width, height) + + self.balls = [] + + # Impact/pop/launch events produced by the last update(), consumed + # by effects.py: (kind, x, y, normal_x, normal_y, speed, + # ball). kind is "obstacle", "ball", "pop" or "launch". + self.events = [] + + self._solids = [] + self._sticker_solids = [] + self._solid_source = None + + self._random = random.Random() + self._hue_cursor = self._random.uniform( + BALL_HUE_MIN_DEGREES, + BALL_HUE_MAX_DEGREES, + ) + + # ======================================================== + # Balls + # ======================================================== + + def add_ball(self, ball): + if ball is not None: + self.balls.append(ball) + + def live_ball_count(self): + return sum( + 1 + for ball in self.balls + if ball.alive and not ball.dying + ) + + def _next_hue(self): + # Golden-ratio hop around the allowed hue range: successive + # balls always get clearly different colors. + span = BALL_HUE_MAX_DEGREES - BALL_HUE_MIN_DEGREES + + self._hue_cursor = ( + BALL_HUE_MIN_DEGREES + + ( + self._hue_cursor + - BALL_HUE_MIN_DEGREES + + span * 0.618034 + ) + % span + ) + + return self._hue_cursor + + def spawn_ball(self): + """ + Spawns a new ball emerging from the launcher nozzle. + Returns the ball, or None if blocked at nozzle exit or max balls reached. + """ + if self.live_ball_count() >= MAX_SIMULTANEOUS_BALLS: + return None + + spawn_x, spawn_y, dir_x, dir_y = self.launcher.get_spawn_transform() + + rng = self._random + + radius = BALL_RADIUS * ( + 1.0 + + rng.uniform( + -BALL_RADIUS_VARIATION, + BALL_RADIUS_VARIATION, + ) + ) + + min_distance = (radius + BALL_RADIUS * (1.0 + BALL_RADIUS_VARIATION)) * 1.05 + + for other in self.balls: + if not other.alive or other.dying: + continue + + if math.hypot(spawn_x - other.x, spawn_y - other.y) < min_distance: + return None + + vel_x = dir_x * self.launcher.spawn_speed + vel_y = dir_y * self.launcher.spawn_speed + + ball = Ball( + x=spawn_x, + y=spawn_y, + radius=radius, + velocity_x=vel_x, + velocity_y=vel_y, + gravity=BALL_GRAVITY, + hue=self._next_hue(), + ) + ball.entered = True + + self.balls.append(ball) + + if len(self.events) < _MAX_EVENTS_PER_FRAME: + self.events.append( + ( + "launch", + spawn_x, + spawn_y, + dir_x, + dir_y, + self.launcher.spawn_speed, + ball, + ) + ) + + return ball + + # ======================================================== + # Obstacles + # ======================================================== + + def set_obstacles(self, obstacles): + # The vision thread publishes a NEW list object whenever + # something changed; rebuild sticker solids only then. + if obstacles is not self._solid_source: + self._solid_source = obstacles + + solids = [] + + for obstacle in obstacles or (): + obb = extract_obb(obstacle) + + if obb is None: + continue + + solids.append(_Solid(*obb)) + + self._sticker_solids = solids + + # Combine sticker solids + launcher tube solid + self._solids = list(self._sticker_solids) + if self.launcher is not None: + self._solids.append(_Solid(*self.launcher.get_solid_params())) + + # ======================================================== + # Update + # ======================================================== + + def update(self, delta_time, obstacles=None): + self.events = [] + + if delta_time <= 0: + return + + delta_time = min(delta_time, 0.05) + + if self.launcher is not None: + self.launcher.update(delta_time) + + self.set_obstacles(obstacles) + + movers = [ + ball + for ball in self.balls + if ball.alive and not ball.dying + ] + + if movers: + fastest = max(ball.speed for ball in movers) + + smallest = min(ball.radius for ball in movers) + + # Enough slices that (a) none is longer than the + # configured maximum and (b) no ball travels more than + # half its own radius in one slice - which is what + # guarantees it can't skip through a thin sticker. + steps = max( + math.ceil( + delta_time / PHYSICS_MAX_STEP_SECONDS + ), + math.ceil( + fastest * delta_time + / (0.5 * smallest) + ), + 1, + ) + + steps = min(steps, 10) + + step = delta_time / steps + + for _ in range(steps): + self._step(movers, step) + + self._housekeeping(delta_time) + + def _step(self, movers, h): + for ball in movers: + ball.velocity_y += ball.gravity * h + + speed = ball.speed + + if speed > BALL_MAX_SPEED: + scale = BALL_MAX_SPEED / speed + ball.velocity_x *= scale + ball.velocity_y *= scale + + ball.x += ball.velocity_x * h + ball.y += ball.velocity_y * h + + if BALL_BALL_COLLISION_ENABLED and len(movers) > 1: + self._collide_balls(movers) + + for ball in movers: + # Up to 3 passes so a ball squeezed between two + # stickers (or a sticker and another ball's push) ends + # up somewhere consistent instead of half inside one. + for _ in range(3): + touched = False + + for solid in self._solids: + if self._collide_solid(ball, solid, h): + touched = True + + if not touched: + break + + self._keep_inside_walls(ball) + + # ======================================================== + # Ball vs. sticker (oriented rectangle) + # ======================================================== + + def _collide_solid(self, ball, solid, h): + radius = ball.radius + + dx = ball.x - solid.cx + dy = ball.y - solid.cy + + reach = solid.bound + radius + + # Cheap reject: outside the sticker's bounding circle. + if dx * dx + dy * dy > reach * reach: + return False + + cos_t = solid.cos + sin_t = solid.sin + + # Ball center in the sticker's own un-rotated frame, where + # this is plain circle-vs-axis-aligned-box. + local_x = dx * cos_t + dy * sin_t + local_y = -dx * sin_t + dy * cos_t + + half_long = solid.half_long + half_short = solid.half_short + + closest_x = max(-half_long, min(local_x, half_long)) + closest_y = max(-half_short, min(local_y, half_short)) + + diff_x = local_x - closest_x + diff_y = local_y - closest_y + + distance_sq = diff_x * diff_x + diff_y * diff_y + + if distance_sq > radius * radius: + return False + + if distance_sq > 1e-9: + # Center outside the box: (diff) already points from the + # nearest surface point to the ball - the true surface + # normal for both a flat face hit and a corner hit. + distance = math.sqrt(distance_sq) + + normal_x = diff_x / distance + normal_y = diff_y / distance + + penetration = radius - distance + + local_x += normal_x * penetration + local_y += normal_y * penetration + else: + # Center already inside the box (very fast ball / thin + # sticker): leave through whichever side is nearest. + overlap_x = half_long + radius - abs(local_x) + overlap_y = half_short + radius - abs(local_y) + + if overlap_x < overlap_y: + normal_x = 1.0 if local_x >= 0 else -1.0 + normal_y = 0.0 + local_x = normal_x * (half_long + radius) + else: + normal_x = 0.0 + normal_y = 1.0 if local_y >= 0 else -1.0 + local_y = normal_y * (half_short + radius) + + ball.x = solid.cx + local_x * cos_t - local_y * sin_t + ball.y = solid.cy + local_x * sin_t + local_y * cos_t + + local_vx = ( + ball.velocity_x * cos_t + + ball.velocity_y * sin_t + ) + local_vy = ( + -ball.velocity_x * sin_t + + ball.velocity_y * cos_t + ) + + normal_speed = local_vx * normal_x + local_vy * normal_y + + # Only react if the ball is actually moving INTO the + # surface. (Reflecting a ball that is already leaving is + # what makes bounces flip-flop and jitter.) + if normal_speed < 0: + impact = -normal_speed + + tangent_x = -normal_y + tangent_y = normal_x + + tangent_speed = ( + local_vx * tangent_x + local_vy * tangent_y + ) + + if impact >= BALL_REST_SPEED: + bounce = BALL_RESTITUTION + tangent_speed *= 1.0 - BALL_FRICTION + else: + # Gentle contact = resting/rolling: no bounce, just + # a little rolling resistance. + bounce = 0.0 + tangent_speed *= max( + 0.0, + 1.0 - _ROLLING_DRAG * h, + ) + + new_normal_speed = -normal_speed * bounce + + local_vx = ( + new_normal_speed * normal_x + + tangent_speed * tangent_x + ) + local_vy = ( + new_normal_speed * normal_y + + tangent_speed * tangent_y + ) + + ball.velocity_x = local_vx * cos_t - local_vy * sin_t + ball.velocity_y = local_vx * sin_t + local_vy * cos_t + + if ( + impact >= IMPACT_EFFECT_MIN_SPEED + and len(self.events) < _MAX_EVENTS_PER_FRAME + ): + world_nx = normal_x * cos_t - normal_y * sin_t + world_ny = normal_x * sin_t + normal_y * cos_t + + self.events.append( + ( + "obstacle", + ball.x - world_nx * ball.radius, + ball.y - world_ny * ball.radius, + world_nx, + world_ny, + impact, + ball, + ) + ) + + return True + + # ======================================================== + # Ball vs. ball + # ======================================================== + + def _collide_balls(self, movers): + count = len(movers) + + for i in range(count - 1): + first = movers[i] + + for j in range(i + 1, count): + second = movers[j] + + reach = first.radius + second.radius + + dx = second.x - first.x + dy = second.y - first.y + + if abs(dx) >= reach or abs(dy) >= reach: + continue + + distance_sq = dx * dx + dy * dy + + if distance_sq >= reach * reach: + continue + + distance = math.sqrt(distance_sq) + + if distance > 1e-6: + normal_x = dx / distance + normal_y = dy / distance + else: + normal_x = 0.0 + normal_y = 1.0 + + total_mass = first.mass + second.mass + + # Push apart, the lighter ball moving more. + penetration = reach - distance + + first_share = penetration * (second.mass / total_mass) + second_share = penetration * (first.mass / total_mass) + + first.x -= normal_x * first_share + first.y -= normal_y * first_share + second.x += normal_x * second_share + second.y += normal_y * second_share + + closing = ( + (second.velocity_x - first.velocity_x) * normal_x + + (second.velocity_y - first.velocity_y) * normal_y + ) + + if closing >= 0: + continue + + impact = -closing + + bounce = ( + BALL_BALL_RESTITUTION + if impact >= BALL_REST_SPEED + else 0.0 + ) + + impulse = ( + -(1.0 + bounce) + * closing + / (1.0 / first.mass + 1.0 / second.mass) + ) + + first.velocity_x -= impulse / first.mass * normal_x + first.velocity_y -= impulse / first.mass * normal_y + second.velocity_x += impulse / second.mass * normal_x + second.velocity_y += impulse / second.mass * normal_y + + if ( + impact >= IMPACT_EFFECT_MIN_SPEED + and len(self.events) < _MAX_EVENTS_PER_FRAME + ): + self.events.append( + ( + "ball", + first.x + normal_x * first.radius, + first.y + normal_y * first.radius, + normal_x, + normal_y, + impact, + first, + ) + ) + + # ======================================================== + # Walls + # ======================================================== + # + # All four screen edges are solid: a ball bounces off every one + # of them forever and never leaves the screen (the only ways a + # ball ever disappears are the lifetime/stuck-timeout removal in + # _housekeeping below, each with its own little pop effect). + # With BALL_RESTITUTION left at its lossless default (1.0) these + # bounces never bleed off energy, so together with gravity a + # ball just keeps bouncing indefinitely, exactly like a perfect + # rubber ball in a box, until its time is up. + + def _keep_inside_walls(self, ball): + radius = ball.radius + + if ball.x - radius < 0: + ball.x = radius + + if ball.velocity_x < 0: + ball.velocity_x *= -BALL_RESTITUTION + + elif ball.x + radius > self.width: + ball.x = self.width - radius + + if ball.velocity_x > 0: + ball.velocity_x *= -BALL_RESTITUTION + + if not ball.entered: + if ball.y - radius < 0: + # Still on its way in from above the visible + # screen - leave the top wall unenforced for now. + return + + ball.entered = True + + if ball.y - radius < 0: + ball.y = radius + + if ball.velocity_y < 0: + ball.velocity_y *= -BALL_RESTITUTION + + elif ball.y + radius > self.height: + ball.y = self.height - radius + + if ball.velocity_y > 0: + ball.velocity_y *= -BALL_RESTITUTION + + # ======================================================== + # Lifetime, stuck balls, removal + # ======================================================== + + def _housekeeping(self, delta_time): + for ball in self.balls: + if not ball.alive: + continue + + if ball.dying: + ball.fade -= delta_time / _DYING_SECONDS + + if ball.fade <= 0.0: + ball.alive = False + + continue + + ball.age += delta_time + + if BALL_STUCK_TIMEOUT_SECONDS > 0: + moved = math.hypot( + ball.x - ball._anchor_x, + ball.y - ball._anchor_y, + ) + + if moved > BALL_STUCK_DISTANCE: + ball._anchor_x = ball.x + ball._anchor_y = ball.y + ball._stuck_time = 0.0 + else: + ball._stuck_time += delta_time + + if ball._stuck_time >= BALL_STUCK_TIMEOUT_SECONDS: + self._start_dying(ball) + continue + + if ( + BALL_MAX_LIFETIME_SECONDS > 0 + and ball.age >= BALL_MAX_LIFETIME_SECONDS + ): + self._start_dying(ball) + + self.balls = [ + ball for ball in self.balls if ball.alive + ] + + def _start_dying(self, ball): + ball.dying = True + ball.fade = 1.0 + + if len(self.events) < _MAX_EVENTS_PER_FRAME: + self.events.append( + ( + "pop", + ball.x, + ball.y, + 0.0, + -1.0, + 0.0, + ball, + ) + ) + + # ======================================================== + # Reset + # ======================================================== + + def clear(self): + self.balls = [] + self.events = [] + + def reset(self): + self.clear() diff --git a/rendering.py b/rendering.py new file mode 100644 index 0000000..40901ed --- /dev/null +++ b/rendering.py @@ -0,0 +1,965 @@ +# rendering.py +# +# Draws the game onto the monitor. +# +# Speed: with USE_DIRTY_RECTS the screen is split into a grid of +# small cells. Only cells where something was drawn last frame or is +# drawn now get repainted (background, then the sticker patches in +# them, then the effects in them) and only those cells are pushed to +# the display. A few balls touch a few dozen cells instead of all +# 2 million pixels. The calibration border and black interior are +# pre-rendered ONCE into a background surface and just copied in. +# +# Camera safety: the camera watches this very monitor for WHITE +# stickers. So everything here except the required sticker patch is +# drawn in saturated colour, with dim additive glows (never near +# white) and only tiny pastel highlights. The sticker patch is drawn +# slightly smaller than the sticker (see geometry.paint_dimensions) +# and the detector blanks it out, so a removed sticker can't be +# "seen" in its own leftover paint. + +import colorsys +import math +import os +import time + +import pygame + +from geometry import ( + obb_corners, + extract_obb, + paint_dimensions, +) + +from config import ( + SCREEN_WIDTH, + SCREEN_HEIGHT, + BACKGROUND_COLOR, + CALIBRATION_PATTERN_OUTER_COLOR, + CALIBRATION_PATTERN_INNER_COLOR, + CALIBRATION_OUTER_BORDER_SIZE, + CALIBRATION_INNER_BORDER_SIZE, + TARGET_FPS, + USE_DIRTY_RECTS, + FULL_REPAINT_SECONDS, + VISUAL_EFFECTS_ENABLED, + BALL_GLOW_ENABLED, + BALL_SQUASH_STRETCH_ENABLED, + SHOW_DETECTED_OBSTACLES, + DETECTED_OBSTACLE_COLOR, + DETECTED_OBSTACLE_BORDER_WIDTH, + DETECTED_OBSTACLE_PAINT_INSET_RATIO, +) + + +# Side length (px) of one dirty-repaint cell. +_CELL = 96 + +# Additive glow strengths (fraction of the full hue colour at the +# glow's centre). Deliberately dim: see "camera safety" above. +_GLOW_LEVELS = (0.16, 0.30, 0.48, 0.66) + +# Layers, drawn in this order inside every repainted cell. +_LAYER_GLOW = 0 +_LAYER_RING = 1 +_LAYER_BODY = 2 + + +class Renderer: + # ======================================================== + # Initialization + # ======================================================== + + def __init__(self): + # Ask SDL to place the (borderless) window at the top-left + # corner so it lines up exactly with the screen. + os.environ.setdefault("SDL_VIDEO_WINDOW_POS", "0,0") + + pygame.init() + + pygame.display.set_caption("Monitor Ball") + + # A borderless window sized to the full screen ("fake + # fullscreen"/kiosk mode) instead of pygame.FULLSCREEN. + # Exclusive SDL fullscreen needs a real videofullscreen-mode switch, + # which is unreliable on several X11/Wayland/XWayland driver + # combinations (blank frames, crashes on exit). + self.screen = pygame.display.set_mode( + (SCREEN_WIDTH, SCREEN_HEIGHT), + pygame.NOFRAME, + ) + + # pygame.mouse.set_visible(False) + + # try: + # pygame.event.set_grab(True) + # except Exception: + # pass + + # SDL2 turns "text input mode" on by default, which on + # systems with an active IME can swallow certain keys + # before they reach us as a normal KEYDOWN. + try: + pygame.key.stop_text_input() + except Exception: + pass + + self.clock = pygame.time.Clock() + + self._screen_rect = pygame.Rect( + 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT + ) + + self._cols = (SCREEN_WIDTH + _CELL - 1) // _CELL + self._rows = (SCREEN_HEIGHT + _CELL - 1) // _CELL + + self._background = self._build_background() + + self._mode = None + self._need_full = True + self._last_full = 0.0 + + # Sticker patches, rebuilt only when the detector publishes + # a new list. + self._obstacle_source = None + self._obstacle_polys = [] + self._obstacle_cells = {} + self._obstacle_dirty = set() + + self._prev_cells = set() + + self._glow_cache = {} + self._color_cache = {} + + def _build_background(self): + surface = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)) + surface.fill(BACKGROUND_COLOR) + return surface.convert() + + # ======================================================== + # Calibration screens + # ======================================================== + + def render_calibration(self): + """ + Solid pink over the whole screen: the simplest possible + target for the camera's first lock-on. It never changes, so + it is painted once (and again every FULL_REPAINT_SECONDS as + a safety net) instead of 144 times a second. + """ + now = time.monotonic() + + if ( + self._mode != "calibration" + or now - self._last_full >= FULL_REPAINT_SECONDS + ): + self.draw_full_calibration_screen() + + pygame.display.flip() + + self._mode = "calibration" + self._last_full = now + + self._need_full = True + self._prev_cells = set() + + def draw_full_calibration_screen(self): + self.screen.set_clip(None) + + self.screen.fill(CALIBRATION_PATTERN_OUTER_COLOR) + + def draw_calibration_border(self): + # Border ring + black interior (pre-rendered). + self.screen.set_clip(None) + + self.screen.blit(self._background, (0, 0)) + + def force_full_repaint(self): + self._need_full = True + + # ======================================================== + # Main rendering + # ======================================================== + + def render(self, balls, obstacles, effects=None, launcher=None): + now = time.monotonic() + + self._sync_obstacles(obstacles) + + items = self._collect_items(balls, effects, launcher) + + cell_items = {} + + for layer, bounds, draw in items: + self._add_to_cells( + cell_items, + bounds, + (layer, draw), + ) + + full = ( + not USE_DIRTY_RECTS + or self._need_full + or self._mode != "game" + or now - self._last_full >= FULL_REPAINT_SECONDS + ) + + if full: + self._paint_full(cell_items, now) + else: + self._paint_dirty(cell_items) + + self._prev_cells = set(cell_items) + + def _paint_full(self, cell_items, now): + screen = self.screen + + screen.set_clip(None) + + screen.blit(self._background, (0, 0)) + + for poly in self._obstacle_polys: + self._draw_obstacle(poly) + + everything = [] + + for entries in cell_items.values(): + everything.extend(entries) + + # An item spanning several cells appears once per cell; + # drawing it once per cell is wasteful when nothing is + # clipped, so de-duplicate by identity. + seen = set() + ordered = [] + + for entry in everything: + key = id(entry[1]) + + if key in seen: + continue + + seen.add(key) + ordered.append(entry) + + ordered.sort(key=lambda entry: entry[0]) + + for _layer, draw in ordered: + self._draw_item(draw) + + pygame.display.flip() + + self._need_full = False + self._mode = "game" + self._last_full = now + self._obstacle_dirty = set() + + def _paint_dirty(self, cell_items): + screen = self.screen + background = self._background + + dirty = ( + set(cell_items) + | self._prev_cells + | self._obstacle_dirty + ) + + self._obstacle_dirty = set() + + rects = [] + + for cell in dirty: + rect = self._cell_rect(cell) + + if rect.width <= 0 or rect.height <= 0: + continue + + rects.append(rect) + + screen.set_clip(rect) + + screen.blit(background, rect.topleft, rect) + + for index in self._obstacle_cells.get(cell, ()): + self._draw_obstacle(self._obstacle_polys[index]) + + entries = cell_items.get(cell) + + if entries: + entries.sort(key=lambda entry: entry[0]) + + for _layer, draw in entries: + self._draw_item(draw) + + screen.set_clip(None) + + if rects: + pygame.display.update(rects) + + # ======================================================== + # Grid helpers + # ======================================================== + + def _cell_rect(self, cell): + column, row = cell + + return pygame.Rect( + column * _CELL, + row * _CELL, + _CELL, + _CELL, + ).clip(self._screen_rect) + + def _cells_for(self, rect): + left = max(0, rect.left // _CELL) + top = max(0, rect.top // _CELL) + + right = min(self._cols - 1, (rect.right - 1) // _CELL) + bottom = min(self._rows - 1, (rect.bottom - 1) // _CELL) + + if rect.right <= 0 or rect.bottom <= 0: + return () + + if rect.left >= SCREEN_WIDTH or rect.top >= SCREEN_HEIGHT: + return () + + return [ + (column, row) + for row in range(top, bottom + 1) + for column in range(left, right + 1) + ] + + def _add_to_cells(self, cell_items, bounds, entry): + for cell in self._cells_for(bounds): + cell_items.setdefault(cell, []).append(entry) + + # ======================================================== + # Sticker patches + # ======================================================== + + def _sync_obstacles(self, obstacles): + if obstacles is self._obstacle_source: + return + + self._obstacle_source = obstacles + + # Cells that used to show a patch must be repainted, as + # must cells that now do. + self._obstacle_dirty |= set(self._obstacle_cells) + + polys = [] + cells = {} + + if SHOW_DETECTED_OBSTACLES: + for obstacle in obstacles or (): + if not obstacle.get("painted", True): + continue + + obb = extract_obb(obstacle) + + if obb is None: + continue + + cx, cy, long_side, short_side, angle = obb + + long_side, short_side = paint_dimensions( + long_side, + short_side, + DETECTED_OBSTACLE_PAINT_INSET_RATIO, + ) + + if long_side <= 0 or short_side <= 0: + continue + + corners = obb_corners( + cx, + cy, + long_side, + short_side, + angle, + ) + + points = [ + (int(round(px)), int(round(py))) + for px, py in corners + ] + + xs = [p[0] for p in points] + ys = [p[1] for p in points] + + bounds = pygame.Rect( + min(xs) - 2, + min(ys) - 2, + max(xs) - min(xs) + 4, + max(ys) - min(ys) + 4, + ) + + index = len(polys) + + polys.append(points) + + for cell in self._cells_for(bounds): + cells.setdefault(cell, []).append(index) + + self._obstacle_polys = polys + self._obstacle_cells = cells + + self._obstacle_dirty |= set(cells) + + def _draw_obstacle(self, points): + if DETECTED_OBSTACLE_BORDER_WIDTH <= 0: + pygame.draw.polygon( + self.screen, + DETECTED_OBSTACLE_COLOR, + points, + ) + else: + pygame.draw.polygon( + self.screen, + DETECTED_OBSTACLE_COLOR, + points, + int(DETECTED_OBSTACLE_BORDER_WIDTH), + ) + + # ======================================================== + # Collecting what to draw this frame + # ======================================================== + # + # Everything is turned into a flat list of + # (layer, bounding_rect, draw_tuple) ONCE per frame; the + # per-cell painting then only replays those tuples. + + def _collect_items(self, balls, effects, launcher=None): + items = [] + + if launcher is not None: + self._collect_launcher(items, launcher) + + effects_on = ( + VISUAL_EFFECTS_ENABLED and effects is not None + ) + + if effects_on: + for particle in effects.particles: + self._collect_particle(items, particle) + + for ring in effects.rings: + self._collect_ring(items, ring) + + for ball in balls or (): + if ball.alive: + self._collect_ball(items, ball, effects_on) + + return items + + def _collect_launcher(self, items, launcher): + points = launcher.get_points() + + xs = [p[0] for p in points] + ys = [p[1] for p in points] + + bounds = pygame.Rect( + int(min(xs)) - 3, + int(min(ys)) - 3, + int(max(xs) - min(xs)) + 6, + int(max(ys) - min(ys)) + 6, + ) + + items.append((_LAYER_BODY, bounds, ("launcher", launcher))) + + # ---- particles / rings ------------------------------------- + + def _collect_particle(self, items, particle): + x, y, _vx, _vy, age, life, hue, size = particle + + fraction = 1.0 - age / life + + level = min( + len(_GLOW_LEVELS) - 1, + int(fraction * len(_GLOW_LEVELS)), + ) + + radius = max(2, int(round(size * (0.6 + 0.6 * fraction))) + 2) + + sprite = self._glow(hue, radius, level) + + position = (int(x) - radius, int(y) - radius) + + bounds = pygame.Rect( + position[0], + position[1], + radius * 2, + radius * 2, + ) + + items.append( + ( + _LAYER_GLOW, + bounds, + ("sprite", sprite, position), + ) + ) + + def _collect_ring(self, items, ring): + x, y, age, life, hue, max_radius = ring + + fraction = age / life + + radius = max(2, int(max_radius * (0.25 + 0.75 * fraction))) + + fade = (1.0 - fraction) ** 1.5 + + color = self._hue_rgb(hue, 0.85, 0.9 * fade) + + width = max(1, int(3 * (1.0 - fraction)) + 1) + + bounds = pygame.Rect( + int(x) - radius - 2, + int(y) - radius - 2, + radius * 2 + 4, + radius * 2 + 4, + ) + + items.append( + ( + _LAYER_RING, + bounds, + ( + "ring", + color, + (int(x), int(y)), + radius, + width, + ), + ) + ) + + # ---- balls -------------------------------------------------- + + def _collect_ball(self, items, ball, effects_on): + fade = max(0.0, ball.fade) + + radius = ball.radius * fade + + if radius < 1.0: + return + + if effects_on: + self._collect_trail(items, ball, radius) + + if BALL_GLOW_ENABLED: + self._collect_glow(items, ball, radius, fade) + + points, angle_bounds = self._ball_polygon( + ball, + radius, + effects_on, + ) + + body = self._hue_rgb(ball.hue, 0.85, 1.0) + rim = self._hue_rgb(ball.hue, 0.95, 0.72) + + # A small pastel highlight (saturated enough that the + # camera can never count it as "white"). + highlight_color = self._hue_rgb(ball.hue, 0.42, 1.0) + + highlight_radius = max(1, int(round(radius * 0.24))) + + highlight_position = ( + int(round(ball.x - radius * 0.32)), + int(round(ball.y - radius * 0.34)), + ) + + items.append( + ( + _LAYER_BODY, + angle_bounds, + ( + "body", + points, + body, + rim, + highlight_position, + highlight_radius, + highlight_color, + ), + ) + ) + + def _ball_polygon(self, ball, radius, effects_on): + # Ellipse approximated by a polygon: stretched along the + # direction of travel with speed, flattened against the + # surface for a moment after a hit. + semi_a = radius + semi_b = radius + angle = 0.0 + + if effects_on and BALL_SQUASH_STRETCH_ENABLED: + speed = ball.speed + + if speed > 30.0: + stretch = min(0.35, speed / 4000.0) + + semi_a = radius * (1.0 + stretch) + semi_b = radius / (1.0 + stretch * 0.9) + + angle = math.atan2( + ball.velocity_y, + ball.velocity_x, + ) + + weight = ball.squash * ball.squash + + if weight > 0.12: + k = weight * 0.38 + + # First axis lies along the surface normal. + semi_a = radius * (1.0 - k) + semi_b = radius * (1.0 + k * 0.7) + + angle = math.atan2( + ball.squash_ny, + ball.squash_nx, + ) + + count = 18 if radius < 20 else 26 + + cos_a = math.cos(angle) + sin_a = math.sin(angle) + + points = [] + + for i in range(count): + theta = 2.0 * math.pi * i / count + + ex = semi_a * math.cos(theta) + ey = semi_b * math.sin(theta) + + points.append( + ( + ball.x + ex * cos_a - ey * sin_a, + ball.y + ex * sin_a + ey * cos_a, + ) + ) + + xs = [p[0] for p in points] + ys = [p[1] for p in points] + + bounds = pygame.Rect( + int(min(xs)) - 2, + int(min(ys)) - 2, + int(max(xs) - min(xs)) + 5, + int(max(ys) - min(ys)) + 5, + ) + + return points, bounds + + def _collect_glow(self, items, ball, radius, fade): + glow_radius = int(radius * 2.4) + + level = min( + len(_GLOW_LEVELS) - 1, + int(ball.speed / 450.0), + ) + + if fade < 0.6: + level = max(0, level - 1) + + sprite = self._glow(ball.hue, glow_radius, level) + + position = ( + int(round(ball.x)) - glow_radius, + int(round(ball.y)) - glow_radius, + ) + + bounds = pygame.Rect( + position[0], + position[1], + glow_radius * 2, + glow_radius * 2, + ) + + items.append( + ( + _LAYER_GLOW, + bounds, + ("sprite", sprite, position), + ) + ) + + def _collect_trail(self, items, ball, radius): + trail = ball.trail + + count = len(trail) + + if count < 2: + return + + sprites = [] + + min_x = min_y = 10 ** 9 + max_x = max_y = -(10 ** 9) + + biggest = 0 + + for index, (tx, ty) in enumerate(trail): + fraction = (index + 1) / count + + sprite_radius = max( + 2, + int(radius * (0.35 + 0.6 * fraction)), + ) + + level = min( + len(_GLOW_LEVELS) - 1, + int(fraction * len(_GLOW_LEVELS)), + ) + + position = ( + int(round(tx)) - sprite_radius, + int(round(ty)) - sprite_radius, + ) + + sprites.append( + ( + self._glow(ball.hue, sprite_radius, level), + position, + ) + ) + + biggest = max(biggest, sprite_radius) + + min_x = min(min_x, tx) + max_x = max(max_x, tx) + min_y = min(min_y, ty) + max_y = max(max_y, ty) + + bounds = pygame.Rect( + int(min_x) - biggest - 1, + int(min_y) - biggest - 1, + int(max_x - min_x) + biggest * 2 + 3, + int(max_y - min_y) + biggest * 2 + 3, + ) + + items.append( + ( + _LAYER_GLOW, + bounds, + ("trail", sprites), + ) + ) + + # ======================================================== + # Drawing one collected item + # ======================================================== + + def _draw_item(self, draw): + kind = draw[0] + + screen = self.screen + + if kind == "sprite": + screen.blit( + draw[1], + draw[2], + special_flags=pygame.BLEND_RGB_ADD, + ) + + elif kind == "trail": + for sprite, position in draw[1]: + screen.blit( + sprite, + position, + special_flags=pygame.BLEND_RGB_ADD, + ) + + elif kind == "ring": + _kind, color, center, radius, width = draw + + pygame.draw.circle( + screen, + color, + center, + radius, + width, + ) + + elif kind == "body": + ( + _kind, + points, + body, + rim, + highlight_position, + highlight_radius, + highlight_color, + ) = draw + + pygame.draw.polygon(screen, body, points) + + # Anti-aliased outline in the rim colour smooths the + # jagged polygon fill edge. + pygame.draw.aalines(screen, rim, True, points) + + pygame.draw.circle( + screen, + highlight_color, + highlight_position, + highlight_radius, + ) + + elif kind == "launcher": + launcher = draw[1] + points = launcher.get_points() + + # Solid metallic tube fill + pygame.draw.polygon(screen, (48, 55, 66), points) + + # Highlight & Shadow lines along the tube + rad = math.radians(launcher.angle) + u_x = math.sin(rad) + u_y = math.cos(rad) + v_x = -u_y + v_y = u_x + + half_w = launcher.tube_width / 2.0 + length = launcher.length + + # Highlight line along left inner edge + hl_start = ( + launcher.pivot_x - v_x * (half_w * 0.4), + launcher.pivot_y - v_y * (half_w * 0.4), + ) + hl_end = ( + launcher.pivot_x + u_x * length - v_x * (half_w * 0.4), + launcher.pivot_y + u_y * length - v_y * (half_w * 0.4), + ) + pygame.draw.line( + screen, + (115, 126, 142), + hl_start, + hl_end, + width=max(2, int(half_w * 0.45)), + ) + + # Shadow line along right inner edge + sh_start = ( + launcher.pivot_x + v_x * (half_w * 0.5), + launcher.pivot_y + v_y * (half_w * 0.5), + ) + sh_end = ( + launcher.pivot_x + u_x * length + v_x * (half_w * 0.5), + launcher.pivot_y + u_y * length + v_y * (half_w * 0.5), + ) + pygame.draw.line( + screen, + (26, 30, 38), + sh_start, + sh_end, + width=max(2, int(half_w * 0.35)), + ) + + # Dark nozzle rim at tip + p_bot_right = points[2] + p_bot_left = points[3] + rim_points = [ + ( + launcher.pivot_x + u_x * (length - 6.0) - v_x * half_w, + launcher.pivot_y + u_y * (length - 6.0) - v_y * half_w, + ), + ( + launcher.pivot_x + u_x * (length - 6.0) + v_x * half_w, + launcher.pivot_y + u_y * (length - 6.0) + v_y * half_w, + ), + p_bot_right, + p_bot_left, + ] + pygame.draw.polygon(screen, (32, 36, 44), rim_points) + + # Crisp metallic outline + pygame.draw.polygon(screen, (78, 88, 102), points, width=2) + pygame.draw.aalines(screen, (95, 108, 124), True, points) + + # ======================================================== + # Colours and glow sprites + # ======================================================== + + def _hue_rgb(self, hue, saturation, value): + key = ( + int(hue) % 360, + int(saturation * 50), + int(value * 50), + ) + + color = self._color_cache.get(key) + + if color is None: + r, g, b = colorsys.hsv_to_rgb( + (key[0] % 360) / 360.0, + saturation, + max(0.0, min(1.0, value)), + ) + + color = ( + int(r * 255), + int(g * 255), + int(b * 255), + ) + + self._color_cache[key] = color + + return color + + def _glow(self, hue, radius, level): + # Soft radial gradient, cached by (hue bucket, size, level). + radius = max(2, min(int(radius), 96)) + + bucket = int(hue // 6) % 60 + + key = (bucket, radius, level) + + sprite = self._glow_cache.get(key) + + if sprite is None: + color = self._hue_rgb(bucket * 6 + 3, 0.9, 1.0) + + gain = _GLOW_LEVELS[level] + + sprite = pygame.Surface((radius * 2, radius * 2)) + + sprite.fill((0, 0, 0)) + + for i in range(radius, 0, -1): + t = 1.0 - i / radius + + k = (t ** 1.8) * gain + + pygame.draw.circle( + sprite, + ( + int(color[0] * k), + int(color[1] * k), + int(color[2] * k), + ), + (radius, radius), + i, + ) + + sprite = sprite.convert() + + self._glow_cache[key] = sprite + + return sprite + + # ======================================================== + # Timing and shutdown + # ======================================================== + + def get_delta_time(self): + delta_time = self.clock.tick(TARGET_FPS) / 1000.0 + + return max(0.0, min(delta_time, 0.05)) + + def close(self): + pygame.mouse.set_visible(True) + + pygame.quit() diff --git a/vision.py b/vision.py new file mode 100644 index 0000000..1c2097a --- /dev/null +++ b/vision.py @@ -0,0 +1,228 @@ +# vision.py +# +# Runs everything that looks at the camera - screen calibration and +# sticker detection - on its own background thread, so the render / +# physics loop never waits on OpenCV. (Before this, a detection pass +# ran inside the game loop and every pass was a small visible hitch.) +# OpenCV releases the GIL while it works, so the two really do run +# side by side. +# +# The game loop only ever calls snapshot(), which is a cheap lock + +# a few attribute reads. The obstacle list it returns is the SAME +# list object until something actually changed (Detector publishes a +# new list only on change), so physics/rendering can detect changes +# with a plain identity check. + +import threading +import time + +from config import ( + DETECTION_SCALE, + MONITOR_DETECTION_INTERVAL, + OBSTACLE_DETECTION_INTERVAL, +) + + +class Vision: + def __init__(self, camera, calibration, detector): + self.camera = camera + self.calibration = calibration + self.detector = detector + + self._lock = threading.Lock() + + self._calibrated = False + self._frozen = False + self._obstacles = [] + self._message = "Camera not connected yet." + + # Bumped by request_reset(); a result computed against an + # older generation is discarded instead of being published. + self._generation = 0 + self._reset_pending = False + + self._stop_event = threading.Event() + + self._thread = threading.Thread( + target=self._run, + name="vision", + daemon=True, + ) + + # ======================================================== + # Public API (called from the game loop) + # ======================================================== + + def start(self): + self._thread.start() + + def stop(self): + self._stop_event.set() + + def request_reset(self): + """Forget calibration and stickers; start over.""" + with self._lock: + self._generation += 1 + self._reset_pending = True + self._calibrated = False + self._obstacles = [] + + def snapshot(self): + """(calibrated, camera_frozen, obstacles, camera_message).""" + with self._lock: + return ( + self._calibrated, + self._frozen, + self._obstacles, + self._message, + ) + + # ======================================================== + # Thread + # ======================================================== + + def _run(self): + last_frame_id = None + last_monitor_pass = 0.0 + last_obstacle_pass = 0.0 + last_error_print = 0.0 + + while not self._stop_event.is_set(): + try: + with self._lock: + reset = self._reset_pending + self._reset_pending = False + generation = self._generation + + if reset: + self.calibration.reset() + self.detector.reset() + + last_frame_id = None + last_monitor_pass = 0.0 + last_obstacle_pass = 0.0 + + try: + frame, frame_id = self.camera.read_with_id() + except RuntimeError as error: + self._publish( + generation, + message=str(error), + ) + + time.sleep(0.05) + continue + + frozen = self.camera.is_frozen() + + if frame_id == last_frame_id: + # Same frame as last time: nothing new to + # analyse (the camera delivers ~30 fps, the + # loop spins faster). Still keep the frozen + # flag fresh in case frames stopped entirely. + self._publish(generation, frozen=frozen) + + time.sleep(0.004) + continue + + last_frame_id = frame_id + + if frozen: + # A stuck feed must not be trusted for + # calibration or stickers. + self._publish( + generation, + frozen=True, + message="", + ) + continue + + now = time.monotonic() + + calibrated = self.calibration.is_calibrated() + + # Until locked on, look at every new frame; once + # locked, only re-verify the monitor occasionally. + if ( + not calibrated + or now - last_monitor_pass + >= MONITOR_DETECTION_INTERVAL + ): + self.calibration.update(frame) + + last_monitor_pass = now + + calibrated = self.calibration.is_calibrated() + + obstacles = None + + if ( + calibrated + and now - last_obstacle_pass + >= OBSTACLE_DETECTION_INTERVAL + ): + small = self.calibration.transform_frame( + frame, + DETECTION_SCALE, + ) + + # Keep the sticker detector's idea of "what does + # white look like right now" in sync with what + # calibration is continuously learning off the + # ring - see Calibration.white_reference(). + self.detector.set_white_reference( + self.calibration.white_reference() + ) + + obstacles = self.detector.detect(small) + + last_obstacle_pass = time.monotonic() + + self._publish( + generation, + calibrated=calibrated, + frozen=False, + message="", + obstacles=obstacles, + ) + + except Exception as error: + # Never let one bad frame kill the thread (and with + # it, all vision until the next restart). + now = time.monotonic() + + if now - last_error_print > 5.0: + print( + "[VISION] unexpected error, " + f"continuing: {error!r}" + ) + + last_error_print = now + + time.sleep(0.1) + + def _publish( + self, + generation, + calibrated=None, + frozen=None, + message=None, + obstacles=None, + ): + with self._lock: + if generation != self._generation: + # A reset happened while this pass was running; its + # results describe the old state. + return + + if calibrated is not None: + self._calibrated = calibrated + + if frozen is not None: + self._frozen = frozen + + if message is not None: + self._message = message + + if obstacles is not None: + self._obstacles = obstacles