Note
Go to the end to download the full example code.
Interactive 1-D Gaussian Fitting#
A noisy composite signal built from two Gaussians is displayed. Two additional overlay lines show the individual component curves and a white sum curve that always equals the current manual model.
Interaction
Click any coloured component line to reveal its control widgets:
Circular handle — drag to move the peak centre (μ) and amplitude (A).
Shaded range — drag either edge to widen or narrow the width (σ).
The sum curve updates on every drag frame. Press f (with the plot canvas focused) to run a least-squares fit. The components — and all active widgets — will snap to the fitted values, and the sum curve will jump to the optimal fit. Click a component line again to hide its widgets.
import numpy as np
from scipy.optimize import curve_fit
import anyplotlib as apl
# ── Gaussian helpers ───────────────────────────────────────────────────────
def gaussian(x, amp, mu, sigma):
return amp * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
# Half-width at half-maximum = sigma * _FWHM_K (full FWHM = 2 * sigma * _FWHM_K)
_FWHM_K = np.sqrt(2.0 * np.log(2.0))
# ── Data ───────────────────────────────────────────────────────────────────
x = np.linspace(0, 10, 500)
TRUE_P = [
dict(amp=1.0, mu=3.2, sigma=0.55),
dict(amp=0.75, mu=6.8, sigma=0.80),
]
COLORS = ["#ff6b6b", "#69db7c"]
rng = np.random.default_rng(42)
signal = sum(gaussian(x, **p) for p in TRUE_P) + rng.normal(0, 0.03, len(x))
# Initial component guesses (slightly off from truth)
INIT_P = [
dict(amp=1.0, mu=3.0, sigma=0.6),
dict(amp=0.7, mu=7.0, sigma=0.9),
]
# ── Figure ─────────────────────────────────────────────────────────────────
fig, ax = apl.subplots(1, 1, figsize=(720, 380),
help="Click a coloured line → show/hide its widgets\n"
"Drag circle handle → move peak center (μ) and amplitude (A)\n"
"Drag range edge → widen / narrow the width (σ)\n"
"press: f → run least-squares fit")
plot = ax.plot(signal, axes=[x], color="#adb5bd", linewidth=1.5,
alpha=0.6, label="data")
#
# Live sum of all components — this IS the fit after pressing 'f'
sum_line = plot.add_line(
sum(gaussian(x, **p) for p in INIT_P), x_axis=x,
color="#e0e0e0", linewidth=1.5, linestyle="dashed", label="sum",
)
comp_lines = [
plot.add_line(gaussian(x, **p), x_axis=x,
color=c, linewidth=2.0,
label=f"comp {i+1}")
for i, (p, c) in enumerate(zip(INIT_P, COLORS))
]
# ── GaussianComponent ──────────────────────────────────────────────────────
class GaussianComponent:
"""Manages a PointWidget (peak) + RangeWidget (σ) for one component.
Assign ``.model`` after constructing the ``Model`` so the component
can notify it on every drag frame.
"""
def __init__(self, line, p, color):
self.line = line
self.amp = p["amp"]
self.mu = p["mu"]
self.sigma = p["sigma"]
self.color = color
self.model = None # injected after Model is constructed
self._active = False
self._syncing = False # guard against callback loops
self._pt = None # PointWidget — created once on first toggle
self._rng_w = None # RangeWidget
def component_y(self):
return gaussian(x, self.amp, self.mu, self.sigma)
def toggle(self):
if self._active:
self._pt.hide()
self._rng_w.hide()
self._active = False
else:
if self._pt is None:
self._pt = plot.add_point_widget(self.mu, self.amp,
color=self.color,
show_crosshair=False)
self._rng_w = plot.add_range_widget(
self.mu - self.sigma * _FWHM_K,
self.mu + self.sigma * _FWHM_K,
y=self.amp / 2.0,
color=self.color,
style="fwhm",
)
self._wire()
else:
self._pt.show()
self._rng_w.show()
self._active = True
def _wire(self):
@self._pt.on_changed
def _peak_moved(event):
if self._syncing:
return
self._syncing = True
try:
self.amp = event.data["y"]
self.mu = event.data["x"]
self._rng_w.set(x0=self.mu - self.sigma * _FWHM_K,
x1=self.mu + self.sigma * _FWHM_K,
y=self.amp / 2.0)
self.line.set_data(self.component_y())
if self.model:
self.model.update()
finally:
self._syncing = False
@self._rng_w.on_changed
def _range_moved(event):
if self._syncing:
return
self._syncing = True
try:
x0, x1 = event.data["x0"], event.data["x1"]
self.mu = (x0 + x1) / 2.0
self.sigma = abs(x1 - x0) / (2.0 * _FWHM_K)
self._pt.set(x=self.mu)
self.line.set_data(self.component_y())
if self.model:
self.model.update()
finally:
self._syncing = False
def snap(self, amp: float, mu: float, sigma: float) -> None:
"""Update parameters and snap **all** widgets to the new values.
Creates and shows the point and FWHM range widgets if they do not
exist yet (so pressing **f** always reveals the fitted widths), then
updates their positions. Uses the ``_syncing`` guard so widget
callbacks do not fire during the programmatic update.
"""
self._syncing = True
try:
self.amp = amp
self.mu = mu
self.sigma = sigma
self.line.set_data(self.component_y())
if self._pt is None:
# First fit — create widgets at the fitted position and show them.
self._pt = plot.add_point_widget(self.mu, self.amp,
color=self.color,
show_crosshair=False)
self._rng_w = plot.add_range_widget(
self.mu - self.sigma * _FWHM_K,
self.mu + self.sigma * _FWHM_K,
y=self.amp / 2.0,
color=self.color,
style="fwhm",
)
self._wire()
self._active = True
else:
# Widgets already exist — move them to the new fitted position.
self._pt.set(x=self.mu, y=self.amp)
self._rng_w.set(x0=self.mu - self.sigma * _FWHM_K,
x1=self.mu + self.sigma * _FWHM_K,
y=self.amp / 2.0)
# If the user had hidden the widgets, bring them back.
if not self._active:
self._pt.show()
self._rng_w.show()
self._active = True
finally:
self._syncing = False
# ── Model ──────────────────────────────────────────────────────────────────
class Model:
"""A list of GaussianComponents with a live sum line.
``update()`` redraws the sum line from the current component state and
is called on every drag frame.
``fit()`` runs a least-squares fit, snaps every component (and its
widgets) to the optimal parameters, then calls ``update()`` so the sum
line jumps to the best fit. It is also triggered by pressing **f**.
Parameters
----------
components : list[GaussianComponent]
sum_line : Line1D
Always-live manual-sum / fit-result overlay.
x_data, y_data : ndarray
Observed signal to fit against.
"""
def __init__(self, components, sum_line, x_data, y_data):
self.components = list(components)
self.sum_line = sum_line
self.x_data = x_data
self.y_data = y_data
def update(self):
"""Redraw the sum line as the manual sum of all components."""
self.sum_line.set_data(
sum(c.component_y() for c in self.components)
)
def fit(self):
"""Least-squares fit; snaps components and FWHM widgets to the result.
Builds a generic n-Gaussian model from the component list and uses
their current state as the initial guess. On success every component
snaps to the fitted (amp, μ, σ): the component line, the peak handle,
**and** the FWHM range widget are all moved to the optimal values.
If a component's widgets have not been shown yet they are created and
revealed automatically. The sum line redraws as the best fit.
On failure the components are left unchanged.
"""
n = len(self.components)
p0 = [v for c in self.components for v in (c.amp, c.mu, c.sigma)]
lo = [v for c in self.components for v in (0, self.x_data[0], 1e-3)]
hi = [v for c in self.components
for v in (np.inf, self.x_data[-1],
self.x_data[-1] - self.x_data[0])]
def _model_fn(x, *params):
return sum(
gaussian(x, params[3 * i], params[3 * i + 1], params[3 * i + 2])
for i in range(n)
)
try:
popt, _ = curve_fit(
_model_fn, self.x_data, self.y_data,
p0=p0, bounds=(lo, hi), maxfev=3000 * n,
)
for i, comp in enumerate(self.components):
comp.snap(popt[3 * i], popt[3 * i + 1], popt[3 * i + 2])
self.update()
except RuntimeError:
pass # leave components unchanged if fit did not converge
# ── Assemble ───────────────────────────────────────────────────────────────
components = [
GaussianComponent(comp_lines[i], INIT_P[i], COLORS[i])
for i in range(2)
]
model = Model(components, sum_line, x, signal)
for comp in components:
comp.model = model
# ── Key binding — press 'f' to fit ─────────────────────────────────────────
@plot.on_key('f')
def _on_fit(event):
model.fit()
# ── Click handlers — toggle widgets per component ─────────────────────────
for comp, line in zip(components, comp_lines):
@line.on_click
def _clicked(event, c=comp):
c.toggle()
fig
Total running time of the script: (0 minutes 1.364 seconds)