Skip to content

Instantly share code, notes, and snippets.

@promto-c
Created December 8, 2025 19:26
Show Gist options
  • Select an option

  • Save promto-c/55abbe13ed169259b27ca1aead175742 to your computer and use it in GitHub Desktop.

Select an option

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.
"""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