计步器
用软件改装,让原来破旧的自行车在功能上焕然一新。
原文链接: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 in100 // Iterate series in a chart101 ForEach(102 Array(series.measurements.enumerated()),103 id: \\.offset) { index, datum in104 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 }117118/// in the separate three-axis view, you can set the low-pass filter factor and the quantizing factor if the waveform119/// filtering is on, and then once you can see your stationary pedaling reflected in the waveform, you can see how120/// many times per time window you're pedaling. With such an inevitably-noisy sensor environment, I already know121/// 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 on123/// gyroscope axis z is 520, which tells you these readings are really small numbers.124125 if applyingFilter {126 Slider(127 value: $lowPassFilterFactor,128 in: 0.75 ... 1.0,129 onEditingChanged: { _ in130 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: { _ in142 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}155156// MARK: - MotionManager157158/// MotionManager is the sensor management module.159160class MotionManager: ObservableObject {161 // MARK: Lifecycle162163 init() {164 self.manager = CMMotionManager()165 for name in SensorNames166 .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 = sensorUpdateInterval184 self.manager.accelerometerUpdateInterval = sensorUpdateInterval185 self.manager.gyroUpdateInterval = sensorUpdateInterval186 self.manager.magnetometerUpdateInterval = sensorUpdateInterval187 self.startDeviceUpdates(manager: manager)188 }189190 // MARK: Public191192 public func updateFilteringFor( // Manage the callbacks from the UI193 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 = applyFilter200 self.sensors[index].lowPassFilterFactor = lowPassFilterFactor201 self.sensors[index].quantizeFactor = quantizeFactor202 }203 }204205 // MARK: Internal206207 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 = false212 var lowPassFilterFactor = 0.75213 var quantizeFactor = 1.0214215 func lowPassFilter(lastReading: Double?, newReading: Double) -> Double {216 guard let lastReading else { return newReading }217 return self218 .lowPassFilterFactor * lastReading +219 (1.0 - self.lowPassFilterFactor) * newReading220 }221 }222223 struct Axis: Hashable {224 var axisName: String225 var measurements: [Double] = []226 var peaks = 0227 var updatesSinceLastPeakCount = 0228229/// I love sets, like, a lot. Enough that when I first thought "but what's an *elegant* way to know when it's a230/// good time to count the peaks again?" I thought of a one-liner set intersection, very semantic, very accurate to the231/// 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:233234 mutating func shouldCountPeaks()235 -> Bool { // Peaks are only counted once a second236 updatesSinceLastPeakCount += 1237 if updatesSinceLastPeakCount == MotionManager.updatesPerSecond {238 updatesSinceLastPeakCount = 0239 return true240 }241 return false242 }243 }244245 @Published var sensors: [ThreeAxisReadings] = []246247 // MARK: Private248249 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 }258259 private static let updatesPerSecond: Int = 30260261 private let motionQueue = OperationQueue() // Don't read sensors on main262263 private let secondsToShow = 5 // Time window to observe264 private let sensorUpdateInterval = 1.0 / Double(updatesPerSecond)265 private let manager: CMMotionManager266267 private func startDeviceUpdates(manager _: CMMotionManager) {268 self.manager269 .startDeviceMotionUpdates(to: motionQueue) { motion, error in270 self.collectReadings(motion, error)271 }272 self.manager273 .startAccelerometerUpdates(to: motionQueue) { motion, error in274 self.collectReadings(motion, error)275 }276 self.manager.startGyroUpdates(to: motionQueue) { motion, error in277 self.collectReadings(motion, error)278 }279 self.manager280 .startMagnetometerUpdates(to: motionQueue) { motion, error in281 self.collectReadings(motion, error)282 }283 }284285 private func collectReadings(_ motion: CMLogItem?, _ error: Error?) {286 DispatchQueue.main.async { // Add new readings on main287 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 }325326 private func appendReadings(327 _ newReadings: [Double],328 to threeAxisReadings: inout ThreeAxisReadings) {329 for index in 0 ..< threeAxisReadings.axes330 .count { // For each of the axes331 var axis = threeAxisReadings.axes[index]332 let newReading = newReadings[index]333334 axis.measurements335 .append(threeAxisReadings336 .applyFilter ? // Append new reading, as-is or filtered337 threeAxisReadings.lowPassFilter(338 lastReading: axis.measurements.last,339 newReading: newReading) : newReading)340341 if threeAxisReadings.applyFilter,342 axis343 .shouldCountPeaks() {344 // And occasionally count peaks if filtering345 axis.peaks = countPeaks(346 in: axis.measurements,347 quantizeFactor: threeAxisReadings.quantizeFactor)348 }349350 if axis.measurements351 .count >=352 Int(1.0 / self353 .sensorUpdateInterval * Double(self.secondsToShow)) {354 axis.measurements355 .removeFirst() // trim old data to keep our moving window representing secondsToShow356 }357 threeAxisReadings.axes[index] = axis358 }359 }360361 private func countPeaks(362 in readings: [Double],363 quantizeFactor: Double) -> Int { // Count local maxima364 let quantizedreadings = readings.map { Int($0 * quantizeFactor) }365 // Quantize into small Ints (instead of extremely small Doubles) to remove detail from little component waves366367 var ascendingWave = true368 var numberOfPeaks = 0369 var lastReading = 0370371 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 = true381 }382 lastReading = reading383 }384 return numberOfPeaks385 }386}387388extension CaseIterable where Self: Equatable {389 func index() -> Self.AllCases390 .Index {391 // Force-unwrap of index of enum case in CaseIterable always succeeds.392 return Self.allCases.firstIndex(of: self)!393 }394}395396typealias Sensor = MotionManager.ThreeAxisReadings下面是完成后的原型。运行良好,可以看到,对于踏板动作没有反应的传感器都被关掉了,只需要查看有关系的三个传感器即可。我关掉了前两个,因为我觉得单车的波形并不是很清晰。但最后一个我可以在Z轴上清晰地看到运动。所以,我打开了低通滤波器,然后将其量化成飞航达的数字。这样就能精确地计算出踩踏板的次数。
完整的代码,请参见GitHub:https://github.com/Halle/StationaryBikeStepCounter/blob/main/ContentView.swift。
","force_purephv":"0","gnid":"9336715eb16817adf","img_data":[{"flag":2,"img":[{"desc":"","height":"80","s_url":"https://p0.ssl.img.360kuai.com/t0186957a1ca5352752_1.gif","title":"","url":"https://p0.ssl.img.360kuai.com/t0186957a1ca5352752.gif","width":"640"}]}],"original":0,"pat":"art_src_1,fts0,sts0","powerby":"hbase","pub_time":1659507726000,"pure":"","rawurl":"http://zm.news.so.com/30e35a02b9800da9e8416d385e7a45e4","redirect":0,"rptid":"e454df35b36c4d42","s":"t","src":"CSDN","tag":[{"clk":"ktechnology_1:动感单车","k":"动感单车","u":""},{"clk":"ktechnology_1:ts","k":"ts","u":""}],"title":"“我用 400 行 Swift 代码给破旧的自行车加了一个动感单车计步器!”
彭帖诞4338计步器的作用是什么? -
任态宇15088799034 ______ 国外许多健康研究机构认为,每天步行10000步,就能消耗一定的体内脂肪,改善身体的肥胖状况.这对于在忙碌生活中的都市男女来说,只需要每天上下班少坐几站公交,就可以轻松达到这个运动指标.计步器主要是测量步数的,带上计步器,提醒你该走路了,并且让你有一个目标,每天走多少..我现在就用着百利达的计步器,推荐你可以去了解看看..
彭帖诞4338电子计步器怎么用 -
任态宇15088799034 ______ 1,首先,使用计步器应该先详细阅读使用说明书. 2,然后装入电池或将电池隔离条抽出;再按照产品说明书中的步骤设定时间、体重、身高等信息,确认计步器所需数据设定完成后. 3,在使用时将它放在衣袋、裤兜里、皮带夹上,或夹在衣服上面均可,在运动的过程中不易脱落. 4,开始步行运动后,它将自动检测人的活动.运动完成后读取计步器数据. 5,使用计步器时应注意避免挤压与磕碰;要及时更换电池,以免泄露的电池腐蚀计步器零件;每隔一段时间应根据自身的体重等变化对计步器中的数据进行更新,从而使计数数据精确. 6,计步器是根据走路时腰部上下运动而自动测量的,精确度很高.如果步法不当,会产生很大误差.
彭帖诞4338计步器是什么原理 -
任态宇15088799034 ______ 计步器的原理是利用机械运动带动感应器,并由电子电路完成计数后的后续工作,如︰计数、储存、计算、显示等.感应器的原理一般都是用一个可随意移动的小型重锤,利用装置移动时物理的惯性来产生装置与重锤间的相互运动,再用感测元件来检查其动作,感测器可以用机械开关(让两片金属片开或关、也可以用光电开关、或者在重锤上附加一个磁铁以霍尔元件来检出.
彭帖诞4338计步器的原理是什么? -
任态宇15088799034 ______ 一听到计步器这个名字,相信大家都应该知道它是干什么的了.随着社会的发展,人们越来越注重自己的身心健康,跑步已经成为了一种方便而又有效的锻炼方式.这同时又带动了计步器的产生和普遍化.对于它的科学原理我们也不妨来了解一...
彭帖诞4338计步器的有什么实际作用? -
任态宇15088799034 ______ 简单点说,计步器主要就是帮助你测量自己走了多少步路.每天快走30分钟(动作幅度大),可以消耗热量300卡路里,它对减肥也是很有帮助的哦!我自己就买了一台计步器,是百利达的,它还附带有挂绳,可以把它挂在脖子上,我觉得很方便...
彭帖诞4338计步器,有什么实际用途吗? -
任态宇15088799034 ______ 最近的微博上不是有许多人都在晒计步器,分享自己一天走了多少.据说步行能使筋、韧带变得更有弹性,制止骨骼老化 ;步行能改善代谢机能消耗血液中的糖份、节约胰岛素;步行让更多的脂肪燃烧提高心肺机能、促进新陈代谢.每天快走30分钟(动作幅度大),可以消耗热量300卡路里.自己也去买一个开始走起来了(主要是有长胖的趋势...)我买的计步器是百利达的一款计步器,它有步行节奏提示音,你走路的速度可以跟着这个音乐走...真的建议备一个计步器多走走...
彭帖诞4338计步器的具体作用到底是什么呀? -
任态宇15088799034 ______ 计步器主要就是帮助你测量自己走了多少步路...步行能使筋、韧带变得更有弹性,制止骨骼老化等等的作用...我自己就买了一台计步器,是百利达的FB-731,它附带有挂绳,可以把它挂在脖子上,我觉得很方便...现在每天都会带上它,下班的路上能步行的就步行...为了健康,建议你也可以备一台..我的是在天猫百利达店买的..
彭帖诞4338计步器的具体作用是什么? -
任态宇15088799034 ______ 计步器主要就是测量步数的,让你知道每天步行了多少步.自己定一个目标,每天坚持走,对身体是非常有好处的,现在提倡快步走进行减肥..现在我每天晚上都会带着它去快走一圈的.我用的是百利达的,在天猫旗舰店买的..
彭帖诞4338计步器具体是个什么东东? -
任态宇15088799034 ______ 计步器 的用途很多,可以测量步子的数目,可以测脂肪含量等等,具体的建议你去5413shop看看罗,还可以咨询他的客服,之前我在这离买过感觉挺不错的...附:计步器小常识 坚持步行,能帮助高血压患者改善症状.步行时,通过伸缩肌肉,血液在流动时的抵抗值下降,血压下降且稳定.(经常步行的人,很少患高血压或低血压病) 由于运动,动脉和毛细血管变粗,血流量增大.如果持续运动,血管自身的弹性、柔软性恢复,也就能预防和改善“硬化”. 由于血液中的脂肪被作为能量来燃烧,能减少血管内附着的脂肪性物质.运动使体重减轻,也逐渐减少心脏的负担.
彭帖诞4338计步器的作用?各位新年好,我看到本地商店有电子计步器卖,想请过来
任态宇15088799034 ______ 你好 计步器的作用很简单,就是告诉你在器材上走了多少步.标准的步伐一步为75厘米,如果你走了7500步,就说明你走了7.5公里. 计步器对于锻炼的作用,就是有一个量化的指标. 帮助你掌握锻炼的时间和运动量.让你有规律的运动,有一个你想要的可以确定的量. 祝你健康.