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

1"""Thumbnail rendering for fast signal previews. 

2 

3This module provides fast preview rendering with reduced detail 

4for gallery and browser contexts. 

5 

6 

7Example: 

8 >>> from tracekit.visualization.thumbnails import render_thumbnail 

9 >>> fig = render_thumbnail(signal, sample_rate, size=(400, 300)) 

10 

11References: 

12 Aggressive decimation for performance 

13 Simplified rendering without expensive features 

14""" 

15 

16from __future__ import annotations 

17 

18from typing import TYPE_CHECKING 

19 

20import numpy as np 

21 

22if TYPE_CHECKING: 

23 from matplotlib.figure import Figure 

24 from numpy.typing import NDArray 

25 

26try: 

27 import matplotlib # noqa: F401 

28 import matplotlib.pyplot as plt 

29 

30 HAS_MATPLOTLIB = True 

31except ImportError: 

32 HAS_MATPLOTLIB = False 

33 

34 

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. 

48 

49 : Fast preview rendering mode with reduced detail, 

50 simplified styles, and lower resolution for quick plot generation. 

51 

52 Target performance: <100ms for typical signals (goal: 50ms) 

53 

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) 

64 

65 Returns: 

66 Matplotlib Figure object configured for fast rendering 

67 

68 Raises: 

69 ValueError: If signal is empty or sample_rate is invalid 

70 ImportError: If matplotlib is not available 

71 

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) 

78 

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

85 

86 # Default sample rate if not provided 

87 if sample_rate is None: 

88 sample_rate = 1.0 

89 

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

96 

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) 

103 

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 

118 

119 # Create figure with no fancy features 

120 fig, ax = plt.subplots(figsize=(width_inches, height_inches), dpi=dpi) 

121 

122 # Decimate signal to max_samples 

123 decimated_signal = _decimate_uniform(signal, max_samples) 

124 

125 # Create time vector for decimated signal 

126 total_time = len(signal) / sample_rate 

127 time = np.linspace(0, total_time, len(decimated_signal)) 

128 

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" 

139 

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 

143 

144 # Plot with simplified style 

145 ax.plot(time_scaled, decimated_signal, "b-", linewidth=0.5, antialiased=False) 

146 

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) 

150 

151 if title: 

152 ax.set_title(title, fontsize=9) 

153 

154 # Reduce tick label size 

155 ax.tick_params(labelsize=7) 

156 

157 # Tight layout to maximize plot area 

158 fig.tight_layout(pad=0.5) 

159 

160 return fig 

161 

162 

163def _decimate_uniform(signal: NDArray[np.float64], target_samples: int) -> NDArray[np.float64]: 

164 """Decimate signal to exactly target_samples using uniform stride. 

165 

166 Args: 

167 signal: Input signal 

168 target_samples: Target number of samples 

169 

170 Returns: 

171 Decimated signal with exactly target_samples 

172 """ 

173 if len(signal) <= target_samples: 

174 return signal 

175 

176 # Calculate uniform stride 

177 stride = len(signal) // target_samples 

178 

179 # Sample at uniform intervals 

180 indices = np.arange(0, len(signal), stride)[:target_samples] 

181 

182 decimated: NDArray[np.float64] = signal[indices] 

183 return decimated 

184 

185 

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. 

197 

198 : Fast multi-channel preview rendering. 

199 

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 

208 

209 Returns: 

210 Matplotlib Figure object 

211 

212 Raises: 

213 ValueError: If inputs are invalid 

214 ImportError: If matplotlib is not available 

215 

216 Example: 

217 >>> signals = [ch1_data, ch2_data, ch3_data] 

218 >>> fig = render_thumbnail_multichannel(signals, 1e6) 

219 

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

225 

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

230 

231 n_channels = len(signals) 

232 

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)] 

235 

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 

250 

251 fig, axes = plt.subplots( 

252 n_channels, 

253 1, 

254 figsize=(width_inches, height_inches), 

255 dpi=dpi, 

256 sharex=True, 

257 ) 

258 

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] 

261 

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" 

276 

277 time_multipliers = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9} 

278 multiplier = time_multipliers.get(time_unit, 1.0) 

279 

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 

284 

285 # Decimate signal 

286 decimated = _decimate_uniform(sig, max_samples) 

287 

288 # Time vector 

289 total_time = len(sig) / sample_rate 

290 time = np.linspace(0, total_time, len(decimated)) * multiplier 

291 

292 # Plot 

293 ax.plot(time, decimated, "b-", linewidth=0.5, antialiased=False) 

294 

295 # Channel label 

296 ax.set_ylabel(name, fontsize=7, rotation=0, ha="right", va="center") 

297 ax.tick_params(labelsize=6) 

298 

299 # Only x-label on bottom 

300 if i == n_channels - 1: 

301 ax.set_xlabel(f"Time ({time_unit})", fontsize=8) 

302 

303 fig.tight_layout(pad=0.3) 

304 

305 return fig 

306 

307 

308__all__ = [ 

309 "render_thumbnail", 

310 "render_thumbnail_multichannel", 

311]