개발일지

[Mulminono] Chat GPT와 함께 3D 멀미 완화 프로그램 만들기

타자치는 문돌이

완성 프로젝트

TaeAhnK/MulmiNoNo: MulmiNoNo - Motion Sickness Reducer (github.com)

 

GitHub - TaeAhnK/MulmiNoNo: MulmiNoNo - Motion Sickness Reducer

MulmiNoNo - Motion Sickness Reducer. Contribute to TaeAhnK/MulmiNoNo development by creating an account on GitHub.

github.com

 

아이디어

Apple, 눈 추적 등 새로운 손쉬운 사용 기능 공개 - Apple (KR)

애플이 iOS18의 신기능으로 차량 모션 큐라는 기능을 추가했다.
차량 모션 큐는 자동차에서 휴대폰을 볼 때 발생하는 멀미를 완화하는 기능이다.

멀미를 막을 수 있다길래 매우 복잡한 기능일 줄 알았는데, 생각보다는 단순하다.
자동차를 타고 휴대폰을 보면 우리의 몸은 움직이고 있지만, 우리 눈이 보는 화면은 정지해 있다.
이 괴리로 인해 전정기관은 이상함을 느끼고 멀미를 일으킨다.
차량 모션 큐는 눈에도 우리가 움직이고 있다는 신호를 주기 위해 화면에 몇 개의 점을 그리고 자동차의 움직임에 맞춰 함께 움직인다. 이렇게 하면 눈도 화면이 몸과 함께 움직인다고 착각해 멀미를 완화하는 원리라고 한다.

3D 멀미는 멀미와 정반대의 경우에 발생한다고 한다.
몸은 가만히 있지만 우리가 보는 게임 속 화면은 심하게 움직인다.
멀미와는 반대의 괴리로 멀미가 일어난다.그렇다면 이 경우에는 화면에 고정되어 있는 점을 표시해서 눈이 화면이 고정되어 있음을 인지하면 3D 멀미가 완화되지 않을까?

 

실제로 화면에 포스트잇을 붙이면 3D 멀미가 줄어든다는 글이 있긴 하다.


그래서 화면에 점을 그려 멀미를 완화하는 프로그램을 만들어 보기로 했다.

 

구현

이런 프로그램을 구현해 보려고 한다.
아래 코드는 완성된 코드를 바탕으로 작성한 것으로, 많은 수정과 리팩토링을 거친 것을 구현 순서만 재현한 것이다.
(아마 프로그램 종료 부분에 이미 트레이 아이콘 종료 코드가 있을 것이다.)
대단한 프로그램도 아니고 빠르고 단순하게 만들 것이므로 GPT 선생님께 도움을 요청했다.

화면의 특정 위치에 사각형을 오버레이하는 프로그램을 만들고 싶어. 이 사각형은 어떤 프로그램이 실행 중이든 항상 맨 앞에서 오버레이되어야 해. 그리고 오버레이되는 중에 실행되는 프로그램의 클릭이 가능해야 해. 메뉴에서 오버레이 표시와 숨김 메뉴를 선택할 수 있으면 좋겠어.

지선생님은 Python의 tkinter를 사용해 프로그램 예제를 짜주셨다. 이상하게 Python을 좋아한다.
일단 조금 다듬어 보자.

import tkinter as tk 
from ctypes import windll, byref 
from ctypes.wintypes import HWND, POINT, RECT

class OverlayApp:
    def __init__(self, root):
        # Variables
        self.screen_width = root.winfo_screenwidth()
        self.screen_height = root.winfo_screenheight()
        self.overlays = []

        # Window
        ## Title Bar
        self.root = root
        self.root.title("MulmiNoNo")

        ## Menu Bar
        self.menu_bar = tk.Menu(root)
        self.root.config(menu=self.menu_bar)

        ### Overlay Menu
        overlay_menu = tk.Menu(self.menu_bar, tearoff=0)
        self.menu_bar.add_cascade(label="Overlay", menu=overlay_menu)
        overlay_menu.add_command(label="Show Overlay (Space)", command=self.show_overlays)
        overlay_menu.add_command(label="Hide Overlay (Space)", command=self.hide_overlays)

        # Show Overlays
        self.show_overlays()

    ### Set Overlays to Clickthroughable
    def set_window_clickthrough(self, hwnd):
        try:
            WS_EX_LAYERED = 0x00080000
            WS_EX_TRANSPARENT = 0x00000020
            GWL_EXSTYLE = -20
            style = windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
            if windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, style | WS_EX_LAYERED | WS_EX_TRANSPARENT) == 0:
                raise WindowsError("Failed Setting Window Style")
        except Exception as e:
            print(f"Erorr on Overlay Window Setting: {e}")

    ### Create Overlays
    def create_overlay(self, x, y):
        width = self.size
        height = self.size
        overlay = tk.Toplevel(self.root)
        overlay.overrideredirect(True)
        overlay.attributes('-topmost', True)
        overlay.attributes('-alpha', 0.9)

        overlay.wm_attributes("-transparentcolor", "white")
        overlay.update_idletasks()
        hwnd = windll.user32.GetParent(overlay.winfo_id())
        self.set_window_clickthrough(hwnd)

        canvas = tk.Canvas(overlay, width=self.size, height=self.size, highlightthickness=0, bg="white")
        canvas.pack()

        canvas.create_rectangle(0, 0, width, height, outline="black", fill=self.color, width=0)

        self.overlays.append(overlay)
        overlay.geometry(f"{width}x{height}+{x}+{y}")

    ### Show Overlays
    def show_overlays(self):
        self.isOverlayed = True
        if self.overlays:
            return

        width, height = self.size, self.size
        margin = 15
        positions = []

        positions = [
            (margin, margin),  # Left Top
            (self.screen_width - width - margin, margin),  # Right Top
            (margin, self.screen_height - height - margin),  # Left Bottom
            (self.screen_width - width - margin, self.screen_height - height - margin),  # Right Bottom
        ]

        for x, y in positions:
            self.create_overlay(x, y)

    ### Hide Overlays
    def hide_overlays(self):
        self.isOverlayed = False
        for overlay in self.overlays:
            overlay.destroy()
        self.overlays = []


if __name__ == "__main__":
root = tk.Tk()
app = OverlayApp(root)
root.geometry("300x0")
try:
    root.mainloop()
finally:
    app.cleanup()

실행하면 작은 메뉴창이 나오고, 코너에 사각형이 그려진다. 사각형은 뒤의 내용이 조금 보이게 투명도를 0.9로 설정했다.

조금 더 커스텀을 해보자. 사각형이 검정색이라 어두운 화면에서는 잘 보이지 않는다. 흰색을 선택할 수 있게 추가해보자.

    def __init__(self, root):
        # Variables
        self.screen_width = root.winfo_screenwidth()
        self.screen_height = root.winfo_screenheight()
        self.taskbar_height = get_taskbar_height()
        self.overlays = []
        self.color = "black"
        ...
         ### Color Menu
        color_menu = tk.Menu(self.menu_bar, tearoff=0)
        self.menu_bar.add_cascade(label="Color", menu=color_menu)
        color_menu.add_command(label="Set Color to Black (B)", command=lambda: self.set_color("black"))
        color_menu.add_command(label="Set Color to White (W)", command=lambda: self.set_color(f'#EEEEEE'))

            ...
    ### Color
    def set_color(self, color):
        self.color = color
        self.outline = color
        self.hide_overlays()
        self.show_overlays()

사각형을 모서리, 가장자리, 8군데 모두 표시하도록 선택할 수 있게 해보자.
하다보니 작업 표시줄을 가리기도 한다. 이것도 고려해보자.

...
import sys
import os
...

# User Window Data
class AppBarData(Structure):
    _fields_ = [("cbSize", DWORD),
                ("hWnd", HWND),
                ("uCallbackMessage", DWORD),
                ("uEdge", DWORD),
                ("rc", RECT),
                ("lParam", DWORD)]

def get_taskbar_height():
    try:
        appbar_data = AppBarData()
        appbar_data.cbSize = DWORD(36)
        if windll.shell32.SHAppBarMessage(5, byref(appbar_data)) == 0:
            raise WindowsError("Failed fetching Taskbar Data")
        taskbar_height = appbar_data.rc.bottom - appbar_data.rc.top
        if appbar_data.uEdge == 3 or appbar_data.uEdge == 1:
            return taskbar_height
    except Exception as e:
        print(f"Failed fetching Taskbar Height: {e}")
    return 0

...

    def __init__(self, root):
        # Variables
        self.screen_width = root.winfo_screenwidth()
        self.screen_height = root.winfo_screenheight()
        self.taskbar_height = get_taskbar_height()
        self.overlays = []
        self.color = "black"
        self.mode = "corners"
        ...
        ### Draw Mode Menu
            mode_menu = tk.Menu(self.menu_bar, tearoff=0)
            self.menu_bar.add_cascade(label="Draw Mode", menu=mode_menu)
            mode_menu.add_command(label="Draw at Corners (1)", command=lambda: self.set_draw_mode("corners"))
            mode_menu.add_command(label="Draw on Sides (2)", command=lambda: self.set_draw_mode("sides"))
            mode_menu.add_command(label="Draw All (3)", command=lambda: self.set_draw_mode("eight"))
        ...

    ### Show Overlays
    def show_overlays(self):
        self.isOverlayed = True
        if self.overlays:
            return

        width, height = self.size, self.size
        margin = 15
        positions = []

        if self.mode == "corners":
            positions = [
                (margin, margin),  # Left Top
                (self.screen_width - width - margin, margin),  # Right Top
                (margin, self.screen_height - height - self.taskbar_height - margin),  # Left Bottom
                (self.screen_width - width - margin, self.screen_height - height - self.taskbar_height- margin),  # Right Bottom
            ]

        elif self.mode == "sides":
            positions = [
                (self.screen_width // 2 - width // 2 - margin, margin),  #23 Center Top
                (self.screen_width // 2 - width // 2 - margin, self.screen_height - height - self.taskbar_height - margin),  # Center Bottom
                (margin, self.screen_height // 2 - height // 2 - self.taskbar_height // 2 - margin // 2),  # Left Center
                (self.screen_width - width - margin, self.screen_height // 2 - height // 2 - self.taskbar_height // 2 - margin // 2)  # Right Center
            ]

        elif self.mode == "eight":
            positions = [
                (self.screen_width // 2 - width // 2 - margin, margin),  # Top Center
                (self.screen_width // 2 - width // 2 - margin, self.screen_height - height - self.taskbar_height - margin),  # Bottom Center
                (margin, self.screen_height // 2 - height // 2 - self.taskbar_height // 2 - margin // 2),  # Left Center
                (self.screen_width - width - margin, self.screen_height // 2 - height // 2 - self.taskbar_height // 2 - margin // 2),  # Right Center
                (margin, margin),  # Left Top
                (self.screen_width - width - margin, margin),  # Right Top
                (margin, self.screen_height - height - self.taskbar_height - margin),  # Left Bottom
                (self.screen_width - width - margin, self.screen_height - height - self.taskbar_height- margin),  # Right Bottom
            ]            

        for x, y in positions:
            self.create_overlay(x, y)

위치 계산을 조잡하게 했지만 넘어가자.

사이즈도 조절해보자.

    def __init__(self, root):
        # Variables
        self.screen_width = root.winfo_screenwidth()
        self.screen_height = root.winfo_screenheight()
        self.taskbar_height = get_taskbar_height()
        self.overlays = []
        self.color = "black"
        self.mode = "corners"
        self.size = 75
        ...
        ### Size Menu
        size_menu = tk.Menu(self.menu_bar, tearoff=0)
        self.menu_bar.add_cascade(label="Size", menu=size_menu)
        size_menu.add_command(label="25% (-)", command=lambda: self.set_size(25))
        size_menu.add_command(label="50% (-)", command=lambda: self.set_size(50))
        size_menu.add_command(label="75% (+)", command=lambda: self.set_size(75))
        size_menu.add_command(label="100% (+)", command=lambda: self.set_size(100))
        ...

    ### Size
    def set_size(self, size):
        if (size > 100):
            size = 100
        elif size < 25:
            size = 25
        self.size = size
        self.hide_overlays()
        self.show_overlays()

단축키도 있으면 좋을 것 같다.
스페이스로 보이기/숨기기, Q로 종료, W/B로 색 전환, 1/2/3으로 모드 전환, +/-로 크기 조절을 설정해보자.

    def __init__(self, root):
        ...
        self.isOverlayed = True
        self.isExiting = False
        ...
        # Shortcuts
        #### Space : Overlay
        self.root.bind('<space>', lambda e: self.space_pressed())

        #### B/b : Black , W/w : White
        self.root.bind('<b>', lambda e: self.set_color("black"))
        self.root.bind('<B>', lambda e: self.set_color("black"))
        self.root.bind('<w>', lambda e: self.set_color(f'#EEEEEE'))
        self.root.bind('<W>', lambda e: self.set_color(f'#EEEEEE'))

        #### 1/2/3 : Draw Mode
        self.root.bind('<Key-1>', lambda e: self.set_draw_mode("corners"))
        self.root.bind('<Key-2>', lambda e: self.set_draw_mode("sides"))
        self.root.bind('<Key-3>', lambda e: self.set_draw_mode("eight"))

        #### +/-/= : Size
        self.root.bind('<plus>', lambda e: self.set_size(self.size + 25))
        self.root.bind('<equal>', lambda e: self.set_size(self.size + 25))
        self.root.bind('<minus>', lambda e: self.set_size(self.size - 25))

        #### Q/q : Exit
        self.root.bind('<q>', lambda e: self.exit_program())
        self.root.bind('<Q>', lambda e: self.exit_program())
    ...
    ### Show / Hide
    def space_pressed(self):
        if (self.isOverlayed):
            self.hide_overlays()
        else:
            self.show_overlays()

    ...
    # Program
    ### Exit Program
    def exit_program(self):
        if self.icon:
            self.icon.stop()
        self.cleanup()

    ### Cleanup and Quit Program
    def cleanup(self):
        if not self.isExiting:
            self.isExiting = True
            self.hide_overlays()
            self.root.quit()
            self.root.destroy()
    ...

if __name__ == "__main__":
    root = tk.Tk()
    app = OverlayApp(root)
    root.geometry("300x0")
    try:
        root.mainloop()
    finally:
        app.cleanup()

프로그램을 닫을 때 특정 상황에서 오버레이가 남거나 두번 지우려고 해 에러가 나는 경우가 있어 그 부분도 수정했다.

보통 이런 프로그램은 닫기를 누르면 트레이로 들어간다.
트레이로 들어가게 해보자. 닫기를 누르면 닫는 대신 아이콘이 되게 해야한다.
트레이를 사용하기 위해선 아이콘도 필요하다.
Bing Image Creator로 아이콘도 만들어 불러온다.

"멀미"라는 키워드로 이미지를 생성해달라고 하니 이런 그림을 줬다.

import pystray
from  PIL import Image
import threading
...

    def __init__(self, root):
        # Window
        ## Title Bar
        self.root = root
        self.root.title("MulmiNoNo")
        self.root.iconbitmap(self.resource_path("mulminono.ico"))
        ...
        # Tray Mode
        self.create_tray_icon()
        self.root.protocol("WM_DELETE_WINDOW", self.minimize_to_tray)
        ...

    ### Get Icon Path
    def resource_path(self, relative_path):
        try:
            base_path = sys._MEIPASS
        except Exception:
            base_path = os.path.abspath(".")

        return os.path.join(base_path, relative_path)
    ...

    # Tray
    ### Create Tray Icon and Window
    def create_tray_icon(self):
        self.icon = pystray.Icon("overlay_app")
        self.icon.icon = Image.open(self.resource_path("mulminono.ico"))
        self.icon.menu = pystray.Menu(
            pystray.MenuItem("Open Window", self.restore_window),
            pystray.MenuItem("Quit", self.exit_program)
        )

    ### Minimize
    def minimize_to_tray(self):
        self.root.withdraw()
        if self.icon is None:
            self.create_tray_icon()
        threading.Thread(target=self.icon.run, daemon=True).start()

    ### Restore back to Window Mode
    def restore_window(self, icon, item):
        self.root.after(0, self.root.deiconify)
        if self.icon:
            self.icon.stop()
            self.icon = None

그럴싸한 프로그램이 되었다! About도 넣어서 Github 링크에 연결해 주었다.

마지막으로 PyInstaller로 배포 가능한 exe 파일로 만들어주었다.
아이콘과 같은 폴더에서 실행해야하는 불편함을 줄이기 위해 포함시켜서 빌드했다.

소스코드와 빌드 파일은 Github에서 확인할 수 있다.

TaeAhnK/MulmiNoNo: MulmiNoNo - Motion Sickness Reducer (github.com)

 

나중에 알게된 버그

설정>디스플레이의 배율이 100%가 아니면 작업 표시줄의 크기를 잘 못불러온다!
실제 크기 * 배율의 값이 반환되는 듯 하다.
언젠가 고칠수도 있지만 일단은 내 실사용에 문제가 없으니... 나중에 여유될 때 고치기로 하자.

 

후기


생각보다 3D 멀미가 줄어든 것 같기도 하다? 몬헌을 더 오래할 수 있게 되었다. 플라시보 효과인가?
이 프로그램에 더해 각종 3D 멀미를 줄이는 설정을 해주면 좋다.

그리고 단순한 프로그램은 GPT로 빠르게 구현하는 것이 마음 편한 것 같다.
한 3시간 정도 걸린 것 같다. (빌드에서 아이콘을 포함하기 위해 삽질을 좀 많이 했다.)

반응형