Created
December 8, 2025 19:26
-
-
Save promto-c/55abbe13ed169259b27ca1aead175742 to your computer and use it in GitHub Desktop.
Interactive PyQt visualizer for the Ramer–Douglas–Peucker (RDP) curve simplification algorithm. Includes live controls for epsilon, noise, point count, distance mode, threshold band visualization, and real-time plotting.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """Copyright (C) 2025 promto-c | |
| Permission Notice: | |
| - You are free to use, copy, modify, and distribute this software for any purpose. | |
| - No restrictions are imposed on its use. | |
| - Credit is appreciated but not required. | |
| - Use at your own risk; this software is provided "AS IS", without any warranty — express or implied — including, but not limited to, warranties of merchantability or fitness for a particular purpose. | |
| - This notice does not apply to any third-party libraries or dependencies; those are subject to their respective licenses. | |
| """ | |
| # Standard Library Imports | |
| # ------------------------ | |
| import math | |
| import random | |
| # Third Party Imports | |
| # ------------------- | |
| from PyQt5 import QtCore, QtGui, QtWidgets | |
| # Local Imports | |
| # ------------- | |
| Point = tuple[float, float] | |
| random.seed(42) | |
| # Class Definitions | |
| # ----------------- | |
| class RdpCanvas(QtWidgets.QWidget): | |
| """Canvas that renders original vs simplified curve with optional threshold band. | |
| Attributes: | |
| original (list[Point]): Raw data points. | |
| simplified (list[Point]): Simplified polyline returned by RDP. | |
| epsilon (float): Tolerance value. | |
| show_markers (bool): Whether to draw markers for points. | |
| show_threshold (bool): Whether to draw the ±epsilon band. | |
| distance_mode (str): 'perp' for perpendicular distance, 'vertical' for absolute vertical distance. | |
| """ | |
| def __init__(self, parent: QtWidgets.QWidget = None): | |
| """Initialize the canvas with default parameters. | |
| Args: | |
| parent: Optional parent widget. | |
| """ | |
| super().__init__(parent) | |
| self.setAutoFillBackground(False) | |
| self.setMinimumHeight(260) | |
| # Data/state | |
| self.original: list[Point] = [] | |
| self.simplified: list[Point] = [] | |
| self.epsilon: float = 0.05 | |
| self.show_markers: bool = True | |
| self.show_threshold: bool = True | |
| self.distance_mode: str = "perp" | |
| # Padding for axes | |
| self._pad_left = 48 | |
| self._pad_right = 18 | |
| self._pad_top = 24 | |
| self._pad_bottom = 36 | |
| self._bg_brush = QtGui.QBrush(QtGui.QColor("#0a0a0a")) | |
| self._grid_pen = QtGui.QPen(QtGui.QColor("#18181b")) | |
| self._axis_pen = QtGui.QPen(QtGui.QColor("#27272a")) | |
| self._orig_pen = QtGui.QPen(QtGui.QColor("#9CA3AF")) | |
| self._orig_pen.setWidthF(1.25) | |
| self._simp_pen = QtGui.QPen(QtGui.QColor("#F87171")) | |
| self._simp_pen.setWidthF(2.25) | |
| self._band_brush = QtGui.QBrush(QtGui.QColor(239, 68, 68, int(0.16 * 255))) # #ef4444 @ 16% | |
| self._band_pen = QtGui.QPen(QtGui.QColor(239, 68, 68, int(0.22 * 255))) | |
| self._band_pen.setWidth(1) | |
| self._title_font = QtGui.QFont() | |
| self._title_font.setPointSize(10) | |
| self._title_font.setBold(True) | |
| # Public Methods | |
| # -------------- | |
| def set_data(self, original: list[Point], simplified: list[Point]): | |
| """Set the data and trigger repaint. | |
| Args: | |
| original: Original sample points. | |
| simplified: Simplified polyline. | |
| """ | |
| self.original = original | |
| self.simplified = simplified | |
| self.update() | |
| def set_flags(self, epsilon: float, show_markers: bool, show_threshold: bool, distance_mode: str): | |
| """Update flags and trigger repaint. | |
| Args: | |
| epsilon: RDP epsilon. | |
| show_markers: Toggle for markers. | |
| show_threshold: Toggle for threshold band. | |
| distance_mode: 'perp' or 'vertical'. | |
| """ | |
| self.epsilon = epsilon | |
| self.show_markers = show_markers | |
| self.show_threshold = show_threshold | |
| self.distance_mode = distance_mode | |
| self.update() | |
| # Overridden Methods | |
| # ------------------ | |
| def paintEvent(self, event: QtGui.QPaintEvent): | |
| """Paint the scene: background, grid, axes, bands, polylines, markers.""" | |
| painter = QtGui.QPainter(self) | |
| painter.setRenderHint(QtGui.QPainter.Antialiasing, True) | |
| rect = self.rect() | |
| painter.fillRect(rect, self._bg_brush) | |
| # Content rect | |
| left = self._pad_left | |
| right = rect.width() - self._pad_right | |
| top = self._pad_top | |
| bottom = rect.height() - self._pad_bottom | |
| # Axes & grid | |
| self._draw_grid(painter, left, top, right, bottom) | |
| self._draw_axes(painter, left, top, right, bottom) | |
| if not self.original: | |
| return | |
| # Scales | |
| x_min = self.original[0][0] | |
| x_max = self.original[-1][0] | |
| y_min = min(y for _, y in self.original) | |
| y_max = max(y for _, y in self.original) | |
| x_span = max(1e-12, x_max - x_min) | |
| y_span = max(1e-12, y_max - y_min) | |
| def x_scale(x: float) -> float: | |
| return left + (x - x_min) / x_span * (right - left) | |
| def y_scale(y: float) -> float: | |
| return bottom - (y - y_min) / y_span * (bottom - top) | |
| # Threshold bands along simplified segments, clipped to graph area | |
| if self.show_threshold and len(self.simplified) >= 2: | |
| graph_rect = QtCore.QRectF( | |
| left, | |
| top, | |
| right - left, | |
| bottom - top, | |
| ) | |
| painter.save() | |
| painter.setClipRect(graph_rect) | |
| painter.setPen(self._band_pen) | |
| painter.setBrush(self._band_brush) | |
| for i in range(len(self.simplified) - 1): | |
| a = self.simplified[i] | |
| b = self.simplified[i + 1] | |
| path = self._band_path_for_segment( | |
| a, | |
| b, | |
| self.epsilon, | |
| x_scale, | |
| y_scale, | |
| self.distance_mode, | |
| ) | |
| painter.drawPath(path) | |
| painter.restore() | |
| # Original path | |
| painter.setPen(self._orig_pen) | |
| self._draw_polyline(painter, [QtCore.QPointF(x_scale(x), y_scale(y)) for x, y in self.original]) | |
| # Simplified path | |
| painter.setPen(self._simp_pen) | |
| self._draw_polyline(painter, [QtCore.QPointF(x_scale(x), y_scale(y)) for x, y in self.simplified]) | |
| # Markers | |
| if self.show_markers: | |
| # Original markers (faint) | |
| painter.setBrush(QtGui.QColor("#9CA3AF")) | |
| painter.setPen(QtCore.Qt.NoPen) | |
| for x, y in self.original: | |
| painter.setOpacity(0.35) | |
| painter.drawEllipse(QtCore.QPointF(x_scale(x), y_scale(y)), 2.5, 2.5) | |
| # Simplified markers (solid red) | |
| painter.setOpacity(1.0) | |
| painter.setBrush(QtGui.QColor("#EF4444")) | |
| for x, y in self.simplified: | |
| painter.drawEllipse(QtCore.QPointF(x_scale(x), y_scale(y)), 4, 4) | |
| # Title | |
| painter.setOpacity(1.0) | |
| painter.setFont(self._title_font) | |
| painter.setPen(QtGui.QColor("#E5E7EB")) | |
| painter.drawText(left, top - 6, f"RDP Simplification - epsilon: {self.epsilon:.3f}") | |
| # Utility Methods | |
| # --------------- | |
| def _draw_axes(self, p: QtGui.QPainter, left: int, top: int, right: int, bottom: int): | |
| p.setPen(self._axis_pen) | |
| p.drawLine(left, bottom, right, bottom) | |
| p.drawLine(left, top, left, bottom) | |
| def _draw_grid(self, p: QtGui.QPainter, left: int, top: int, right: int, bottom: int): | |
| p.setPen(self._grid_pen) | |
| cols = 10 | |
| rows = 6 | |
| for i in range(cols + 1): | |
| x = left + (right - left) * i / cols | |
| p.drawLine(int(x), top, int(x), bottom) | |
| for j in range(rows + 1): | |
| y = top + (bottom - top) * j / rows | |
| p.drawLine(left, int(y), right, int(y)) | |
| def _draw_polyline(self, p: QtGui.QPainter, pts: list[QtCore.QPointF]): | |
| if len(pts) < 2: | |
| return | |
| path = QtGui.QPainterPath(pts[0]) | |
| for q in pts[1:]: | |
| path.lineTo(q) | |
| p.drawPath(path) | |
| def _band_path_for_segment(self, a: Point, b: Point, epsilon: float, | |
| x_scale, y_scale, mode: str) -> QtGui.QPainterPath: | |
| if mode == "vertical": | |
| ax, ay = a | |
| bx, by = b | |
| top_a = QtCore.QPointF(x_scale(ax), y_scale(ay + epsilon)) | |
| top_b = QtCore.QPointF(x_scale(bx), y_scale(by + epsilon)) | |
| bot_b = QtCore.QPointF(x_scale(bx), y_scale(by - epsilon)) | |
| bot_a = QtCore.QPointF(x_scale(ax), y_scale(ay - epsilon)) | |
| path = QtGui.QPainterPath(top_a) | |
| path.lineTo(top_b) | |
| path.lineTo(bot_b) | |
| path.lineTo(bot_a) | |
| path.closeSubpath() | |
| return path | |
| # Perpendicular band (offset by normal) | |
| a_plus, b_plus, a_minus, b_minus = _offset_endpoints_by_epsilon(a, b, epsilon) | |
| pts = [a_plus, b_plus, b_minus, a_minus] | |
| qpts = [QtCore.QPointF(x_scale(x), y_scale(y)) for x, y in pts] | |
| path = QtGui.QPainterPath(qpts[0]) | |
| for q in qpts[1:]: | |
| path.lineTo(q) | |
| path.closeSubpath() | |
| return path | |
| class SegmentedControl(QtWidgets.QFrame): | |
| """Two-option segmented control for distance mode selection.""" | |
| modeChanged = QtCore.pyqtSignal(str) | |
| def __init__(self, parent: QtWidgets.QWidget = None): | |
| """Initialize the segmented control.""" | |
| super().__init__(parent) | |
| self.setObjectName("SegmentedControl") | |
| self._perp_btn = QtWidgets.QPushButton("⟂ Perpendicular") | |
| self._vert_btn = QtWidgets.QPushButton("↕ Vertical only") | |
| for b in (self._perp_btn, self._vert_btn): | |
| b.setCheckable(True) | |
| b.setCursor(QtCore.Qt.PointingHandCursor) | |
| b.setMinimumHeight(28) | |
| self._perp_btn.setChecked(True) | |
| lay = QtWidgets.QHBoxLayout() | |
| lay.setContentsMargins(0, 0, 0, 0) | |
| lay.setSpacing(0) | |
| lay.addWidget(self._perp_btn) | |
| lay.addWidget(self._vert_btn) | |
| self.setLayout(lay) | |
| self._perp_btn.clicked.connect(self._on_perp) | |
| self._vert_btn.clicked.connect(self._on_vertical) | |
| # Style (dark, flat, container-colored selection) | |
| self.setStyleSheet( | |
| """ | |
| QFrame#SegmentedControl { border: 1px solid #262626; border-radius: 10px; background: #0f0f10; } | |
| QFrame#SegmentedControl QPushButton { border: none; padding: 6px 10px; color: #d4d4d8; background: transparent; } | |
| QFrame#SegmentedControl QPushButton:checked { background: #1f1f22; color: white; } | |
| QFrame#SegmentedControl QPushButton + QPushButton { border-left: 1px solid #262626; } | |
| """ | |
| ) | |
| def _on_perp(self): | |
| if not self._perp_btn.isChecked(): | |
| self._perp_btn.setChecked(True) | |
| self._vert_btn.setChecked(False) | |
| self.modeChanged.emit("perp") | |
| def _on_vertical(self): | |
| if not self._vert_btn.isChecked(): | |
| self._vert_btn.setChecked(True) | |
| self._perp_btn.setChecked(False) | |
| self.modeChanged.emit("vertical") | |
| class ControlPanel(QtWidgets.QWidget): | |
| """Top controls (epsilon, noise, points, toggles, distance mode).""" | |
| paramsChanged = QtCore.pyqtSignal(float, float, int, bool, bool, str) | |
| def __init__(self, parent: QtWidgets.QWidget = None): | |
| """Initialize the control panel.""" | |
| super().__init__(parent) | |
| # Sliders | |
| self._epsilon = self._make_slider(0, 1000, 5) # step 0.005 | |
| self._noise = self._make_slider(0, 200, 5) # step 0.005 (0..0.2) | |
| self._points = self._make_slider(30, 4000, 10) | |
| # Defaults | |
| self._epsilon.setValue(int(0.05 * 1000)) | |
| self._noise.setValue(int(0.01 * 1000)) | |
| self._points.setValue(200) | |
| self._epsilon_lbl = QtWidgets.QLabel("Smoothness (epsilon): 0.050") | |
| self._noise_lbl = QtWidgets.QLabel("Noise σ: 0.010") | |
| self._points_lbl = QtWidgets.QLabel("Points: 200") | |
| for l in (self._epsilon_lbl, self._noise_lbl, self._points_lbl): | |
| l.setStyleSheet("color:#a3a3a3;font-size:11px") | |
| # Checkboxes | |
| self._show_markers = QtWidgets.QCheckBox("Show markers") | |
| self._show_markers.setChecked(True) | |
| self._show_markers.setStyleSheet("color:#d4d4d8") | |
| self._show_band = QtWidgets.QCheckBox("Show threshold (±ε)") | |
| self._show_band.setChecked(True) | |
| self._show_band.setStyleSheet("color:#d4d4d8") | |
| # Segmented control | |
| self._seg = SegmentedControl() | |
| # Layouts | |
| g = QtWidgets.QGridLayout() | |
| g.setContentsMargins(0, 0, 0, 0) | |
| g.setHorizontalSpacing(12) | |
| g.setVerticalSpacing(8) | |
| # Row 0: epsilon | |
| g.addWidget(self._wrap_card(self._epsilon_lbl, self._epsilon), 0, 0) | |
| # Row 0: noise | |
| g.addWidget(self._wrap_card(self._noise_lbl, self._noise), 0, 1) | |
| # Row 0: points | |
| g.addWidget(self._wrap_card(self._points_lbl, self._points), 0, 2) | |
| # Row 1: others | |
| row1 = QtWidgets.QHBoxLayout() | |
| row1.setSpacing(12) | |
| row1.addWidget(self._seg) | |
| row1.addWidget(self._show_markers) | |
| row1.addWidget(self._show_band) | |
| row1.addStretch(1) | |
| g.addLayout(row1, 1, 0, 1, 3) | |
| self.setLayout(g) | |
| self.setStyleSheet("background:transparent") | |
| # Signals | |
| self._epsilon.valueChanged.connect(self._emit) | |
| self._noise.valueChanged.connect(self._emit) | |
| self._points.valueChanged.connect(self._emit) | |
| self._show_markers.toggled.connect(self._emit) | |
| self._show_band.toggled.connect(self._emit) | |
| self._seg.modeChanged.connect(self._emit) | |
| # Initial emit | |
| QtCore.QTimer.singleShot(0, self._emit) | |
| # Utility Methods | |
| # --------------- | |
| def _make_slider(self, mn: int, mx: int, step: int) -> QtWidgets.QSlider: | |
| s = QtWidgets.QSlider(QtCore.Qt.Horizontal) | |
| s.setRange(mn, mx) | |
| s.setSingleStep(step) | |
| s.setPageStep(step) | |
| s.setTickPosition(QtWidgets.QSlider.NoTicks) | |
| s.setStyleSheet("QSlider{height:18px}") | |
| return s | |
| def _wrap_card(self, title_lbl: QtWidgets.QLabel, slider: QtWidgets.QSlider) -> QtWidgets.QWidget: | |
| w = QtWidgets.QWidget() | |
| w.setObjectName("Card") | |
| v = QtWidgets.QVBoxLayout(w) | |
| v.setContentsMargins(12, 10, 12, 10) | |
| v.setSpacing(6) | |
| title = QtWidgets.QLabel(title_lbl.text()) | |
| title.setStyleSheet("color:#a3a3a3;font-size:11px") | |
| v.addWidget(title) | |
| v.addWidget(slider) | |
| w.setStyleSheet("#Card{border:1px solid #262626;border-radius:16px;background:#161617}") | |
| # keep label reference updated | |
| w._title_label = title | |
| return w | |
| def _emit(self): | |
| eps = self._epsilon.value() / 1000.0 | |
| noi = self._noise.value() / 1000.0 | |
| pts = self._points.value() | |
| self._epsilon_lbl.setText(f"Smoothness (epsilon): {eps:.3f}") | |
| self._noise_lbl.setText(f"Noise σ: {noi:.3f}") | |
| self._points_lbl.setText(f"Points: {pts}") | |
| mode = "perp" if self._seg._perp_btn.isChecked() else "vertical" | |
| self.paramsChanged.emit(eps, noi, pts, self._show_markers.isChecked(), self._show_band.isChecked(), mode) | |
| class RdpDemo(QtWidgets.QWidget): | |
| """Main widget combining controls and canvas.""" | |
| def __init__(self, parent: QtWidgets.QWidget = None): | |
| """Initialize the demo widget and wire up interactions.""" | |
| super().__init__(parent) | |
| # Title | |
| title = QtWidgets.QLabel("Interactive Curve Simplifier (Ramer-Douglas-Peucker)") | |
| title.setStyleSheet("color:#e5e7eb;font-size:18px;font-weight:600") | |
| subtitle = QtWidgets.QLabel( | |
| 'Adjust <span style="font-family:monospace">epsilon</span> to control simplification.\n' | |
| 'Smaller values keep more points, larger values make it smoother.' | |
| ) | |
| subtitle.setTextFormat(QtCore.Qt.RichText) | |
| subtitle.setStyleSheet("color:#a3a3a3;font-size:12px") | |
| # Controls + Canvas | |
| self._controls = ControlPanel() | |
| self._canvas = RdpCanvas() | |
| # Layout | |
| v = QtWidgets.QVBoxLayout(self) | |
| v.setContentsMargins(12, 12, 12, 12) | |
| v.setSpacing(10) | |
| v.addWidget(title) | |
| v.addWidget(subtitle) | |
| v.addWidget(self._controls) | |
| v.addWidget(self._canvas, 1) | |
| # Styling (dark container) | |
| self.setStyleSheet("background:#0b0b0c") | |
| # Data/state | |
| self._original: list[Point] = [] | |
| self._simplified: list[Point] = [] | |
| # Connect | |
| self._controls.paramsChanged.connect(self._on_params) | |
| # Generate initial data | |
| QtCore.QTimer.singleShot(0, self._generate_and_update) | |
| # Private Methods | |
| # --------------- | |
| def _on_params(self, epsilon: float, noise: float, n_points: int, show_markers: bool, show_band: bool, mode: str): | |
| self._generate_and_update(epsilon, noise, n_points, show_markers, show_band, mode) | |
| def _generate_and_update(self, epsilon: float = 0.05, noise: float = 0.01, n_points: int = 200, | |
| show_markers: bool = True, show_band: bool = True, mode: str = "perp"): | |
| # Generate data and compute simplified polyline | |
| self._original = _generate_data(n_points, noise) | |
| self._simplified = rdp(self._original, epsilon, mode) | |
| self._canvas.set_data(self._original, self._simplified) | |
| self._canvas.set_flags(epsilon, show_markers, show_band, mode) | |
| # RDP + Helpers | |
| # ------------- | |
| def rdp(points: list[Point], epsilon: float, mode: str = "perp") -> list[Point]: | |
| """Simplify points with Ramer-Douglas-Peucker. | |
| Args: | |
| points: Input points [(x, y), ...]. | |
| epsilon: Tolerance value. | |
| mode: 'perp' for perpendicular distance, 'vertical' for absolute vertical difference. | |
| Returns: | |
| Simplified list of points retaining the endpoints. | |
| Examples: | |
| >>> pts = [(0, 0), (1, 0.1), (2, -0.1), (3, 5), (4, 6), (5, 7)] | |
| >>> rdp(pts, epsilon=1.0) | |
| [(0, 0), (3, 5), (5, 7)] | |
| """ | |
| if len(points) < 3: | |
| return points | |
| first = points[0] | |
| last = points[-1] | |
| max_dist = 0.0 | |
| index = 0 | |
| for i in range(1, len(points) - 1): | |
| d = _distance_to_segment(points[i], first, last, mode) | |
| if d > max_dist: | |
| max_dist = d | |
| index = i | |
| if max_dist > epsilon: | |
| left = rdp(points[: index + 1], epsilon, mode) | |
| right = rdp(points[index:], epsilon, mode) | |
| return left[:-1] + right | |
| return [first, last] | |
| def _distance_to_segment(p: Point, a: Point, b: Point, mode: str) -> float: | |
| if mode == "vertical": | |
| return _vertical_distance(p, a, b) | |
| return _perp_distance(p, a, b) | |
| def _perp_distance(p: Point, a: Point, b: Point) -> float: | |
| ax, ay = a | |
| bx, by = b | |
| px, py = p | |
| dx = bx - ax | |
| dy = by - ay | |
| if dx == 0 and dy == 0: | |
| return math.hypot(px - ax, py - ay) | |
| return abs(dx * (ay - py) - dy * (ax - px)) / math.hypot(dx, dy) | |
| def _vertical_distance(p: Point, a: Point, b: Point) -> float: | |
| ax, ay = a | |
| bx, by = b | |
| px, py = p | |
| denom = (bx - ax) or 1e-12 | |
| t = (px - ax) / denom | |
| y_on_line = ay + t * (by - ay) | |
| return abs(py - y_on_line) | |
| def _offset_endpoints_by_epsilon(a: Point, b: Point, epsilon: float): | |
| dx = b[0] - a[0] | |
| dy = b[1] - a[1] | |
| length = math.hypot(dx, dy) or 1.0 | |
| nx = -dy / length | |
| ny = dx / length | |
| a_plus = (a[0] + epsilon * nx, a[1] + epsilon * ny) | |
| b_plus = (b[0] + epsilon * nx, b[1] + epsilon * ny) | |
| a_minus = (a[0] - epsilon * nx, a[1] - epsilon * ny) | |
| b_minus = (b[0] - epsilon * nx, b[1] - epsilon * ny) | |
| return a_plus, b_plus, a_minus, b_minus | |
| def _generate_data(n: int = 200, sigma: float = 0.01) -> list[Point]: | |
| xs = _linspace(0.0, 10.0, n) | |
| pts: list[Point] = [] | |
| for x in xs: | |
| y = math.sin(x) + _randn(0.0, sigma) | |
| pts.append((x, y)) | |
| return pts | |
| def _linspace(a: float, b: float, n: int) -> list[float]: | |
| if n <= 1: | |
| return [a] | |
| step = (b - a) / (n - 1) | |
| return [a + i * step for i in range(n)] | |
| def _randn(mu: float = 0.0, sigma: float = 1.0) -> float: | |
| # Box-Muller | |
| u = 0.0 | |
| v = 0.0 | |
| while u == 0.0: | |
| u = random.random() | |
| while v == 0.0: | |
| v = random.random() | |
| return mu + sigma * math.sqrt(-2.0 * math.log(u)) * math.cos(2.0 * math.pi * v) | |
| # Main Function | |
| # ------------- | |
| def main(): | |
| """Create the application and show the demo widget.""" | |
| import sys | |
| app = QtWidgets.QApplication(sys.argv) | |
| # Global dark style touches | |
| palette = QtGui.QPalette() | |
| palette.setColor(QtGui.QPalette.Window, QtGui.QColor("#0b0b0c")) | |
| palette.setColor(QtGui.QPalette.Base, QtGui.QColor("#0b0b0c")) | |
| palette.setColor(QtGui.QPalette.Text, QtGui.QColor("#e5e7eb")) | |
| palette.setColor(QtGui.QPalette.ButtonText, QtGui.QColor("#d4d4d8")) | |
| app.setPalette(palette) | |
| w = RdpDemo() | |
| w.setWindowTitle("RDP Simplifier - PyQt") | |
| w.resize(1024, 480) | |
| w.show() | |
| sys.exit(app.exec_()) | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment