Skip to content
Merged
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
18 changes: 18 additions & 0 deletions ios/HouseScan/Contract/ScanContract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,22 @@ final class ScanViewState {
var closeUpFailedAttempts = 0
/// Nil until the close-up photo is taken.
var meterNumber: MeterNumberState?
/// The meter's maker as read from the close-up, shown above the number candidates. It exists
/// only beside those candidates or the number confirmed from them: while `meterNumber` is
/// nil, reading or skipped it is nil, so a brand from an earlier photo can't outlive its
/// number through a retake, a skip or a new close-up. The homeowner can reject it
/// (`rejectMeterBrand`). Like the number, it stays on the phone.
var meterBrand: String? {
get {
switch meterNumber {
case .choose, .confirmed: offeredMeterBrand
case .reading, .skipped, nil: nil
}
}
set { offeredMeterBrand = newValue }
}
/// Storage for `meterBrand`, set with the candidates it was read with. Read `meterBrand`.
private var offeredMeterBrand: String?

var captureCount = 0
var lastCapture: CaptureEvent?
Expand Down Expand Up @@ -718,6 +734,8 @@ protocol ScanActions: AnyObject {
/// The homeowner's pick from `MeterNumberState.choose`; nil means "None of these", which
/// asks for a retake.
func chooseMeterNumber(_ candidate: MeterNumberCandidate?)
/// "Not <brand>" beside the number candidates: drops `ScanViewState.meterBrand`.
func rejectMeterBrand()
/// Marks a wall end where `point` meets the wall, on whichever side of the meter that is.
func markWallEnd(at point: CGPoint?, viewSize: CGSize)
/// "Wall ends here" during the walk: ends the wall on the side being walked where
Expand Down
4 changes: 4 additions & 0 deletions ios/HouseScan/Runtime/MeterNumberReading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ struct MeterReadout: Sendable, Equatable {
/// The photo decoded and passed the focus check, whether or not a number was read in it. Only
/// such a photo's view goes into coverage (`CloseUpCredit`).
var photoPassedChecks: Bool
/// The maker named on the meter (`MeterBrand.read`), or nil when no line names one. Shown
/// beside the candidates; it counts only when the homeowner confirms a number without
/// rejecting it.
var brand: String? = nil
}

/// Reads the meter number from the close-up JPEG. Runs off the main actor.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ struct VisionMeterNumberReader: MeterNumberReader {
},
retake: nil,
numberTooSmall: choices.numberTooSmall,
photoPassedChecks: true
photoPassedChecks: true,
brand: MeterBrand.read(lines)
)
}

Expand Down
8 changes: 7 additions & 1 deletion ios/HouseScan/Runtime/ScanEngine+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,17 @@ extension ScanEngine: ScanActions {
}
guard let chosen = candidates.first(where: { $0.id == candidate.id }) else { return }
state.meterNumber = .confirmed(chosen.text)
RuntimeLog.engine.info("meter number confirmed (\(chosen.barcodeConfirmed ? "barcode-confirmed" : "text only", privacy: .public))")
RuntimeLog.engine.info("meter number confirmed (\(chosen.barcodeConfirmed ? "barcode-confirmed" : "text only", privacy: .public)), brand \(self.state.meterBrand == nil ? "none" : "kept", privacy: .public)")
observeCloseUpView()
finishCloseUp()
}

func rejectMeterBrand() {
guard state.phase == .meterCloseUp, case .choose = state.meterNumber else { return }
state.meterBrand = nil
RuntimeLog.engine.info("meter brand rejected")
}

/// Puts the close-up photo's view into coverage, under the same rules as a walk keyframe: a
/// cell counts once seen from two positions at least 0.25 m apart, and the view is replayed
/// with the kept keyframes whenever the wall moves (`CoverageMap.observedCameras`).
Expand Down
1 change: 1 addition & 0 deletions ios/HouseScan/Runtime/ScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,7 @@ final class ScanEngine {
}
RuntimeLog.engine.info("meter number: \(readout.candidates.count) candidates to choose from")
meterReadout = readout
state.meterBrand = readout.brand
Comment thread
SamGu-NRX marked this conversation as resolved.
state.meterNumber = .choose(readout.candidates)
}

Expand Down
3 changes: 3 additions & 0 deletions ios/HouseScan/UI/Copy/ScanCopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ enum ScanCopy {
}

static let barcodeMatch = "Matches the barcode"
/// The maker read on the close-up, above the number candidates.
static func meterBrand(_ brand: String) -> String { "\(brand) meter" }
static func notMeterBrand(_ brand: String) -> String { "Not \(brand)" }
static let noneOfThese = "None of these"

// MARK: Features
Expand Down
10 changes: 10 additions & 0 deletions ios/HouseScan/UI/Preview/DemoEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ final class DemoEngine: ScanActions {
}
if arguments.contains("-uiDemoMeterChoose") {
state.closeUp = .captured(DemoScene.meterThumbnail)
state.meterBrand = Self.demoBrand
state.meterNumber = .choose(Self.demoCandidates)
}
// Frozen, the upload script never runs, so show where it would end.
Expand Down Expand Up @@ -377,6 +378,7 @@ final class DemoEngine: ScanActions {
state.meterNumber = .reading
guard await pause(1.2) else { return }
// Waits here for the homeowner's pick (`chooseMeterNumber`).
state.meterBrand = Self.demoBrand
state.meterNumber = .choose(Self.demoCandidates)
}

Expand Down Expand Up @@ -641,6 +643,11 @@ final class DemoEngine: ScanActions {
enterWalk()
}

func rejectMeterBrand() {
guard case .choose = state.meterNumber else { return }
state.meterBrand = nil
}

func chooseMeterNumber(_ candidate: MeterNumberCandidate?) {
guard case .choose = state.meterNumber else { return }
guard let candidate else {
Expand Down Expand Up @@ -861,6 +868,9 @@ final class DemoEngine: ScanActions {

// MARK: Sample data

/// The made-up meter's maker, as `MeterBrand.read` would name it.
static let demoBrand = "Itron"

/// Made-up readings of a made-up meter: the barcode-confirmed one first, then two near
/// misses the way a reader confuses 8 with 6 and 3 with 8.
static let demoCandidates = [
Expand Down
47 changes: 42 additions & 5 deletions ios/HouseScan/UI/Screens/MeterCloseUpScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,13 @@ struct MeterCloseUpScreen: View {
) {
VStack(spacing: 12) {
if case .choose(let candidates) = state.meterNumber {
MeterNumberPicker(candidates: candidates, choose: actions.chooseMeterNumber)
.transition(reduceMotion ? .opacity : .opacity.combined(with: .offset(y: 12)))
MeterNumberPicker(
candidates: candidates,
brand: state.meterBrand,
choose: actions.chooseMeterNumber,
rejectBrand: actions.rejectMeterBrand
)
.transition(reduceMotion ? .opacity : .opacity.combined(with: .offset(y: 12)))
} else if state.meterNumber == .reading {
ProgressView()
.controlSize(.large)
Expand Down Expand Up @@ -193,15 +198,45 @@ struct MeterCloseUpScreen: View {
}
}

/// "Which number is on your meter?": up to three readings as full-width answers, then "None of
/// these". The numbers are set in a monospaced face so similar readings line up character by
/// character (8 against B, 0 against O) and the homeowner can compare them with the meter.
/// "Which number is on your meter?": the maker when one was read, up to three readings as
/// full-width answers, then "None of these". The numbers are set in a monospaced face so similar
/// readings line up character by character (8 against B, 0 against O) and the homeowner can
/// compare them with the meter. The maker is never taken silently: it sits above the numbers
/// with "Not <maker>", and counts only with the number the homeowner taps.
private struct MeterNumberPicker: View {
var candidates: [MeterNumberCandidate]
var brand: String?
var choose: (MeterNumberCandidate?) -> Void
var rejectBrand: () -> Void

var body: some View {
VStack(spacing: 8) {
if let brand {
// A chip, not a full-width answer: it states what was read, and the one thing to
// do with it is say it's wrong. Caption size keeps it below the numbers in rank.
HStack(spacing: 10) {
Text(ScanCopy.meterBrand(brand))
.font(Typeface.caption)
.foregroundStyle(Palette.chalk)
Button(action: rejectBrand) {
Text(ScanCopy.notMeterBrand(brand))
.font(Typeface.caption)
.underline()
.foregroundStyle(Palette.chalk.opacity(0.8))
// 44 pt both ways: "Not GE" in caption type is narrower than that.
.frame(minWidth: 44, minHeight: 44)
.contentShape(.rect)
}
.buttonStyle(PressableStyle())
.accessibilityHint("Leaves the brand out.")
.accessibilityIdentifier("action.rejectMeterBrand")
}
.padding(.horizontal, 16)
.background(ScrimShape.capsule)
.transition(.opacity)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("meter.brand")
}
ForEach(Array(candidates.prefix(3).enumerated()), id: \.element.id) { index, candidate in
Button {
choose(candidate)
Expand Down Expand Up @@ -234,5 +269,7 @@ private struct MeterNumberPicker: View {
.accessibilityHint("Takes another photo of the meter.")
.accessibilityIdentifier("action.noneOfThese")
}
// "Not <brand>" fades the chip out, and the numbers close the gap in the same 0.2 s.
.animation(Motion.text, value: brand)
}
}
131 changes: 131 additions & 0 deletions ios/HouseScanKit/Sources/HouseScanKit/Meter/MeterBrand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import Foundation

// Names the meter's maker from the lines Vision read on the close-up. A port of the autodetect
// lab's brands.py and its pick rule (experiments/autodetect/meter_brand/ on t3/autodetect-lab at
// 538f60e): on #16's photos it named the maker correctly on 17 of 19 where the name is printed in
// plain letters and 22 of 42 where it is part of a logo, named none on the 9 without a name, and
// once named the wrong one, on a plate printed with two makers' names.

public enum MeterBrand {
/// A maker, as shown to the homeowner, and its spellings as printed on meters. Each printed
/// name is its own brand, even where one company later bought another (Elster and Honeywell,
/// Actaris and Itron), because the phone reports what is printed. A spelling of 4 characters
/// or fewer must be a whole token; one of 6 letters or more may be one edit away.
public static let makers: [(name: String, spellings: [String])] = [
("Itron", ["ITRON"]),
("Actaris", ["ACTARIS"]),
("Schlumberger", ["SCHLUMBERGER"]),
("Landis+Gyr", ["LANDIS+GYR", "LANDIS GYR", "LANDIS & GYR", "LANDIS&GYR", "L+G", "LANDIS"]),
("GE", ["GE", "GENERAL ELECTRIC"]),
("Aclara", ["ACLARA"]),
("Elster", ["ELSTER"]),
("Honeywell", ["HONEYWELL"]),
("ABB", ["ABB"]),
("Sensus", ["SENSUS"]),
("Sangamo", ["SANGAMO"]),
("Westinghouse", ["WESTINGHOUSE"]),
("Duncan", ["DUNCAN"]),
("Siemens", ["SIEMENS"]),
("AEG", ["AEG"]),
("Iskra", ["ISKRA", "ISKRAEMECO"]),
("EMH", ["EMH"]),
("DZG", ["DZG"]),
("Kamstrup", ["KAMSTRUP"]),
("EDMI", ["EDMI"]),
("ZIV", ["ZIV"]),
("Sagemcom", ["SAGEMCOM", "SAGEM"]),
("Hexing", ["HEXING"]),
("Holley", ["HOLLEY"]),
("Genus", ["GENUS"]),
("Secure", ["SECURE METERS"]),
("Toshiba", ["TOSHIBA"]),
("Mitsubishi", ["MITSUBISHI"]),
("Osaki", ["OSAKI"]),
("Tatung", ["TATUNG"]),
("Chung-Hsin", ["CHUNGHSIN", "CHUNG HSIN", "CHUNG-HSIN"]),
("CGE", ["CGE"]),
("Enermet", ["ENERMET"]),
("Ampy", ["AMPY"]),
("Logarex", ["LOGAREX"]),
("Echelon", ["ECHELON"]),
]

/// Every spelling with its maker, longest first, so `LANDIS & GYR` is tried before `LANDIS`
/// and `GENERAL ELECTRIC` before `GE`. Equal lengths keep list order, as Python's stable sort.
private static let spellings: [(spelling: String, maker: String)] = makers
.flatMap { maker in maker.spellings.map { (spelling: $0, maker: maker.name) } }
.enumerated()
.sorted { $0.element.spelling.count != $1.element.spelling.count ? $0.element.spelling.count > $1.element.spelling.count : $0.offset < $1.offset }
.map(\.element)

/// The maker to show for the close-up: the one named on the tallest line that names any, or
/// nil. Equal heights go to the maker whose name sorts last, as brands.py's `max` over
/// (height, name) does.
public static func read(_ lines: [MeterTextLine]) -> String? {
lines.compactMap { line in match(line.text).map { (height: line.box.height, maker: $0) } }
.max { ($0.height, $0.maker.uppercased()) < ($1.height, $1.maker.uppercased()) }?
.maker
}

/// The maker one recognized line names, or nil.
///
/// Long spellings match as a substring of the line, or one edit from a token or a pair of
/// adjacent tokens (Vision drops or swaps a letter, or splits `LANDIS+GYR`). Short spellings
/// such as `GE` must be a whole token, because they occur inside unrelated words. The edit
/// needs 6 letters: at 5, `GENUS` is one edit from `GE US`.
public static func match(_ line: String) -> String? {
let norm = normalized(line)
guard !norm.isEmpty else { return nil }
let tokens = norm.split(separator: " ").map(String.init)
let pairs = zip(tokens, tokens.dropFirst())
let windows = tokens + pairs.map { $0 + $1 } + pairs.map { $0 + " " + $1 }
let squeezed = norm.replacingOccurrences(of: " ", with: "")
for (spelling, maker) in spellings {
if spelling.count <= 4 {
if tokens.contains(spelling) { return maker }
continue
}
let bare = spelling.replacingOccurrences(of: " ", with: "")
if (" " + norm + " ").contains(" " + spelling + " ") || squeezed.contains(bare) { return maker }
if bare.count >= 6,
windows.contains(where: { withinOneEdit(bare, $0.replacingOccurrences(of: " ", with: "")) }) {
return maker
}
}
return nil
}

/// Accents folded to plain letters (`Itrón` reads as ITRON) and other non-ASCII dropped, upper
/// case, `&` and `+` kept because they are part of names, other characters to spaces, runs of
/// spaces collapsed.
static func normalized(_ text: String) -> String {
let ascii = String(String.UnicodeScalarView(text.decomposedStringWithCompatibilityMapping.unicodeScalars.filter(\.isASCII)))
let kept = ascii.uppercased().map { character -> Character in
character.isASCII && (character.isUppercase || character.isNumber || character == "+" || character == "&") ? character : " "
}
return String(kept).split(separator: " ").joined(separator: " ")
}

static func withinOneEdit(_ a: String, _ b: String) -> Bool {
if a == b { return true }
var short = Array(a)
var long = Array(b)
guard abs(short.count - long.count) <= 1 else { return false }
if short.count > long.count { swap(&short, &long) }
var i = 0
var j = 0
var edited = false
while i < short.count, j < long.count {
if short[i] == long[j] {
i += 1
j += 1
continue
}
if edited { return false }
edited = true
if short.count == long.count { i += 1 }
j += 1
}
return true
}
}
Loading
Loading