Coverage for src / tracekit / visualization / thumbnails.py: 79%
99 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Thumbnail rendering for fast signal previews.
3This module provides fast preview rendering with reduced detail
4for gallery and browser contexts.
7Example:
8 >>> from tracekit.visualization.thumbnails import render_thumbnail
9 >>> fig = render_thumbnail(signal, sample_rate, size=(400, 300))
11References:
12 Aggressive decimation for performance
13 Simplified rendering without expensive features
14"""
16from __future__ import annotations
18from typing import TYPE_CHECKING
20import numpy as np
22if TYPE_CHECKING:
23 from matplotlib.figure import Figure
24 from numpy.typing import NDArray
26try:
27 import matplotlib # noqa: F401
28 import matplotlib.pyplot as plt
30 HAS_MATPLOTLIB = True
31except ImportError:
32 HAS_MATPLOTLIB = False
35def render_thumbnail(
36 signal: NDArray[np.float64],
37 sample_rate: float | None = None,
38 *,
39 size: tuple[int, int] = (400, 300),
40 width: int | None = None,
41 height: int | None = None,
42 max_samples: int = 1000,
43 time_unit: str = "auto",
44 title: str | None = None,
45 dpi: int = 72,
46) -> Figure:
47 """Render fast preview thumbnail of signal.
49 : Fast preview rendering mode with reduced detail,
50 simplified styles, and lower resolution for quick plot generation.
52 Target performance: <100ms for typical signals (goal: 50ms)
54 Args:
55 signal: Input signal array
56 sample_rate: Sample rate in Hz. If None, uses 1.0 (sample indices as x-axis).
57 size: Thumbnail size in pixels (width, height), default (400, 300)
58 width: Width in pixels (alternative to size). If specified, height defaults to 3/4 of width.
59 height: Height in pixels (alternative to size).
60 max_samples: Maximum samples to plot (default: 1000, aggressive decimation)
61 time_unit: Time unit for x-axis ("s", "ms", "us", "ns", "auto")
62 title: Optional title
63 dpi: DPI for rendering (default: 72)
65 Returns:
66 Matplotlib Figure object configured for fast rendering
68 Raises:
69 ValueError: If signal is empty or sample_rate is invalid
70 ImportError: If matplotlib is not available
72 Example:
73 >>> signal = np.sin(2*np.pi*1000*np.arange(0, 0.01, 1/1e6))
74 >>> fig = render_thumbnail(signal, 1e6, size=(400, 300))
75 >>> fig.savefig("preview.png")
76 >>> # Without sample rate
77 >>> fig = render_thumbnail(data, width=100, height=50)
79 References:
80 VIS-018: Thumbnail Mode
81 Fixed-count decimation for uniform sampling
82 """
83 if not HAS_MATPLOTLIB: 83 ↛ 84line 83 didn't jump to line 84 because the condition on line 83 was never true
84 raise ImportError("matplotlib is required for visualization")
86 # Default sample rate if not provided
87 if sample_rate is None:
88 sample_rate = 1.0
90 if len(signal) == 0:
91 raise ValueError("Signal cannot be empty")
92 if sample_rate <= 0:
93 raise ValueError("Sample rate must be positive")
94 if max_samples < 10: 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true
95 raise ValueError("max_samples must be >= 10")
97 # Handle width/height as alternative to size
98 if width is not None:
99 h = height if height is not None else int(width * 0.75)
100 size = (width, h)
101 elif height is not None: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 size = (int(height * 4 / 3), height)
104 # Configure matplotlib for fast rendering (no anti-aliasing, etc.)
105 with plt.rc_context(
106 {
107 "path.simplify": True,
108 "path.simplify_threshold": 1.0,
109 "agg.path.chunksize": 1000,
110 "lines.antialiased": False,
111 "patch.antialiased": False,
112 "text.antialiased": False,
113 }
114 ):
115 # Calculate figure size in inches
116 width_inches = size[0] / dpi
117 height_inches = size[1] / dpi
119 # Create figure with no fancy features
120 fig, ax = plt.subplots(figsize=(width_inches, height_inches), dpi=dpi)
122 # Decimate signal to max_samples
123 decimated_signal = _decimate_uniform(signal, max_samples)
125 # Create time vector for decimated signal
126 total_time = len(signal) / sample_rate
127 time = np.linspace(0, total_time, len(decimated_signal))
129 # Auto-select time unit
130 if time_unit == "auto": 130 ↛ 140line 130 didn't jump to line 140 because the condition on line 130 was always true
131 if total_time < 1e-6: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true
132 time_unit = "ns"
133 elif total_time < 1e-3:
134 time_unit = "us"
135 elif total_time < 1: 135 ↛ 136line 135 didn't jump to line 136 because the condition on line 135 was never true
136 time_unit = "ms"
137 else:
138 time_unit = "s"
140 time_multipliers = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9}
141 multiplier = time_multipliers.get(time_unit, 1.0)
142 time_scaled = time * multiplier
144 # Plot with simplified style
145 ax.plot(time_scaled, decimated_signal, "b-", linewidth=0.5, antialiased=False)
147 # Minimal labels (no grid, no fancy formatting)
148 ax.set_xlabel(f"Time ({time_unit})", fontsize=8)
149 ax.set_ylabel("Amplitude", fontsize=8)
151 if title:
152 ax.set_title(title, fontsize=9)
154 # Reduce tick label size
155 ax.tick_params(labelsize=7)
157 # Tight layout to maximize plot area
158 fig.tight_layout(pad=0.5)
160 return fig
163def _decimate_uniform(signal: NDArray[np.float64], target_samples: int) -> NDArray[np.float64]:
164 """Decimate signal to exactly target_samples using uniform stride.
166 Args:
167 signal: Input signal
168 target_samples: Target number of samples
170 Returns:
171 Decimated signal with exactly target_samples
172 """
173 if len(signal) <= target_samples:
174 return signal
176 # Calculate uniform stride
177 stride = len(signal) // target_samples
179 # Sample at uniform intervals
180 indices = np.arange(0, len(signal), stride)[:target_samples]
182 decimated: NDArray[np.float64] = signal[indices]
183 return decimated
186def render_thumbnail_multichannel(
187 signals: list[NDArray[np.float64]],
188 sample_rate: float,
189 *,
190 size: tuple[int, int] = (400, 300),
191 max_samples: int = 1000,
192 time_unit: str = "auto",
193 channel_names: list[str] | None = None,
194 dpi: int = 72,
195) -> Figure:
196 """Render fast preview thumbnail of multiple channels.
198 : Fast multi-channel preview rendering.
200 Args:
201 signals: List of signal arrays
202 sample_rate: Sample rate in Hz
203 size: Thumbnail size in pixels (width, height)
204 max_samples: Maximum samples per channel
205 time_unit: Time unit for x-axis
206 channel_names: Optional channel names
207 dpi: DPI for rendering
209 Returns:
210 Matplotlib Figure object
212 Raises:
213 ValueError: If inputs are invalid
214 ImportError: If matplotlib is not available
216 Example:
217 >>> signals = [ch1_data, ch2_data, ch3_data]
218 >>> fig = render_thumbnail_multichannel(signals, 1e6)
220 References:
221 VIS-018: Thumbnail Mode
222 """
223 if not HAS_MATPLOTLIB: 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true
224 raise ImportError("matplotlib is required for visualization")
226 if len(signals) == 0: 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true
227 raise ValueError("Must provide at least one signal")
228 if sample_rate <= 0: 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true
229 raise ValueError("Sample rate must be positive")
231 n_channels = len(signals)
233 if channel_names is None: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true
234 channel_names = [f"CH{i + 1}" for i in range(n_channels)]
236 # Configure matplotlib for fast rendering
237 with plt.rc_context(
238 {
239 "path.simplify": True,
240 "path.simplify_threshold": 1.0,
241 "agg.path.chunksize": 1000,
242 "lines.antialiased": False,
243 "patch.antialiased": False,
244 "text.antialiased": False,
245 }
246 ):
247 # Calculate figure size
248 width_inches = size[0] / dpi
249 height_inches = size[1] / dpi
251 fig, axes = plt.subplots(
252 n_channels,
253 1,
254 figsize=(width_inches, height_inches),
255 dpi=dpi,
256 sharex=True,
257 )
259 if n_channels == 1: 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true
260 axes = [axes]
262 # Get time unit from first signal
263 if len(signals[0]) > 0: 263 ↛ 275line 263 didn't jump to line 275 because the condition on line 263 was always true
264 total_time = len(signals[0]) / sample_rate
265 if time_unit == "auto": 265 ↛ 277line 265 didn't jump to line 277 because the condition on line 265 was always true
266 if total_time < 1e-6: 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true
267 time_unit = "ns"
268 elif total_time < 1e-3: 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true
269 time_unit = "us"
270 elif total_time < 1: 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true
271 time_unit = "ms"
272 else:
273 time_unit = "s"
274 else:
275 time_unit = "s"
277 time_multipliers = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9}
278 multiplier = time_multipliers.get(time_unit, 1.0)
280 # Plot each channel
281 for i, (sig, name, ax) in enumerate(zip(signals, channel_names, axes, strict=False)):
282 if len(sig) == 0: 282 ↛ 283line 282 didn't jump to line 283 because the condition on line 282 was never true
283 continue
285 # Decimate signal
286 decimated = _decimate_uniform(sig, max_samples)
288 # Time vector
289 total_time = len(sig) / sample_rate
290 time = np.linspace(0, total_time, len(decimated)) * multiplier
292 # Plot
293 ax.plot(time, decimated, "b-", linewidth=0.5, antialiased=False)
295 # Channel label
296 ax.set_ylabel(name, fontsize=7, rotation=0, ha="right", va="center")
297 ax.tick_params(labelsize=6)
299 # Only x-label on bottom
300 if i == n_channels - 1:
301 ax.set_xlabel(f"Time ({time_unit})", fontsize=8)
303 fig.tight_layout(pad=0.3)
305 return fig
308__all__ = [
309 "render_thumbnail",
310 "render_thumbnail_multichannel",
311]