计步器大全
用软件改装,让原来破旧的自行车在功能上焕然一新。
原文链接: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 代码给破旧的自行车加了一个动感单车计步器!”
叔耿战5252计步器有哪些牌子?
焦命印18191316226 ______ 计步器的牌子蛮多的啊.. 我用的是百利达的,测量步数很准确..在天猫百利达旗舰店有卖的..
叔耿战5252有没有什么好用又便宜的计步器推荐啊?
焦命印18191316226 ______ 你上天猫百利达旗舰店看看,有没有你想要的..百利达的计步器很好用的,灵敏度高,测量准确..
叔耿战5252有哪些好用的计步器APP -
焦命印18191316226 ______ 乐动力
叔耿战5252步行计步器哪个牌子好
焦命印18191316226 ______ 找个吹风机,吹干,或者打开自动晾干,首先是在取出电子的情况下!计步器的基本功能:时间显示、跑表功能、步数显示、距离显示及个性化步幅设计.另外还有自动收台FM收音机、目标距离、目标时间、响闹提醒功能、自动设置步幅、...
叔耿战5252有什么测量值准确的计步器推荐吗? -
焦命印18191316226 ______ TANITA百利达~~它家的计步器不仅可以准确测量步数,还可以显示消耗的脂肪量,不过根据型号的不同,功能也是不一样的.
叔耿战5252求推荐测量值准确的计步器??
焦命印18191316226 ______ 恩恩,我用的百利达3D式计步器,无论放在哪里(随身携带的包包、挂在脖子上、口袋里)都能精准记数,坐地铁、公交什么的竟然不会记数...在天猫百利达旗舰店有卖的,你可以去看看..
叔耿战5252请行家推荐一款计量准确的计步器
焦命印18191316226 ______ 欧姆龙OMRON电子计步器HJ-005.各地价格不太一样,大概在60-100左右. 各种计步器都有优缺点,3000多的和30块的除了做工和品牌上的差别,实际上在功能方面,他们差别并不是很大,再怎么记步,你都不可能精确到1步2步,所以,100左右的就足以,误差不会太大的.前提是,选品牌,不要杂牌.
叔耿战5252我想找一款计步器,不知道市面上那款比较好?
焦命印18191316226 ______ 迈欧健行者是现在最先进的计步器,采用三维加速度感应原理,可以记录步数 、距离、速度、卡路里消耗,可以识别静坐、走、跑、上下楼梯等日常活动,可以检测到运动强度,可以自动识别并过滤一般身体晃动,如公交车、私家车等单纯晃动.更重要的是它只有5毫米厚,如信用卡一般大小,可以放在钱包里,随身携带很舒适,而且造型非常COOL,是理想的选择!
叔耿战5252有没有灵敏度高的计步器推荐啊?
焦命印18191316226 ______ 有3D搭载的计步器灵敏度都很高的.我用的是TANITA百利达的3D式计步器,在天猫百利达旗舰店买的..
叔耿战5252想买计步器,有什么推荐吗?
焦命印18191316226 ______ 恩恩.我推荐你百利达的..你可以去搜计步器,了解看看..我现在用着它家的计步器,无论放在哪里(随身携带的包包、挂在脖子上、口袋里)都能精准记数,坐地铁、公交什么的竟然不会记数..