파이썬 예제 (아날로그 시계 2편)

NTP에 대해 설명한 아날로그 시계 1편에 이어, 실제 Analog Clock을 그리는 2편입니다.

Python에서 NTP(Network Time Protocol)을 이용한 시간 얻기는 1편을 참조하기 바랍니다.

2편에서는 얻어진 시간정보로 아날로그 시계를 그리는 방법에 대해 살펴보겠습니다.

완성된 모습은 아래와 같습니다.

pyinstaller 실행파일 링크 : Analog Clock.exe

[Python Analog Clock]


자세한 개요는 1편에 있으므로, 바로 소스코드를 분석해 보겠습니다.

소스 코드 분석

전체 코드는 2개의 파이썬 파일로 구성되어 있으며(main.py, NTP.py),
이번 시간에는 시계를 그려내는 main.py 파일을 살펴보겠습니다.

프로그램을 실행하기 위해서는 1편의 NTP.py 파일과 2편의 main.py 파일 2개가 필요합니다.

NTP.py (1편 참조)

main.py

윈도우 창을 구성하고, 시계외형 원, 시간표시, 시, 분, 초침, 날짜 등을 그리는 역할입니다.

세부내용은 아래 main.py 전체 코드를 살펴보고 설명하겠습니다.
import sys
import math
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from NTP import NTP
from threading import Thread
import time

QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)

class CWidget(QWidget):

    displayUpdate = pyqtSignal()

    def __init__(self):
        super().__init__()
        self.diameter = 0
        self.radius = 0
        self.cpt = QPointF()
        self.wrect = QRectF()
        self.bOnline = False
        self.text = 'Ocean Coding School'
        self.utc = 9 #Seoul, Korea

        self.displayUpdate.connect(self.update)

        self.thread = Thread(target=self.threadFunc)
        self.bExit = False
        self.thread.start()
        self.initUI()

    def initUI(self):
        vbox = QVBoxLayout()        
        #UTC time
        self.cmb = QComboBox(self)
        for i in range(-12, 15, 1):
            self.cmb.addItem('UTC {}'.format(i))
        self.cmb.setCurrentIndex(21)
        self.cmb.currentIndexChanged[int].connect(self.onChangedUTC)
        vbox.addWidget(self.cmb)

        #draw option
        text = ['Bezel', 'Index', 'Date', 'Hands']
        self.chk = []
        for i in range(len(text)):
            chk = QCheckBox(text[i], self)
            chk.setChecked(True)
            self.chk.append(chk)
            vbox.addWidget(chk)
        vbox.addStretch(1)

        #Clock frame
        self.frame = QFrame(self)
        self.frame.setFrameStyle(QFrame.Panel);
        
        hbox = QHBoxLayout()                
        hbox.addLayout(vbox)                
        hbox.addWidget(self.frame, 1)
        
        self.setLayout(hbox)
        self.setGeometry(100,100,500,400)
        self.setWindowTitle(self.text)
        self.show()

    def onChangedUTC(self, i):
        self.utc = i-12

    def getRotatedPos(self, deg, radius, cpt):        
        rad = deg * math.pi / 180
        dx = math.sin(rad)*radius
        dy = math.cos(rad)*radius       
        return QPointF(cpt.x()+dx, cpt.y()-dy)

    def paintEvent(self, e):        
        qp = QPainter()
        qp.begin(self)
        qp.setRenderHint(QPainter.Antialiasing)
        self.bOnline = NTP.getNTPtime(utc_std = self.utc)
        if self.chk[0].isChecked():
            self.drawBezel(qp)
        if self.chk[1].isChecked():
            self.drawIndex(qp)
        if self.chk[2].isChecked():
            self.drawDate(qp)
        if self.chk[3].isChecked():
            self.drawClockHands(qp)        
        qp.end()

    def resizeEvent(self, e):
        self.resizeClock()
        
    def closeEvent(self, e):
        self.bExit = True

    def resizeClock(self):        
        # clock resize        
        w = self.frame.rect().width()
        h = self.frame.rect().height()        
        self.diameter = h if w>h else w  
        self.diameter -= 10
        
        self.radius = self.diameter/2
        self.cpt = self.frame.mapToParent(self.frame.rect().center())
        
        self.wrect = QRectF(self.cpt.x()-self.radius, self.cpt.y()-self.radius, self.diameter, self.diameter)

        # logo resize
        size = self.radius                 
        self.logoRect = QRectF(self.cpt, QSizeF(size, size/2))
        self.logoRect.adjust(-size/2, 0, -size/2, 0)

        # date resize
        size = self.radius / 3                 
        self.dateRect = QRectF(self.cpt, QSizeF(size, size/3))
        self.dateRect.adjust(-size*2, 0, -size*2, 0)

        # font resize (Big, Small)
        fname = 'Arial'
        self.fontB = QFont(fname, self.radius/8)        
        self.fontS = QFont(fname, self.radius/25)

    def drawBezel(self, qp):
        qp.drawEllipse(self.wrect)

    def drawIndex(self, qp):
        # logo        
        qp.setFont(self.fontS)
        if self.bOnline:
            conn = 'On-line'
        else:
            conn = 'Off-line'
            
        td = str(NTP.time.utcoffset())        
        utc = 'UTC {}'.format(td)
        qp.drawText(self.logoRect, Qt.AlignCenter, self.text+'\n'+utc+'\n'+conn)
        
        # time mark
        bold = 0  
        hour = 1
        for i in range(30, 390, 6):

            pt1 = self.getRotatedPos(i, self.radius, self.cpt)
            pt2 = self.getRotatedPos(i, -self.radius/10, pt1)

            if bold%5 == 0:                
                qp.setPen(QPen(Qt.black, self.radius/50, Qt.SolidLine, Qt.FlatCap))

                size = self.radius / 8
                pt3 = self.getRotatedPos(i, -self.radius/15, pt2)
                pt3-=QPointF(size, size)
                rect = QRectF(pt3, QSizeF(size*2, size*2))
                
                qp.setFont(self.fontB)
                qp.drawText(rect,  Qt.AlignCenter, str(hour))
                hour+=1;
            else:
                qp.setPen(Qt.black)
            
            qp.drawLine(pt1, pt2)    
            bold+=1

    def drawDate(self, qp):        
        date = NTP.time.strftime('%Y.%m.%d %p')
        size = self.dateRect.height()/4
        qp.drawRoundedRect(self.dateRect, size, size)
        qp.setFont(self.fontS)        
        qp.drawText(self.dateRect, Qt.AlignCenter, date)        

    def drawClockHands(self, qp):        
        sec = NTP.time.second
        min = NTP.time.minute
        hour = NTP.time.hour
        # hand of second
        secdeg = sec*6
        secpt = self.getRotatedPos(secdeg, self.radius*0.9, self.cpt)        
        qp.setPen(QPen(Qt.black, 1, Qt.SolidLine, Qt.RoundCap))
        qp.drawLine(self.cpt, secpt)

        # hand of minute
        mindeg = (min+sec/60)*6
        minpt = self.getRotatedPos(mindeg, self.radius*0.8, self.cpt)
        pensize = self.radius/50        
        qp.setPen(QPen(Qt.black, pensize, Qt.SolidLine, Qt.RoundCap))
        qp.drawLine(self.cpt, minpt)

        # hand of hour
        hourdeg = ((hour % 12) + (min / 60.0))*30
        hourpt = self.getRotatedPos(hourdeg, self.radius*0.7, self.cpt)
        pensize = self.radius/30        
        qp.setPen(QPen(Qt.black, pensize, Qt.SolidLine, Qt.RoundCap))
        qp.drawLine(self.cpt, hourpt)

    def threadFunc(self):
        while not self.bExit:
            self.displayUpdate.emit()
            time.sleep(0.1)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = CWidget()
    sys.exit(app.exec_())

1~8번 라인은 아날로그 시계 구현에 필요한 모듈을 불러오는 부분입니다.
import sys
import math
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from NTP import NTP
from threading import Thread
import time

C, C++의 #include 구문과 유사합니다.

윈도우 창을 구성하기 위한 Qt의 class, 삼각함수를 사용하기 위해 math, 1편의 NTP class, Thread 모듈 등을 import 합니다.

10번 라인에서 4K 모니터를 위한 고해상도 설정을 처리합니다.
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)

12번 라인에서 CWidget class를 선언하고 있습니다. CWidget class는, PyQt의 QWidget(윈도우 창 역할)에서 상속받은 class 입니다.

14번 라인은 Thread에서 QWidget의 paintEvent() 를 update하기 위한 사용자 정의 시그널입니다.

16~31번 라인은 해당 클래스의 생성자 함수이며, 상속관계를 갖는 클래스이므로 CWidget의 부모클래스(QWidget)의 생성자를 super()를 통해 호출합니다.
def __init__(self):
        super().__init__()
        self.diameter = 0
        self.radius = 0
        self.cpt = QPointF()
        self.wrect = QRectF()
        self.bOnline = False
        self.text = 'Ocean Coding School'
        self.utc = 9 #Seoul, Korea

        self.displayUpdate.connect(self.update)

        self.thread = Thread(target=self.threadFunc)
        self.bExit = False
        self.thread.start()
        self.initUI()
이어서 시계의 지름(diameter), 반지름(radius), 중심점(cpt), 시계의 크기를 저장할 사각형(wrect), 등을 CWidget의 객체 변수(self 를 붙임)로 선언합니다.

계속해서 앞서 만들어둔 Signal (displayUpdate)을 QWidget의 update() 함수와 연결(Slot) 합니다.

Signal, Slot은 Qt 고유의 이벤트처리 방식이며, 시그널은 신호(어떤 일이 생겼을때), 슬롯은 시그널이 발생되었을때 호출되는 함수라고 이해하면 됩니다.

생성자의 마지막 행동으로, Thread(실행흐름)를 생성하고 대상함수를 threadFunc() 로 지정한 후 가동시킵니다.

쓰레드는 시간이 바뀔때마다 시계를 계속해서 새로 그려내기 위한 처리를 담당합니다.

33~64번 라인은 생성자에서 호출하는 GUI 초기화 함수이며, 윈도우 창에 각종 컨트롤을 생성하는 역할입니다.
def initUI(self):
        vbox = QVBoxLayout()        
        #UTC time
        self.cmb = QComboBox(self)
        for i in range(-12, 15, 1):
            self.cmb.addItem('UTC {}'.format(i))
        self.cmb.setCurrentIndex(21)
        self.cmb.currentIndexChanged[int].connect(self.onChangedUTC)
        vbox.addWidget(self.cmb)

        #draw option
        text = ['Bezel', 'Index', 'Date', 'Hands']
        self.chk = []
        for i in range(len(text)):
            chk = QCheckBox(text[i], self)
            chk.setChecked(True)
            self.chk.append(chk)
            vbox.addWidget(chk)
        vbox.addStretch(1)

        #Clock frame
        self.frame = QFrame(self)
        self.frame.setFrameStyle(QFrame.Panel);
        
        hbox = QHBoxLayout()                
        hbox.addLayout(vbox)                
        hbox.addWidget(self.frame, 1)
        
        self.setLayout(hbox)
        self.setGeometry(100,100,500,400)
        self.setWindowTitle(self.text)
        self.show()

initUI() 함수의 호출 결과는 아래와 같습니다.

[initUI() 함수호출 결과]

화면 좌측에 UTC시간을 위한 QComboBox를 배치하고, 시계의 내부 구성요소를 부분적으로 그려내기위해 QCheckBox를 배치합니다.

화면 우측은 실제 시계를 그리기 위해 QFrame 을 배치합니다.

66번 라인은 initUI() 함수에서 만든 UTC 콤보박스를 변경하면 호출되는 슬롯함수이며, UTC -12 부터 시작하므로 QComboBox의 0번째 index에서 12를 빼 줍니다.
  • 0-12 = UTC -12
[UTC-12 ~ + 14]

69번 라인은 시계원의 중심점좌표, 반지름, 각도를 삼각함수에 적용해 원의 외곽선 x, y 좌표를 찾아내는 함수입니다.

이를 이용해 시계원의 외곽선, x, y 좌표(P)를 구한 후, 시간 표시나 시침 초침, 분침을 그려야 합니다.

[삼각함수를 이용한 좌표 찾기]

원의 중심점 좌표(cpt)와 반지름(r), 각도를 알고 있다면, 삼각함수를 이용해 P의 x, y 좌표를 간단히 찾아낼 수 있습니다.
def getRotatedPos(self, deg, radius, cpt):        
        rad = deg * math.pi / 180
        dx = math.sin(rad)*radius
        dy = math.cos(rad)*radius       
        return QPointF(cpt.x()+dx, cpt.y()-dy)

점 P의 x 좌표는 sin𝜭 = 높이 / 빗변이므로, sin𝜭 * r = x 를 구하고, y 좌표는 cos𝜭 = 밑변 / 빗변이므로, cos𝜭 * r = y 를 통해 y좌표를 구하면 됩니다.

마지막으로 중심점(cpt) 에서 구해진 x, y 를 더하면 원의 외곽선 점의 좌표를 구할 수 있습니다.

y좌표를 중심점에서 -(minus) 하는 이유는 Qt 기본 화면 좌표계(변경가능)가 y좌표는 위로가면 감소되고 아래로 가면 증가되기 때문입니다.

Qt의 좌표계가 2차원 평면 좌표계(데카르트 좌표계)와 다른 이유는 눈에 보이는 위젯의 화면영역에 음수를 사용하지 않으면 좌표처리가 편리(눈에 보이는 영역이 모두 양수)하기 때문입니다.

75~88번 라인은 QWidget의 paintEvent() 함수로, Widget 즉 윈도우를 새로 그려야 할 필요가 있을때 마다 호출되는 함수입니다.
def paintEvent(self, e):        
        qp = QPainter()
        qp.begin(self)
        qp.setRenderHint(QPainter.Antialiasing)
        self.bOnline = NTP.getNTPtime(utc_std = self.utc)
        if self.chk[0].isChecked():
            self.drawBezel(qp)
        if self.chk[1].isChecked():
            self.drawIndex(qp)
        if self.chk[2].isChecked():
            self.drawDate(qp)
        if self.chk[3].isChecked():
            self.drawClockHands(qp)        
        qp.end()

그림을 그리는 팔 역할을 하는 QPainter class의 객체 qp를 생성하고, begin() , end() 사이에 그려야 할 내용을 코드로 작성하면 됩니다.

계단 효과를 방지하기 위해 Antialiasing 으로 그림을 그리고, initUI() 함수에서 생성한 체크 박스의 체크 유무를 판단해 시계의 일부분만 그려낼 수 있도록 처리합니다.

[안티앨리어싱 적용비교, 출처:위키백과]

아래는 drawBezel(), drawIndex(), drawDate(), drawClockHands() 함수를 체크박스 체크여부에 따라 호출해 시계 내부 구성요소들을 따로 그리는 부분입니다.

[bezel 만 적용]
[bezel + index 적용]
[bezel + index + date 적용]
[bezel + index + date + hands 적용]

90번 라인 resizeEvent() 함수는 Widget의 크기가 변경될때마다 호출되는 함수이며, 이때 resizeClock() 함수를 호출합니다.

위에 Analog Clock.exe 실행파일을 다운받아 윈도우 크기를 변경시켜 보면, 해당 프로그램은 시계를 바뀐 윈도우창의 크기에 맞춰 자동으로 resize 시켜줍니다.

이때, 바뀌는 QFrame의 크기에 비례해 시계의 영역을 재조정하게 되는데 그 비율을 96~121 번 라인의 resizeClock() 함수가 설정합니다.

시계영역의 지름, 반지름, 중심점을 재 조정하고, 로고 표시부나, 날짜 표시부에 대한 영역도 적절한 coefficient(계수)를 통해 재조정하는 역할을 담당합니다.

123번 라인부터는 그림을 그리는 함수들입니다.

paintEvent() 함수에서  호출되는 함수들이며, 모두 QPainter의 객체(그림 그리는 팔)를 함수의 전달인자로 취합니다.

QPainter는 개념적으로 C++ MFC의 CDC class와 유사합니다.

drawBezel() 함수는 시계 외곽원을 그립니다.
def drawBezel(self, qp):
        qp.drawEllipse(self.wrect)

self.wrect 객체 변수에 저장된 QRectF 타입의 사각형 정보를 기반으로 원을 그립니다.

126번 라인 drawIndex() 함수는 시계 로고와 안쪽 시간 마크를 그립니다.
def drawIndex(self, qp):
        # logo        
        qp.setFont(self.fontS)  
        if self.bOnline:
            conn = 'On-line'
        else:
            conn = 'Off-line'
            
        td = str(NTP.time.utcoffset())        
        utc = 'UTC {}'.format(td)
        qp.drawText(self.logoRect, Qt.AlignCenter, self.text+'\n'+utc+'\n'+conn)
        
        # time mark
        bold = 0  
        hour = 1
        for i in range(30, 390, 6):

            pt1 = self.getRotatedPos(i, self.radius, self.cpt)
            pt2 = self.getRotatedPos(i, -self.radius/10, pt1)

            if bold%5 == 0:                
                qp.setPen(QPen(Qt.black, self.radius/50, Qt.SolidLine, Qt.FlatCap))

                size = self.radius / 8
                pt3 = self.getRotatedPos(i, -self.radius/15, pt2)
                pt3-=QPointF(size, size)
                rect = QRectF(pt3, QSizeF(size*2, size*2))
                
                qp.setFont(self.fontB)
                qp.drawText(rect,  Qt.AlignCenter, str(hour))
                hour+=1;
            else:
                qp.setPen(Qt.black)
            
            qp.drawLine(pt1, pt2)    
            bold+=1

QPainter class의 drawText() 함수를 이용해 로고 + 온라인 상태 + UTC 시간을 문자열로 만들어 출력합니다.

138번 부터 이어지는 시간 단위 마크는 처리가 조금 까다롭습니다.

[시간 마크 그리기]

위 그림에서 P1의 좌표를 찾아내는 것은 쉽지만 P2의 좌표도 알아야지만 선을 그릴수 있기 때문입니다.

그런데 조금만 생각해보면, P2의 좌표는 결국 위에서 설명한 '삼각함수를 이용한 좌표찾기' 에서 빗변(반지름 r) 의 길이만 좀더 짧게 주면 된다는 사실을 쉽게 이해할 수 있습니다.

시간표시 마크는 1분 간격으로 표시되므로, 360/60 = 6도 단위로 P1, P2를 구해 그려주면 완성입니다.

다만 for 반복문에서 30도 부터 390도까지 6도 간격으로 반복하는 이유는, 1,2,3...등의 시간 글자 표시를 적을때 1시를 의미하는 30도 부터 그려주면 12시 까지 순차적으로 증가하기 때문에 더 쉽게 구현가능하기 때문에 시작이 30도 입니다.

소스코드의 P3 좌표는 시간 숫자표시를 출력하는 사각형의 중심점 좌표입니다.

아래 그림을 참조하기 바랍니다.

[시간 숫자 마크]

164번 라인은 시계 날짜를 그리기 위한 부분입니다.
def drawDate(self, qp):        
        date = NTP.time.strftime('%Y.%m.%d %p')
        size = self.dateRect.height()/4
        qp.drawRoundedRect(self.dateRect, size, size)
        qp.setFont(self.fontS)        
        qp.drawText(self.dateRect, Qt.AlignCenter, date)

1편에서 제작한 NTP class의 datetime 클래스의 strftime() 함수를 이용해 년, 월, 일, 오전/오후 정보를 문자열로 포맷팅합니다.

그리고, QPainter의 drawRoundedRect() 함수를 이용해 끝이 둥근 사각형으로 표시합니다.

170~192번 라인은 시, 분, 초침을 그려주는 drawHands() 함수입니다.
def drawClockHands(self, qp):        
        sec = NTP.time.second
        min = NTP.time.minute
        hour = NTP.time.hour
        # hand of second
        secdeg = sec*6
        secpt = self.getRotatedPos(secdeg, self.radius*0.9, self.cpt)        
        qp.setPen(QPen(Qt.black, 1, Qt.SolidLine, Qt.RoundCap))
        qp.drawLine(self.cpt, secpt)

        # hand of minute
        mindeg = (min+sec/60)*6
        minpt = self.getRotatedPos(mindeg, self.radius*0.8, self.cpt)
        pensize = self.radius/50        
        qp.setPen(QPen(Qt.black, pensize, Qt.SolidLine, Qt.RoundCap))
        qp.drawLine(self.cpt, minpt)

        # hand of hour
        hourdeg = ((hour % 12) + (min / 60.0))*30
        hourpt = self.getRotatedPos(hourdeg, self.radius*0.7, self.cpt)
        pensize = self.radius/30        
        qp.setPen(QPen(Qt.black, pensize, Qt.SolidLine, Qt.RoundCap))
        qp.drawLine(self.cpt, hourpt)

1편에서 만든 NTP class의 datetime 객체를 이용해 시, 분, 초 값을 정수형으로 읽어온 후, 각 침의 각도를 구합니다.
  • 초침 각도 = sec * 6
  • 분침 각도 = (min + sec / 60) * 6
  • 시침 각도 = ((hour % 12) + (min / 60)) * 30
분침과 시침각도 구하기가 조금 복잡해 보이는 이유는 초침이 변하면 분침의 위치가 미세하게 변하기 때문입니다.

즉, 초가 30이라면 분침은 앞 뒤 분침의 중간에 위치해야 하기 때문입니다.

시침도 마찬가지로 각도를 구하지만, 시간단위 마크는 360 / 12 = 30도 기준입니다.

시침에 hour %(나머지연산자) 12 를 쓴 이유는 아날로그 시계가 12시간 기준으로 표시되기 때문입니다. (13시 는 1시)

따라서 14시가 되면 2시가 되어야 하므로 14를 12로 나눈 나머지를 구하면 2가 됩니다.

이제 각 침의 각도를 구했다면, 나머지는 반지름(삼각함수 빗변, r)의 길이를 적절히 조절해 좌표를 구한 후 중심점으로 부터 선을 그려주면 끝입니다.

194번 라인은 Thread가 실행시 호출되는 함수이며, 무한루프로 구성하고 앞서 만들어둔 displayUpdate 사용자 정의 Signal을 emit() 하면 self.update() 함수가 호출되고, 최종적으로 QWidget의 paintEvent() 함수가 실행, 화면을 다시 그리게 됩니다.

C++ MFC 의 invalidate() 함수와 유사합니다.

마지막 199라인은 실제 프로그램의 메인함수입니다.
if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = CWidget()
    sys.exit(app.exec_())

QApplication class를 통해 이벤트 루프를 생성합니다. (MFC CWinApp와 유사)

이어서 CWidget class를 통해 실제 위젯이 생성되어 화면에 표시됩니다.


이제 모든 코드를 다 분석해 보았습니다.

아날로그 시계를 실행하기 위해서는 1편의 NTP.py 파일과, 2편의 main.py 파일 2개가 필요합니다.

PyQt5 모듈이 파이썬에 설치되어 있지 않다면, 이전 게시물 중 PyQt5 설치 게시물을 참조하기 바랍니다.

감사합니다.

개발환경(Development Environment)

  • Windows 10 pro 64bit, Visual Studio 2017
  • Python 3.7.2
  • PyQt5 5.13.0

댓글

이 블로그의 인기 게시물

Qt Designer 설치하기

C++ 예제 (소켓 서버, 이미지, 파일전송)