완성 프로젝트
TaeAhnK/MulmiNoNo: MulmiNoNo - Motion Sickness Reducer (github.com)
아이디어
Apple, 눈 추적 등 새로운 손쉬운 사용 기능 공개 - Apple (KR)
애플이 iOS18의 신기능으로 차량 모션 큐라는 기능을 추가했다.
차량 모션 큐는 자동차에서 휴대폰을 볼 때 발생하는 멀미를 완화하는 기능이다.
멀미를 막을 수 있다길래 매우 복잡한 기능일 줄 알았는데, 생각보다는 단순하다.
자동차를 타고 휴대폰을 보면 우리의 몸은 움직이고 있지만, 우리 눈이 보는 화면은 정지해 있다.
이 괴리로 인해 전정기관은 이상함을 느끼고 멀미를 일으킨다.
차량 모션 큐는 눈에도 우리가 움직이고 있다는 신호를 주기 위해 화면에 몇 개의 점을 그리고 자동차의 움직임에 맞춰 함께 움직인다. 이렇게 하면 눈도 화면이 몸과 함께 움직인다고 착각해 멀미를 완화하는 원리라고 한다.
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시간 정도 걸린 것 같다. (빌드에서 아이콘을 포함하기 위해 삽질을 좀 많이 했다.)