Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ios/HouseScan/Runtime/ScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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)] = []
/// 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.
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.
Expand All @@ -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 }
Expand All @@ -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
Expand Down Expand Up @@ -172,8 +192,26 @@ 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)
// 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)
// 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 {
if seen {
spells[problem] = Spell(since: spells[problem]?.since ?? time, lastSeen: time)
} else if let spell = spells[problem], !reachedShow(problem, spell) {
Expand All @@ -182,6 +220,17 @@ public struct CoachingDebouncer: Sendable {
}
}
spells = spells.filter { time - $0.value.lastSeen < config.clearAfter }
blurReference = spells[.blurry] == nil ? nil : reference
// 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))
}
}

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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?] {
Expand Down Expand Up @@ -147,6 +148,81 @@ 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 })
}

/// 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 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) }
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) }
Expand Down
2 changes: 2 additions & 0 deletions ios/HouseScanUITests/ScreenStatesUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down