simframe 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +227 -53
- package/native/simframed/Package.swift +16 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +449 -0
- package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
- package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +117 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +89 -0
- package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
- package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
- package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
- package/native/simframed/Sources/SimframeCore/Element.swift +120 -0
- package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
- package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
- package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
- package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
- package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
- package/native/simframed/Sources/simframed/main.swift +357 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +240 -0
- package/package.json +10 -4
- package/scripts/bench-flow.mjs +54 -0
- package/scripts/bench.sh +98 -0
- package/scripts/check-package.mjs +91 -0
- package/scripts/smoke.mjs +76 -0
- package/scripts/verify-baseline.mjs +65 -0
- package/src/actions.js +82 -5
- package/src/cli.js +347 -31
- package/src/control.js +76 -0
- package/src/daemon.js +8 -1
- package/src/engine.js +99 -0
- package/src/fingerprint.js +150 -0
- package/src/graph.js +409 -0
- package/src/index.js +292 -23
- package/src/input.js +80 -2
- package/src/matching.js +194 -0
- package/src/mcp.js +45 -1
- package/src/navigate.js +120 -0
- package/src/regions.js +90 -0
- package/src/screenmap.js +77 -21
- package/src/simctl.js +20 -4
- package/src/store.js +8 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// What the screen is doing, derived from frame history alone.
|
|
5
|
+
///
|
|
6
|
+
/// Everything here works on a small grayscale grid rather than the frame, so a
|
|
7
|
+
/// full analysis costs well under a millisecond and can run on every capture.
|
|
8
|
+
public enum Motion {
|
|
9
|
+
/// Grid the analysis runs on. Large enough to localise a keyboard or a
|
|
10
|
+
/// spinner and to measure a scroll offset; small enough to be free.
|
|
11
|
+
public static let cols = 48
|
|
12
|
+
public static let rows = 96
|
|
13
|
+
|
|
14
|
+
/// Below this mean absolute difference, two frames are the same picture.
|
|
15
|
+
public static let stillThreshold = 0.004
|
|
16
|
+
/// Frames that must agree before the screen counts as settled.
|
|
17
|
+
///
|
|
18
|
+
/// Paired with a duration, because frame count alone is not a measure of
|
|
19
|
+
/// time: the capture loop slows to one frame every two seconds when nothing
|
|
20
|
+
/// is happening, so requiring three consecutive still frames would take six
|
|
21
|
+
/// seconds to call a motionless screen settled.
|
|
22
|
+
public static let stillFramesRequired = 2
|
|
23
|
+
public static let stillDurationRequired: Double = 200
|
|
24
|
+
|
|
25
|
+
public struct Frame: Sendable {
|
|
26
|
+
public let at: Double
|
|
27
|
+
public let gray: [UInt8]
|
|
28
|
+
public init(at: Double, gray: [UInt8]) {
|
|
29
|
+
self.at = at
|
|
30
|
+
self.gray = gray
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public enum Kind: String, Sendable {
|
|
35
|
+
case none, scroll, push, pop
|
|
36
|
+
/// The screen changed substantially, but not by translating — a tab
|
|
37
|
+
/// switch, a reload, a different view entirely.
|
|
38
|
+
case replace
|
|
39
|
+
case sheetPresent = "sheet-present", sheetDismiss = "sheet-dismiss"
|
|
40
|
+
case alertPresent = "alert-present", alertDismiss = "alert-dismiss"
|
|
41
|
+
case keyboardUp = "keyboard-up", keyboardDown = "keyboard-down"
|
|
42
|
+
case toast, loading
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// The quantities a classification was made from. Carried on the result
|
|
46
|
+
/// because a misclassification is otherwise impossible to argue with.
|
|
47
|
+
public struct Evidence: Sendable {
|
|
48
|
+
public var overall = 0.0
|
|
49
|
+
public var shiftDy = 0
|
|
50
|
+
public var shiftScore = 0.0
|
|
51
|
+
public var horizontalDx = 0
|
|
52
|
+
public var horizontalScore = 0.0
|
|
53
|
+
public var brightnessDelta = 0.0
|
|
54
|
+
public var top = 0.0
|
|
55
|
+
public var middle = 0.0
|
|
56
|
+
public var bottom = 0.0
|
|
57
|
+
|
|
58
|
+
public init() {}
|
|
59
|
+
|
|
60
|
+
public var json: [String: Any] {
|
|
61
|
+
func r(_ v: Double) -> Double { (v * 10000).rounded() / 10000 }
|
|
62
|
+
return [
|
|
63
|
+
"overall": r(overall),
|
|
64
|
+
"dy": shiftDy, "dyScore": r(shiftScore),
|
|
65
|
+
"dx": horizontalDx, "dxScore": r(horizontalScore),
|
|
66
|
+
"brightness": (brightnessDelta * 10).rounded() / 10,
|
|
67
|
+
"bands": ["top": r(top), "middle": r(middle), "bottom": r(bottom)],
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public struct Transition: Sendable {
|
|
73
|
+
public let kind: Kind
|
|
74
|
+
/// Pixel offset in points, for scroll and push/pop.
|
|
75
|
+
public let offset: CGPoint?
|
|
76
|
+
public let confidence: Double
|
|
77
|
+
public let evidence: Evidence
|
|
78
|
+
|
|
79
|
+
public init(kind: Kind, offset: CGPoint?, confidence: Double, evidence: Evidence = Evidence()) {
|
|
80
|
+
self.kind = kind
|
|
81
|
+
self.offset = offset
|
|
82
|
+
self.confidence = confidence
|
|
83
|
+
self.evidence = evidence
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public var json: [String: Any] {
|
|
87
|
+
var out: [String: Any] = [
|
|
88
|
+
"kind": kind.rawValue,
|
|
89
|
+
"confidence": (confidence * 100).rounded() / 100,
|
|
90
|
+
"evidence": evidence.json,
|
|
91
|
+
]
|
|
92
|
+
if let offset { out["offset"] = ["x": Int(offset.x.rounded()), "y": Int(offset.y.rounded())] }
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public struct State: Sendable {
|
|
98
|
+
public let settled: Bool
|
|
99
|
+
public let stillForMs: Double
|
|
100
|
+
public let stillFrames: Int
|
|
101
|
+
/// Set when a small area animates while everything else holds still —
|
|
102
|
+
/// a spinner or a progress indicator. The screen is NOT settled.
|
|
103
|
+
public let animatingRegion: CGRect?
|
|
104
|
+
|
|
105
|
+
public var json: [String: Any] {
|
|
106
|
+
var out: [String: Any] = [
|
|
107
|
+
"settled": settled,
|
|
108
|
+
"stillForMs": stillForMs.rounded(),
|
|
109
|
+
"stillFrames": stillFrames,
|
|
110
|
+
]
|
|
111
|
+
if let r = animatingRegion {
|
|
112
|
+
out["animating"] = ["x": Int(r.origin.x), "y": Int(r.origin.y),
|
|
113
|
+
"width": Int(r.width), "height": Int(r.height)]
|
|
114
|
+
}
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// MARK: - Frame comparison
|
|
120
|
+
|
|
121
|
+
public static func difference(_ a: [UInt8], _ b: [UInt8]) -> Double {
|
|
122
|
+
guard a.count == b.count, !a.isEmpty else { return 1 }
|
|
123
|
+
var sum = 0
|
|
124
|
+
for i in 0..<a.count { sum += abs(Int(a[i]) - Int(b[i])) }
|
|
125
|
+
return Double(sum) / Double(a.count) / 255.0
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// Per-cell absolute difference, used to localise what moved.
|
|
129
|
+
public static func differenceMap(_ a: [UInt8], _ b: [UInt8]) -> [Double] {
|
|
130
|
+
guard a.count == b.count else { return [] }
|
|
131
|
+
return (0..<a.count).map { abs(Double(a[$0]) - Double(b[$0])) / 255.0 }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// MARK: - Settle
|
|
135
|
+
|
|
136
|
+
/// Is the screen holding still, and if not, is one small part of it moving?
|
|
137
|
+
public static func state(history: [Frame], now: Double) -> State {
|
|
138
|
+
guard history.count >= 2 else {
|
|
139
|
+
return State(settled: false, stillForMs: 0, stillFrames: 0, animatingRegion: nil)
|
|
140
|
+
}
|
|
141
|
+
var stillFrames = 0
|
|
142
|
+
var stillSince = history.last!.at
|
|
143
|
+
var i = history.count - 1
|
|
144
|
+
while i > 0 {
|
|
145
|
+
let d = difference(history[i].gray, history[i - 1].gray)
|
|
146
|
+
if d > stillThreshold { break }
|
|
147
|
+
stillFrames += 1
|
|
148
|
+
stillSince = history[i - 1].at
|
|
149
|
+
i -= 1
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A spinner keeps a small patch moving while the rest of the screen is
|
|
153
|
+
// finished. Treating that as "still moving" would wait forever; treating
|
|
154
|
+
// it as settled would read a half-drawn screen. It is neither, so it is
|
|
155
|
+
// reported as its own thing.
|
|
156
|
+
//
|
|
157
|
+
// This must run even when the frames look still globally — that is the
|
|
158
|
+
// whole point. A spinner covering half a percent of the screen moves the
|
|
159
|
+
// mean difference by about 0.001, well under the still threshold, so
|
|
160
|
+
// checking only when the global test already says "moving" never fires.
|
|
161
|
+
var animating: CGRect?
|
|
162
|
+
if history.count >= 3 {
|
|
163
|
+
let recent = Array(history.suffix(4))
|
|
164
|
+
var union: [Double] = Array(repeating: 0, count: cols * rows)
|
|
165
|
+
for k in 1..<recent.count {
|
|
166
|
+
let map = differenceMap(recent[k].gray, recent[k - 1].gray)
|
|
167
|
+
for j in 0..<min(union.count, map.count) { union[j] = max(union[j], map[j]) }
|
|
168
|
+
}
|
|
169
|
+
// Per-cell, not global: a cell either changed or it did not.
|
|
170
|
+
let cellThreshold = 0.06
|
|
171
|
+
let moving = union.filter { $0 > cellThreshold }.count
|
|
172
|
+
let fraction = Double(moving) / Double(union.count)
|
|
173
|
+
// Small and persistent, rather than a screen changing.
|
|
174
|
+
if fraction > 0 && fraction < 0.06 {
|
|
175
|
+
animating = boundingBox(of: union, threshold: cellThreshold)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return State(
|
|
180
|
+
settled: stillFrames >= stillFramesRequired
|
|
181
|
+
&& (now - stillSince) >= stillDurationRequired
|
|
182
|
+
&& animating == nil,
|
|
183
|
+
stillForMs: now - stillSince,
|
|
184
|
+
stillFrames: stillFrames,
|
|
185
|
+
animatingRegion: animating
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private static func boundingBox(of map: [Double], threshold: Double) -> CGRect? {
|
|
190
|
+
var minX = cols, minY = rows, maxX = -1, maxY = -1
|
|
191
|
+
for r in 0..<rows {
|
|
192
|
+
for c in 0..<cols where map[r * cols + c] > threshold {
|
|
193
|
+
minX = min(minX, c); maxX = max(maxX, c)
|
|
194
|
+
minY = min(minY, r); maxY = max(maxY, r)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
guard maxX >= minX, maxY >= minY else { return nil }
|
|
198
|
+
return CGRect(x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// MARK: - Shift estimation
|
|
202
|
+
|
|
203
|
+
/// Rows that actually changed between two frames.
|
|
204
|
+
///
|
|
205
|
+
/// A scrolling screen almost never scrolls all of itself: a navigation bar,
|
|
206
|
+
/// a search field, a filter row and a tab bar stay put while the content
|
|
207
|
+
/// between them moves. Searching the whole frame for a translation lets
|
|
208
|
+
/// that fixed chrome — often a quarter of the screen, and pixel-identical —
|
|
209
|
+
/// pin the answer at "no shift". Restricting the search to the rows that
|
|
210
|
+
/// moved is what makes a real scroll measurable.
|
|
211
|
+
public static func movingRows(_ a: [UInt8], _ b: [UInt8], threshold: Double = 0.02) -> ClosedRange<Int>? {
|
|
212
|
+
var first = -1, last = -1
|
|
213
|
+
for r in 0..<rows {
|
|
214
|
+
var sum = 0
|
|
215
|
+
for c in 0..<cols { sum += abs(Int(a[r * cols + c]) - Int(b[r * cols + c])) }
|
|
216
|
+
let rowChange = Double(sum) / Double(cols) / 255.0
|
|
217
|
+
if rowChange > threshold {
|
|
218
|
+
if first < 0 { first = r }
|
|
219
|
+
last = r
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
guard first >= 0, last > first else { return nil }
|
|
223
|
+
return first...last
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// Best whole-cell shift of `b` relative to `a` within `region`, by
|
|
227
|
+
/// minimising absolute difference over candidate offsets.
|
|
228
|
+
///
|
|
229
|
+
/// Phase correlation via FFT is the textbook approach, but at 48x96 a direct
|
|
230
|
+
/// search over the plausible range is a fraction of a millisecond and has no
|
|
231
|
+
/// windowing artefacts to reason about.
|
|
232
|
+
public static func shift(
|
|
233
|
+
_ a: [UInt8], _ b: [UInt8], maxShift: Int = 40, region: ClosedRange<Int>? = nil
|
|
234
|
+
) -> (dx: Int, dy: Int, score: Double) {
|
|
235
|
+
let rowRange = region ?? 0...(rows - 1)
|
|
236
|
+
var best = (dx: 0, dy: 0, score: Double.infinity)
|
|
237
|
+
for dy in -maxShift...maxShift {
|
|
238
|
+
for dx in -2...2 { // horizontal drift during a vertical scroll
|
|
239
|
+
var sum = 0, n = 0
|
|
240
|
+
for r in rowRange {
|
|
241
|
+
let sr = r + dy
|
|
242
|
+
guard sr >= 0, sr < rows else { continue }
|
|
243
|
+
for c in 0..<cols {
|
|
244
|
+
let sc = c + dx
|
|
245
|
+
guard sc >= 0, sc < cols else { continue }
|
|
246
|
+
sum += abs(Int(a[r * cols + c]) - Int(b[sr * cols + sc]))
|
|
247
|
+
n += 1
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
guard n > rowRange.count * cols / 3 else { continue }
|
|
251
|
+
let score = Double(sum) / Double(n) / 255.0
|
|
252
|
+
if score < best.score { best = (dx, dy, score) }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return best
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/// Mean absolute difference restricted to the same rows, so a shift score
|
|
259
|
+
/// and the baseline it is compared against are measured over one sample.
|
|
260
|
+
public static func difference(_ a: [UInt8], _ b: [UInt8], region: ClosedRange<Int>) -> Double {
|
|
261
|
+
var sum = 0, n = 0
|
|
262
|
+
for r in region {
|
|
263
|
+
for c in 0..<cols { sum += abs(Int(a[r * cols + c]) - Int(b[r * cols + c])); n += 1 }
|
|
264
|
+
}
|
|
265
|
+
return n == 0 ? 1 : Double(sum) / Double(n) / 255.0
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/// Same, for a horizontal push or pop.
|
|
269
|
+
public static func horizontalShift(_ a: [UInt8], _ b: [UInt8], maxShift: Int = 40) -> (dx: Int, score: Double) {
|
|
270
|
+
var best = (dx: 0, score: Double.infinity)
|
|
271
|
+
for dx in -maxShift...maxShift {
|
|
272
|
+
var sum = 0, n = 0
|
|
273
|
+
for r in stride(from: 0, to: rows, by: 2) {
|
|
274
|
+
for c in 0..<cols {
|
|
275
|
+
let sc = c + dx
|
|
276
|
+
guard sc >= 0, sc < cols else { continue }
|
|
277
|
+
sum += abs(Int(a[r * cols + c]) - Int(b[r * cols + sc]))
|
|
278
|
+
n += 1
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
guard n > cols * rows / 8 else { continue }
|
|
282
|
+
let score = Double(sum) / Double(n) / 255.0
|
|
283
|
+
if score < best.score { best = (dx, score) }
|
|
284
|
+
}
|
|
285
|
+
return best
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// MARK: - Transition classification
|
|
290
|
+
|
|
291
|
+
extension Motion {
|
|
292
|
+
private static func meanBrightness(_ g: [UInt8]) -> Double {
|
|
293
|
+
g.isEmpty ? 0 : Double(g.reduce(0) { $0 + Int($1) }) / Double(g.count)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// Mean change within a horizontal band, given as fractions of height.
|
|
297
|
+
private static func bandChange(_ map: [Double], from: Double, to: Double) -> Double {
|
|
298
|
+
let r0 = Int(Double(rows) * from), r1 = max(r0 + 1, Int(Double(rows) * to))
|
|
299
|
+
var sum = 0.0
|
|
300
|
+
var n = 0
|
|
301
|
+
for r in r0..<min(r1, rows) {
|
|
302
|
+
for c in 0..<cols { sum += map[r * cols + c]; n += 1 }
|
|
303
|
+
}
|
|
304
|
+
return n == 0 ? 0 : sum / Double(n)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/// What happened between two settled screens.
|
|
308
|
+
///
|
|
309
|
+
/// The order matters: a shift that explains the whole frame is a navigation
|
|
310
|
+
/// or a scroll, and only when nothing explains the frame as a shift is it
|
|
311
|
+
/// worth asking which band changed.
|
|
312
|
+
public static func classify(before: [UInt8], after: [UInt8], pointHeight: Double, pointWidth: Double) -> Transition {
|
|
313
|
+
let overall = difference(before, after)
|
|
314
|
+
guard overall > stillThreshold else { return Transition(kind: .none, offset: nil, confidence: 1) }
|
|
315
|
+
|
|
316
|
+
let map = differenceMap(before, after)
|
|
317
|
+
let top = bandChange(map, from: 0, to: 0.12) // status + nav
|
|
318
|
+
let middle = bandChange(map, from: 0.12, to: 0.60)
|
|
319
|
+
let bottom = bandChange(map, from: 0.60, to: 1.0)
|
|
320
|
+
let brightnessDelta = meanBrightness(after) - meanBrightness(before)
|
|
321
|
+
var evidence = Evidence()
|
|
322
|
+
evidence.overall = overall
|
|
323
|
+
evidence.brightnessDelta = brightnessDelta
|
|
324
|
+
evidence.top = top; evidence.middle = middle; evidence.bottom = bottom
|
|
325
|
+
|
|
326
|
+
// A shift that explains the frame better than the raw difference is
|
|
327
|
+
// motion rather than a repaint.
|
|
328
|
+
// Compare like with like: the shift score and the baseline it is judged
|
|
329
|
+
// against must be measured over the same rows, or the ratio is
|
|
330
|
+
// meaningless.
|
|
331
|
+
let region = movingRows(before, after)
|
|
332
|
+
let baseline = region.map { difference(before, after, region: $0) } ?? overall
|
|
333
|
+
let h = horizontalShift(before, after)
|
|
334
|
+
let v = shift(before, after, region: region)
|
|
335
|
+
evidence.horizontalDx = h.dx; evidence.horizontalScore = h.score
|
|
336
|
+
evidence.shiftDy = v.dy; evidence.shiftScore = v.score
|
|
337
|
+
|
|
338
|
+
// Whichever direction explains the frame better wins. Requiring a
|
|
339
|
+
// stable top band to call something a scroll was wrong: a scroll view
|
|
340
|
+
// filling the screen moves the top too, and such a scroll fell through
|
|
341
|
+
// to "no change".
|
|
342
|
+
let horizontalWins = abs(h.dx) >= 4 && h.score < v.score
|
|
343
|
+
let verticalWins = abs(v.dy) >= 2 && !horizontalWins
|
|
344
|
+
let bestScore = horizontalWins ? h.score : v.score
|
|
345
|
+
|
|
346
|
+
if (horizontalWins || verticalWins), bestScore < baseline * 0.7 {
|
|
347
|
+
let confidence = min(1, (baseline - bestScore) / max(baseline, 0.0001))
|
|
348
|
+
if horizontalWins {
|
|
349
|
+
// Content moving left is a push; moving right is going back.
|
|
350
|
+
return Transition(kind: h.dx < 0 ? .push : .pop,
|
|
351
|
+
offset: CGPoint(x: Double(h.dx) / Double(cols) * pointWidth, y: 0),
|
|
352
|
+
confidence: confidence, evidence: evidence)
|
|
353
|
+
}
|
|
354
|
+
return Transition(kind: .scroll,
|
|
355
|
+
offset: CGPoint(x: 0, y: Double(v.dy) / Double(rows) * pointHeight),
|
|
356
|
+
confidence: confidence, evidence: evidence)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Bottom-only change is the keyboard. Which way is told by whether the
|
|
360
|
+
// bottom gained detail or lost it.
|
|
361
|
+
if bottom > overall * 1.6, top < overall * 0.35, middle < overall * 0.8 {
|
|
362
|
+
let kind: Kind = brightnessDelta < 0 ? .keyboardUp : .keyboardDown
|
|
363
|
+
return Transition(kind: kind, offset: nil, confidence: min(1, bottom / max(overall, 0.0001) / 2), evidence: evidence)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Something laid over the screen dims everything behind it. An alert
|
|
367
|
+
// changes the middle; a sheet takes the lower half.
|
|
368
|
+
if abs(brightnessDelta) > 6 {
|
|
369
|
+
let presenting = brightnessDelta < 0
|
|
370
|
+
let sheetish = bottom > middle
|
|
371
|
+
let kind: Kind = sheetish
|
|
372
|
+
? (presenting ? .sheetPresent : .sheetDismiss)
|
|
373
|
+
: (presenting ? .alertPresent : .alertDismiss)
|
|
374
|
+
return Transition(kind: kind, offset: nil, confidence: min(1, abs(brightnessDelta) / 40), evidence: evidence)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// A small banner at one edge, with the rest of the screen untouched.
|
|
378
|
+
if top > overall * 2.2, middle < overall * 0.4, bottom < overall * 0.4 {
|
|
379
|
+
return Transition(kind: .toast, offset: nil, confidence: min(1, top / max(overall, 0.0001) / 3), evidence: evidence)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Plenty changed and nothing explains it as motion: the screen was
|
|
383
|
+
// replaced. Saying so is more use to a caller than "none", which reads
|
|
384
|
+
// as "nothing happened".
|
|
385
|
+
if overall > stillThreshold * 4 {
|
|
386
|
+
return Transition(kind: .replace, offset: nil,
|
|
387
|
+
confidence: min(1, overall * 12), evidence: evidence)
|
|
388
|
+
}
|
|
389
|
+
return Transition(kind: .none, offset: nil, confidence: 1 - min(1, overall * 4), evidence: evidence)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
extension Motion {
|
|
395
|
+
/// The analysis grid, built with integer arithmetic and subsampling.
|
|
396
|
+
///
|
|
397
|
+
/// `Hashing.grayGrid` averages every pixel in double precision, which is
|
|
398
|
+
/// right for a hash that has to match the JavaScript byte for byte. Motion
|
|
399
|
+
/// analysis has no such constraint and runs on every frame, so it samples
|
|
400
|
+
/// every other pixel and stays in integers — about fifteen milliseconds a
|
|
401
|
+
/// frame cheaper.
|
|
402
|
+
public static func grid(from bmp: Bitmap) -> [UInt8] {
|
|
403
|
+
var out = [UInt8](repeating: 0, count: cols * rows)
|
|
404
|
+
bmp.data.withUnsafeBufferPointer { src in
|
|
405
|
+
for r in 0..<rows {
|
|
406
|
+
let y0 = (r * bmp.height) / rows
|
|
407
|
+
let y1 = max(y0 + 1, ((r + 1) * bmp.height) / rows)
|
|
408
|
+
for c in 0..<cols {
|
|
409
|
+
let x0 = (c * bmp.width) / cols
|
|
410
|
+
let x1 = max(x0 + 1, ((c + 1) * bmp.width) / cols)
|
|
411
|
+
var sum = 0, n = 0
|
|
412
|
+
var y = y0
|
|
413
|
+
while y < y1 {
|
|
414
|
+
let row = y * bmp.width
|
|
415
|
+
var x = x0
|
|
416
|
+
while x < x1 {
|
|
417
|
+
let i = (row + x) * 4
|
|
418
|
+
// Integer luminance: 77/150/29 over 256.
|
|
419
|
+
sum += (Int(src[i]) * 77 + Int(src[i + 1]) * 150 + Int(src[i + 2]) * 29) >> 8
|
|
420
|
+
n += 1
|
|
421
|
+
x += 2
|
|
422
|
+
}
|
|
423
|
+
y += 2
|
|
424
|
+
}
|
|
425
|
+
out[r * cols + c] = n > 0 ? UInt8(min(255, sum / n)) : 0
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return out
|
|
430
|
+
}
|
|
431
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
import Foundation
|
|
3
|
+
import ImageIO
|
|
4
|
+
import UniformTypeIdentifiers
|
|
5
|
+
|
|
6
|
+
public enum PNGWriter {
|
|
7
|
+
/// Write a packed RGBA bitmap as a PNG. Used for the frame files the Node
|
|
8
|
+
/// readers consume, so it must produce an ordinary 8-bit RGBA PNG.
|
|
9
|
+
public static func write(_ bmp: Bitmap, to url: URL) throws {
|
|
10
|
+
try encode(bmp).write(to: url)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// Encode once, write anywhere. The ring frame and `latest.png` are the
|
|
14
|
+
/// same picture; encoding it twice doubled the cost of every capture.
|
|
15
|
+
public static func encode(_ bmp: Bitmap) throws -> Data {
|
|
16
|
+
var data = bmp.data
|
|
17
|
+
guard let provider = CGDataProvider(data: Data(data) as CFData),
|
|
18
|
+
let image = CGImage(
|
|
19
|
+
width: bmp.width, height: bmp.height,
|
|
20
|
+
bitsPerComponent: 8, bitsPerPixel: 32,
|
|
21
|
+
bytesPerRow: bmp.width * 4,
|
|
22
|
+
space: CGColorSpaceCreateDeviceRGB(),
|
|
23
|
+
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
|
|
24
|
+
provider: provider, decode: nil,
|
|
25
|
+
shouldInterpolate: false, intent: .defaultIntent
|
|
26
|
+
) else {
|
|
27
|
+
throw NSError(domain: "PNGWriter", code: 1, userInfo: [NSLocalizedDescriptionKey: "could not build image"])
|
|
28
|
+
}
|
|
29
|
+
data.removeAll()
|
|
30
|
+
let out = NSMutableData()
|
|
31
|
+
guard let dest = CGImageDestinationCreateWithData(out, UTType.png.identifier as CFString, 1, nil) else {
|
|
32
|
+
throw NSError(domain: "PNGWriter", code: 2, userInfo: [NSLocalizedDescriptionKey: "could not create PNG destination"])
|
|
33
|
+
}
|
|
34
|
+
CGImageDestinationAddImage(dest, image, nil)
|
|
35
|
+
guard CGImageDestinationFinalize(dest) else {
|
|
36
|
+
throw NSError(domain: "PNGWriter", code: 3, userInfo: [NSLocalizedDescriptionKey: "could not encode PNG"])
|
|
37
|
+
}
|
|
38
|
+
return out as Data
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
import Foundation
|
|
3
|
+
import PrivateAPI
|
|
4
|
+
import Vision
|
|
5
|
+
|
|
6
|
+
/// Text recognition straight off the framebuffer.
|
|
7
|
+
///
|
|
8
|
+
/// The Node path wrote a PNG and shelled out to a helper; this reads the
|
|
9
|
+
/// IOSurface that is already mapped, so a recognition pass costs one CGImage
|
|
10
|
+
/// wrap rather than an encode, a write, a process spawn and a decode.
|
|
11
|
+
public enum VisionOCR {
|
|
12
|
+
/// Recognised text as elements, in points.
|
|
13
|
+
///
|
|
14
|
+
/// - Parameter scale: points per pixel, so boxes come back in the space
|
|
15
|
+
/// input speaks rather than in framebuffer pixels.
|
|
16
|
+
public static func recognise(
|
|
17
|
+
frame: RawFrame,
|
|
18
|
+
scale: Double,
|
|
19
|
+
startingId: Int = 1,
|
|
20
|
+
minimumConfidence: Float = 0.3
|
|
21
|
+
) throws -> [Element] {
|
|
22
|
+
guard let image = cgImage(from: frame) else { return [] }
|
|
23
|
+
let request = VNRecognizeTextRequest()
|
|
24
|
+
request.recognitionLevel = .accurate
|
|
25
|
+
// Correction rewrites UI strings into dictionary words — "Fryer 3"
|
|
26
|
+
// becomes something else entirely — and a label must be what is on
|
|
27
|
+
// screen, not what the language model thinks was meant.
|
|
28
|
+
request.usesLanguageCorrection = false
|
|
29
|
+
try VNImageRequestHandler(cgImage: image, options: [:]).perform([request])
|
|
30
|
+
|
|
31
|
+
let width = Double(frame.width), height = Double(frame.height)
|
|
32
|
+
var elements: [Element] = []
|
|
33
|
+
var id = startingId
|
|
34
|
+
for observation in request.results ?? [] {
|
|
35
|
+
guard let candidate = observation.topCandidates(1).first,
|
|
36
|
+
candidate.confidence >= minimumConfidence else { continue }
|
|
37
|
+
let box = observation.boundingBox // normalised, origin bottom-left
|
|
38
|
+
let rect = CGRect(
|
|
39
|
+
x: box.origin.x * width / scale,
|
|
40
|
+
// Vision's origin is bottom-left; screens are top-left.
|
|
41
|
+
y: (1 - box.origin.y - box.size.height) * height / scale,
|
|
42
|
+
width: box.size.width * width / scale,
|
|
43
|
+
height: box.size.height * height / scale
|
|
44
|
+
)
|
|
45
|
+
elements.append(Element(
|
|
46
|
+
id: id,
|
|
47
|
+
frame: rect,
|
|
48
|
+
role: "text",
|
|
49
|
+
label: candidate.string,
|
|
50
|
+
source: .ocr,
|
|
51
|
+
confidence: Double(candidate.confidence)
|
|
52
|
+
))
|
|
53
|
+
id += 1
|
|
54
|
+
}
|
|
55
|
+
return elements
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Wrap the surface as a CGImage without copying its pixels.
|
|
59
|
+
private static func cgImage(from frame: RawFrame) -> CGImage? {
|
|
60
|
+
let bytes = frame.bytesPerRow * frame.height
|
|
61
|
+
guard let provider = CGDataProvider(dataInfo: nil, data: frame.pixels, size: bytes, releaseData: { _, _, _ in }) else {
|
|
62
|
+
return nil
|
|
63
|
+
}
|
|
64
|
+
let info = CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipFirst.rawValue).union(.byteOrder32Little)
|
|
65
|
+
return CGImage(
|
|
66
|
+
width: frame.width, height: frame.height,
|
|
67
|
+
bitsPerComponent: 8, bitsPerPixel: 32,
|
|
68
|
+
bytesPerRow: frame.bytesPerRow,
|
|
69
|
+
space: CGColorSpaceCreateDeviceRGB(),
|
|
70
|
+
bitmapInfo: info,
|
|
71
|
+
provider: provider, decode: nil,
|
|
72
|
+
shouldInterpolate: false, intent: .defaultIntent
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|