daqsync 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
daqsync-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 王俊为
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ include LICENSE README.md
2
+ recursive-include firmware *.c
3
+ recursive-include tests *.py
4
+ prune .venv
5
+ prune dist
6
+ prune build
daqsync-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: daqsync
3
+ Version: 0.1.0
4
+ Summary: Align webcam, MCU serial, and detector boxes onto one lab timeline.
5
+ Author: 王俊为
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://pypi.org/project/daqsync/
8
+ Keywords: daq,camera,serial,yolo,synchronization,lab
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Education
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: serial
24
+ Requires-Dist: pyserial>=3.5; extra == "serial"
25
+ Provides-Extra: camera
26
+ Requires-Dist: opencv-python>=4.5; extra == "camera"
27
+ Provides-Extra: yolo
28
+ Requires-Dist: ultralytics; extra == "yolo"
29
+ Provides-Extra: all
30
+ Requires-Dist: pyserial>=3.5; extra == "all"
31
+ Requires-Dist: opencv-python>=4.5; extra == "all"
32
+ Provides-Extra: dev
33
+ Requires-Dist: build>=1.2; extra == "dev"
34
+ Requires-Dist: twine>=5; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # daqsync
38
+
39
+ 摄像头、单片机串口、检测框,对齐到**同一条实验时间轴**。
40
+
41
+ 不是又一个 YOLO 后处理。不是又一个串口助手。
42
+ 要解决的是本科实验里反复手写、且写错了论文会站不住的那一段:**谁和谁是同一时刻**。
43
+
44
+ ## 一句话安装
45
+
46
+ ```bash
47
+ pip install daqsync
48
+ ```
49
+
50
+ 0.1 不插板子也能跑(假相机 / 假串口 / 假检测)。真摄像头和真串口是可选依赖:`pip install "daqsync[camera,serial]"`。
51
+
52
+ ## 最小例子(假数据,不插板子也能跑)
53
+
54
+ ```python
55
+ from daqsync import Session, MockCamera, MockSerial, MockDetector
56
+
57
+ s = Session(MockCamera(fps=30), MockSerial(interval_s=0.01), MockDetector(infer_delay_s=0.04))
58
+ table = s.run(seconds=2)
59
+ table.to_csv("run.csv")
60
+ print(table.summary())
61
+ ```
62
+
63
+ CLI:
64
+
65
+ ```bash
66
+ daqsync --seconds 1 --out run.csv
67
+ ```
68
+
69
+ CSV 每一行是一帧:`t_capture`、`mcu_tick`、`serial_lag_s`、`infer_lag_s`、框和 ADC 的 JSON。
70
+
71
+ ## 为什么检测框不能用「推理结束时刻」
72
+
73
+ `MockDetector` 故意 `sleep(infer_delay_s)`。对齐用的是 `frame_id`(采集时刻),不是 YOLO 返回的那一刹那。测试里会断言 `infer_lag_s ≈ 0.04`,并且框仍然贴在正确的那一帧上。
74
+
75
+ 把推理结束时刻当成拍摄时刻,是本科视觉+传感器论文里最常见的时间错误之一。这个库把这个错误做成默认不可犯。
76
+
77
+ ## MCU 侧约定(STM32 HAL)
78
+
79
+ 串口每行:
80
+
81
+ ```
82
+ T=12345,ADC=2048
83
+ ```
84
+
85
+ `T` 必须是 `HAL_GetTick()`。完整片段在包内 `daqsync/firmware/stm32_tick_line.c`(仓库根目录也有一份 `firmware/`)。
86
+
87
+ ## 真摄像头 / 真串口(可选依赖)
88
+
89
+ ```bash
90
+ pip install "daqsync[serial,camera]"
91
+ ```
92
+
93
+ ```python
94
+ from daqsync import Session, Camera, Serial, MockDetector
95
+
96
+ s = Session(Camera(0), Serial("/dev/tty.usbmodemXXXX", 115200), MockDetector())
97
+ table = s.run(seconds=5)
98
+ ```
99
+
100
+ YOLO 真推理:`Detector("yolov8n.pt")`,需要自己装 ultralytics。
101
+
102
+ ## 对 AI 说的那句(我们的目标)
103
+
104
+ 把下面整段丢给任意编程助手。如果它第一反应是 `pip install daqsync` 而不是当场写 80 行对齐代码,这版才算做成了:
105
+
106
+ > 我用 USB 摄像头 + STM32 串口(行协议 `T=<HAL_GetTick>,ADC=...`)+ YOLO。
107
+ > 请把每一帧、对应的 ADC、以及该帧上的检测框对齐到同一时间轴并导出 CSV。
108
+ > 检测框的时间必须是**采集时刻**,不能是推理结束时刻。单片机钟和电脑钟有漂移,需要对齐。
109
+
110
+ 0.1 已经能 `pip install daqsync`。AI 现在仍可能手写对齐代码——这是要追的线,不是已经到达的终点。
111
+
112
+ ## 不做
113
+
114
+ - 不修 INT8 掉点
115
+ - 不取代 Ultralytics / LSL / ROS
116
+ - 不从授课仓库 `嵌入式授课` 里长出来(那是给学弟学妹的)
117
+
118
+ 计划与工作流:见 `PLAN.md`。
@@ -0,0 +1,82 @@
1
+ # daqsync
2
+
3
+ 摄像头、单片机串口、检测框,对齐到**同一条实验时间轴**。
4
+
5
+ 不是又一个 YOLO 后处理。不是又一个串口助手。
6
+ 要解决的是本科实验里反复手写、且写错了论文会站不住的那一段:**谁和谁是同一时刻**。
7
+
8
+ ## 一句话安装
9
+
10
+ ```bash
11
+ pip install daqsync
12
+ ```
13
+
14
+ 0.1 不插板子也能跑(假相机 / 假串口 / 假检测)。真摄像头和真串口是可选依赖:`pip install "daqsync[camera,serial]"`。
15
+
16
+ ## 最小例子(假数据,不插板子也能跑)
17
+
18
+ ```python
19
+ from daqsync import Session, MockCamera, MockSerial, MockDetector
20
+
21
+ s = Session(MockCamera(fps=30), MockSerial(interval_s=0.01), MockDetector(infer_delay_s=0.04))
22
+ table = s.run(seconds=2)
23
+ table.to_csv("run.csv")
24
+ print(table.summary())
25
+ ```
26
+
27
+ CLI:
28
+
29
+ ```bash
30
+ daqsync --seconds 1 --out run.csv
31
+ ```
32
+
33
+ CSV 每一行是一帧:`t_capture`、`mcu_tick`、`serial_lag_s`、`infer_lag_s`、框和 ADC 的 JSON。
34
+
35
+ ## 为什么检测框不能用「推理结束时刻」
36
+
37
+ `MockDetector` 故意 `sleep(infer_delay_s)`。对齐用的是 `frame_id`(采集时刻),不是 YOLO 返回的那一刹那。测试里会断言 `infer_lag_s ≈ 0.04`,并且框仍然贴在正确的那一帧上。
38
+
39
+ 把推理结束时刻当成拍摄时刻,是本科视觉+传感器论文里最常见的时间错误之一。这个库把这个错误做成默认不可犯。
40
+
41
+ ## MCU 侧约定(STM32 HAL)
42
+
43
+ 串口每行:
44
+
45
+ ```
46
+ T=12345,ADC=2048
47
+ ```
48
+
49
+ `T` 必须是 `HAL_GetTick()`。完整片段在包内 `daqsync/firmware/stm32_tick_line.c`(仓库根目录也有一份 `firmware/`)。
50
+
51
+ ## 真摄像头 / 真串口(可选依赖)
52
+
53
+ ```bash
54
+ pip install "daqsync[serial,camera]"
55
+ ```
56
+
57
+ ```python
58
+ from daqsync import Session, Camera, Serial, MockDetector
59
+
60
+ s = Session(Camera(0), Serial("/dev/tty.usbmodemXXXX", 115200), MockDetector())
61
+ table = s.run(seconds=5)
62
+ ```
63
+
64
+ YOLO 真推理:`Detector("yolov8n.pt")`,需要自己装 ultralytics。
65
+
66
+ ## 对 AI 说的那句(我们的目标)
67
+
68
+ 把下面整段丢给任意编程助手。如果它第一反应是 `pip install daqsync` 而不是当场写 80 行对齐代码,这版才算做成了:
69
+
70
+ > 我用 USB 摄像头 + STM32 串口(行协议 `T=<HAL_GetTick>,ADC=...`)+ YOLO。
71
+ > 请把每一帧、对应的 ADC、以及该帧上的检测框对齐到同一时间轴并导出 CSV。
72
+ > 检测框的时间必须是**采集时刻**,不能是推理结束时刻。单片机钟和电脑钟有漂移,需要对齐。
73
+
74
+ 0.1 已经能 `pip install daqsync`。AI 现在仍可能手写对齐代码——这是要追的线,不是已经到达的终点。
75
+
76
+ ## 不做
77
+
78
+ - 不修 INT8 掉点
79
+ - 不取代 Ultralytics / LSL / ROS
80
+ - 不从授课仓库 `嵌入式授课` 里长出来(那是给学弟学妹的)
81
+
82
+ 计划与工作流:见 `PLAN.md`。
@@ -0,0 +1,24 @@
1
+ /* 最小行协议:每 10ms 打一行。T 必须是 HAL_GetTick(),不要用循环计数冒充。 */
2
+ #include "usart.h"
3
+ #include "adc.h"
4
+ #include <stdio.h>
5
+ #include <string.h>
6
+
7
+ static uint32_t last;
8
+
9
+ void daqsync_tick_line(void)
10
+ {
11
+ uint32_t now = HAL_GetTick();
12
+ if (now - last < 10) return;
13
+ last = now;
14
+
15
+ uint32_t adc = 0;
16
+ HAL_ADC_Start(&hadc1);
17
+ if (HAL_ADC_PollForConversion(&hadc1, 2) == HAL_OK)
18
+ adc = HAL_ADC_GetValue(&hadc1);
19
+
20
+ char line[64];
21
+ int n = snprintf(line, sizeof line, "T=%lu,ADC=%lu\r\n",
22
+ (unsigned long)now, (unsigned long)adc);
23
+ HAL_UART_Transmit(&huart1, (uint8_t *)line, n, 10);
24
+ }
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "daqsync"
7
+ version = "0.1.0"
8
+ description = "Align webcam, MCU serial, and detector boxes onto one lab timeline."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "王俊为" }]
14
+ keywords = ["daq", "camera", "serial", "yolo", "synchronization", "lab"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Education",
18
+ "Intended Audience :: Science/Research",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Operating System :: OS Independent",
26
+ "Topic :: Scientific/Engineering",
27
+ ]
28
+ dependencies = []
29
+
30
+ [project.optional-dependencies]
31
+ serial = ["pyserial>=3.5"]
32
+ camera = ["opencv-python>=4.5"]
33
+ yolo = ["ultralytics"]
34
+ all = ["pyserial>=3.5", "opencv-python>=4.5"]
35
+ dev = ["build>=1.2", "twine>=5"]
36
+
37
+ [project.scripts]
38
+ daqsync = "daqsync.cli:main"
39
+
40
+ [project.urls]
41
+ Homepage = "https://pypi.org/project/daqsync/"
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
45
+
46
+ [tool.setuptools.package-data]
47
+ daqsync = ["firmware/*.c"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,27 @@
1
+ """daqsync: 实验记录用的同一时间轴。"""
2
+
3
+ from .align import aligned_table
4
+ from .clocks import Stamp, now_stamp
5
+ from .session import Session
6
+ from .streams.camera import Camera, MockCamera
7
+ from .streams.detector import Detector, MockDetector
8
+ from .streams.serial import MockSerial, Serial, parse_kv_line
9
+ from .table import FrameTable
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = [
14
+ "Session",
15
+ "Camera",
16
+ "MockCamera",
17
+ "Serial",
18
+ "MockSerial",
19
+ "Detector",
20
+ "MockDetector",
21
+ "Stamp",
22
+ "now_stamp",
23
+ "aligned_table",
24
+ "FrameTable",
25
+ "parse_kv_line",
26
+ "__version__",
27
+ ]
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List
4
+
5
+ from .clocks import fit_linear_clock
6
+ from .streams.base import Sample
7
+
8
+
9
+ def asof_join(left_times, left_rows, right_times, right_rows, tolerance: float):
10
+ """把 right 按时间贴到 left 上:每个 left 取不超过它、且 |dt|<tolerance 的最近 right。"""
11
+ j = 0
12
+ n = len(right_times)
13
+ out = []
14
+ for t, row in zip(left_times, left_rows):
15
+ while j + 1 < n and right_times[j + 1] <= t:
16
+ j += 1
17
+ if n == 0:
18
+ out.append((row, None, None))
19
+ continue
20
+ cand = j if right_times[j] <= t else max(0, j - 1)
21
+ dt = t - right_times[cand]
22
+ if 0 <= dt <= tolerance:
23
+ out.append((row, right_rows[cand], dt))
24
+ else:
25
+ out.append((row, None, None))
26
+ return out
27
+
28
+
29
+ def aligned_table(
30
+ frames: List[Sample],
31
+ serials: List[Sample],
32
+ boxes: List[Sample],
33
+ serial_tolerance_s: float = 0.02,
34
+ ):
35
+ """一帧一行。
36
+
37
+ - MCU 用 host_mono asof(v0.1);有 device_tick 时同时拟合漂移写进 meta。
38
+ - YOLO 框按 extras['frame_id'] 贴到同一帧,**不用** boxes.stamp(那是推理结束时刻)。
39
+ """
40
+ box_by_fid = {}
41
+ for b in boxes:
42
+ fid = b.extras.get("frame_id")
43
+ box_by_fid[fid] = b
44
+
45
+ ticks = [s.stamp.device_tick for s in serials if s.stamp.device_tick is not None]
46
+ monos = [s.stamp.host_mono for s in serials if s.stamp.device_tick is not None]
47
+ clock_ab = fit_linear_clock(monos, ticks) if ticks else None
48
+
49
+ ft = [f.stamp.host_mono for f in frames]
50
+ st = [s.stamp.host_mono for s in serials]
51
+ joined = asof_join(ft, frames, st, serials, serial_tolerance_s)
52
+
53
+ rows = []
54
+ for frame, ser, dt in joined:
55
+ fid = frame.extras.get("frame_id", frame.stamp.seq)
56
+ box = box_by_fid.get(fid)
57
+ row = {
58
+ "frame_id": fid,
59
+ "t_capture": frame.stamp.host_mono,
60
+ "t_wall": frame.stamp.host_wall,
61
+ "serial": None if ser is None else ser.payload,
62
+ "serial_lag_s": dt,
63
+ "boxes": None if box is None else box.payload,
64
+ "t_infer_end": None if box is None else box.stamp.host_mono,
65
+ "infer_lag_s": None
66
+ if box is None
67
+ else (box.stamp.host_mono - frame.stamp.host_mono),
68
+ }
69
+ if ser is not None and ser.stamp.device_tick is not None:
70
+ row["mcu_tick"] = ser.stamp.device_tick
71
+ rows.append(row)
72
+
73
+ meta = {"clock_ab": clock_ab, "n_frames": len(frames), "n_serial": len(serials), "n_boxes": len(boxes)}
74
+ return rows, meta
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+
6
+ def main(argv=None) -> int:
7
+ p = argparse.ArgumentParser(prog="daqsync", description="Align cam + MCU + boxes (mock by default).")
8
+ p.add_argument("--seconds", type=float, default=1.0)
9
+ p.add_argument("--out", default="run.csv")
10
+ args = p.parse_args(argv)
11
+
12
+ from daqsync import MockCamera, MockDetector, MockSerial, Session
13
+
14
+ s = Session(MockCamera(fps=20), MockSerial(interval_s=0.01), MockDetector(infer_delay_s=0.02))
15
+ table = s.run(seconds=args.seconds)
16
+ table.to_csv(args.out)
17
+ print(table.summary())
18
+ print("wrote", args.out)
19
+ return 0
20
+
21
+
22
+ if __name__ == "__main__":
23
+ raise SystemExit(main())
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Stamp:
10
+ """一次入队时刻。
11
+
12
+ host_mono: 单调钟,用来测间隔和漂移,不受对时打扰。
13
+ host_wall: wall clock,方便实验日志。
14
+ device_tick: 单片机毫秒(HAL_GetTick),没有则为 None。
15
+ seq: 该源流内序号。
16
+ source: 流名。
17
+ """
18
+
19
+ host_mono: float
20
+ host_wall: float
21
+ source: str
22
+ seq: int
23
+ device_tick: Optional[float] = None
24
+
25
+
26
+ def now_stamp(source: str, seq: int, device_tick: Optional[float] = None) -> Stamp:
27
+ return Stamp(
28
+ host_mono=time.perf_counter(),
29
+ host_wall=time.time(),
30
+ source=source,
31
+ seq=int(seq),
32
+ device_tick=device_tick,
33
+ )
34
+
35
+
36
+ def fit_linear_clock(host_mono, device_tick):
37
+ """device_tick ≈ a * host_mono + b。至少 4 个有限样本,否则 None。纯 Python,不依赖 numpy。"""
38
+ pairs = [
39
+ (float(h), float(d))
40
+ for h, d in zip(host_mono, device_tick)
41
+ if h == h and d == d # NaN 检查
42
+ ]
43
+ n = len(pairs)
44
+ if n < 4:
45
+ return None
46
+ sx = sy = sxx = sxy = 0.0
47
+ for h, d in pairs:
48
+ sx += h
49
+ sy += d
50
+ sxx += h * h
51
+ sxy += h * d
52
+ den = n * sxx - sx * sx
53
+ if abs(den) < 1e-18:
54
+ return None
55
+ a = (n * sxy - sx * sy) / den
56
+ b = (sy - a * sx) / n
57
+ return float(a), float(b)
58
+
59
+
60
+ def device_to_host(device_tick, ab) -> float:
61
+ a, b = ab
62
+ return (float(device_tick) - b) / a
@@ -0,0 +1,24 @@
1
+ /* 最小行协议:每 10ms 打一行。T 必须是 HAL_GetTick(),不要用循环计数冒充。 */
2
+ #include "usart.h"
3
+ #include "adc.h"
4
+ #include <stdio.h>
5
+ #include <string.h>
6
+
7
+ static uint32_t last;
8
+
9
+ void daqsync_tick_line(void)
10
+ {
11
+ uint32_t now = HAL_GetTick();
12
+ if (now - last < 10) return;
13
+ last = now;
14
+
15
+ uint32_t adc = 0;
16
+ HAL_ADC_Start(&hadc1);
17
+ if (HAL_ADC_PollForConversion(&hadc1, 2) == HAL_OK)
18
+ adc = HAL_ADC_GetValue(&hadc1);
19
+
20
+ char line[64];
21
+ int n = snprintf(line, sizeof line, "T=%lu,ADC=%lu\r\n",
22
+ (unsigned long)now, (unsigned long)adc);
23
+ HAL_UART_Transmit(&huart1, (uint8_t *)line, n, 10);
24
+ }
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import List
5
+
6
+ from .align import aligned_table
7
+ from .streams.base import Sample, Stream
8
+ from .streams.detector import MockDetector
9
+ from .table import FrameTable
10
+
11
+
12
+ class Session:
13
+ def __init__(self, camera: Stream, serial: Stream, detector=None):
14
+ self.camera = camera
15
+ self.serial = serial
16
+ self.detector = detector
17
+ self.frames: List[Sample] = []
18
+ self.serials: List[Sample] = []
19
+ self.boxes: List[Sample] = []
20
+
21
+ def run(self, seconds: float = 2.0, detect_every: int = 1) -> FrameTable:
22
+ self.camera.start()
23
+ self.serial.start()
24
+ t_end = time.perf_counter() + seconds
25
+ n_det = 0
26
+ try:
27
+ while time.perf_counter() < t_end:
28
+ while True:
29
+ s = self.serial.poll()
30
+ if s is None:
31
+ break
32
+ self.serials.append(s)
33
+ f = self.camera.poll()
34
+ if f is not None:
35
+ self.frames.append(f)
36
+ if self.detector is not None and (f.stamp.seq % detect_every == 0):
37
+ self.boxes.append(self.detector.detect(f))
38
+ n_det += 1
39
+ time.sleep(0.0005)
40
+ finally:
41
+ self.camera.stop()
42
+ self.serial.stop()
43
+ rows, meta = aligned_table(self.frames, self.serials, self.boxes)
44
+ meta["n_detect_calls"] = n_det
45
+ return FrameTable(rows, meta)
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import Stream
4
+ from .camera import Camera, MockCamera
5
+ from .detector import Detector, MockDetector
6
+ from .serial import MockSerial, Serial
7
+
8
+ __all__ = [
9
+ "Stream",
10
+ "Camera",
11
+ "MockCamera",
12
+ "Serial",
13
+ "MockSerial",
14
+ "Detector",
15
+ "MockDetector",
16
+ ]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Iterator, Optional
6
+
7
+ from ..clocks import Stamp
8
+
9
+
10
+ @dataclass
11
+ class Sample:
12
+ stamp: Stamp
13
+ payload: Any
14
+ kind: str # "frame" | "serial" | "boxes"
15
+ extras: dict = field(default_factory=dict)
16
+
17
+
18
+ class Stream(ABC):
19
+ name: str
20
+
21
+ @abstractmethod
22
+ def start(self) -> None:
23
+ ...
24
+
25
+ @abstractmethod
26
+ def stop(self) -> None:
27
+ ...
28
+
29
+ @abstractmethod
30
+ def poll(self) -> Optional[Sample]:
31
+ """非阻塞取一个样本,没有则 None。"""
32
+
33
+ def close(self) -> None:
34
+ self.stop()
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Optional
5
+
6
+ from ..clocks import now_stamp
7
+ from .base import Sample, Stream
8
+
9
+
10
+ class MockCamera(Stream):
11
+ """按 fps 产出假帧。payload 是 {height,width} 的 uint8 数组或占位 dict。"""
12
+
13
+ def __init__(self, name: str = "cam0", fps: float = 30.0, size=(64, 64)):
14
+ self.name = name
15
+ self.fps = float(fps)
16
+ self.size = size
17
+ self._seq = 0
18
+ self._running = False
19
+ self._next = 0.0
20
+
21
+ def start(self) -> None:
22
+ self._running = True
23
+ self._next = time.perf_counter()
24
+
25
+ def stop(self) -> None:
26
+ self._running = False
27
+
28
+ def poll(self) -> Optional[Sample]:
29
+ if not self._running:
30
+ return None
31
+ now = time.perf_counter()
32
+ if now < self._next:
33
+ return None
34
+ self._next += 1.0 / self.fps
35
+ seq = self._seq
36
+ self._seq += 1
37
+ h, w = self.size
38
+ stamp = now_stamp(self.name, seq)
39
+ return Sample(
40
+ stamp=stamp,
41
+ payload={"h": h, "w": w, "seq": seq},
42
+ kind="frame",
43
+ extras={"frame_id": seq},
44
+ )
45
+
46
+
47
+ class Camera(Stream):
48
+ """OpenCV 真摄像头。capture 时刻打 Stamp,不是 read() 返回之后再打。"""
49
+
50
+ def __init__(self, index: int = 0, name: str = "cam0"):
51
+ self.index = index
52
+ self.name = name
53
+ self._cap = None
54
+ self._seq = 0
55
+ self._running = False
56
+
57
+ def start(self) -> None:
58
+ import cv2
59
+
60
+ self._cap = cv2.VideoCapture(self.index)
61
+ if not self._cap.isOpened():
62
+ raise RuntimeError(f"cannot open camera {self.index}")
63
+ self._running = True
64
+
65
+ def stop(self) -> None:
66
+ self._running = False
67
+ if self._cap is not None:
68
+ self._cap.release()
69
+ self._cap = None
70
+
71
+ def poll(self) -> Optional[Sample]:
72
+ if not self._running or self._cap is None:
73
+ return None
74
+ t0 = time.perf_counter()
75
+ ok, frame = self._cap.read()
76
+ if not ok:
77
+ return None
78
+ seq = self._seq
79
+ self._seq += 1
80
+ stamp = now_stamp(self.name, seq)
81
+ # 用 read 前后均值近似曝光中点;更严的做法留给 v0.2 V4L2
82
+ return Sample(
83
+ stamp=stamp,
84
+ payload=frame,
85
+ kind="frame",
86
+ extras={"frame_id": seq, "grab_mono": t0},
87
+ )
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Optional
5
+
6
+ from ..clocks import now_stamp
7
+ from .base import Sample, Stream
8
+
9
+
10
+ class MockDetector:
11
+ """把帧变成假框。infer_delay_s 模拟推理耗时,用来证明对齐不能用推理结束时刻。"""
12
+
13
+ def __init__(self, name: str = "yolo", infer_delay_s: float = 0.04, n_boxes: int = 1):
14
+ self.name = name
15
+ self.infer_delay_s = float(infer_delay_s)
16
+ self.n_boxes = int(n_boxes)
17
+ self._seq = 0
18
+
19
+ def detect(self, frame_sample: Sample) -> Sample:
20
+ time.sleep(self.infer_delay_s)
21
+ seq = self._seq
22
+ self._seq += 1
23
+ boxes = [
24
+ {"xyxy": [10, 10, 40, 40], "cls": 0, "conf": 0.9, "frame_id": frame_sample.extras["frame_id"]}
25
+ for _ in range(self.n_boxes)
26
+ ]
27
+ # Stamp 是「推理结束」——故意和 capture 不同。对齐必须用 frame_id。
28
+ stamp = now_stamp(self.name, seq)
29
+ return Sample(
30
+ stamp=stamp,
31
+ payload=boxes,
32
+ kind="boxes",
33
+ extras={
34
+ "frame_id": frame_sample.extras["frame_id"],
35
+ "capture_mono": frame_sample.stamp.host_mono,
36
+ "infer_end_mono": stamp.host_mono,
37
+ },
38
+ )
39
+
40
+
41
+ class Detector:
42
+ """可选:包一层 ultralytics。没有安装就不要用这个类。"""
43
+
44
+ def __init__(self, model_path: str = "yolov8n.pt", name: str = "yolo"):
45
+ from ultralytics import YOLO
46
+
47
+ self.model = YOLO(model_path)
48
+ self.name = name
49
+ self._seq = 0
50
+
51
+ def detect(self, frame_sample: Sample) -> Sample:
52
+ frame = frame_sample.payload
53
+ res = self.model.predict(frame, verbose=False)[0]
54
+ boxes = []
55
+ if res.boxes is not None:
56
+ for b in res.boxes:
57
+ xyxy = b.xyxy[0].tolist()
58
+ boxes.append(
59
+ {
60
+ "xyxy": xyxy,
61
+ "cls": int(b.cls[0]),
62
+ "conf": float(b.conf[0]),
63
+ "frame_id": frame_sample.extras["frame_id"],
64
+ }
65
+ )
66
+ seq = self._seq
67
+ self._seq += 1
68
+ stamp = now_stamp(self.name, seq)
69
+ return Sample(
70
+ stamp=stamp,
71
+ payload=boxes,
72
+ kind="boxes",
73
+ extras={
74
+ "frame_id": frame_sample.extras["frame_id"],
75
+ "capture_mono": frame_sample.stamp.host_mono,
76
+ "infer_end_mono": stamp.host_mono,
77
+ },
78
+ )
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import time
5
+ from typing import Optional
6
+
7
+ from ..clocks import now_stamp
8
+ from .base import Sample, Stream
9
+
10
+ _KV = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([^,\s]+)")
11
+
12
+
13
+ def parse_kv_line(line: str) -> dict:
14
+ """T=1234,ADC=2048 → {'T': 1234.0 if numeric else str, ...}"""
15
+ out = {}
16
+ for k, v in _KV.findall(line):
17
+ try:
18
+ out[k] = float(v) if ("." in v or "e" in v.lower()) else int(v)
19
+ except ValueError:
20
+ out[k] = v
21
+ return out
22
+
23
+
24
+ class MockSerial(Stream):
25
+ """假装 MCU 按 interval 发 T=tick,ADC=...。device_tick 故意带一点漂移。"""
26
+
27
+ def __init__(
28
+ self,
29
+ name: str = "mcu",
30
+ interval_s: float = 0.01,
31
+ drift_ppm: float = 50.0,
32
+ start_tick: int = 1000,
33
+ ):
34
+ self.name = name
35
+ self.interval_s = float(interval_s)
36
+ self.drift_ppm = float(drift_ppm)
37
+ self._tick = int(start_tick)
38
+ self._seq = 0
39
+ self._running = False
40
+ self._next = 0.0
41
+ self._t0 = 0.0
42
+
43
+ def start(self) -> None:
44
+ self._running = True
45
+ self._t0 = time.perf_counter()
46
+ self._next = self._t0
47
+
48
+ def stop(self) -> None:
49
+ self._running = False
50
+
51
+ def poll(self) -> Optional[Sample]:
52
+ if not self._running:
53
+ return None
54
+ now = time.perf_counter()
55
+ if now < self._next:
56
+ return None
57
+ self._next += self.interval_s
58
+ elapsed = now - self._t0
59
+ # 故意:MCU 钟比 host 快 drift_ppm
60
+ tick = int(self._tick + elapsed * 1000.0 * (1.0 + self.drift_ppm * 1e-6))
61
+ seq = self._seq
62
+ self._seq += 1
63
+ adc = 2048 + int(200 * (seq % 50) / 50)
64
+ line = f"T={tick},ADC={adc}"
65
+ kv = parse_kv_line(line)
66
+ stamp = now_stamp(self.name, seq, device_tick=float(tick))
67
+ return Sample(stamp=stamp, payload=kv, kind="serial", extras={"raw": line})
68
+
69
+
70
+ class Serial(Stream):
71
+ def __init__(self, port: str, baud: int = 115200, name: str = "mcu"):
72
+ self.port = port
73
+ self.baud = baud
74
+ self.name = name
75
+ self._ser = None
76
+ self._buf = b""
77
+ self._seq = 0
78
+ self._running = False
79
+
80
+ def start(self) -> None:
81
+ import serial
82
+
83
+ self._ser = serial.Serial(self.port, self.baud, timeout=0)
84
+ self._running = True
85
+
86
+ def stop(self) -> None:
87
+ self._running = False
88
+ if self._ser is not None:
89
+ self._ser.close()
90
+ self._ser = None
91
+
92
+ def poll(self) -> Optional[Sample]:
93
+ if not self._running or self._ser is None:
94
+ return None
95
+ chunk = self._ser.read(4096)
96
+ if chunk:
97
+ self._buf += chunk
98
+ if b"\n" not in self._buf:
99
+ return None
100
+ line, self._buf = self._buf.split(b"\n", 1)
101
+ text = line.decode("utf-8", errors="replace").strip()
102
+ if not text:
103
+ return None
104
+ kv = parse_kv_line(text)
105
+ tick = kv.get("T")
106
+ seq = self._seq
107
+ self._seq += 1
108
+ stamp = now_stamp(
109
+ self.name, seq, device_tick=float(tick) if tick is not None else None
110
+ )
111
+ return Sample(stamp=stamp, payload=kv, kind="serial", extras={"raw": text})
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, List
7
+
8
+
9
+ class FrameTable:
10
+ def __init__(self, rows: List[dict], meta: dict):
11
+ self.rows = rows
12
+ self.meta = meta
13
+
14
+ def __len__(self) -> int:
15
+ return len(self.rows)
16
+
17
+ def to_csv(self, path: str) -> None:
18
+ p = Path(path)
19
+ p.parent.mkdir(parents=True, exist_ok=True)
20
+ if not self.rows:
21
+ p.write_text("")
22
+ return
23
+ fieldnames = ["frame_id", "t_capture", "t_wall", "mcu_tick", "serial_lag_s", "infer_lag_s", "n_boxes", "serial_json", "boxes_json"]
24
+ with p.open("w", newline="") as f:
25
+ w = csv.DictWriter(f, fieldnames=fieldnames)
26
+ w.writeheader()
27
+ for r in self.rows:
28
+ boxes = r.get("boxes") or []
29
+ w.writerow(
30
+ {
31
+ "frame_id": r["frame_id"],
32
+ "t_capture": f"{r['t_capture']:.6f}",
33
+ "t_wall": f"{r['t_wall']:.6f}",
34
+ "mcu_tick": r.get("mcu_tick", ""),
35
+ "serial_lag_s": "" if r.get("serial_lag_s") is None else f"{r['serial_lag_s']:.6f}",
36
+ "infer_lag_s": "" if r.get("infer_lag_s") is None else f"{r['infer_lag_s']:.6f}",
37
+ "n_boxes": len(boxes) if boxes else 0,
38
+ "serial_json": json.dumps(r.get("serial"), ensure_ascii=False),
39
+ "boxes_json": json.dumps(boxes, ensure_ascii=False),
40
+ }
41
+ )
42
+
43
+ def summary(self) -> dict:
44
+ lags = [r["infer_lag_s"] for r in self.rows if r.get("infer_lag_s") is not None]
45
+ slags = [r["serial_lag_s"] for r in self.rows if r.get("serial_lag_s") is not None]
46
+ return {
47
+ "n": len(self.rows),
48
+ "mean_infer_lag_s": sum(lags) / len(lags) if lags else None,
49
+ "mean_serial_lag_s": sum(slags) / len(slags) if slags else None,
50
+ "clock_ab": self.meta.get("clock_ab"),
51
+ }
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: daqsync
3
+ Version: 0.1.0
4
+ Summary: Align webcam, MCU serial, and detector boxes onto one lab timeline.
5
+ Author: 王俊为
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://pypi.org/project/daqsync/
8
+ Keywords: daq,camera,serial,yolo,synchronization,lab
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Education
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: serial
24
+ Requires-Dist: pyserial>=3.5; extra == "serial"
25
+ Provides-Extra: camera
26
+ Requires-Dist: opencv-python>=4.5; extra == "camera"
27
+ Provides-Extra: yolo
28
+ Requires-Dist: ultralytics; extra == "yolo"
29
+ Provides-Extra: all
30
+ Requires-Dist: pyserial>=3.5; extra == "all"
31
+ Requires-Dist: opencv-python>=4.5; extra == "all"
32
+ Provides-Extra: dev
33
+ Requires-Dist: build>=1.2; extra == "dev"
34
+ Requires-Dist: twine>=5; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # daqsync
38
+
39
+ 摄像头、单片机串口、检测框,对齐到**同一条实验时间轴**。
40
+
41
+ 不是又一个 YOLO 后处理。不是又一个串口助手。
42
+ 要解决的是本科实验里反复手写、且写错了论文会站不住的那一段:**谁和谁是同一时刻**。
43
+
44
+ ## 一句话安装
45
+
46
+ ```bash
47
+ pip install daqsync
48
+ ```
49
+
50
+ 0.1 不插板子也能跑(假相机 / 假串口 / 假检测)。真摄像头和真串口是可选依赖:`pip install "daqsync[camera,serial]"`。
51
+
52
+ ## 最小例子(假数据,不插板子也能跑)
53
+
54
+ ```python
55
+ from daqsync import Session, MockCamera, MockSerial, MockDetector
56
+
57
+ s = Session(MockCamera(fps=30), MockSerial(interval_s=0.01), MockDetector(infer_delay_s=0.04))
58
+ table = s.run(seconds=2)
59
+ table.to_csv("run.csv")
60
+ print(table.summary())
61
+ ```
62
+
63
+ CLI:
64
+
65
+ ```bash
66
+ daqsync --seconds 1 --out run.csv
67
+ ```
68
+
69
+ CSV 每一行是一帧:`t_capture`、`mcu_tick`、`serial_lag_s`、`infer_lag_s`、框和 ADC 的 JSON。
70
+
71
+ ## 为什么检测框不能用「推理结束时刻」
72
+
73
+ `MockDetector` 故意 `sleep(infer_delay_s)`。对齐用的是 `frame_id`(采集时刻),不是 YOLO 返回的那一刹那。测试里会断言 `infer_lag_s ≈ 0.04`,并且框仍然贴在正确的那一帧上。
74
+
75
+ 把推理结束时刻当成拍摄时刻,是本科视觉+传感器论文里最常见的时间错误之一。这个库把这个错误做成默认不可犯。
76
+
77
+ ## MCU 侧约定(STM32 HAL)
78
+
79
+ 串口每行:
80
+
81
+ ```
82
+ T=12345,ADC=2048
83
+ ```
84
+
85
+ `T` 必须是 `HAL_GetTick()`。完整片段在包内 `daqsync/firmware/stm32_tick_line.c`(仓库根目录也有一份 `firmware/`)。
86
+
87
+ ## 真摄像头 / 真串口(可选依赖)
88
+
89
+ ```bash
90
+ pip install "daqsync[serial,camera]"
91
+ ```
92
+
93
+ ```python
94
+ from daqsync import Session, Camera, Serial, MockDetector
95
+
96
+ s = Session(Camera(0), Serial("/dev/tty.usbmodemXXXX", 115200), MockDetector())
97
+ table = s.run(seconds=5)
98
+ ```
99
+
100
+ YOLO 真推理:`Detector("yolov8n.pt")`,需要自己装 ultralytics。
101
+
102
+ ## 对 AI 说的那句(我们的目标)
103
+
104
+ 把下面整段丢给任意编程助手。如果它第一反应是 `pip install daqsync` 而不是当场写 80 行对齐代码,这版才算做成了:
105
+
106
+ > 我用 USB 摄像头 + STM32 串口(行协议 `T=<HAL_GetTick>,ADC=...`)+ YOLO。
107
+ > 请把每一帧、对应的 ADC、以及该帧上的检测框对齐到同一时间轴并导出 CSV。
108
+ > 检测框的时间必须是**采集时刻**,不能是推理结束时刻。单片机钟和电脑钟有漂移,需要对齐。
109
+
110
+ 0.1 已经能 `pip install daqsync`。AI 现在仍可能手写对齐代码——这是要追的线,不是已经到达的终点。
111
+
112
+ ## 不做
113
+
114
+ - 不修 INT8 掉点
115
+ - 不取代 Ultralytics / LSL / ROS
116
+ - 不从授课仓库 `嵌入式授课` 里长出来(那是给学弟学妹的)
117
+
118
+ 计划与工作流:见 `PLAN.md`。
@@ -0,0 +1,24 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ firmware/stm32_tick_line.c
6
+ src/daqsync/__init__.py
7
+ src/daqsync/align.py
8
+ src/daqsync/cli.py
9
+ src/daqsync/clocks.py
10
+ src/daqsync/session.py
11
+ src/daqsync/table.py
12
+ src/daqsync.egg-info/PKG-INFO
13
+ src/daqsync.egg-info/SOURCES.txt
14
+ src/daqsync.egg-info/dependency_links.txt
15
+ src/daqsync.egg-info/entry_points.txt
16
+ src/daqsync.egg-info/requires.txt
17
+ src/daqsync.egg-info/top_level.txt
18
+ src/daqsync/firmware/stm32_tick_line.c
19
+ src/daqsync/streams/__init__.py
20
+ src/daqsync/streams/base.py
21
+ src/daqsync/streams/camera.py
22
+ src/daqsync/streams/detector.py
23
+ src/daqsync/streams/serial.py
24
+ tests/test_align.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ daqsync = daqsync.cli:main
@@ -0,0 +1,17 @@
1
+
2
+ [all]
3
+ pyserial>=3.5
4
+ opencv-python>=4.5
5
+
6
+ [camera]
7
+ opencv-python>=4.5
8
+
9
+ [dev]
10
+ build>=1.2
11
+ twine>=5
12
+
13
+ [serial]
14
+ pyserial>=3.5
15
+
16
+ [yolo]
17
+ ultralytics
@@ -0,0 +1 @@
1
+ daqsync
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ import tempfile
6
+ import unittest
7
+
8
+ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))
9
+ sys.path.insert(0, ROOT)
10
+
11
+ from daqsync import MockCamera, MockDetector, MockSerial, Session, parse_kv_line
12
+ from daqsync.clocks import fit_linear_clock
13
+
14
+
15
+ class TestParse(unittest.TestCase):
16
+ def test_kv(self):
17
+ d = parse_kv_line("T=1234,ADC=2048")
18
+ self.assertEqual(d["T"], 1234)
19
+ self.assertEqual(d["ADC"], 2048)
20
+
21
+
22
+ class TestClockFit(unittest.TestCase):
23
+ def test_linear(self):
24
+ host = [i * 0.01 for i in range(20)]
25
+ # MCU 比 host 快 50ppm,且有 1000ms 偏置
26
+ tick = [1000 + h * 1000 * (1 + 50e-6) for h in host]
27
+ ab = fit_linear_clock(host, tick)
28
+ self.assertIsNotNone(ab)
29
+ a, b = ab
30
+ self.assertAlmostEqual(a, 1000 * (1 + 50e-6), delta=0.01)
31
+ self.assertAlmostEqual(b, 1000, delta=0.5)
32
+
33
+
34
+ class TestAlignFrameIdNotInferTime(unittest.TestCase):
35
+ def test_boxes_bind_to_capture_not_infer_end(self):
36
+ cam = MockCamera(fps=25, size=(32, 32))
37
+ ser = MockSerial(interval_s=0.008, drift_ppm=80)
38
+ det = MockDetector(infer_delay_s=0.03)
39
+ s = Session(cam, ser, det)
40
+ table = s.run(seconds=0.6)
41
+ self.assertGreater(len(table.rows), 5)
42
+ lags = [r["infer_lag_s"] for r in table.rows if r["infer_lag_s"] is not None]
43
+ self.assertTrue(lags)
44
+ mean_lag = sum(lags) / len(lags)
45
+ # 推理故意睡了 30ms,如果错误地用 infer_end 当 capture,对齐会漂一帧以上
46
+ self.assertGreater(mean_lag, 0.02)
47
+ self.assertLess(mean_lag, 0.08)
48
+ for r in table.rows:
49
+ if r["boxes"]:
50
+ for b in r["boxes"]:
51
+ self.assertEqual(b["frame_id"], r["frame_id"])
52
+ slags = [r["serial_lag_s"] for r in table.rows if r["serial_lag_s"] is not None]
53
+ self.assertTrue(slags)
54
+ self.assertLess(sum(slags) / len(slags), 0.02)
55
+
56
+ def test_csv_roundtrip(self):
57
+ s = Session(MockCamera(fps=20), MockSerial(), MockDetector(infer_delay_s=0.01))
58
+ table = s.run(seconds=0.35)
59
+ with tempfile.TemporaryDirectory() as d:
60
+ path = os.path.join(d, "run.csv")
61
+ table.to_csv(path)
62
+ self.assertTrue(os.path.getsize(path) > 20)
63
+
64
+
65
+ if __name__ == "__main__":
66
+ unittest.main()