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

c6a8f871e303b78e4de13804464079e2.gif

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

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

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

作者 | Halle Winkler

译者 | 弯月       责编 | 屠敏

出品 | CSDN(ID:CSDNnews)

以下为译文:

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

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

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

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

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

dd22b416fc0ba55356a319534c8bc641.png

可行吗?

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

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

9b07b75f8c24ea2e0da1aa95725545c7.png

Swift Charts

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

67c2ed227f672cba117891975ca2aa02.png

目标

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

1.能查看所有传感器。

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

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

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

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

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

7d6e49e90e52876586331bbc865c8419.png

代码

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                    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。

—活动推荐 —

【浓情七夕,与你相伴】《新程序员》祝所有程序员都能“New 到女/男朋友” ,告别“找不到对象”!

凡在七夕当日订阅《新程序员》的朋友,都将获得CSDN定制的杯子一个!

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

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

你可能感兴趣的:(资讯,传感器,go,swift,ios,gwt)