파이썬 예제 (DragFit, 근사함수 생성기)

개요

이번 예제는 마우스로 드래그한 좌표를 기반으로 근사함수를 Fitting 하는 예제 입니다. 

차트 위 마우스로 선을 그리면 해당 좌표에 적합한 근사함수를 차수에 따라 만들어주는 기능을 가지고 있습니다. 

(함수 항의 0.00소수점 표시가 2자리로 고정되어 그 이하값이 생략되지만 연산에는 반영) 

[DragFit 실행화면]

동영상을 통해 차수가 올라갈 수록 보다 적합해지는 과정을 확인 할 수 있습니다.


참고로 위 예제는 Numpy의 polyfit () 함수에 의해 데이터 셋을 근사화 하고 있습니다.

좀 더 들여다 보면 polyfit 함수는 3개의 전달인자 x data, y data, deg(차수) 를 전달하면 최소제곱법을 사용하여 직선의 기울기절편을 찾을 수 있습니다.

직선의 기울기와 절편은 데이터 셋과 직선 사이의 오차의 제곱합을 최소화하는 값입니다.

예를 들면 아래의 데이터 셋을 최소제곱법으로 근사하면 아래와 같습니다.

import numpy as np

# 주어진 데이터 포인트들
x = np.array([1, 2, 3, 4, 5])
y = np.array([3, 6, 5, 8, 10])

z = np.polyfit(x, y, 1)

print(z) 

# z = [1.6 1.6] # 기울기, 절편

# f(x) = 1.6 * x + 1.6


[최소제곱법 예]


개발 환경

  • Windows 11 Pro, Visual Studio 2022
  • Python 3.9 64bit, PyQt5, numpy, matplitlib

 

소스코드 

2개의 파이썬 파일로 구성 (main.py, chart.py)

참고로 소스코드는 직접 작성하였으며,

이 게시물의 아래 소스코드 설명은 코드를 chat GPT 3.5 에 제공한 후 A.I에 의해 작성되었습니다.

(검수해보니 꽤 괜찮은 설명입니다.)

main.py 소스코드

from PyQt5.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel,
                            QComboBox, QPushButton, QTableWidget, QTableWidgetItem,QHeaderView)
from PyQt5.QtCore import Qt, pyqtSignal
import sys

from matplotlib import pyplot as plt
from matplotlib.backend_bases import MouseButton
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
from chart import LineBuilder

import numpy as np

QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)

class Window(QWidget):  

    def __init__(self):
        super().__init__()
        self.setWindowTitle('Ocean Coding School')
        self.initUi()     

    def initUi(self):
        # left groupbox
        lgb = QGroupBox('Drag a Coordinate', self)
        hbox = QHBoxLayout()
        vbox = QVBoxLayout()
        label = QLabel('Degree of the fitting polynomial:')
        self.cmb = QComboBox()
        self.cmb.addItems( [str(i) for i in range(1,10)] )
        self.btn1 = QPushButton('Extract')
        self.btn2 = QPushButton('Clear')
        hbox.addWidget(label)
        hbox.addWidget(self.cmb)
        hbox.addWidget(self.btn1)
        hbox.addWidget(self.btn2)

        self.fig = plt.Figure()
        self.canvas = FigureCanvasQTAgg(self.fig)
        self.ax = self.fig.subplots()        

        self.ax.set_title('Drag mouse')
        line, = self.ax.plot([], [], linestyle="none", marker="o", color="tab:red")
        self.linebuilder = LineBuilder(self, line)

        self.line2, = self.ax.plot([], [], linestyle="--", color="tab:green")

        vbox.addLayout(hbox,1)
        self.label = QLabel('')
        vbox.addWidget(self.label,1)
        vbox.addWidget(self.canvas,50)
        lgb.setLayout(vbox)

        # right groupbox
        rgb = QGroupBox('Extracted Coordinate', self)
        vbox = QVBoxLayout()
        self.tw = QTableWidget()
        txt = ('X', 'Y')
        self.tw.setColumnCount(len(txt))
        self.tw.setHorizontalHeaderLabels(txt)
        header = self.tw.horizontalHeader()       
        header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)

        vbox.addWidget(self.tw)
        rgb.setLayout(vbox)

        # combine groupbox
        hbox = QHBoxLayout()
        hbox.addWidget(lgb, 9)
        hbox.addWidget(rgb, 1)
        self.setLayout(hbox)

        # signal
        self.canvas.mpl_connect('scroll_event', self.onWScroll)

        self.btn1.clicked.connect(self.onClickExtract)
        self.btn2.clicked.connect(self.onClickClear)

        self.ctrl = False

    def keyPressEvent(self, e):
        if e.key()==Qt.Key_Control:
            self.ctrl = True            

    def onKeyRelease(self, e):
        self.ctrl = False

    def onWScroll(self, e):
        if not self.ctrl:
            return

        # https://stackoverflow.com/questions/11551049/matplotlib-plot-zooming-with-scroll-wheel
        scale = 2.0
        # get the current x and y limits
        cur_xlim = self.ax.get_xlim()
        cur_ylim = self.ax.get_ylim()
        cur_xrange = (cur_xlim[1] - cur_xlim[0])*.5
        cur_yrange = (cur_ylim[1] - cur_ylim[0])*.5
        xdata = e.xdata # get event x location
        ydata = e.ydata # get event y location
        if e.button == 'up':
            # deal with zoom in
            scale_factor = 1/scale
        elif e.button == 'down':
            # deal with zoom out
            scale_factor = scale
        else:
            # deal with something that should never happen
            scale_factor = 1
            print (e.button)
        # set new limits
        self.ax.set_xlim([xdata - cur_xrange*scale_factor,
                     xdata + cur_xrange*scale_factor])
        self.ax.set_ylim([ydata - cur_yrange*scale_factor,
                     ydata + cur_yrange*scale_factor])
        self.canvas.draw()

    def onClickExtract(self):
        xs, ys = self.linebuilder.getData()
        if not xs:
            return

        # fitting polynomial
        sym = ('⁹', '⁸', '⁷', '⁶', '⁵', '⁴', '³', '²', '¹')
        deg = int(self.cmb.currentText())
        term = np.polyfit(xs, ys, deg)
        func = f'𝒇(fit{deg}) = '
        idx = len(sym)-deg

        for i, t in enumerate(term[:-1]):
            s = f'({t:.2f} × 𝒙{sym[i+idx]})+'
            func += s
        func += f'{term[-1]:.2f}'
        self.label.setText(func)

        # approximation
        y2 = np.polyval(term, xs)
        self.line2.set_data(xs, y2)
        self.canvas.draw()


        # tablewidget
        row = len(xs)
        self.tw.setRowCount(row)

        for r in range(row):
            xi = QTableWidgetItem(f'{xs[r]:.2f}')
            yi = QTableWidgetItem(f'{ys[r]:.2f}')
            self.tw.setItem(r, 0, xi)
            self.tw.setItem(r, 1, yi)

    def onClickClear(self):       
        self.label.clear()
        self.linebuilder.clear()
        self.line2.set_data([], [])
        self.canvas.draw()
        self.canvas.flush_events()
        self.tw.setRowCount(0)

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

 

[chat GPT가 작성한 main.py 분석]

이 코드는 PyQt5를 사용하여 좌표를 드래그하여 근사 함수를 추출하는 앱의 기본 뼈대를 보여줍니다.

1. `__init__(self)`

   - 클래스의 초기화 메서드입니다.
   - `QWidget` 클래스의 `__init__()` 메서드를 호출하여 초기화합니다.
   - 창의 제목을 설정합니다.

2. `initUi(self)`

   - 사용자 인터페이스를 초기화하는 메서드입니다.
   - 좌측 그룹박스를 생성하고, 드래그할 좌표를 선택하는 UI 요소들을 배치합니다.
   - `matplotlib`의 Figure와 FigureCanvas를 생성하여 드래그된 좌표를 표시할 그래프 영역을 만듭니다.
   - 우측 그룹박스를 생성하고, 추출된 좌표를 표시할 테이블 위젯을 생성합니다.
   - 좌측 그룹박스와 우측 그룹박스를 합쳐 전체 레이아웃을 구성합니다.
   - 시그널과 슬롯 연결을 설정하여 버튼 클릭 및 스크롤 이벤트를 처리합니다.

3. `keyPressEvent(self, e)`

   - 키가 눌렸을 때 호출되는 이벤트 처리 메서드입니다.
   - 눌린 키가 Ctrl 키인지 확인하고, `self.ctrl` 플래그를 설정합니다.

4. `onKeyRelease(self, e)`

   - 키가 놓였을 때 호출되는 이벤트 처리 메서드입니다.
   - Ctrl 키가 놓였을 때 `self.ctrl` 플래그를 해제합니다.

5. `onWScroll(self, e)`

   - 마우스 스크롤 이벤트가 발생했을 때 호출되는 이벤트 처리 메서드입니다.
   - Ctrl 키가 눌려져 있는지 확인합니다.
   - 현재 x 및 y 축의 범위를 가져오고, 스케일링 요인을 설정합니다.
   - 스크롤 방향에 따라 새로운 x 및 y 축 범위를 설정하고, 그래프를 다시 그립니다.

6. `onClickExtract(self)`

   - 'Extract' 버튼 클릭 시 호출되는 이벤트 처리 메서드입니다.
   - 사용자가 드래그한 좌표를 가져옵니다.
   - 선택한 좌표를 기반으로 근사 함수를 계산합니다.
   - 계산된 근사 함수를 레이블에 표시하고, 그래프에 근사한 선을 그립니다.
   - 추출된 좌표를 테이블 위젯에 표시합니다.

7. `onClickClear(self)`

   - 'Clear' 버튼 클릭 시 호출되는 이벤트 처리 메서드입니다.
   - 레이블을 지우고,

 그래프와 테이블 위젯의 데이터를 초기화합니다.

이 설명을 참고하시면 코드의 각 부분이 어떤 역할을 하는지 이해할 수 있을 것입니다.


chart.py 소스코드

from matplotlib import pyplot as plt
from matplotlib.backend_bases import MouseButton

class LineBuilder:
    def __init__(self, parent, line):
        self.parent = parent
        self.line = line
        self.xs = list(line.get_xdata())
        self.ys = list(line.get_ydata())
        self.cid = line.figure.canvas.mpl_connect('motion_notify_event', self)

    def __call__(self, event):
        if event.button is MouseButton.LEFT:               
            if event.inaxes!=self.line.axes:
               return
            self.xs.append(event.xdata)
            self.ys.append(event.ydata)
            self.line.set_data(self.xs, self.ys)
            self.line.figure.canvas.draw()

    def getData(self):
        return self.xs, self.ys

    def clear(self):
        self.xs.clear()
        self.ys.clear()
        self.line.set_data(self.xs, self.ys)
        self.line.figure.canvas.draw() 

 

[chat GPT가 작성한 chart.py 분석]

 위의 코드는 `chart.py`라는 모듈에서 `LineBuilder` 클래스를 정의하는 부분입니다. 이 클래스는 `matplotlib`를 사용하여 그래프 위에서 선을 그리고, 드래그로 좌표를 추출하는 역할을 합니다. 아래에 각 메서드에 대한 설명을 작성해드리겠습니다:

1. `__init__(self, parent, line)`

   - `LineBuilder` 클래스의 초기화 메서드입니다.
   - `parent`는 부모 위젯을 의미하며, `Window` 클래스의 인스턴스입니다.
   - `line`은 `matplotlib`에서 그려진 선 객체입니다.
   - `line`의 x 좌표와 y 좌표를 가져와서 `xs`와 `ys` 리스트에 저장합니다.
   - `line.figure.canvas.mpl_connect()`를 사용하여 마우스 이벤트를 처리할 콜백 함수를 등록합니다.

2. `__call__(self, event)`

   - 클래스의 인스턴스를 함수처럼 호출할 때 호출되는 메서드입니다.
   - 이벤트가 발생한 버튼이 왼쪽 마우스 버튼인지 확인합니다.
   - 이벤트가 그래프 위에서 발생한 것인지 확인합니다.
   - 좌표 데이터를 `xs`와 `ys` 리스트에 추가합니다.
   - `line` 객체의 데이터를 업데이트하고, 그래프를 다시 그립니다.

3. `getData(self)`

   - 추출된 좌표 데이터인 `xs`와 `ys`를 반환하는 메서드입니다.

4. `clear(self)`

   - `xs`와 `ys` 리스트를 초기화하여 좌표 데이터를 지웁니다.
   - `line` 객체의 데이터를 업데이트하고, 그래프를 다시 그립니다.

`LineBuilder` 클래스는 `matplotlib`의 이벤트 처리를 통해 좌표를 추출하고, 그래프를 업데이트하는 역할을 합니다. 이를 통해 사용자는 마우스 드래그를 통해 원하는 좌표를 선택할 수 있습니다.

 

참조

1. https://matplotlib.org/stable/users/explain/event_handling.html

2. https://stackoverflow.com/questions/11551049/matplotlib-plot-zooming-with-scroll-wheel

 

감사합니다.

댓글

이 블로그의 인기 게시물

Qt Designer 설치하기

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