결과물

https://twitter.com/EB36_Be_B3b/status/1576431492720259072?s=20&t=Apa3Q7Qp1-s73raR_15Ttg

맥북에는 CPU사용량에 맞춰서 뛰어다니는 고양이가 있다고하는데 Windows에는 없는가 또 이걸 고양이가 아니라 원하는대로는 불가능한가? 에서 시작하게 되었습니다.

Windows 앱 개발 용 언어는 써본 적도 없고 대부분 이런 취미용 코드는 Python으로 작업하기 때문에 이번에도 Python으로 간단하게 만들 방법은 없나? 하고 고민하다가tkinter 모듈을 이용하여 화면을 띄우는 방식으로 제작하였습니다.

이후 다양한 움짤 등을 확보하면 좀 더 자연스럽게 화면에서 걸어다니거나 상호작용하는 모니터 안에 흔한 오타쿠게임들 마이룸(ex. 프리코네 길드하우스) 같은 기능을 만들고 싶다고 생각합니다.

오늘은 제가 좋아하는 컨텐츠인 IDOLY PRIDE에서 게임개발블로그가 공개해준 움짤을 사용하여 CPU사용량에 맞춰서 뛰어다니게 하는 것 까지를 정리하겠습니다.

목차

  • 움짤 가공
  • 윈도우 설정
  • CPU사용량 받아오기
  • 테스트

1. 움짤(gif 파일) 가공

from : https://technote.qualiarts.jp/

게임의 SD 캐릭터 구성에 대해서 정말 자세하게 적어주셔서 많은 도움이 되었던 글입니다

이런 식으로 배경이 확실할 수록 투명처리가 간단해서 예쁘게 나옵니다!

이번 움짤의 경우에는 배경색이 RGB (254,254,254)와 (255,255,255) 가 섞여있어서 투명처리가 예쁘게 되지 않았습니다.

from PIL import Image, ImageSequence, GifImagePlugin
import os

GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_ALWAYS
dirname = os.path.dirname(__file__)
im = Image.open(dirname+"\\gifs\\sakura_run.gif")
new_gif = []
for i in range (0,im.n_frames):
    im.seek(i)
    print(im)
    [xs,ys] = im.size
    for x in range(0,xs):
        for y in range(0,ys):
            if im.getpixel((x,y)) == (254,254,254) or im.getpixel((x,y)) == (255,255,255):
                im.putpixel((x,y),(0,0,0))
    im.resize((int(xs/11),int(ys/11)))
    im.save(dirname+"\\gifs\\sakura_run\\sakura_run_"+str(i)+".png")
    new_gif.append(im)
print(len(new_gif))

거기서 위와같은 스크립트를 작성하여 (254,254,254)와 (255,255,255) 인 픽셀들을 전부 검은색 (0,0,0)으로 설정하도록 했습니다.
GIF의 1번이미지는 RGB값이 아닌 포맷으로 불러와지는 경우가 있어서 이 변환이 잘 작동하지 않았는데 다움과 같은 코드를 위에 추가해서 해결했습니다

GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_ALWAYS

아래는 모든프레임의 모든픽셀을 돌아보면서 색을 변환하고 리사이즈를 하는 과정입니다.

드래그해서 크기 조절이 자유롭게 가능하면 좋겠는데 아직 그 기능까지는 구현을 안했기 때문에 그냥 GIF를 줄이는 방식으로 했습니다.

위 스크립트를 실행하면 해당 프레임이 각각 png파일로 저장되게 됩니다. 이 프레임들을 다시 gif로 묶어줘야하는데 이 때는 아래의 코드를 이용했습니다.

import glob
import os
from traceback import FrameSummary
from PIL import Image
def make_gif(frame_folder):
    frames = [Image.open(image).resize((44,54)) for image in glob.glob(f"{frame_folder}/*.png")]
    frame_one = frames[0]
    print(len(frames))
    frame_one.save(os.path.dirname(__file__)+"\\gifs\\sakura_run2.gif", format="GIF", append_images=frames[1:],
               save_all=True, duration=100, loop=0)

if __name__ == "__main__":
    make_gif(os.path.dirname(__file__)+"\\gifs\\sakura_run\\")

어라 여기서 리사이즈 어차피 하고있네요 ㅋㅋㅋㅋ 위에서 왜했지
보통 os가지고 파일을 알아서 탐색하게 직접 스크립트를 짰던 것 같은데 인터넷을 뒤적거려보니 glob이라는 모듈을 사용하면 저렇게 편리하게 탐색이 가능하더라구요 코드 수가 줄었습니다 ㅜㅜ

duration 같은 경우에는 어느정도 적절하게만 조절해주시면 됩니다. 실제로는 CPU사용량에 맞춰서 움직이게 Tkinter의 윈도우 설정부분에서 update 간격을 통해 속도가 결정됩니다.

가공완료!

2. 윈도우 설정

가장 중요한 부분이라 할 수 있는 Tkinter의 윈도우를 구성하는 방식입니다.

  • 프레임이 없을 것
  • 투명색을 설정
  • 표시 위치를 조절

이 3개가 가장 기본적인 작업이라고 생각됩니다.
일반적인 윈도우는 가장 위에 닫기 버튼이 있는 프레임이 존재하는데 이번에는 그런게 없이 만드는 것이 목적이니까요!

2-1. 이미지데이터와 메인 프레임 작성

dirname = os.path.dirname(__file__)
main_frame = tk.Tk()
im = Image.open(dirname+"\\gifs\\sakura_run2.gif")
running_sakura = [tk.PhotoImage(file=dirname+"\\gifs\\sakura_run2.gif", format="gif -index %i" %(i)) for i in range(im.n_frames)]

dirname에 자신의 현재 디렉토리를 불러오게 합니다

Tkinter의 메인프레임을 생성합니다

PIL로 이미지를 불러옵니다. ( 프레임 수 로드 용 )

gif의 각 프레임을 tk 이미지 프레임 목록에 load해둡니다.

2-2. Window 설정하기

label = tk.Label(main_frame,bd=0,bg='black')
main_frame.overrideredirect(True)
main_frame.wm_attributes('-transparentcolor','black')
main_frame.wm_attributes('-topmost',True)
main_frame.geometry("44x54+5+5")
label.pack()

이번에는 label위젯이라는 형식을 통해 표시하겠습니다.

main_frame의 내용을 label 형식으로 표기하며 경계선은 없음 배경색은 거음색
main_frame은 닫기버튼, alt f4등의 영향을 받지 않는다 ( 이걸로 가장 위의 X버튼이 표시되는 것을 방지 )

main_frame.overrideredirect(True) 가 없을 경우 위와 같이 표시됩니다.

main_frame에서는 black (0,0,0)을 투명으로 처리한다

main_frame은 언제나 상단에 존재한다

main_frame은 44*54의크기로 모니터상에서 5,5 ( 좌측상단) 에 위치한다

포장

2-3. Window 업데이트 설정

다음은 여기에 움짤 각 프레임이 어떻게 표시되고 어떤 조건으로 업데이트 될 것인가에 대한 부분입니다.

def update_window(cycle):
    global cpu_usage
    if not cpu_usage_queue.empty():
        cpu_usage = cpu_usage_queue.get()
        print("CPU Usage : "+str(cpu_usage))
    frame = running_sakura[cycle]
    cycle = (cycle + 1) % len(running_sakura)
    label.configure(image=frame)
    main_frame.after(int(2000/cpu_usage),update_window,cycle)
main_frame.after(1,update_window,0)
main_frame.mainloop()

main_frame.after(time ms, function, function parameter) 순으로 들어가게 됩니다.

즉 main_frame.after(1,update_window,0)는 main_frame만들고 1ms이후에 update_window(0)를 실행하라는 뜻입니다.

그럼 update_window 함수의 역할을 보면

cpu사용량을 가져옵니다. ( 이 부분에 대해서는 다음 파트에서 설명하겠습니다 )

아까 불러 온 이미지의 각 프레임에서 cycle 파라미터로 몇번째 프레임을 고를지 설정합니다.

예를들면 update_window(0)면 첫 번째 프레임이 선택될 것 입니다.
다음 update_window 함수를 위한 cycle의 값을 계산합니다

label의 이미지에 frame을 집어 넣습니다.

main_frame.after(int(2000/cpu_usage),update_window,cycle) 여기서 gif의 움직이는 속도를 cpu사용량에 의존하게 합니다.

2000ms를 cpu 사용량으로 나눈 값 ms 이후에 해당 윈도우(라벨위젯)의 이미지를 cycle = (cycle + 1) % len(running_sakura) 프레임이미지로 변경합니다

예를 들면 cpu 사용량이 50이면 40ms간격으로 프레임이 진행합니다 25fps 정도되겠네요

main_frame.mainloop() 로 윈도우를 실행합니다.

3. CPU 사용량 가져오기

psutil 라이브러리를 사용하면 CPU사용량을 가져올 수 있습니다.
다만 문제점은 CPU사용량이라는 것은 일정 sample 시간을 두고 그 결과를 줍니다.
예를 들면 psutil.cpu_percent(3) 은 3초동안의 CPU 사용량의 평균을 주기 때문에 필연적으로 동기식 처리에서는 3초간 멈추게 됩니다.

이 뜻은 이미지의 업데이트도 3초를 멈춘다는 것으로 치명적이였습니다.
이 간격을 줄이기에도 성능저하문제나 함수 자체에서 최소 0.1초이상의 샘플링을 요구하고 있는 문제도 있어서 이 부분은 비동기처리를 위해 Thread 로 CPU사용량을 불러오는 함수를 불러서 그 안에서 Queue를 통해 메인(윈도우업데이트)와 통신하도록 했습니다.

from threading import Thread
from multiprocessing import Queue

def get_cpu_usage(queue):
    while True:
        queue.put(psutil.cpu_percent(3))

cpu_usage = 10
cpu_usage_queue = Queue()
cpu_usage_process = Thread(target=get_cpu_usage, args=(cpu_usage_queue,))
cpu_usage_process.start()

cpu_usage는 초기치는 10입니다. 이 때문에 실행 직후 3초간은 CPU사용량이 10%로 가정하게 되고 그 다음부터 실제 CPU사용량이 불러와지게 됩니다.

먼저 Queue를 생성합니다. 그 다음에 이 queue를 인수로 get_cpu_usage라는 Thread를 생성합니다. 이걸로 이 Thread는 메인스트림과 별개로 혼자서 뻉글뺑글 돌아가면서 3초마다 CPU 사용량을 샘플링하여 Queue에 집어넣게 됩니다.

다시 update_window 함수를 살펴보면

def update_window(cycle):
    global cpu_usage
    if not cpu_usage_queue.empty():
        cpu_usage = cpu_usage_queue.get()
        print("CPU Usage : "+str(cpu_usage))

여기서 cpu_usage를 가져오고 있습니다.
이 함수안에서는 global 변수 cpu_usage를 처음에 사용하고, 만약 cpu_usage_queue가 empty가 아니면 즉 Thread로부터 Queue에 CPU값이 들어와 있으면 그 값을 가져와서 cpu_usage로 사용하게 됩니다.

Pipe를 사용하면 반드시 그 값이 올 때 까지 기다려야한다는 문제가 있어서 이번에는 queue기능을 사용하여 empty()를 통해 확인하는 과정으로 업데이트가 없으면 이전 값을 그대로 사용하도록 할 수 있었습니다.

테스트

아래의 사이트에서 Thread수와 Power를 높여서 CPU사용량을 높여가며 점점 빨라지는 것을 확인하실 수 있습니다!

https://cpux.net/cpu-stress-test-online

코드

좀 더 기능 추가하고 정리하면 github으로 공유하고싶기도 하네요~ 지금은 작동가능성 확인단계의 코드라 좀 꺼려지지만요!

from threading import Thread
import tkinter as tk
import os
from PIL import Image
import psutil
from multiprocessing import Queue

def get_cpu_usage(queue):
    while True:
        queue.put(psutil.cpu_percent(3))

cpu_usage_queue = Queue()
cpu_usage = 10
cpu_usage_process = Thread(target=get_cpu_usage, args=(cpu_usage_queue,))
cpu_usage_process.start()

dirname = os.path.dirname(__file__)
main_frame = tk.Tk()
im = Image.open(dirname+"\\gifs\\sakura_run2.gif")
# print(ImageSequence.Iterator(im)[0].convert("RGB").getpixel((50,50)))
running_sakura = [tk.PhotoImage(file=dirname+"\\gifs\\sakura_run2.gif", format="gif -index %i" %(i)) for i in range(im.n_frames)]

def update_window(cycle):
    global cpu_usage
    if not cpu_usage_queue.empty():
        cpu_usage = cpu_usage_queue.get()
        print("CPU Usage : "+str(cpu_usage))
    frame = running_sakura[cycle]
    cycle = (cycle + 1) % len(running_sakura)
    label.configure(image=frame)
    main_frame.after(int(2000/cpu_usage),update_window,cycle)

label = tk.Label(main_frame,bd=0,bg='black')
main_frame.overrideredirect(True)
main_frame.wm_attributes('-transparentcolor','black')
main_frame.wm_attributes('-topmost',True)
main_frame.geometry("44x54+5+5")
label.pack()
main_frame.after(1,update_window,0)
main_frame.mainloop()