From d27cd7e5661290e732b240fc2f5fcb5d01303922 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 14:25:59 +0000 Subject: [PATCH 1/3] Plan: add 3D model support --- __pycache__/triptych360.cpython-312.pyc | Bin 0 -> 25273 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 __pycache__/triptych360.cpython-312.pyc diff --git a/__pycache__/triptych360.cpython-312.pyc b/__pycache__/triptych360.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a2f9e17db03a4ded2305490c2678af3341ba91e0 GIT binary patch literal 25273 zcmdUXYj7Obm0r&a)APWCzyJh5JQ@#@7?1$p?>7m8FA#iz6bZ>5577f~zySv88Gr-? zO4LS<0ZBB0c)bKIIfN`#35=D9&Z^c_#VN`5)~=lVFj_OrKy5mv6IFJV{Slhft`%=p zD&M(1-8~pKDSMq%B|X6H`#AU9drzNx@44rk?*EvZ>*jE+d05{2tF0XOUn!woj&$Uq z)yi?#IFS=A5w26cTRJT)ZSAzNyS>xS?v73eyXSP~;BJjL`}j`YLUn8rSD(An-RJ4_ z^m#kIeYu^veR-XEefgdFeFdEbeG57l^c8j%GFW@0sL$8wV{u2MxUZzMgvE0r3;Rkt zOZ&S4_(u9=@m3%)v;Lh4mZSOvf48~o|6Z5_gTF_6+%HBiD9&R~ zg~gsw9J!}wbP)j%%iS{V2V|&hp zy3V5tTFpInWFXYPuT8~^jJ#tf2Bq#`SEw~A^#$X8yJ9^SS8`5-d-{VB#eO0ZMe0~b zxGyA)eA8Vw6p~`-rm$wsN}($%h1}tOVJOtKS_thi17JYDwjqstIe=C!7gjZ|^lx!r zatGf7XPR_rus__5!3eQn-#{cJt!@$Gp^IDGT6T9N8jK6w(V>786tlB~v4K!m93^Nz zyKEpF?>d*AIUKyO#jR689dnulYlZGWS2Plp007di5kiCLPDk``FfN5JHmLnzNS6z0 z0?0HfsTo}ZAVF6&*1*6pY9gRo6r-XT==x|;*KoQP;hR^73aE}zz&dvPBJwHL0NSRh ztkRkhylY^&AT-4gvLrsd{F8a zjt_$wj^jyPa+|RYaoIpLhISYSH43YnR|-wU4eAh4);uh%*e1+>hD5OpvW=iVst7BD zc(A`A%>_sSS-LmixHW6WvGB?WH;AT$q69aZ;g1%yc1@t!ZAh zcGbE~tJkkvvu;x+A7DNN1_98ZHnl=%AX+vG17W{Eb1JI12%2VW!vI=bK|q->YR6SZ z2M~bqFhdQvFK6oj#R7xNsUR>!6uac^%3Lnfy9@V_mi%vEj|_3NFLqi**BIAr5iKvd zus>Q~;;=i~5IRIV!W_|o&?)91u{w4faP*g+mwA#jZ1D#$e|S-D@*D4Sjwf@{EP)iMS{+#v>iRke+TAmuYd}?Pp=l%|Vk>ZFA4um9y4+O&f;dmgR zv312S zD)s>hJDY9jY!pE-syNeZr{qa!B1}7LK*an}T$H{L7USm>H$~5d!ae8Wii=H3)(Izd zihARu#egE-%moA+qCFOhbW4k4vWDbqWrg3eV`kJ<0=We?FAt}88OQJf?3l}(fHAs3TGFYDE(1A}9MHetXl zdMPFfQ77wq^5lu@to@EYJ%BSuLlW1SwpV9Zt&HP(AToc?BHG@uYg6kIrmtx)ii}!X zY}E4o^cc~i-jB!BU#QhG?ts)M-@II(kinV(!NvmR< zW~4JMiTU#tzAp;w{XG#SuS*IA;~{JT{WJ}wYO3a>8H&lG`GZ+IM(wdq+A$5?CYs1BxRkVINa+&JK2Wha{~(GIoEbf?wH=FVNml@MJwc@6}Rj^bp60}d+i4s-{1J*nMBbM)`llP<*AZARkNN( zwhYwC-nu*9#rJKTcbT*T{W71YLu{UcR{)WHN#*AGy_p+hx{|?HsAD#R^_9SA1(5n2 zotLX`V@7VbRis&|&oHATV@~Lqaea35>f4uI$UV8V8=IaPH3E9CXp=OBFg9Flm8KAS z|1tOM?Vk}LJ6io7sSXjvDPivl#zJgDNQ;prEunx|SK2@jIc*2p_wG8b1x|}TgofQ zULonN#qxjc;^f8D!lm-Ur8B;Ft8P}^_I*(Oe)Vj_fsa;wB&OPfa(ghbkT~0Ww|eP~ z7p}kX9{jSggkH@Rcf;X7U#C5v3oVLvRh^ju}la zTZSPPfjcat)^3O)W-tMr`$oY>=UYHe55bRkAf9FHKC~v@w6-%@#cxv=CVmJK;XqW1 zOO5CSa@lfWxu96i#i(=G$knw14eO6i)wE@L z)pT&G4aDHb`6OR9=kbm``|`T!;7rXMp^5rCg>_@=o-mF3(UT_;$eb)sn#kA$ExnpH z3SHK&jEQbB+m`83KuS$NM##ckuRd`!93TK5nt6#?o1#_QWTAPYsijXN1LF`U`PjrX zDHCmF05k*wk=Vop4mhe%VzOkI!;AJTypVezgLhS2myUaN$=K*U>X~f#+NjO3ijMiM z1FFQpsKt;=+j7=5g7hV;m}8C^bolcAng|_V(%+o<{S}>$ZGGvNP)p|ra}1($eoKI= z$RYrey(D*JU2#Fn=q0Hk>(a(Y!jqqG@A=Mky58Tx)(gc3iGr;W>d64gBE`Xu4T_D< z@`dVhQYePvq%mR#166cUY=g0o;^@NSFR`6Km2n(H!N_1p#b}4N;2buHpcoGJ$0R>G zCN)q%rwnN+1=L?9M+)^r%2IcWypH1scD3(o+Z#B6*imiivUTGWMkabG3_KS4>5L$_?$8wsrgyj`pJvV+XmFJi9{E56}W41ZobJaQS#9;#w*SKq< zZX!0dC&>$JZyDb@>#3eDnd7~*DWv#PoMomK(B|MNm}t3nVDffqDz!KY{J@49ZfZZ*pdyOIt2<~+VB`wiE1m+Yze zOZ#7Yf9(CJ^|PwP*3;9zx69rro7o`OtVz~v%IxEh>>qgF_d*QY zcqFT{7#f!HtNG*kuk)$W6>{l{JN!y@2`33tyJ(@L|A&8p082Qj4zw~_A~Rvr`7gF2 z|H(_UX+5*$8kqFfjEKSLDA%Q$jPi6;Jw@EKwEG>Zlv0NR#nTr&AKEi0ffwV7Xo~V>h z2qEy>nOGxrBaTPL^dC8@?juHIfRK}xatcTplRi7CkEc58@ni#BLH*c%1R2k!tO_C` zbPNJ+`LrT1S*SYeNu|7Wib;AG&wDz;6P`{xn)w5tjuTa~u1qSbG_Z$4An@#9Frt<)y^^#W71Sqwlwx0_pofC<6!arda??7h zu23Wr2;eBugj{x%*vVvEiG&mu9HfF}2)=lU`+IAVLwL~WbS!yLn1i6e=2-r~o#$Bf zKyWz9ALKb5<^NDXXx3c{{^oD1m7 zkm$fKM|6su$iMDDiAK3EInnm&qNmOV;=S4FfdE1>e(WcnkLxoU)#Q zAaKcc$sRwXL5pMmrX0jY`6@Je$$_}nZj{$48W)z7<8a_5V+O)QeyBP-n z8|{}_m(dz1(vnq(Wbd&@^F|#;tyf3wplk1mN1;TY0R;%!F|#hQ*no&Xr4?%1@ui&6 z95dGmu|)lSKfO{y>5$X=v{u`=iwiTbe?`j|OSN2Z11U<`h5mh2iyQaepb^(%VwqN- z(cY}qyzNe{7Z_DWi=aW~^e^v>8&b>S$5v>$m-tb>_mAK%)Ha)il^T9FlT0{i zls6#amo%#B^VBu!>U}}WHSVK1MlFo?MWaNEjk@MR{J!gPl~zmI_hjAvV$ zW_7&f#{Sm(Ct7cfyJ%&yWc5qDu@2J#Y?!O#RzZNiV6|{(I17Fg_E*1l(RPNrV5vju z45iPhX}{1uQqp1AgM~dXk4e!8T!6x#W6ugNM=;`n(X2b%)7%@2_Ol~^MB+j#&foMP zr49;c8cWL&C~g>>e1HsnBeMa&O1NPhKFo5&NsSgdrIA<^o6Jhh) zM)KN1-ErZmU?d`tpTtOhtAtKwW*jGTxseeTATxIae}$&pbcWQfw4Ykq!%NGhdp8g^ zbqD*xk>SlkQxKkRA@~Kw;-S9f!tO}8|NP-#*9n$x1>|y}_C%;B8WK((s9i1`kDiUj zqsxW;p~z4O#=PKiVV4B!-Ez2g^~aiESqXQmZY__rNSgc2!m2d`a3{(vyfiO$%H67V*ZCePI@m8ZF*mNwLfy)h8+Ns}&PPe;wenVWJz9P?DnX6684gALp~scN#YYSe z#x@JK3+Vj=POMZIqQ?ltp?lhtSD+*sEUjz>Z)YJ!;2ApP*yn$d9{k=8$+ zNS~2z;4_l)M+(}b0-JLJJO{;)*sQpXK8%#NAye4<>|j`e9yr+FGZ+a&(aN%62UkHnlQ1gh+ha0*!bZuO@MV)N;V-Gs(aUFoEQ zC}%2&%S;9FJAP{<*mqV8ZXa1}9MyFvndYsLXjd>2+m46|mXF@WXc2?k%*@)Vh4GK_CFP zK_#sxWvwyYoKsbt!qr!eoKsvwVKEd9bOi?_I)5r2HBE>m_&MQ5KEO^;vmgAX6v5Ae zyc_~57&AjCu2>&DnTFv0pyV^(tJnZMeEX;y3Xd`sS^fYxDv(j+h{c0aT=9}_3r}(~jGuYo#7Z!HC)cK%+4tL^KYsfn@}5>4uj|OtmKwQpFNha#W`|#ZFH~ z$%&|b;QZNme}ForctI05*~Q}0AXvf$-iX8O1G`ZZFvdIxzNW3#f;#O~*R&#&8TTet zv0+*GpYz?)c$jq-$^4?(inU389a(yyzMR-IRg~l_@8@t8e$vvH&Q;aS?740KXx;4B zM7bmcb+G5IC->@x@eNnEk8huy<&8OExF@EksUMN)(>jo#d-Q(n@&ck?R1wG@hG$?%E5JFHApoYj0xV z<}vTxvf7X9)+Nf;Kj0j$W#b3Nc8v|)EvmUy^5NEG(b2IMrg*QGz11HJr)SS}%EHr0 z?=xww`;=|!MABQMgQUDmW$)4%d#Yih+^{j}-NZ^NWp8E5TQ7U-r#tR=8}ED2&V3)} z!U&eh{IZ#rB)^IcwOr=Q?{f<)yq|LmUAdpHUdjqOQ(wN5C8x<1J- zWeqWVqwhxL^-AP?S_H&f5=C3#e8m@}c z1xL)Nj`1B+Ta%uKcaFdN)Xk^f{p!uH-fsEe(EEoz z^2%FJCYw%u>Mcxp>FG{&CF(lwc%Q!S;F_PZ%z7GryNhPmZ)gtw<2`Tbzi9bgtR9QO zk5`v;)N()JS9BEHezJi_JX!4MsCFjHS-jTTvD7wCwN48p5!y6QFFQ*l8)=T6)Vxssqv$esG#i||Wq%-kc^pIVKidwWWSE56PgXyT%9Bu^r zk%sG%ebi=l5QXWN`IGeOies%s!);R=6AtD!Gv&H=1h;UnJ_iUo8)jV@5U<*CK!qC` zJN~tPMpB&LE0KVwIAd^>48hajb)=;4P(Z?t^coe~BB6f8))&UhN7$hDg`$1fm4_9+ zM~mgf&P6Y@4n`snLV*Pi(MaQnO8b@c?-`1)cu^vYH+D1z6F^3c9w^&)REMepNI>kh z9!<6wS(LH=WEUpsmHi;w@ zB_Dz&;iFgANUD>bM{e55Mfy5HSOW8$Tcmf8IZ}~9uty}cDxw=1P>6{tQ-w`(VbhE_ zySC-Sv$F@EPZYi|?i{m^1wZlRvrUiuBByCu=COxX8upx95PO_|-jlrW zFwafhX{8$Td^7WaG2_{rAA0}HS3(I6lKptpFl-Q&*=0P+C;1%o>V`6{9+c8HZN-|f zO2U_Tb?K&)EdPl#%NbZnwLy}IGd$P?{a|AFPGIF(;hF!a3WIFsG*;=jEhH=+g(e7xC78rK@lSZ^et8Lsh+}KHNqT@Wg z0KqXiY+p5dPJW}N7So;+TSDxY9HVydMtKHrG-{B>ebkXbXHrIHUHabY$iUN_1%%`% zOy~wI3}Cv6)qvId2kPk@!`!lTkHm;aqr!ekw{04nIO7HZRC`#ahZ09;@r~D!zp2}R=c-Viy3!=a%OLdiyjRd zGin_&>S?ifixx9`TYO$^6T>O-bLRY>H~XCb*E!Am$8j3tFnzUVPw)Q+4m++rG2?D# z(gLrZG9a`Va!+e<_QlNRa@84YQ@W%-V+?4w}qkEi;?IDo!OgI z&k1q31Fw=PHmKv&LyB+DuA_mY$B!Hh;03b7djn4$XzAE5eUqSzvx@fbJ+NIgmpR24HYk9T(-m(?%ZvR)?W#7`-9VfVK2^O+u3q(CU263~dG*0W^`XR~HlS%U0XQVDK9s0#ODsA}0CR;U*P17r zQ-$?%Vf|YtQj6Egi`OLz*N^R)%P*V=zI<^?nkt$rtGrQly((3meLP%9K$rVixpq6>cGSLFvXv*i8eg2vHcURn8f!7ANzP}ZY?G-<(xZ^u@9~zve zEI6*Jq)Qeo)sMKTf+cdnlDA?rYj4%v-2CPX|EXZZeY+=jzvXVpqHDdAz0X}#=#r1}5wzhMeJ?ra!S-;)AZ_VuJwqj5No9mLL*T@`7w1ACkl0_-x= zkY#>(;4wI9G+CD>4QBKRBZ`y8k6mUichI33Jt2!p)}_nu)(kv(SwKkAt9}OTsMSp8 zru7f98fi<|h0&22H|v6Ijnf~Udd#$HsH2~>jEb7X2zj}hbZt@_%AjInyWTGm$A$;h z*&T`%=^M|{BPY~7>xA^5Q6l|{f_oJFnu2Z$m`1J#G0k!Rzf-H(0B=Tn+dO#GRIj+MR1w`7 z2Y(OxW`adqz_mMOqmJouJr(~S;Um=pQ1t>NUq5Z_@O#^7>BhBkry3di^PN4ntG@ns z$8P`29e=}0E)VU*=>z^2sxKZbV?EFL{>{E$kG!-aUHOYN=+2WLs&#gr-09C#Ee33p zIEhN^h#>tf#R!M$8~AOCvHiyh9eOa1LzgXhHYR<9d>D3u7o`snOB-zPSswBzQe5=i zn{fXCeNayQ8jhO4*KYb_S*9Cl3ianLb6Ktz`SCYvJv>MD6y8Lw6UJPi^|v^E3937cPI#cI!}L<<1Y+e!OxYAOV@svr!4n(+|%c8wa800%B35>>%P0VVWt>ZT5i{W(D;61 zV&&e%;?^nq>u#<3Ub)oIYN{Pfm9CUaS7K1*KPbQ5@xkf$Pk$Ky>x)0Wc&GH}{d{iK zehc1M^pzo~ZXVnI#r+B{w`>xYSTa}sWL1l+bqDuXJFNR`wl00fqJ-kK-aVW~KrhUh z`EY5%QCzQSwl18JfGz#r4#(d`6bd>xJ_fp|JUd<|dj(Bww zDnl~z6wo`RuWWz)_>Hez|H{Ys)=GtdgW_R2#KAD1s9inQlEjt`o;@;t$gA;WNtWRitqL@UWgBg!Pl0ygC1>gPvPf@GS> z9w~j15C}kJP7)mN@glA9<(bTpJ5h^qUMq4RckIEA>U->7OCK%J_W)o^UmAB||2F+F zg;tK*Br;`PWS}Q&=93I);9GrP6fI=(c+AdceG*SY!A-j;_F+gm`ZyRa6Zzpwjx8!K zbw?)3vdfSgM5`eUun%XLw4m**+8v|RF+FaC`VO1X|KHc-2Kt-y0=Xu;KXP@Wi#9qK z2XskI-C_L>?Nbat+adh};H2MD@HqnPhm7Oden{J!1W$ZUT_R|ER4f9t60z;kal?7t`I>9G zMJ{cSy$$M4dGO_fQ;Tmby}tA{|Fj4{I@#NJ<>1}23Oa+$*k{CddvEq8>Nh3IHe(Sx z_y_K-cf2k4i@2($v2BkUzV%st6k}zx9QW)?n~`kB>U)O)rIkNvwqwrHEYuYSMr}I& zXUsGme_Y?0FgrXNJewURShQK8-7!0KOyEwFbA*{09FV%%S8I`-PE3>rfHmt!2G7G~ zC;jP>o0=@RJuOIncy_1Xt;%t3xR&FazKmVnpuQ^lnK3yN3~4rK&J)Dxk_W*&_X))f z&+$-1PWx@&W2crZ(oo+! zHuy(g;qMleOvGP50nMoWUH&GYs9cpOB4sP?cHI}LfNgR)3w zU&zhepVGymg{)sF(^q_>-eYQ5(=|+<-eYRm(lt!>BQu9IZ9Y5(n>~%qgeM~6hl;(QAhn}yf4*A&0^%8m zseg?mocUv#6ZaJ={ssmAjDj&Lwu^&(159a0pZS4phrMZ_xLKS{D9t;LO}&3h75{>Q zCItG~D(#7lPqcIM+`bGfQ#AiRL7gu!0V`{6oW6b%H)4ovW_9F>-w*owE-v z$0jg!OI267RNZ#DZhNARzS~9!4q=MU)i=C-~*1#*UDXM0s{Y2Z>&vE}O&0j!jh)*F?R#-q%BN}d%G53F;P}BSZBU}S z%05*;ZCXCH?haq`+gz$a`2O)8@0+x%9;QFZD`|0aKdGw&#&cC!d z5&uiKwI$Ey_eoKr-~a{BQb3#yvlo26;_41hY@-_Em}Kw{CCT2!^rcKw$Ih{m15}lq z6nH717fK|02okMyQVc=nCo%>plY(g~_$mT?BIA?dMN@mH*Uc=3)B5H1&+QS5;~C3? zXKcKq;(^cNsCF5^`JrzN+K&ad!dDW%SzfzNphB9?~}0M zvgZpUbYGmkL~%;}(mabFm7?q&A`cTRju2<}MabyUpkAVSvosAYhvy}y97mop~&-+GU6H~;_u literal 0 HcmV?d00001 From 217b880838b4b2bb865b06d9ee57baae76a5edd9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 14:28:17 +0000 Subject: [PATCH 2/3] Add point-cloud and splat rendering support --- README.md | 3 +- __pycache__/triptych360.cpython-312.pyc | Bin 25273 -> 41294 bytes requirements.txt | 2 + triptych360.py | 274 +++++++++++++++++++++++- 4 files changed, 270 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0c6a756..31b940f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ The projection mapping is entirely GPU-accelerated using GLSL fragment shaders, ## Features - **GPU-Accelerated**: Fast real-time Equirectangular to Rectilinear conversion. - **Video Playback**: Native support for auto-looping 360 videos (`.mp4`, `.mkv`, `.avi`, `.mov`) via OpenCV. +- **3D Model Support**: Load LiDAR point clouds from `.las` / `.laz` and Gaussian splats from `.ply` / `.splat`. - **Multi-Window Display & Kiosk Mode**: Automatically opens up 3 separate views. Check the "Kiosk Mode" box to make them frameless and smoothly lock onto your 3 monitors. The projection math cleanly locks the edges to precisely 90 degrees horizontally, ensuring a seamless U-shape layout. - **Interactive Unified Controller**: Use your mouse to rotate and zoom across all display outputs instantly. - **3Dconnexion SpaceMouse Support**: Move around your panoramas smoothly using native SpaceMouse integration. @@ -45,7 +46,7 @@ The projection mapping is entirely GPU-accelerated using GLSL fragment shaders, 2. **Load Media:** * A controller window will appear. Click "Load Equirectangular Media". - * You can test with the provided `sample_equirectangular.jpg` or load any supported video/image file. + * You can test with the provided `sample_equirectangular.jpg` or load any supported video/image/3D file (`.las`, `.laz`, `.ply`, `.splat`). * Three new windows will open showing the Left, Front, and Right segments of the media. 3. **Navigate & Control:** diff --git a/__pycache__/triptych360.cpython-312.pyc b/__pycache__/triptych360.cpython-312.pyc index a2f9e17db03a4ded2305490c2678af3341ba91e0..93e3736995790a1e5b3a7c7e8aecfbdd45d17fbe 100644 GIT binary patch literal 41294 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 M175!|B0}o_0Z 1.0) { + discard; + } + float falloff = exp(-r2 * 4.0); + f_color = vec4(v_color.rgb, v_color.a * falloff); +} +""" + # ========================================== # OpenGL ViewPort Widget # ========================================== @@ -83,6 +114,17 @@ def __init__(self, yaw_offset_deg=0.0, frameless=False): self.vbo = None self.vao = None self.texture = None + self.render_mode = "pano" + + self.point_prog = None + self.point_vao = None + self.point_pos_vbo = None + self.point_color_vbo = None + self.point_size_vbo = None + self.point_count = 0 + self.point_positions = None + self.point_colors = None + self.point_sizes = None self.raw_image_data = None self.raw_width = 0 @@ -96,11 +138,14 @@ def __init__(self, yaw_offset_deg=0.0, frameless=False): def initializeGL(self): self.ctx = moderngl.create_context() self.prog = self.ctx.program(vertex_shader=VERTEX_SHADER, fragment_shader=FRAGMENT_SHADER) + self.point_prog = self.ctx.program(vertex_shader=POINT_VERTEX_SHADER, fragment_shader=POINT_FRAGMENT_SHADER) vertices = np.array([-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0], dtype='f4') self.vbo = self.ctx.buffer(vertices) self.vao = self.ctx.simple_vertex_array(self.prog, self.vbo, 'in_vert') if self.raw_image_data is not None: self._create_gl_texture() + if self.point_positions is not None: + self._create_point_buffers() def _create_gl_texture(self): if self.texture: @@ -120,7 +165,21 @@ def paintGL(self): fbo = self.ctx.detect_framebuffer() fbo.use() fbo.clear(0.1, 0.1, 0.1) - if self.texture is None: return + + if self.render_mode == "points": + if self.point_vao is None or self.point_count == 0: + return + self.ctx.enable(moderngl.DEPTH_TEST | moderngl.BLEND) + self.ctx.blend_func = moderngl.SRC_ALPHA, moderngl.ONE_MINUS_SRC_ALPHA + final_yaw = self.yaw_deg + self.yaw_offset_deg + mvp = self._compute_mvp_matrix(self.pitch_deg, final_yaw, self.fov_deg) + self.point_prog['mvp'].write(mvp.astype('f4').T.tobytes()) + self.point_vao.render(moderngl.POINTS) + return + + self.ctx.disable(moderngl.DEPTH_TEST) + if self.texture is None: + return self.texture.use(0) if 'tex' in self.prog: self.prog['tex'].value = 0 if 'aspect_ratio' in self.prog: self.prog['aspect_ratio'].value = self.aspect_ratio @@ -139,6 +198,7 @@ def update_texture_data(self, image_data): self.update() def set_texture_data(self, image_data, width, height): + self.render_mode = "pano" self.raw_image_data = image_data self.raw_width = width self.raw_height = height @@ -146,6 +206,71 @@ def set_texture_data(self, image_data, width, height): self.makeCurrent() self._create_gl_texture() self.update() + + def set_point_data(self, points, colors, sizes): + self.render_mode = "points" + self.point_positions = np.ascontiguousarray(points.astype('f4')) + self.point_colors = np.ascontiguousarray(colors.astype('f4')) + self.point_sizes = np.ascontiguousarray(sizes.astype('f4')) + self.point_count = self.point_positions.shape[0] + if self.ctx is not None: + self.makeCurrent() + self._create_point_buffers() + self.update() + + def _create_point_buffers(self): + if self.point_vao is not None: + self.point_vao.release() + self.point_vao = None + for attr in ("point_pos_vbo", "point_color_vbo", "point_size_vbo"): + buf = getattr(self, attr) + if buf is not None: + buf.release() + setattr(self, attr, None) + + self.point_pos_vbo = self.ctx.buffer(self.point_positions.tobytes()) + self.point_color_vbo = self.ctx.buffer(self.point_colors.tobytes()) + self.point_size_vbo = self.ctx.buffer(self.point_sizes.tobytes()) + self.point_vao = self.ctx.vertex_array(self.point_prog, [ + (self.point_pos_vbo, '3f', 'in_pos'), + (self.point_color_vbo, '4f', 'in_color'), + (self.point_size_vbo, '1f', 'in_size') + ]) + + def _compute_mvp_matrix(self, pitch_deg, yaw_deg, fov_deg): + fov = math.radians(max(20.0, min(150.0, fov_deg))) + near, far = 0.01, 100.0 + tan_half = math.tan(fov / 2.0) + proj = np.array([ + [1.0 / (self.aspect_ratio * tan_half), 0.0, 0.0, 0.0], + [0.0, 1.0 / tan_half, 0.0, 0.0], + [0.0, 0.0, -(far + near) / (far - near), -(2.0 * far * near) / (far - near)], + [0.0, 0.0, -1.0, 0.0] + ], dtype='f4') + + pitch = math.radians(pitch_deg) + yaw = math.radians(yaw_deg) + cp, sp = math.cos(pitch), math.sin(pitch) + cy, sy = math.cos(yaw), math.sin(yaw) + rx = np.array([ + [1.0, 0.0, 0.0, 0.0], + [0.0, cp, -sp, 0.0], + [0.0, sp, cp, 0.0], + [0.0, 0.0, 0.0, 1.0] + ], dtype='f4') + ry = np.array([ + [cy, 0.0, -sy, 0.0], + [0.0, 1.0, 0.0, 0.0], + [sy, 0.0, cy, 0.0], + [0.0, 0.0, 0.0, 1.0] + ], dtype='f4') + model = np.array([ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, -2.5], + [0.0, 0.0, 0.0, 1.0] + ], dtype='f4') + return proj @ (ry @ rx @ model) def update_view(self, pitch, yaw, fov): self.pitch_deg = pitch @@ -185,6 +310,7 @@ def __init__(self): self.image_data = None self.image_w = 0 self.image_h = 0 + self.scene_loaded = False self.video_cap = None self.video_timer = QTimer(self) @@ -240,13 +366,13 @@ def __init__(self): layout.setContentsMargins(40, 40, 40, 40) layout.setSpacing(20) - self.info_lbl = QLabel("No media loaded.") + self.info_lbl = QLabel("No media or 3D model loaded.") self.info_lbl.setObjectName("TitleLabel") self.info_lbl.setAlignment(Qt.AlignCenter) self.info_lbl.setWordWrap(True) layout.addWidget(self.info_lbl) - btn_load = QPushButton("Load Equirectangular Media") + btn_load = QPushButton("Load Media / 3D Model") btn_load.setCursor(Qt.PointingHandCursor) btn_load.clicked.connect(self.load_media_prompt) layout.addWidget(btn_load) @@ -284,7 +410,12 @@ def reset_idle(self): self.idle_time = 0.0 def load_media_prompt(self): - path, _ = QFileDialog.getOpenFileName(self, "Open 360 Media", "", "Media (*.png *.jpg *.jpeg *.mp4 *.mkv *.avi *.mov)") + path, _ = QFileDialog.getOpenFileName( + self, + "Open 360 Media / 3D Data", + "", + "Media & 3D (*.png *.jpg *.jpeg *.mp4 *.mkv *.avi *.mov *.las *.laz *.ply *.splat)" + ) if path: self.load_media_file(path) @@ -295,8 +426,9 @@ def load_media_file(self, path): self.video_cap = None sm_status = " | SpaceMouse ✅" if self.sm_device else " | UI Idle Pan ✅" + path_lower = str(path).lower() - if str(path).lower().endswith(('.mp4', '.mkv', '.avi', '.mov')): + if path_lower.endswith(('.mp4', '.mkv', '.avi', '.mov')): self.video_cap = cv2.VideoCapture(path) if not self.video_cap.isOpened(): self.info_lbl.setText("Error: Could not open video file.") @@ -311,11 +443,26 @@ def load_media_file(self, path): frame = cv2.flip(frame, 0) self.image_data = frame.tobytes() self.image_path = path + self.scene_loaded = True self.info_lbl.setText(f"Loaded Video: {os.path.basename(path)} @ {fps}fps{sm_status}") for view in self.view_windows: view.set_texture_data(self.image_data, self.image_w, self.image_h) self.position_windows() self.video_timer.start(int(1000 / fps)) + elif path_lower.endswith(('.las', '.laz')): + try: + points, colors, sizes = self._load_las_laz(path) + self._apply_point_scene(points, colors, sizes, f"Loaded Point Cloud: {os.path.basename(path)}{sm_status}") + except Exception as e: + self.info_lbl.setText(f"Error loading LAS/LAZ: {str(e)}") + self.scene_loaded = False + elif path_lower.endswith(('.ply', '.splat')): + try: + points, colors, sizes = self._load_gaussian_splats(path) + self._apply_point_scene(points, colors, sizes, f"Loaded Gaussian Splats: {os.path.basename(path)}{sm_status}") + except Exception as e: + self.info_lbl.setText(f"Error loading splats: {str(e)}") + self.scene_loaded = False else: try: img = Image.open(path).convert('RGB') @@ -323,14 +470,125 @@ def load_media_file(self, path): self.image_w, self.image_h = img.size self.image_data = img.tobytes() self.image_path = path + self.scene_loaded = True self.info_lbl.setText(f"Loaded Image: {os.path.basename(path)}{sm_status}") for view in self.view_windows: view.set_texture_data(self.image_data, self.image_w, self.image_h) self.position_windows() except Exception as e: self.info_lbl.setText(f"Error loading image: {str(e)}") + self.scene_loaded = False self.camera_updated.emit(self.pitch, self.yaw, self.fov) + + def _apply_point_scene(self, points, colors, sizes, status_text): + self.image_data = None + self.image_w = 0 + self.image_h = 0 + self.scene_loaded = True + self.info_lbl.setText(status_text) + for view in self.view_windows: + view.set_point_data(points, colors, sizes) + self.position_windows() + + def _normalize_points(self, points): + points = points.astype('f4') + center = points.mean(axis=0) + points = points - center + max_extent = np.max(np.linalg.norm(points, axis=1)) + if max_extent > 0: + points /= max_extent + return points + + def _load_las_laz(self, path): + las = laspy.read(path) + points = np.column_stack((las.x, las.y, las.z)).astype('f4') + if points.size == 0: + raise ValueError("File has no points.") + points = self._normalize_points(points) + + colors = np.ones((points.shape[0], 4), dtype='f4') + if all(hasattr(las, attr) for attr in ('red', 'green', 'blue')): + rgb = np.column_stack((las.red, las.green, las.blue)).astype('f4') + max_val = np.max(rgb) + if max_val > 0: + rgb /= max_val + colors[:, :3] = np.clip(rgb, 0.0, 1.0) + + sizes = np.full((points.shape[0],), 2.0, dtype='f4') + self.image_path = path + return points, colors, sizes + + def _load_gaussian_splats(self, path): + if path.lower().endswith('.ply'): + return self._load_gaussian_splat_ply(path) + return self._load_splat_binary(path) + + def _load_gaussian_splat_ply(self, path): + ply = PlyData.read(path) + vertex = ply['vertex'].data + names = vertex.dtype.names or () + required = ('x', 'y', 'z') + if not all(name in names for name in required): + raise ValueError("PLY missing x/y/z vertex fields.") + + points = np.column_stack((vertex['x'], vertex['y'], vertex['z'])).astype('f4') + if points.size == 0: + raise ValueError("PLY has no vertices.") + points = self._normalize_points(points) + + colors = np.ones((points.shape[0], 4), dtype='f4') + if all(name in names for name in ('red', 'green', 'blue')): + rgb = np.column_stack((vertex['red'], vertex['green'], vertex['blue'])).astype('f4') + max_val = np.max(rgb) + if max_val > 0: + rgb /= max_val + colors[:, :3] = np.clip(rgb, 0.0, 1.0) + elif all(name in names for name in ('f_dc_0', 'f_dc_1', 'f_dc_2')): + dc = np.column_stack((vertex['f_dc_0'], vertex['f_dc_1'], vertex['f_dc_2'])).astype('f4') + colors[:, :3] = np.clip(0.5 + dc, 0.0, 1.0) + + if 'opacity' in names: + opacity = vertex['opacity'].astype('f4') + colors[:, 3] = 1.0 / (1.0 + np.exp(-opacity)) + + sizes = np.full((points.shape[0],), 6.0, dtype='f4') + scale_fields = [f for f in ('scale_0', 'scale_1', 'scale_2') if f in names] + if scale_fields: + scales = np.column_stack([vertex[f] for f in scale_fields]).astype('f4') + sizes = np.clip(np.exp(scales.mean(axis=1)) * 3.0, 1.0, 24.0) + + self.image_path = path + return points, colors, sizes + + def _load_splat_binary(self, path): + dtype = np.dtype([ + ('x', ' 5000.0 and self.image_data: + if self.idle_time > 5000.0 and self.scene_loaded: self.yaw += 0.03 self.yaw %= 360.0 self.camera_updated.emit(self.pitch, self.yaw, self.fov) - if self.sm_device and self.image_data: + if self.sm_device and self.scene_loaded: try: state = self.sm_device.read() if state: @@ -387,7 +645,7 @@ def mouseReleaseEvent(self, event): self.dragging = False def mouseMoveEvent(self, event): - if self.dragging and self.image_data: + if self.dragging and self.scene_loaded: self.reset_idle() delta = event.position() - self.last_mouse_pos self.last_mouse_pos = event.position() From e8f77f3c7f0555cd6f1b9dcb3702b6460d982663 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 09:38:03 +0000 Subject: [PATCH 3/3] Update README for 3D media support --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 31b940f..6bac455 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Triptych360 Banner -# Triptych360 - 360 Video Wall Controller +# Triptych360 - 360 Media + 3D Video Wall Controller -Triptych360 is a Python application built with PySide6 and ModernGL that takes a 360-degree equirectangular image or video and projects it in real-time onto three separate windows representing Left, Front, and Right views. This is designed for video wall installations mapped in a U-shape. +Triptych360 is a Python application built with PySide6 and ModernGL that takes a 360-degree equirectangular image/video or a 3D point-based model and projects it in real-time onto three separate windows representing Left, Front, and Right views. This is designed for video wall installations mapped in a U-shape. The projection mapping is entirely GPU-accelerated using GLSL fragment shaders, making it highly performant and capable of decoding high-framerate videos smoothly. @@ -37,6 +37,15 @@ The projection mapping is entirely GPU-accelerated using GLSL fragment shaders, pip install -r requirements.txt ``` +## Supported File Types + +- **360 Media** + - Images: `.png`, `.jpg`, `.jpeg` + - Videos: `.mp4`, `.mkv`, `.avi`, `.mov` +- **3D Data** + - LiDAR point clouds: `.las`, `.laz` + - Gaussian splats: `.ply`, `.splat` + ## Usage 1. **Launch the application:** @@ -45,7 +54,7 @@ The projection mapping is entirely GPU-accelerated using GLSL fragment shaders, ``` 2. **Load Media:** - * A controller window will appear. Click "Load Equirectangular Media". + * A controller window will appear. Click **"Load Media / 3D Model"**. * You can test with the provided `sample_equirectangular.jpg` or load any supported video/image/3D file (`.las`, `.laz`, `.ply`, `.splat`). * Three new windows will open showing the Left, Front, and Right segments of the media.