From 7094fbf42684df16ad0d4c2a6a41de88d2bc9c8c Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 08:51:03 -0500 Subject: [PATCH 1/4] ios: steady blur is coached, judged against the sharpness before it The gate compares each frame with the median of its last 15, so steady blur stops reading as blurry within a second, and with blur shown only after 2 s its coaching never came up. The debouncer now also counts a frame as blurry when it is under the gate's ratio of the median sharpness from the 1.5 s before the spell began, held fixed while the spell lasts. A short drop, such as a tilt, still says nothing, and steady blur shows from 2 s in until the photos are sharp again. A package test runs the real gate into the debouncer. The dark walk card is also checked at AX5. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/ScanEngine.swift | 6 +- .../Guidance/CoachingDebouncer.swift | 56 ++++++++++++++++--- .../CoachingDebouncerTests.swift | 42 +++++++++++++- .../ScreenStatesUITests.swift | 2 + 4 files changed, 93 insertions(+), 13 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 0baff4f43..1f938da20 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -993,7 +993,7 @@ final class ScanEngine { keptSourceIDs.insert(frame.id) keep(frame) } - state.coaching = walkCoaching(tracking: frame.tracking, skip: skip, meanLuma: frame.quality?.meanLuma, pastEnd: pastEnd != nil, time: frame.timestamp) + state.coaching = walkCoaching(tracking: frame.tracking, skip: skip, meanLuma: frame.quality?.meanLuma, sharpness: frame.quality?.sharpness, pastEnd: pastEnd != nil, time: frame.timestamp) afterCoverageChange(camera: frame.camera, time: frame.timestamp) askOverheadIfTiltedUp(frame) } @@ -1006,11 +1006,11 @@ final class ScanEngine { /// (`pastEnd`) shows once it has lasted `showAfter` seconds, clears after `clearAfter` /// seconds without it, and comes before the gate's problems: no photo is kept there whatever /// the gate says. Both durations are guesses to try on a phone, not measured. - private func walkCoaching(tracking: TrackingQuality, skip: CaptureDecision.SkipReason?, meanLuma: Double?, pastEnd: Bool, time: Double) -> Coaching? { + private func walkCoaching(tracking: TrackingQuality, skip: CaptureDecision.SkipReason?, meanLuma: Double?, sharpness: Double?, pastEnd: Bool, time: Double) -> Coaching? { let showAfter = 0.7 let clearAfter = 0.5 let aiming = isAiming - let gate = gateCoaching.update(time: time, skip: skip, meanLuma: meanLuma, aiming: aiming) + let gate = gateCoaching.update(time: time, skip: skip, meanLuma: meanLuma, sharpness: sharpness, aiming: aiming) guard tracking == .normal else { gateProblem = nil gateClearSince = nil diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift index eac8462ac..be84e1478 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift @@ -17,6 +17,10 @@ import Foundation /// - Moving fast, turning fast and blur each show after their own spell of frames that have /// them, and clear a moment after the last such frame. A spell that ends before it shows says /// nothing. +/// - Blur is also judged from the frames' own sharpness against the sharpness from just before +/// the blur began. The gate compares each frame with the median of its last 15, so it stops +/// calling steady blur blurry within a second; held against the earlier frames, steady blur +/// stays a spell until the photos are sharp again. /// - Walking too fast is not raised, or counted toward a spell, while the homeowner is asked to /// stand and aim (`aiming`): the motion there is turning the phone, and "walk slower" is the /// wrong advice. @@ -62,13 +66,20 @@ public struct CoachingDebouncer: Sendable { /// Blur shows only after 2 s of it. The gate's blur test compares a frame with the recent /// median, and a tilt changes the view enough to trip it with no real blur. /// - /// In practice this turns blur coaching off. The median is over the last 15 measured + /// The gate alone can't make a spell that long: its median is over the last 15 measured /// frames (`AutoCaptureConfig.sharpnessWindow`), so after about 8 blurry frames in a row - /// the median is itself blurry and the next frame passes, ending the spell: at about ten - /// measured frames a second an unbroken spell lasts under a second. Steady blur (shaky - /// hands at night, a long exposure) never says "Hold steady". Whether blur should be judged - /// by its share of a window instead, as darkness is, is for the device run. + /// the median is itself blurry and the next frame passes. So a frame also counts as + /// blurry when it is under `blurRatio` of `blurReference`, the sharpness from before the + /// spell began, and steady blur (shaky hands at night, a long exposure) keeps its spell. public var blurShowAfter: Double = 2 + /// A frame under this share of the sharpness from before the blur counts as blurry: the + /// gate's own `AutoCaptureConfig.sharpnessRatio`. + public var blurRatio: Double = AutoCaptureConfig().sharpnessRatio + /// The sharpness from before the blur is the median of the measured frames of the 1.5 s + /// before its spell began, about the gate's 15 frames, and needs `minDarkSamples` of them. + /// It stays fixed while the spell lasts, so it doesn't catch up with the blur as the + /// gate's median does. + public var blurReferenceWindow: Double = 1.5 /// A motion or blur problem that shows clears 0.5 s after the last frame that had it. public var clearAfter: Double = 0.5 @@ -93,6 +104,11 @@ public struct CoachingDebouncer: Sendable { private var spells: [GateProblem: Spell] = [:] /// Measured frames of the last `persistentDarkAfter` seconds: time and whether it was dark. private var luma: [(time: Double, dark: Bool)] = [] + /// Measured frames of the last `blurReferenceWindow` seconds: time and sharpness. + private var sharpness: [(time: Double, value: Double)] = [] + /// The median sharpness from before the current blur spell (`Config.blurReferenceWindow`), + /// nil while there is no spell. + private var blurReference: Double? /// When the dark coaching went up, nil while it is down. private var darkSince: Double? /// Since when the light has been back, while the dark coaching is up. @@ -112,13 +128,15 @@ public struct CoachingDebouncer: Sendable { /// restarted) starts over. /// - `skip`: the gate's reason for skipping the frame, nil when the gate passed it. /// - `meanLuma`: the frame's own mean luma, nil when it wasn't measured. + /// - `sharpness`: the frame's own sharpness (`FrameQuality.sharpness`), nil when it wasn't + /// measured. /// - `aiming`: the homeowner is asked to stand and aim (an aim, tilt, step-back, see-behind /// or marking step), so walking too fast is not raised. - public mutating func update(time: Double, skip: CaptureDecision.SkipReason?, meanLuma: Double?, aiming: Bool) -> GateProblem? { + public mutating func update(time: Double, skip: CaptureDecision.SkipReason?, meanLuma: Double?, sharpness: Double? = nil, aiming: Bool) -> GateProblem? { if let lastTime, time < lastTime { self = CoachingDebouncer(config: config) } lastTime = time updateDarkness(time: time, meanLuma: meanLuma) - updateMotion(time: time, skip: skip) + updateMotion(time: time, skip: skip, sharpness: sharpness) // Walking speed isn't coached while aiming, so a spell there mustn't build up either: it // would say "Slow down" on the first frames of the next walking step, however calm. if aiming { spells[.movingFast] = nil } @@ -133,6 +151,8 @@ public struct CoachingDebouncer: Sendable { /// the light hasn't changed because the phone lost its place. public mutating func forgetMotion() { spells = [:] + sharpness = [] + blurReference = nil } // MARK: Darkness @@ -172,8 +192,14 @@ public struct CoachingDebouncer: Sendable { // MARK: Motion and blur - private mutating func updateMotion(time: Double, skip: CaptureDecision.SkipReason?) { - for (problem, seen) in Self.evidence(skip) { + private mutating func updateMotion(time: Double, skip: CaptureDecision.SkipReason?, sharpness value: Double?) { + var evidence = Self.evidence(skip) + // The sharpness from before any blur: fixed while a spell lasts, else the recent median. + let reference = blurReference ?? Self.median(sharpness.map(\.value), atLeast: config.minDarkSamples) + if skip != .trackingNotReady, let value, let reference, value < reference * config.blurRatio { + evidence[.blurry] = true + } + for (problem, seen) in evidence { if seen { spells[problem] = Spell(since: spells[problem]?.since ?? time, lastSeen: time) } else if let spell = spells[problem], !reachedShow(problem, spell) { @@ -182,6 +208,18 @@ public struct CoachingDebouncer: Sendable { } } spells = spells.filter { time - $0.value.lastSeen < config.clearAfter } + blurReference = spells[.blurry] == nil ? nil : reference + if let value { + sharpness.append((time: time, value: value)) + if let firstKept = sharpness.firstIndex(where: { $0.time > time - config.blurReferenceWindow }), firstKept > 0 { + sharpness.removeFirst(firstKept) + } + } + } + + private static func median(_ values: [Double], atLeast count: Int) -> Double? { + guard values.count >= count else { return nil } + return values.sorted()[values.count / 2] } private func isShown(_ problem: GateProblem, at time: Double) -> Bool { diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift index d383f01dd..209e08c87 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift @@ -12,11 +12,12 @@ import Testing var skip: CaptureDecision.SkipReason? var luma: Double? = 128 var aiming = false + var sharpness: Double? = nil } /// What the debouncer says for each frame. static func run(_ frames: [Frame], on debouncer: inout CoachingDebouncer) -> [Problem?] { - frames.map { debouncer.update(time: $0.time, skip: $0.skip, meanLuma: $0.luma, aiming: $0.aiming) } + frames.map { debouncer.update(time: $0.time, skip: $0.skip, meanLuma: $0.luma, sharpness: $0.sharpness, aiming: $0.aiming) } } static func run(_ frames: [Frame]) -> [Problem?] { @@ -147,6 +148,45 @@ import Testing #expect(Self.run(long).last! == .blurry) } + /// The real gate feeding the debouncer, frame by frame: 3 s of sharp frames, 4 s at under a + /// third of that sharpness (shaky hands, a long exposure), then sharp again. The gate calls + /// only the first second of the blur blurry, because its 15-frame median catches up; the + /// coaching still says blur from 2 s in until the photos are sharp again. + @Test func sustainedBlurThroughTheGateIsCoached() { + var gate = AutoCapture() + var debouncer = CoachingDebouncer() + var skips: [CaptureDecision.SkipReason?] = [] + var said: [Problem?] = [] + for t in Self.times(for: 8) { + let sharpness: Double = (3..<7).contains(t) ? 30 : 100 + let frame = AutoCaptureTests.frame(t, quality: AutoCaptureTests.good(sharpness: sharpness)) + let decision = gate.evaluate(frame, newlySeenCells: 0) + var skip: CaptureDecision.SkipReason? + switch decision { + case .keep: gate.didKeep(frame) + case .skip(let reason): skip = reason + } + skips.append(skip) + said.append(debouncer.update(time: t, skip: skip, meanLuma: 128, sharpness: sharpness, aiming: true)) + } + // The gate adapts: by 4.5 s it no longer calls the blurry frames blurry. + #expect(skips[30] == .blurry) + #expect(skips[45...69].allSatisfy { $0 != .blurry }) + // The coaching says nothing for the first 2 s of blur, then blur until it's sharp again. + #expect(said[..<50].allSatisfy { $0 == nil }) + #expect(said[50..<70].allSatisfy { $0 == .blurry }) + #expect(said.last! == nil) + } + + /// A tilt that makes the view much less sharp for under 2 s says nothing, also when judged by + /// sharpness against the frames before it. + @Test func shortSharpnessDropSaysNothing() { + let before = Self.times(for: 2).map { Frame(time: $0, skip: .redundant, sharpness: 100) } + let tilt = Self.times(from: 2, for: 1.8).map { Frame(time: $0, skip: .redundant, sharpness: 30) } + let after = Self.times(from: 3.8, for: 2).map { Frame(time: $0, skip: .redundant, sharpness: 100) } + #expect(Self.run(before + tilt + after).allSatisfy { $0 == nil }) + } + /// A shown problem clears half a second after its last frame. @Test func motionClearsAfterItsLastFrame() { let fast = Self.times(for: 1).map { Frame(time: $0, skip: .movingFast) } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 827092d3f..c42184427 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -93,6 +93,8 @@ final class ScreenStatesUITests: XCTestCase { // The card's reply under the aim step's words and under coaching. "wallWalk-aim", "wallWalk-slowDown", "spotConfirm", "spotConfirm-answered", + // The longest walk card: the task, its second line and the dark coaching's advice (#80). + "wallWalk-tooDark", ] /// Words a state must show: in the named element's label or value, or with no identifier, From df44d5e0892cddf5c6443f2b188e3030185cf37d Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 22 Oct 2026 16:53:35 -0500 Subject: [PATCH 2/4] fix(ios): judge blur only against sharpness from the last reference window Codex on #317: frames with no sharpness measurement append nothing and never pruned the buffer, so after a gap the median came from sharpness older than blurReferenceWindow. A scene merely softer than the one before the gap then read as blur and was coached. Prune by time before taking the median. Co-Authored-By: Claude Opus 5.5 --- .../HouseScanKit/Guidance/CoachingDebouncer.swift | 11 ++++++++--- .../HouseScanKitTests/CoachingDebouncerTests.swift | 10 ++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift index be84e1478..6634811d3 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift @@ -194,6 +194,14 @@ public struct CoachingDebouncer: Sendable { private mutating func updateMotion(time: Double, skip: CaptureDecision.SkipReason?, sharpness value: Double?) { var evidence = Self.evidence(skip) + // Drop samples older than the window before taking the median. Frames without a + // measurement append nothing, so after a gap the buffer would otherwise hold sharpness + // from before the gap, and a scene that is merely softer would read as blur. + if let firstKept = sharpness.firstIndex(where: { $0.time > time - config.blurReferenceWindow }) { + sharpness.removeFirst(firstKept) + } else { + sharpness.removeAll() + } // The sharpness from before any blur: fixed while a spell lasts, else the recent median. let reference = blurReference ?? Self.median(sharpness.map(\.value), atLeast: config.minDarkSamples) if skip != .trackingNotReady, let value, let reference, value < reference * config.blurRatio { @@ -211,9 +219,6 @@ public struct CoachingDebouncer: Sendable { blurReference = spells[.blurry] == nil ? nil : reference if let value { sharpness.append((time: time, value: value)) - if let firstKept = sharpness.firstIndex(where: { $0.time > time - config.blurReferenceWindow }), firstKept > 0 { - sharpness.removeFirst(firstKept) - } } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift index 209e08c87..bb3c3e594 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift @@ -187,6 +187,16 @@ import Testing #expect(Self.run(before + tilt + after).allSatisfy { $0 == nil }) } + /// Codex on #317: after a gap with no measured sharpness, the next frame was judged against + /// sharpness from before the gap, older than `blurReferenceWindow`. A scene that is simply + /// softer than the one before the gap then read as blur and was coached. + @Test func staleSharpnessAfterAGapIsNotTheBlurReference() { + let before = Self.times(for: 1).map { Frame(time: $0, skip: .redundant, sharpness: 100) } + let gap = Self.times(from: 1, for: 4).map { Frame(time: $0, skip: .redundant, sharpness: nil) } + let softer = Self.times(from: 5, for: 3).map { Frame(time: $0, skip: .redundant, sharpness: 40) } + #expect(!Self.run(before + gap + softer).contains(.blurry)) + } + /// A shown problem clears half a second after its last frame. @Test func motionClearsAfterItsLastFrame() { let fast = Self.times(for: 1).map { Frame(time: $0, skip: .movingFast) } From 8f32ed513955227dfb41811446ef6cffafe76717 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 23 Oct 2026 06:13:10 -0500 Subject: [PATCH 3/4] fix: ignore sharpness from exposure-rejected frames Co-Authored-By: Claude Opus 5.5 --- .../HouseScanKit/Guidance/CoachingDebouncer.swift | 8 ++++++-- .../HouseScanKitTests/CoachingDebouncerTests.swift | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift index 6634811d3..8cddcbc95 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift @@ -204,7 +204,11 @@ public struct CoachingDebouncer: Sendable { } // The sharpness from before any blur: fixed while a spell lasts, else the recent median. let reference = blurReference ?? Self.median(sharpness.map(\.value), atLeast: config.minDarkSamples) - if skip != .trackingNotReady, let value, let reference, value < reference * config.blurRatio { + // The gate only reaches sharpness after tracking, motion, and exposure pass. Do not + // infer blur from a sharpness value on a frame it rejected earlier, and do not let that + // value change the reference for later valid frames. + let sharpnessWasReached = evidence[.blurry] != nil + if sharpnessWasReached, let value, let reference, value < reference * config.blurRatio { evidence[.blurry] = true } for (problem, seen) in evidence { @@ -217,7 +221,7 @@ public struct CoachingDebouncer: Sendable { } spells = spells.filter { time - $0.value.lastSeen < config.clearAfter } blurReference = spells[.blurry] == nil ? nil : reference - if let value { + if sharpnessWasReached, let value { sharpness.append((time: time, value: value)) } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift index bb3c3e594..ab499cab7 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift @@ -197,6 +197,16 @@ import Testing #expect(!Self.run(before + gap + softer).contains(.blurry)) } + /// A dark frame's sharpness is not judged until exposure passes again. + @Test func tooDarkFramesDoNotCreateBlur() { + let sharp = Self.times(for: 2).map { Frame(time: $0, skip: .redundant, sharpness: 100) } + let dark = Self.times(from: 2, for: 4).map { Frame(time: $0, skip: .tooDark, luma: 25, sharpness: 30) } + let light = Self.times(from: 6, for: 4).map { Frame(time: $0, skip: .redundant, luma: 90, sharpness: 100) } + let said = Self.run(sharp + dark + light) + #expect(!said.contains(.blurry)) + #expect(said.last! == nil) + } + /// A shown problem clears half a second after its last frame. @Test func motionClearsAfterItsLastFrame() { let fast = Self.times(for: 1).map { Frame(time: $0, skip: .movingFast) } From adcd09181d376c916343baf7c26d25eab3cce059 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 23 Oct 2026 13:01:52 -0500 Subject: [PATCH 4/4] fix(ios): exclude blurry samples from coaching references Co-Authored-By: Claude Opus 5.5 --- .../Guidance/CoachingDebouncer.swift | 6 ++++-- .../CoachingDebouncerTests.swift | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift index 8cddcbc95..e587a730c 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift @@ -104,7 +104,7 @@ public struct CoachingDebouncer: Sendable { private var spells: [GateProblem: Spell] = [:] /// Measured frames of the last `persistentDarkAfter` seconds: time and whether it was dark. private var luma: [(time: Double, dark: Bool)] = [] - /// Measured frames of the last `blurReferenceWindow` seconds: time and sharpness. + /// Non-blurry measured frames of the last `blurReferenceWindow` seconds: time and sharpness. private var sharpness: [(time: Double, value: Double)] = [] /// The median sharpness from before the current blur spell (`Config.blurReferenceWindow`), /// nil while there is no spell. @@ -221,7 +221,9 @@ public struct CoachingDebouncer: Sendable { } spells = spells.filter { time - $0.value.lastSeen < config.clearAfter } blurReference = spells[.blurry] == nil ? nil : reference - if sharpnessWasReached, let value { + // Blurry frames must not lower the next spell's reference after a short sharp gap. + // Keep recovery frames, including those while the previous coaching is clearing. + if evidence[.blurry] == false, let value { sharpness.append((time: time, value: value)) } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift index ab499cab7..4dfb19bb5 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift @@ -197,6 +197,22 @@ import Testing #expect(!Self.run(before + gap + softer).contains(.blurry)) } + /// A second blur after a short sharp gap must not use the first spell's blurry samples. + @Test func eachBlurSpellUsesSharpnessBeforeThatSpell() { + var debouncer = CoachingDebouncer() + let sharp = Self.times(for: 2).map { Frame(time: $0, skip: .redundant, sharpness: 100) } + let firstBlur = Self.times(from: 2, for: 3).map { Frame(time: $0, skip: .redundant, sharpness: 30) } + let sharpGap = Self.times(from: 5, for: 0.6).map { Frame(time: $0, skip: .redundant, sharpness: 100) } + let secondBlur = Self.times(from: 5.6, for: 3).map { Frame(time: $0, skip: .redundant, sharpness: 30) } + + #expect(Self.run(sharp, on: &debouncer).allSatisfy { $0 == nil }) + #expect(Self.run(firstBlur, on: &debouncer).last! == .blurry) + #expect(Self.run(sharpGap, on: &debouncer).last! == nil) + let again = Self.run(secondBlur, on: &debouncer) + #expect(again.prefix(20).allSatisfy { $0 == nil }) + #expect(again.last! == .blurry) + } + /// A dark frame's sharpness is not judged until exposure passes again. @Test func tooDarkFramesDoNotCreateBlur() { let sharp = Self.times(for: 2).map { Frame(time: $0, skip: .redundant, sharpness: 100) }