“我用 400 行 Swift 代码给破旧的自行车加了一个动感单车计步器!”

2022 年 8 月 3 日 CSDN

用软件改装,让原来破旧的自行车在功能上焕然一新。

原文链接:https://theoffcuts.org/posts/prototyping-a-stationary-bike-stepper/

声明:本文为 CSDN 翻译,未经允许不可转载。


作者 | Halle Winkler
译者 | 弯月       责编 | 屠敏
出品 | CSDN(ID:CSDNnews)

以下为译文:

最近我跟朋友聊起SwiftUI。SwiftUI刚发布第一年的时候并不怎么好用,但幸运的是当时我并没有使用。后来,我掌握了这门语言之后,它就成了我所有快乐的源泉。朋友问,“为什么?”我略加思索,然后说:“我喜欢做原型,而SwiftUI扫清了许多我早已习惯的障碍。”

回想起远古时代,我做技术原型时喜欢用Objective-C编写UI。它的优点是你可以在一张图中看到所有逻辑。相应地,副作用就是很难让人集中注意力。

SwiftUI可以带来相同的感觉,不过更为简洁,而且也没有副作用。

以我最近的一个项目为例:我有一台廉价的动感单车,用来锻炼身体很合适,但它的界面非常不友好。我一直想要一个显示屏!用单片机和信号线自己做一个显示屏?然后用计算机视觉来处理数据?

或者,也许可以完全不管显示屏的问题,而是根据手机的传感器来估算动感单车的速度,然后计算其他数据?


可行吗?


我之前在硬件项目里接触过九轴的传感器,了解应该通过怎样的运动来进行测量。尽管理论上我知道应该在动感单车上采用哪种传感器(加速度计和陀螺仪),但我不确定踩踏板的动作能否可靠地被某个传感器识别。而且,即使能识别,这种数据也有很大噪声。我需要一个原型。

iPhone的九轴传感器会输出一个双精度型数组,但与其他电动设备一样,这些采样数据只是真实运动的片面表示而已。所以,在提取采样数据之后,还需要进行平滑处理。如果一切可行,就应该能用可视化的方式来表示数据,比如画出传感器数据的图表。


Swift Charts


在动笔之前,我尝试了SwiftUI的所有图表库,但没有一个能满足我的要求。我想了几天,决定先选一个,以后再慢慢改进,但我突然发现,苹果恰好在WWDC上发布了一个非常好用的图表框架!这个框架正好能满足我原型的需要。但这也意味着,下面的代码只能在Xcode 14 Beta上运行,也只能在iOS 16 beta的设备上运行。


目标


用最少的代码,为每个传感器实现一个图表。不需要考虑状态和错误,只需要展示数据,可以认为设备全部正常工作。也不需要考虑用户交互。功能要求如下:

1.能查看所有传感器。

2.当没有传感器数据时关闭视图。

3.分开显示传感器的三个轴的数据。

4.平滑数据,并计算波峰的数量(等价于踩踏板的次数)。

原型的目的是验证这个思路是否可行,所以一切从简,只需找出问题的答案即可。而实际的产品则会考虑另一个问题:“是否需要通用化?”而至少目前该问题的答案是否定的。

在应用程序中,我不会把模型和界面放在同一个文件中。但是,另一个我喜欢SwiftUI的点是,你只需要写一个文件放到应用中,然后在App的构造中初始化,即可看到UI。太棒了。由于我的目的只是尝试,所以只需要使用一个文件。不过我在一些有意思的地方加了注释。


代码


ContentView.swift

  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                    in0.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                    in1 ... 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。

   
   
     
— 推荐阅读 —
    
    
      
☞那天,比尔・盖茨差点“砍掉”了这个 160 亿美元的项目:不运行 Windows,是种侮辱
“敏捷欺骗了开发人员”
B站回应HR称核心用户是Loser;微博回应宕机原因;Go 1.19 正式发布|极客头条

新程序员001-004》已全面上市 

扫描下方二维码或点击进入立即订阅

登录查看更多
0

相关内容

【经典书】现代C语言教程,408页pdf手写教你写C代码
专知会员服务
62+阅读 · 2022年8月5日
《Julia数据科学》及代码,166页pdf
专知会员服务
47+阅读 · 2021年11月4日
专知会员服务
21+阅读 · 2021年5月27日
【2020新书】Ruby 3 编程: 从小白到专家,598页pdf
专知会员服务
30+阅读 · 2020年12月17日
【2020新书】懒人程序员专用书C++20,681页pdf
专知会员服务
43+阅读 · 2020年12月15日
【干货书】Python 编程,480页pdf
专知会员服务
238+阅读 · 2020年8月14日
在 macOS 上一键拼图,你只需要一个快捷指令
少数派
0+阅读 · 2022年7月26日
开发容器:可重用的开发环境
InfoQ
0+阅读 · 2022年7月8日
Spot四足机器人:从今起,我正式在工厂上班了
机器之心
0+阅读 · 2022年5月4日
我们最近又买了 6 个好用的东西
少数派
0+阅读 · 2022年3月27日
JavaScript 中的 7 个“杀手级”单行代码
InfoQ
0+阅读 · 2022年3月17日
国家自然科学基金
0+阅读 · 2014年12月31日
国家自然科学基金
0+阅读 · 2013年12月31日
国家自然科学基金
0+阅读 · 2013年12月31日
国家自然科学基金
0+阅读 · 2013年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
1+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
1+阅读 · 2011年12月31日
国家自然科学基金
0+阅读 · 2010年12月31日
已删除
Arxiv
32+阅读 · 2020年3月23日
VIP会员
相关VIP内容
【经典书】现代C语言教程,408页pdf手写教你写C代码
专知会员服务
62+阅读 · 2022年8月5日
《Julia数据科学》及代码,166页pdf
专知会员服务
47+阅读 · 2021年11月4日
专知会员服务
21+阅读 · 2021年5月27日
【2020新书】Ruby 3 编程: 从小白到专家,598页pdf
专知会员服务
30+阅读 · 2020年12月17日
【2020新书】懒人程序员专用书C++20,681页pdf
专知会员服务
43+阅读 · 2020年12月15日
【干货书】Python 编程,480页pdf
专知会员服务
238+阅读 · 2020年8月14日
相关基金
国家自然科学基金
0+阅读 · 2014年12月31日
国家自然科学基金
0+阅读 · 2013年12月31日
国家自然科学基金
0+阅读 · 2013年12月31日
国家自然科学基金
0+阅读 · 2013年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
1+阅读 · 2012年12月31日
国家自然科学基金
0+阅读 · 2012年12月31日
国家自然科学基金
1+阅读 · 2011年12月31日
国家自然科学基金
0+阅读 · 2010年12月31日
Top
微信扫码咨询专知VIP会员