用软件改装,让原来破旧的自行车在功能上焕然一新。
原文链接:https://theoffcuts.org/posts/prototyping-a-stationary-bike-stepper/
声明:本文为 CSDN 翻译,未经允许不可转载。
以下为译文:
最近我跟朋友聊起SwiftUI。SwiftUI刚发布第一年的时候并不怎么好用,但幸运的是当时我并没有使用。后来,我掌握了这门语言之后,它就成了我所有快乐的源泉。朋友问,“为什么?”我略加思索,然后说:“我喜欢做原型,而SwiftUI扫清了许多我早已习惯的障碍。”
回想起远古时代,我做技术原型时喜欢用Objective-C编写UI。它的优点是你可以在一张图中看到所有逻辑。相应地,副作用就是很难让人集中注意力。
SwiftUI可以带来相同的感觉,不过更为简洁,而且也没有副作用。
以我最近的一个项目为例:我有一台廉价的动感单车,用来锻炼身体很合适,但它的界面非常不友好。我一直想要一个显示屏!用单片机和信号线自己做一个显示屏?然后用计算机视觉来处理数据?
或者,也许可以完全不管显示屏的问题,而是根据手机的传感器来估算动感单车的速度,然后计算其他数据?
可行吗?
我之前在硬件项目里接触过九轴的传感器,了解应该通过怎样的运动来进行测量。尽管理论上我知道应该在动感单车上采用哪种传感器(加速度计和陀螺仪),但我不确定踩踏板的动作能否可靠地被某个传感器识别。而且,即使能识别,这种数据也有很大噪声。我需要一个原型。
iPhone的九轴传感器会输出一个双精度型数组,但与其他电动设备一样,这些采样数据只是真实运动的片面表示而已。所以,在提取采样数据之后,还需要进行平滑处理。如果一切可行,就应该能用可视化的方式来表示数据,比如画出传感器数据的图表。
在动笔之前,我尝试了SwiftUI的所有图表库,但没有一个能满足我的要求。我想了几天,决定先选一个,以后再慢慢改进,但我突然发现,苹果恰好在WWDC上发布了一个非常好用的图表框架!这个框架正好能满足我原型的需要。但这也意味着,下面的代码只能在Xcode 14 Beta上运行,也只能在iOS 16 beta的设备上运行。
用最少的代码,为每个传感器实现一个图表。不需要考虑状态和错误,只需要展示数据,可以认为设备全部正常工作。也不需要考虑用户交互。功能要求如下:
1.能查看所有传感器。
2.当没有传感器数据时关闭视图。
3.分开显示传感器的三个轴的数据。
4.平滑数据,并计算波峰的数量(等价于踩踏板的次数)。
原型的目的是验证这个思路是否可行,所以一切从简,只需找出问题的答案即可。而实际的产品则会考虑另一个问题:“是否需要通用化?”而至少目前该问题的答案是否定的。
在应用程序中,我不会把模型和界面放在同一个文件中。但是,另一个我喜欢SwiftUI的点是,你只需要写一个文件放到应用中,然后在App的构造中初始化,即可看到UI。太棒了。由于我的目的只是尝试,所以只需要使用一个文件。不过我在一些有意思的地方加了注释。
代码
1// Created by Halle Winkler on July/11/22. Copyright © 2022. All rights reserved.
2// Requires Xcode 14.x and iOS 16.x, betas included.
3
4import Charts
5import CoreMotion
6import SwiftUI
7
8// MARK: - ContentView
9
10/// ContentView is a collection of motion sensor UIs and a method of calling back to the model.
11
12struct ContentView {
13 @ObservedObject var manager: MotionManager
14}
15
16extension ContentView: View {
17 var body: some View {
18 VStack {
19 ForEach(manager.sensors, id: \.sensorName) { sensor in
20 SensorChart(sensor: sensor) { applyFilter, lowPassFilterFactor, quantizeFactor in
21 manager.updateFilteringFor(
22 sensor: sensor,
23 applyFilter: applyFilter,
24 lowPassFilterFactor: lowPassFilterFactor,
25 quantizeFactor: quantizeFactor)
26 }
27 }
28 }.padding([.leading, .trailing], 6)
29 }
30}
31
32// MARK: - SensorChart
33
34/// I like to compose SwiftUI interfaces out of many small modules. But, there is a tension when it's a
35/// small UI overall, and the modules will each have overhead from propagating state, binding and callbacks.
36
37struct SensorChart {
38 @State private var chartIsVisible = true
39 @State private var breakOutAxes = false
40 @State private var applyingFilter = false
41 @State private var lowPassFilterFactor: Double = 0.75
42 @State private var quantizeFactor: Double = 50
43 var sensor: Sensor
44 let updateFiltering: (Bool, Double, Double) -> Void
45 private func toggleFiltering() {
46 applyingFilter.toggle()
47 updateFiltering(applyingFilter, lowPassFilterFactor, quantizeFactor)
48 }
49}
50
51extension SensorChart: View {
52 var body: some View {
53/// Per-sensor controls: apply filtering to the waveform, hide and show sensor, break out the axes into separate charts.
54
55 HStack {
56 Text("\(sensor.sensorName)")
57 .font(.system(size: 12, weight: .semibold, design: .default))
58 .foregroundColor(chartIsVisible ? .black : .gray)
59 Spacer()
60 Button(action: toggleFiltering) {
61 Image(systemName: applyingFilter ? "waveform.circle.fill" :
62 "waveform.circle")
63 }
64 .opacity(chartIsVisible ? 1.0 : 0.0)
65 Button(action: { chartIsVisible.toggle() }) {
66 Image(systemName: chartIsVisible ? "eye.circle.fill" :
67 "eye.slash.circle")
68 }
69 Button(action: { breakOutAxes.toggle() }) {
70 Image(systemName: breakOutAxes ? "1.circle.fill" :
71 "3.circle.fill")
72 }
73 .opacity(chartIsVisible ? 1.0 : 0.0)
74 }
75
76/// Sensor charts, either one chart with three axes, or three charts with one axis. I love how concise Swift Charts can be.
77
78 if chartIsVisible {
79 if breakOutAxes {
80 ForEach(sensor.axes, id: \.axisName) { series in
81 // Iterate charts from series
82 Chart {
83 ForEach(
84 Array(series.measurements.enumerated()),
85 id: \.offset) { index, datum in
86 LineMark(
87 x: .value("Count", index),
88 y: .value("Measurement", datum))
89 }
90 }
91 Text(
92 "Axis: \(series.axisName)\(applyingFilter ? "\t\tPeaks in window: \(series.peaks)" : "")")
93 }
94 .chartXAxis {
95 AxisMarks(values: .automatic(desiredCount: 0))
96 }
97 } else {
98 Chart {
99 ForEach(sensor.axes, id: \.axisName) { series in
100 // Iterate series in a chart
101 ForEach(
102 Array(series.measurements.enumerated()),
103 id: \.offset) { index, datum in
104 LineMark(
105 x: .value("Count", index),
106 y: .value("Measurement", datum))
107 }
108 .foregroundStyle(by: .value("MeasurementName",
109 series.axisName))
110 }
111 }.chartXAxis {
112 AxisMarks(values: .automatic(desiredCount: 0))
113 }.chartYAxis {
114 AxisMarks(values: .automatic(desiredCount: 2))
115 }
116 }
117
118/// in the separate three-axis view, you can set the low-pass filter factor and the quantizing factor if the waveform
119/// filtering is on, and then once you can see your stationary pedaling reflected in the waveform, you can see how
120/// many times per time window you're pedaling. With such an inevitably-noisy sensor environment, I already know
121/// the low-pass filter factor will have to be very high, so I'm starting it at 0.75.
122/// In the case of my exercise bike, the quantizing factor that delivers very accurate peak-counting results on
123/// gyroscope axis z is 520, which tells you these readings are really small numbers.
124
125 if applyingFilter {
126 Slider(
127 value: $lowPassFilterFactor,
128 in: 0.75 ... 1.0,
129 onEditingChanged: { _ in
130 updateFiltering(
131 true,
132 lowPassFilterFactor,
133 quantizeFactor)
134 })
135 Text("Lowpass: \(String(format: "%.2f", lowPassFilterFactor))")
136 .font(.system(size: 12))
137 .frame(width: 100, alignment: .trailing)
138 Slider(
139 value: $quantizeFactor,
140 in: 1 ... 600,
141 onEditingChanged: { _ in
142 updateFiltering(
143 true,
144 lowPassFilterFactor,
145 quantizeFactor)
146 })
147 Text("Quantize: \(Int(quantizeFactor))")
148 .font(.system(size: 12))
149 .frame(width: 100, alignment: .trailing)
150 }
151 }
152 Divider()
153 }
154}
155
156// MARK: - MotionManager
157
158/// MotionManager is the sensor management module.
159
160class MotionManager: ObservableObject {
161 // MARK: Lifecycle
162
163 init() {
164 self.manager = CMMotionManager()
165 for name in SensorNames
166 .allCases {
167// self.sensors and func collectReadings(...) use SensorNames to index,
168 if name ==
169 .attitude {
170// so if you change how one creates/derives a sensor index, change them both.
171 sensors.append(ThreeAxisReadings(
172 sensorName: SensorNames.attitude.rawValue,
173 // The one exception to sensor axis naming:
174 axes: [
175 Axis(axisName: "Pitch"),
176 Axis(axisName: "Roll"),
177 Axis(axisName: "Yaw"),
178 ]))
179 } else {
180 sensors.append(ThreeAxisReadings(sensorName: name.rawValue))
181 }
182 }
183 self.manager.deviceMotionUpdateInterval = sensorUpdateInterval
184 self.manager.accelerometerUpdateInterval = sensorUpdateInterval
185 self.manager.gyroUpdateInterval = sensorUpdateInterval
186 self.manager.magnetometerUpdateInterval = sensorUpdateInterval
187 self.startDeviceUpdates(manager: manager)
188 }
189
190 // MARK: Public
191
192 public func updateFilteringFor( // Manage the callbacks from the UI
193 sensor: ThreeAxisReadings,
194 applyFilter: Bool,
195 lowPassFilterFactor: Double,
196 quantizeFactor: Double) {
197 guard let index = sensors.firstIndex(of: sensor) else { return }
198 DispatchQueue.main.async {
199 self.sensors[index].applyFilter = applyFilter
200 self.sensors[index].lowPassFilterFactor = lowPassFilterFactor
201 self.sensors[index].quantizeFactor = quantizeFactor
202 }
203 }
204
205 // MARK: Internal
206
207 struct ThreeAxisReadings: Equatable {
208 var sensorName: String // Usually, these have the same naming:
209 var axes: [Axis] = [Axis(axisName: "x"), Axis(axisName: "y"),
210 Axis(axisName: "z")]
211 var applyFilter: Bool = false
212 var lowPassFilterFactor = 0.75
213 var quantizeFactor = 1.0
214
215 func lowPassFilter(lastReading: Double?, newReading: Double) -> Double {
216 guard let lastReading else { return newReading }
217 return self
218 .lowPassFilterFactor * lastReading +
219 (1.0 - self.lowPassFilterFactor) * newReading
220 }
221 }
222
223 struct Axis: Hashable {
224 var axisName: String
225 var measurements: [Double] = []
226 var peaks = 0
227 var updatesSinceLastPeakCount = 0
228
229/// I love sets, like, a lot. Enough that when I first thought "but what's an *elegant* way to know when it's a
230/// good time to count the peaks again?" I thought of a one-liner set intersection, very semantic, very accurate to the
231/// underlying question of freshness of sensor data, and it made me happy, and I smiled.
232/// Anyway, a counter does the same thing with a 0s execution time, here's one of those:
233
234 mutating func shouldCountPeaks()
235 -> Bool { // Peaks are only counted once a second
236 updatesSinceLastPeakCount += 1
237 if updatesSinceLastPeakCount == MotionManager.updatesPerSecond {
238 updatesSinceLastPeakCount = 0
239 return true
240 }
241 return false
242 }
243 }
244
245 @Published var sensors: [ThreeAxisReadings] = []
246
247 // MARK: Private
248
249 private enum SensorNames: String, CaseIterable {
250 case attitude = "Attitude"
251 case rotationRate = "Rotation Rate"
252 case gravity = "Gravity"
253 case userAcceleration = "User Acceleration"
254 case acceleration = "Acceleration"
255 case gyroscope = "Gyroscope"
256 case magnetometer = "Magnetometer"
257 }
258
259 private static let updatesPerSecond: Int = 30
260
261 private let motionQueue = OperationQueue() // Don't read sensors on main
262
263 private let secondsToShow = 5 // Time window to observe
264 private let sensorUpdateInterval = 1.0 / Double(updatesPerSecond)
265 private let manager: CMMotionManager
266
267 private func startDeviceUpdates(manager _: CMMotionManager) {
268 self.manager
269 .startDeviceMotionUpdates(to: motionQueue) { motion, error in
270 self.collectReadings(motion, error)
271 }
272 self.manager
273 .startAccelerometerUpdates(to: motionQueue) { motion, error in
274 self.collectReadings(motion, error)
275 }
276 self.manager.startGyroUpdates(to: motionQueue) { motion, error in
277 self.collectReadings(motion, error)
278 }
279 self.manager
280 .startMagnetometerUpdates(to: motionQueue) { motion, error in
281 self.collectReadings(motion, error)
282 }
283 }
284
285 private func collectReadings(_ motion: CMLogItem?, _ error: Error?) {
286 DispatchQueue.main.async { // Add new readings on main
287 switch motion {
288 case let motion as CMDeviceMotion:
289 self.appendReadings(
290 [motion.attitude.pitch, motion.attitude.roll,
291 motion.attitude.yaw],
292 to: &self.sensors[SensorNames.attitude.index()])
293 self.appendReadings(
294 [motion.rotationRate.x, motion.rotationRate.y,
295 motion.rotationRate.z],
296 to: &self.sensors[SensorNames.rotationRate.index()])
297 self.appendReadings(
298 [motion.gravity.x, motion.gravity.y, motion.gravity.z],
299 to: &self.sensors[SensorNames.gravity.index()])
300 self.appendReadings(
301 [motion.userAcceleration.x, motion.userAcceleration.y,
302 motion.userAcceleration.z],
303 to: &self.sensors[SensorNames.userAcceleration.index()])
304 case let motion as CMAccelerometerData:
305 self.appendReadings(
306 [motion.acceleration.x, motion.acceleration.y,
307 motion.acceleration.z],
308 to: &self.sensors[SensorNames.acceleration.index()])
309 case let motion as CMGyroData:
310 self.appendReadings(
311 [motion.rotationRate.x, motion.rotationRate.y,
312 motion.rotationRate.z],
313 to: &self.sensors[SensorNames.gyroscope.index()])
314 case let motion as CMMagnetometerData:
315 self.appendReadings(
316 [motion.magneticField.x, motion.magneticField.y,
317 motion.magneticField.z],
318 to: &self.sensors[SensorNames.magnetometer.index()])
319 default:
320 print(error != nil ? "Error: \(String(describing: error))" :
321 "Unknown device")
322 }
323 }
324 }
325
326 private func appendReadings(
327 _ newReadings: [Double],
328 to threeAxisReadings: inout ThreeAxisReadings) {
329 for index in 0 ..< threeAxisReadings.axes
330 .count { // For each of the axes
331 var axis = threeAxisReadings.axes[index]
332 let newReading = newReadings[index]
333
334 axis.measurements
335 .append(threeAxisReadings
336 .applyFilter ? // Append new reading, as-is or filtered
337 threeAxisReadings.lowPassFilter(
338 lastReading: axis.measurements.last,
339 newReading: newReading) : newReading)
340
341 if threeAxisReadings.applyFilter,
342 axis
343 .shouldCountPeaks() {
344 // And occasionally count peaks if filtering
345 axis.peaks = countPeaks(
346 in: axis.measurements,
347 quantizeFactor: threeAxisReadings.quantizeFactor)
348 }
349
350 if axis.measurements
351 .count >=
352 Int(1.0 / self
353 .sensorUpdateInterval * Double(self.secondsToShow)) {
354 axis.measurements
355 .removeFirst() // trim old data to keep our moving window representing secondsToShow
356 }
357 threeAxisReadings.axes[index] = axis
358 }
359 }
360
361 private func countPeaks(
362 in readings: [Double],
363 quantizeFactor: Double) -> Int { // Count local maxima
364 let quantizedreadings = readings.map { Int($0 * quantizeFactor) }
365 // Quantize into small Ints (instead of extremely small Doubles) to remove detail from little component waves
366
367 var ascendingWave = true
368 var numberOfPeaks = 0
369 var lastReading = 0
370
371 for reading in quantizedreadings {
372 if ascendingWave == true,
373 lastReading >
374 reading { // If we were going up but it stopped being true,
375 numberOfPeaks += 1 // we just passed a peak,
376 ascendingWave = false // and we're going down.
377 } else if lastReading <
378 reading {
379 // If we just started to or continue to go up, note we're ascending.
380 ascendingWave = true
381 }
382 lastReading = reading
383 }
384 return numberOfPeaks
385 }
386}
387
388extension CaseIterable where Self: Equatable {
389 func index() -> Self.AllCases
390 .Index {
391 // Force-unwrap of index of enum case in CaseIterable always succeeds.
392 return Self.allCases.firstIndex(of: self)!
393 }
394}
395
396typealias Sensor = MotionManager.ThreeAxisReadings
下面是完成后的原型。运行良好,可以看到,对于踏板动作没有反应的传感器都被关掉了,只需要查看有关系的三个传感器即可。我关掉了前两个,因为我觉得单车的波形并不是很清晰。但最后一个我可以在Z轴上清晰地看到运动。所以,我打开了低通滤波器,然后将其量化成飞航达的数字。这样就能精确地计算出踩踏板的次数。
完整的代码,请参见GitHub:https://github.com/Halle/StationaryBikeStepCounter/blob/main/ContentView.swift。
— 推荐阅读 —
《新程序员001-004》已全面上市
扫描下方二维码或点击进入立即订阅