Files
cse5543_homework/lab2/ibspline.py
2026-02-24 22:26:45 -05:00

551 lines
18 KiB
Python

# Interactive plot of Bspline interpolation.
# Interactively set polyline vertices or read vertices from a file.
# Buttons to subdivide polygonal line.
# Button to save vertices (points) to a file.
# Created: 1/24/2026 - R. Wenger
# Edited: 2/24/2026 - Zhe Yuan
import sys
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.widgets import Button, TextBox
def ibspline(inputA, inputB=None):
# Global variables
global vX, vY, plot_center, text_message, fig, ax, save_message # fix: fig should be global
global jv, iv_selected, numv
global default_output_filename, filename_textbox
global cid_bpress, cid_pick
global save_message_xloc
global num_sample
global curve_degree, knot_vector, add_cp_mode # added for Bspline
# de Boor's algorithm to evaluate a point on a B-spline.
# Returns (x, y) coordinate at parameter t
def deBoor(points, knots, degree, t):
m = len(points)
# Find knot span k such that knots[k] <= t < knots[k+1]
k = degree - 1
while k < m -2 and knots[k + 1] <= t:
k += 1
if k == m-2 and t == knots[m-1]:
pass # k reach max
# Copy relevant control points: P_{k-degree} to P_k
d = [list(points[j]) for j in range(k +1 - degree, k + 2)]
# Apply de Boor's algorithm
for r in range(1, degree + 1):
for j in range(degree, r - 1, -1):
idx_i = j + k - degree
denom = knots[idx_i + degree - r + 1] - knots[idx_i]
# Prevent division by zero if knots are duplicated
alpha = 0.0
if denom > 1e-8:
alpha = (t - knots[idx_i]) / denom
d[j][0] = (1.0 - alpha) * d[j-1][0] + alpha * d[j][0]
d[j][1] = (1.0 - alpha) * d[j-1][1] + alpha * d[j][1]
return (d[degree][0], d[degree][1])
# Insert a knot using Boehm's algorithm given the selected control point index h
def applyBoehm(h):
global vX, vY, knot_vector, curve_degree
n = curve_degree
m = len(vX)
# !If h < n, do nothing, print error message
if h < n:
outputPlotMessage(f"Cannot add here. Please select index h >= {n}.")
plt.draw()
return
t_h = knot_vector[h-1]
t_h1 = knot_vector[h]
t_hat = (t_h + t_h1) / 2.0
points = list(zip(vX, vY))
new_points = []
for i in range(m + 1):
if i <= h - n:
new_points.append(points[i])
elif i >= h + 1:
new_points.append(points[i - 1])
else:
denom = knot_vector[i+n] - knot_vector[i]
alpha = (t_hat - knot_vector[i]) / denom if denom > 1e-8 else 0.0
px = (1 - alpha) * points[i-1][0] + alpha * points[i][0]
py = (1 - alpha) * points[i-1][1] + alpha * points[i][1]
new_points.append((px, py))
# Update globals
vX = [p[0] for p in new_points]
vY = [p[1] for p in new_points]
# Update knot vector: insert t_hat
knot_vector.insert(h + 1, t_hat)
outputPlotMessage(f"Added Control Point. (m={len(vX)})")
redrawPlot(vX, vY)
# Draw the B-Spline curve
def drawBSplineCurve(vX, vY):
global num_sample, curve_degree, knot_vector
m = len(vX)
n = curve_degree
points = list(zip(vX, vY))
pX, pY = [], []
# Curve is defined on [t_n, t_m]
t_start = knot_vector[n-1]
t_end = knot_vector[m-1]
steps = np.linspace(t_start, t_end, num_sample)
for t in steps:
cx, cy = deBoor(points, knot_vector, n, t)
pX.append(cx)
pY.append(cy)
# Draw B-Spline curve
plt.plot(pX, pY, color="red", linewidth=1.5)
# Draw control polygon
plt.plot(vX, vY, '--', color='gray', linewidth=0.5)
# Draw control points
plt.plot(vX, vY, 'ob', picker=True, pickradius=5)
return
# Redraw the entire plot
def redrawPlot(vX, vY):
global text_message, ax
plt.subplot(111)
# Store current axes limits
xL, xR = ax.get_xlim()
yB, yT = ax.get_ylim()
# Bug: Clearing the axes resets the navigation toolbar so you can't return
# to previous states.
# In particular, you can't return to the starting (0,0), (1,1) configuration.
plt.cla()
text_message=None
# Restore axes limits
plt.xlim([xL,xR])
plt.ylim([yB,yT])
drawBSplineCurve(vX, vY) # change to bspline
outputPlotMessage("Drag control points, or add control point then select")
# change message, we now use bottom to add cp.
clearSaveMessage()
plt.draw()
return
# Output a message above the plot
def outputPlotMessage(s):
global text_message, ax
text_yloc=1.02
if text_message is None:
text_message=plt.text(0.0, text_yloc, s, transform=ax.transAxes)
else:
text_message.set_text(s)
return
# Output the message: Select vertex str(j) location
def outputSelectVertexLocation(j):
s = "Select vertex " + str(j) + " location"
outputPlotMessage(s)
return
# Set a message related to saving control points to file
def outputSaveMessage(s):
global save_message, save_message_xloc
text_yloc=1.02
if save_message is not None:
save_message.remove()
save_message=plt.text(save_message_xloc-0.14, text_yloc, s, transform=ax.transAxes)
return
# Set the save message to ''
def clearSaveMessage():
global save_message
if save_message is not None:
save_message.remove()
save_message = None
return
# CallbackS
# callback of add cp
def addCPButtonCallback(event):
global add_cp_mode
if len(vX) == 0: return
add_cp_mode = True
outputPlotMessage("Select an existing control point p_h (h >= n) to split")
plt.draw()
return
# update pick point callback to handle mova & add cp
def pickPointCallback(event):
global iv_selected, cid_moveCallback, add_cp_mode
if (len(event.ind) < 1): return
if add_cp_mode:
h = event.ind[0]
applyBoehm(h)
add_cp_mode = False # Reset state
return
if (event.ind[0] >= 0) and (event.ind[0] < len(vX)):
iv_selected = event.ind[0]
cid_moveCallback = fig.canvas.mpl_connect('button_release_event', moveVertexCallback)
return
# Save control points to file
def saveCallback(event):
global vX, vY, filename_textbox, curve_degree, knot_vector # add knot
if len(vX) == 0: return
filename = filename_textbox.text
if (filename == '' or filename.isspace()):
# Should pop up an error window/message, but the GUI code would become
# even more complicated.
outputSaveMessage('Illegal filename. Reset filename to default. No file saved.')
filename_textbox.set_val(default_output_filename)
plt.draw()
else:
# Prepare data for Bspline format
try:
with open(filename, 'w') as f:
f.write("BSPLINE\n")
f.write(f"# Saved from ibspline.py\n")
f.write(f"2 {len(vX)} {curve_degree}\n")
# Write knot vector as space-separated string
knots_str = " ".join([str(k) for k in knot_vector])
f.write(f"{knots_str}\n")
for i in range(len(vX)):
f.write(f"{vX[i]} {vY[i]}\n")
outputSaveMessage('Saved to ' + filename)
except Exception as e:
outputSaveMessage('Error saving file.')
print(e)
plt.draw()
return
# Create TextBox containing output filename and
# output message text box
def createSaveTextBoxes(default_filename):
global filename_textbox, save_message_xloc, fig
# *** DEBUG ***
print("Creating save text box.")
ax_filename_textbox = fig.add_axes([save_message_xloc, 0.94, 0.3, 0.03])
filename_textbox = TextBox(ax_filename_textbox, 'Output filename:', default_filename)
plt.draw()
return
# Create buttons
def createButtons(left_pos, button_width, button_height):
global ax_add_button, add_button
global ax_save_button, save_button
# Colors
inactive_color = 'lightgray'
# ADd cp
ax_add_button = plt.axes([left_pos, 0.8, button_width, button_height])
add_button = Button(ax_add_button, "Add CP")
add_button.on_clicked(addCPButtonCallback)
add_button.color = inactive_color
add_button.hovercolor = inactive_color
# Save
ax_save_button = plt.axes([left_pos, 0.6, button_width, button_height])
save_button = Button(ax_save_button, "Save")
save_button.on_clicked(saveCallback)
save_button.color = inactive_color
save_button.hovercolor = inactive_color
createSaveTextBoxes(default_output_filename)
return
# not enable all buttons as smooth needs subdivision first
def enableButtons():
global add_button, save_button
add_button.color = 'white'
add_button.hovercolor = 'green'
save_button.color = 'white'
save_button.hovercolor = 'green'
plt.draw()
return
# Add a vertex at (event.xdata, event.ydata)
def addVertexCallback(event):
global jv, vX, vY, numv, cid_bpress, fig, curve_degree, knot_vector # add knot_vector
plt.subplot(111)
x=event.xdata
y=event.ydata
# Check that x and y are defined
if (x is None) or (y is None): return
# get (again) plot lower left (LL) and upper right (UR)
# (Coordinates can change if window is moved from one screen
# to another.)
axLL = ax.transData.transform((0,0));
axUR = ax.transData.transform((1,1));
# check that event.x and event.y are within draw region
if ((event.x < axLL[0]) or (event.x > axUR[0])): return
if ((event.y < axLL[1]) or (event.y > axUR[1])): return
if (jv < numv):
vX.append(x)
vY.append(y)
plt.plot(x, y, 'ob')
jv =jv+1
if (jv < numv):
outputSelectVertexLocation(jv)
plt.draw()
else:
# Disconnect button press callback before creating pick event
fig.canvas.mpl_disconnect(cid_bpress)
# init knot vector
knot_vector = list(range(1, numv + curve_degree))
drawBezierEnableButtonsPickEvent(vX, vY)
enableButtons()
return
# Pick and move a vertex
def pickPointCallback(event):
global iv_selected, cid_moveCallback
if (len(event.ind) < 1): return
if (event.ind[0] >= 0) and (event.ind[0] < len(vX)):
iv_selected = event.ind[0]
cid_moveCallback = \
fig.canvas.mpl_connect('button_release_event', moveVertexCallback)
return
# Move selected vertex to location (event.xdata, event.ydata)
def moveVertexCallback(event):
global cid_moveCallback, iv_selected, vX, vY
global points, polyline
plt.subplot(111)
x=event.xdata
y=event.ydata
# Check that x and y are defined
if (x is None) or (y is None): return
# get (again) plot lower left (LL) and upper right (UR)
# (Coordinates can change if window is moved from one screen
# to another.)
axLL = ax.transData.transform((0,0));
axUR = ax.transData.transform((1,1));
# check that event.x and event.y are within draw region
if ((event.x < axLL[0]) or (event.x > axUR[0])): return
if ((event.y < axLL[1]) or (event.y > axUR[1])): return
vX[iv_selected] = x
vY[iv_selected] = y
redrawPlot(vX, vY)
fig.canvas.mpl_disconnect(cid_moveCallback)
# reset iv_selected
iv_selected = -1
return
# After control points are created or read, draw bezier
# curve and enable buttons and pick event
def drawBezierEnableButtonsPickEvent(vX, vY):
global cid_pick, fig
plt.subplot(111)
outputPlotMessage("Select and move vertices")
redrawPlot(vX, vY)
cid_pick = fig.canvas.mpl_connect('pick_event', pickPointCallback)
enableButtons()
return
# load vX and vY from a textfile
# edit for BEZIER format
def loadTextFile(filename):
global vX, vY, jv, numv, curve_degree, knot_vector # add knot
try:
with open(filename, 'r') as f:
lines = f.readlines()
# Strip whitespace and remove empty lines
lines = [l.strip() for l in lines if l.strip()]
if not lines:
raise ValueError("Empty file")
if lines[0] != "BSPLINE":
raise ValueError("File must start with BSPLINE")
# Find the line with dimension/nump/degree
data_start_idx = 1
header_vals = []
while data_start_idx < len(lines):
line = lines[data_start_idx]
if line.startswith('#'):
data_start_idx += 1
continue
else:
# Found the header line
parts = lines[data_start_idx].split()
header_vals = [int(p) for p in parts]
data_start_idx += 1
break
if not header_vals:
raise ValueError("Could not find data dimensions")
ndim = header_vals[0]
nump = header_vals[1]
ndegree = header_vals[2]
# Read knot
for i in range(data_start_idx, len(lines)):
if lines[i].startswith('#'):
data_start_idx += 1
continue
else:
knot_line = lines[data_start_idx]
knot_vector = [float(k) for k in knot_line.split()]
data_start_idx += 1
break
tempX, tempY = [], []
for i in range(data_start_idx, len(lines)):
if lines[i].startswith('#'): continue
parts = lines[i].split()
if len(parts) >= 2:
tempX.append(float(parts[0]))
tempY.append(float(parts[1]))
vX, vY = tempX, tempY
numv = len(vX)
jv = numv
curve_degree = ndegree
except Exception as e:
print(f"Error loading file: {e}")
sys.exit(1)
return
# Initialize global variables
text_message = None
save_message = None
filename_textbox = None
save_message_xloc = 0.5
iv_selected = -1
jv = 0
plot_center = [ 0.5, 0.5 ]
default_output_filename = 'bspline_pts.txt'
num_sample = 100
add_cp_mode = False # State to track if user is adding a control point
knot_vector = []
# If inputA is a string, read control points from inputA
if isinstance(inputA, int) and isinstance(inputB, int):
numv = inputA
curve_degree = inputB
if numv < curve_degree + 1:
print(f"Error: m ({numv}) must be >= n+1 ({curve_degree+1})")
return
jv = 0
vX, vY = [], []
elif isinstance(inputA, str):
# file logic remains same
loadTextFile(inputA)
else:
print('Illegal input: ', inputA)
print('Exiting.')
return
if (numv < 2):
print('Illegal number of vertices: ', numv)
print('Requires at least two vertices.')
print('Exiting')
return
fig, ax = plt.subplots(1, 1, figsize=(12, 8))
plt.xlim([0.0, 1.0])
plt.ylim([0.0, 1.0])
plt.subplots_adjust(right=0.9)
outputSelectVertexLocation(0)
# get plot lower left (LL) and upper right (UR)
axLL = ax.transData.transform((0,0));
axUR = ax.transData.transform((1,1));
createButtons(0.91, 0.08, 0.05)
if (jv < numv):
# User interactively selects vertices
cid_bpress = fig.canvas.mpl_connect('button_press_event', addVertexCallback)
else:
# Vertices already read in from input file
drawBezierEnableButtonsPickEvent(vX, vY)
# Enable smooth for already loaded curve if cpts - 1 is multiple of degree
# and cpts - 1 != degree
if (len(vX) - 1) % curve_degree == 0 and curve_degree != len(vX) - 1:
# Set flag to enable Smooth button
is_subdivided = True
plt.show()
return
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python ibspline.py <m> <n> OR <filename>")
sys.exit()
arg1 = sys.argv[1]
if len(sys.argv) == 3:
try:
m = int(arg1)
n = int(sys.argv[2])
ibspline(m, n)
except ValueError:
print("m and n must be integers.")
else:
ibspline(arg1)