Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 
 
 
 

360 wiersze
15 KiB

  1. # -*- coding: utf-8 -*-
  2. """
  3. .bin 波形文件 -> 时频曲线查看/出图工具(PLSR 项目用)
  4. 数据格式: 逻辑分析仪导出的单通道数字采样,每字节 1 个采样点;
  5. 电平 >= 阈值(默认128) 判为高(脉冲),否则为低。
  6. 频率定义: 用连续 N 个脉冲的上升沿间隔估计频率;
  7. 频率点放在 N 周期测量窗口中间;
  8. 频率 = N * 采样率 /(第 N 个后续上升沿 - 当前上升沿);
  9. 时间定义: 频率点时间为测量窗口中点;高电平宽度取中心脉冲。
  10. 用法:
  11. python bin_to_time_freq.py <bin文件> [采样率Hz] [选项]
  12. 未填写采样率时默认使用 3125000 Hz。
  13. 示例:
  14. python bin_to_time_freq.py "Document/PLSR_document/波形/10段.bin" 3125000
  15. python bin_to_time_freq.py xxx.bin 3125000 --ymax=12000
  16. python bin_to_time_freq.py xxx.bin 3125000 --save=out.png
  17. python bin_to_time_freq.py xxx.bin 3125000 --selftest
  18. python bin_to_time_freq.py xxx.bin 3125000 --reverse --cycles=8
  19. 选项:
  20. --thresh=128 电平阈值(默认128)
  21. --cycles=8 用连续多少个周期估计频率(默认8;1为逐周期)
  22. --reverse 反相电平后再检测脉冲(低电平作为脉冲高电平)
  23. --ymax=12000 频率轴上限(默认自适应)
  24. --save=路径 保存 PNG 后退出(不弹窗)
  25. --selftest 仅打印统计,不画图
  26. 交互(弹窗模式):
  27. 滚轮 缩放 X 轴(向上放大/向下缩小,以鼠标位置为中心)
  28. Ctrl+滚轮 缩放 Y 轴(以鼠标 Y 位置为中心)
  29. 左键拖拽 双向平移(上下左右跟随鼠标)
  30. 双击 复位到全图
  31. 鼠标悬停 高亮最近的点并显示其测量点序号、时间与频率
  32. 依赖: pip install numpy matplotlib
  33. """
  34. import sys
  35. import os
  36. import numpy as np
  37. try:
  38. import matplotlib
  39. matplotlib.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei']
  40. matplotlib.rcParams['axes.unicode_minus'] = False
  41. except Exception:
  42. pass
  43. def load_time_freq(path, fs, threshold=128, cycles=8, reverse=False):
  44. """读取 bin,返回 (t_sec, freq_hz, hi_width_sec, meta)。
  45. t_sec 以第一个脉冲高电平中点为 0 时刻;hi_width 为中心脉冲高电平宽度。"""
  46. data = np.fromfile(path, dtype=np.uint8)
  47. if data.size == 0:
  48. raise ValueError("文件为空: %s" % path)
  49. if not 0 <= threshold <= 255:
  50. raise ValueError("阈值必须在0到255之间")
  51. samples = (data >= threshold).astype(np.int8)
  52. if reverse:
  53. samples = 1 - samples
  54. # run 级压缩
  55. changes = np.flatnonzero(np.diff(samples) != 0) + 1
  56. starts = np.concatenate(([0], changes))
  57. ends = np.concatenate((changes, [len(samples)]))
  58. runs = np.column_stack((starts, ends, samples[starts]))
  59. # 每个高电平 run 的起点都是一个可用于测周期的上升沿。
  60. # 若采集从高电平开始,第一个高电平起点不是被观测到的上升沿,丢弃它。
  61. hi = np.flatnonzero(runs[:, 2] == 1)
  62. if hi.size and runs[hi[0], 0] == 0:
  63. hi = hi[1:]
  64. if hi.size == 0:
  65. raise ValueError("未检测到上升沿脉冲: %s" % path)
  66. # 保留浮点上升沿位置:在原始电平相邻采样点之间线性插值,
  67. # 再配合多周期测量,显著降低整数采样点带来的量化抖动。
  68. rise_idx = runs[hi, 0].astype(np.int64)
  69. t_start = rise_idx.astype(np.float64)
  70. valid = rise_idx > 0
  71. if np.any(valid):
  72. y0 = data[rise_idx[valid] - 1].astype(np.float64)
  73. y1 = data[rise_idx[valid]].astype(np.float64)
  74. delta = y1 - y0
  75. frac = np.zeros_like(y0)
  76. np.divide(float(threshold) - y0, delta, out=frac, where=delta != 0)
  77. t_start[valid] = rise_idx[valid] - 1.0 + np.clip(frac, 0.0, 1.0)
  78. hi_width = (runs[hi, 1] - runs[hi, 0]).astype(np.float64)
  79. if fs <= 0:
  80. raise ValueError("采样率必须大于0")
  81. try:
  82. cycles = int(cycles)
  83. except (TypeError, ValueError):
  84. raise ValueError("cycles 必须是正整数")
  85. if cycles < 1:
  86. raise ValueError("cycles 必须是正整数")
  87. if len(t_start) < 2:
  88. raise ValueError("脉冲不足两个,无法计算频率")
  89. # 用下降沿的阈值 crossing 计算高电平宽度,时间单位仍为采样点。
  90. # 文件末尾若停在高电平,频率仍可用该上升沿作为前一窗口终点;
  91. # 该脉冲的宽度只取到文件末尾,不用于猜测频率。
  92. has_fall = np.zeros(len(hi), dtype=bool)
  93. adjacent = (hi + 1) < len(runs)
  94. has_fall[adjacent] = runs[hi[adjacent] + 1, 2] == 0
  95. fall_idx = np.where(has_fall, runs[hi, 1], len(data)).astype(np.int64)
  96. fall_edge = fall_idx.astype(np.float64)
  97. valid = has_fall & (fall_idx > 0) & (fall_idx < len(data))
  98. if np.any(valid):
  99. y0 = data[fall_idx[valid] - 1].astype(np.float64)
  100. y1 = data[fall_idx[valid]].astype(np.float64)
  101. delta = y1 - y0
  102. frac = np.zeros_like(y0)
  103. np.divide(float(threshold) - y0, delta, out=frac, where=delta != 0)
  104. fall_edge[valid] = fall_idx[valid] - 1.0 + np.clip(frac, 0.0, 1.0)
  105. hi_width = np.maximum(fall_edge - t_start, 0.0)
  106. pulse_mid = t_start + hi_width / 2.0
  107. # 单周期只看到整数采样点,周期接近半个采样点时会严重跳变。
  108. # 跨 cycles 个周期计算总时长,再换算回单周期频率,可显著抑制量化抖动。
  109. cycles = min(cycles, len(t_start) - 1)
  110. left = np.arange(len(t_start) - cycles, dtype=np.int64)
  111. right = left + cycles
  112. period = t_start[right] - t_start[left]
  113. freq = cycles * float(fs) / np.maximum(period, np.finfo(np.float64).eps)
  114. # 频率点放在测量窗口的时间中心;宽度取中心附近脉冲,仅用于兼容绘图接口。
  115. t_center = (t_start[left] + t_start[right]) / 2.0
  116. center_idx = np.minimum(left + cycles // 2, len(hi_width) - 1)
  117. hi_width_out = hi_width[center_idx]
  118. # 时间原点仍为第一个脉冲的高电平中点;多周期测量点自然位于窗口中心。
  119. t_sec = (t_center - pulse_mid[0]) / fs
  120. meta = {
  121. 'total_samples': int(len(samples)),
  122. 'duration': len(samples) / fs,
  123. 'pulses': int(len(freq)),
  124. 'detected_pulses': int(len(t_start)),
  125. 'cycles': int(cycles),
  126. 'offset_ms': t_start[0] / fs * 1000,
  127. 'activity_duration': max(0.0, (fall_edge[-1] - pulse_mid[0]) / fs),
  128. }
  129. return t_sec, freq, hi_width_out / fs, meta
  130. def selftest(path, fs, thresh, cycles, reverse=False):
  131. import statistics
  132. t, freq, hi_w, meta = load_time_freq(path, fs, thresh, cycles, reverse)
  133. print("文件: %s" % path)
  134. print("总采样: %d (%.3f s @ %.2f MS/s)" % (meta['total_samples'], meta['duration'], fs / 1e6))
  135. print("起始采集偏移: %.1f ms" % meta['offset_ms'])
  136. print("检测到脉冲: %d,频率测量点: %d" % (
  137. meta['detected_pulses'], meta['pulses']))
  138. if meta['pulses']:
  139. print("频率 min=%.0f max=%.0f 中位=%.0f Hz" % (
  140. freq.min(), freq.max(), statistics.median(freq)))
  141. print("末测量点: t=%.3f ms, 高电平 %.3f ms, %.0f Hz" % (
  142. t[-1] * 1000, hi_w[-1] * 1000, freq[-1]))
  143. print("估计周期窗口: %d 个周期" % meta['cycles'])
  144. print("波形活动时长: %.1f ms" % (meta['activity_duration'] * 1000))
  145. def plot_show(path, fs, thresh, ymax, cycles, reverse, save_path=None):
  146. t, freq, hi_w, meta = load_time_freq(path, fs, thresh, cycles, reverse)
  147. import matplotlib.pyplot as plt
  148. from matplotlib.ticker import MaxNLocator
  149. fig, ax = plt.subplots(figsize=(15, 7))
  150. fig.subplots_adjust(bottom=0.10, top=0.92)
  151. # 全脉冲散点:复用 artist,重绘时按可见范围抽稀(大文件流畅)
  152. MAX_VISIBLE_POINTS = 5000 # 可见范围内最多绘制的点数,超过则等间隔抽稀
  153. (scatter,) = ax.plot([], [], '.', ms=1.5, color='C0', alpha=0.5, zorder=1)
  154. tx_ms = t * 1000
  155. x_full = tx_ms
  156. y_full = freq
  157. ax.set_xlim(0.0, meta['activity_duration'] * 1000 * 1.02)
  158. ax.set_ylim(0, ymax if ymax > 0 else freq.max() * 1.08)
  159. ax.set_title("%s 时频曲线(%d 个测量点,%d 周期平均,活动时长 %.0f ms)" % (
  160. os.path.basename(path), meta['pulses'], meta['cycles'],
  161. meta['activity_duration'] * 1000))
  162. ax.set_xlabel("时间 (ms)")
  163. ax.set_ylabel("频率 (Hz)")
  164. # 更密的刻度
  165. ax.xaxis.set_major_locator(MaxNLocator(nbins=20))
  166. ax.yaxis.set_major_locator(MaxNLocator(nbins=15))
  167. ax.grid(True, which='major', alpha=0.35)
  168. ax.grid(True, which='minor', alpha=0.15)
  169. ax.minorticks_on()
  170. if save_path:
  171. fig.savefig(save_path, dpi=130)
  172. print("已保存: %s" % save_path)
  173. plt.close(fig)
  174. return
  175. # 交互:滚轮缩放 X(Ctrl+滚轮缩放 Y)/ 左键双向拖拽平移 / 双击复位 / 悬停高亮
  176. state = {'press_x': None, 'press_y': None,
  177. 'press_xlim': None, 'press_ylim': None}
  178. # 悬停高亮:一个红点标记 + 一个带框文本
  179. (hl_marker,) = ax.plot([], [], 'o', ms=9, mfc='red', mec='white',
  180. mew=1.0, zorder=5, visible=False)
  181. hl_text = ax.text(0, 0, '', fontsize=10, color='black',
  182. bbox=dict(boxstyle='round,pad=0.3', fc='yellow', ec='red', alpha=0.9),
  183. zorder=6, visible=False)
  184. hover_last = {'idx': -1, 'visible': False}
  185. # 可见范围内抽稀绘制散点(大文件性能优化)
  186. def update_scatter():
  187. x0, x1 = ax.get_xlim()
  188. mask = (x_full >= x0) & (x_full <= x1)
  189. n_vis = int(np.count_nonzero(mask))
  190. if n_vis > MAX_VISIBLE_POINTS:
  191. # 等间隔抽稀:取 n_vis 中的 MAX_VISIBLE_POINTS 个
  192. step = (n_vis + MAX_VISIBLE_POINTS - 1) // MAX_VISIBLE_POINTS
  193. idx = np.flatnonzero(mask)[::step]
  194. else:
  195. idx = np.flatnonzero(mask)
  196. scatter.set_data(x_full[idx], y_full[idx])
  197. def on_scroll(event):
  198. if event.inaxes is not ax or event.xdata is None:
  199. return
  200. zoom_in = event.button == 'up'
  201. factor = 1.0 / 1.5 if zoom_in else 1.5
  202. if event.key in ('control', 'ctrl'):
  203. # Ctrl+滚轮:缩放 Y 轴,以鼠标 Y 位置为锚点(锚点数据点不动)
  204. y0, y1 = ax.get_ylim()
  205. cy = event.ydata
  206. n0 = cy - (cy - y0) * factor
  207. n1 = cy + (y1 - cy) * factor
  208. if n1 - n0 < 1.0:
  209. return
  210. ax.set_ylim(n0, n1)
  211. else:
  212. # 普通滚轮:缩放 X 轴,以鼠标 X 位置为锚点(锚点数据点不动)
  213. x0, x1 = ax.get_xlim()
  214. n0 = event.xdata - (event.xdata - x0) * factor
  215. n1 = event.xdata + (x1 - event.xdata) * factor
  216. if n1 - n0 < 1e-6:
  217. return
  218. ax.set_xlim(n0, n1)
  219. update_scatter()
  220. fig.canvas.draw_idle()
  221. def on_press(event):
  222. if event.inaxes is ax and event.button == 1:
  223. state['press_x'] = event.xdata
  224. state['press_y'] = event.ydata
  225. state['press_xlim'] = ax.get_xlim()
  226. state['press_ylim'] = ax.get_ylim()
  227. def on_motion(event):
  228. if event.inaxes is not ax or event.xdata is None:
  229. return
  230. if state['press_x'] is not None:
  231. # 拖拽平移:X/Y 双向跟随鼠标
  232. x0, x1 = state['press_xlim']
  233. y0, y1 = state['press_ylim']
  234. dx = event.xdata - state['press_x']
  235. dy = event.ydata - state['press_y']
  236. ax.set_xlim(x0 - dx, x1 - dx)
  237. ax.set_ylim(y0 - dy, y1 - dy)
  238. update_scatter()
  239. fig.canvas.draw_idle()
  240. return
  241. # 悬停:找可见范围内距鼠标最近的脉冲点(屏幕像素距离 < 20px 才高亮)
  242. x0, x1 = ax.get_xlim()
  243. mask = (x_full >= x0) & (x_full <= x1)
  244. if not np.any(mask):
  245. if hover_last['visible']:
  246. hl_marker.set_visible(False)
  247. hl_text.set_visible(False)
  248. fig.canvas.draw_idle()
  249. hover_last['visible'] = False
  250. return
  251. px, py = ax.transData.transform(np.column_stack([x_full[mask], y_full[mask]])).T
  252. dist = np.hypot(px - event.x, py - event.y)
  253. k = int(np.argmin(dist))
  254. if dist[k] <= 20.0:
  255. idx = int(np.flatnonzero(mask)[k])
  256. hl_marker.set_data([x_full[idx]], [y_full[idx]])
  257. hl_text.set_text("测量点 #%d\nt = %.3f ms\nf = %.0f Hz"
  258. % (idx + 1, x_full[idx], y_full[idx]))
  259. hl_text.set_position((x_full[idx] + (x1 - x0) * 0.01,
  260. y_full[idx] + (ax.get_ylim()[1] - ax.get_ylim()[0]) * 0.02))
  261. hl_marker.set_visible(True)
  262. hl_text.set_visible(True)
  263. if hover_last['idx'] != idx or not hover_last['visible']:
  264. fig.canvas.draw_idle()
  265. hover_last['idx'] = idx
  266. hover_last['visible'] = True
  267. else:
  268. if hover_last['visible']:
  269. hl_marker.set_visible(False)
  270. hl_text.set_visible(False)
  271. fig.canvas.draw_idle()
  272. hover_last['visible'] = False
  273. def on_release(event):
  274. state['press_x'] = None
  275. def on_double(event):
  276. if event.dblclick:
  277. ax.set_xlim(0.0, meta['activity_duration'] * 1000 * 1.02)
  278. ax.set_ylim(0, ymax if ymax > 0 else freq.max() * 1.08)
  279. update_scatter()
  280. fig.canvas.draw_idle()
  281. update_scatter()
  282. fig.canvas.mpl_connect('scroll_event', on_scroll)
  283. fig.canvas.mpl_connect('button_press_event', on_press)
  284. fig.canvas.mpl_connect('button_release_event', on_release)
  285. fig.canvas.mpl_connect('motion_notify_event', on_motion)
  286. fig.canvas.mpl_connect('button_press_event', on_double)
  287. print("打开窗口:滚轮缩放X / Ctrl+滚轮缩放Y / 左键双向拖拽平移 / 双击复位")
  288. print("测量点 %d 个,使用 %d 周期平均,活动时长 %.1f ms" % (
  289. meta['pulses'], meta['cycles'], meta['activity_duration'] * 1000))
  290. plt.show()
  291. def main():
  292. args = [a for a in sys.argv[1:] if not a.startswith('--')]
  293. opts = {a.split('=', 1)[0]: (a.split('=', 1)[1] if '=' in a else True)
  294. for a in sys.argv[1:] if a.startswith('--')}
  295. if len(args) < 1 or '--help' in opts or '-h' in sys.argv[1:]:
  296. print(__doc__)
  297. sys.exit(0)
  298. path = args[0]
  299. fs = float(args[1]) if len(args) > 1 else 3.125e6
  300. thresh = int(opts.get('--thresh', 128))
  301. cycles = int(opts.get('--cycles', 8))
  302. reverse = '--reverse' in opts
  303. ymax = float(opts.get('--ymax', 0))
  304. save_path = opts.get('--save', '')
  305. if not os.path.isfile(path):
  306. print("错误:文件不存在 - %s" % path)
  307. sys.exit(1)
  308. if '--selftest' in opts:
  309. selftest(path, fs, thresh, cycles, reverse)
  310. else:
  311. plot_show(path, fs, thresh, ymax, cycles, reverse, save_path or None)
  312. if __name__ == '__main__':
  313. main()