simframe 0.4.1 → 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,303 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
import Foundation
|
|
3
|
+
import ImageIO
|
|
4
|
+
import PrivateAPI
|
|
5
|
+
|
|
6
|
+
/// Writes the on-disk layout the Node readers already expect.
|
|
7
|
+
///
|
|
8
|
+
/// This is a compatibility surface, not a design: every field, filename and
|
|
9
|
+
/// retention rule here exists because `src/daemon.js` produced it and
|
|
10
|
+
/// `simframe state/look/wait/recall` reads it. Changing anything means changing
|
|
11
|
+
/// both sides together.
|
|
12
|
+
public final class FrameStore {
|
|
13
|
+
public struct Options {
|
|
14
|
+
public var ringSize = 400
|
|
15
|
+
public var historySize = 400
|
|
16
|
+
public var historyMs: Double = 90_000
|
|
17
|
+
public var retainMs: Double = 60_000
|
|
18
|
+
public var fineMs: Double = 6_000
|
|
19
|
+
public var keyframeMs: Double = 450
|
|
20
|
+
public var fullKeep = 8
|
|
21
|
+
public var recallScale = 0.5
|
|
22
|
+
public var maxRingBytes = 12 << 20
|
|
23
|
+
public var changeThreshold = 0.004
|
|
24
|
+
public var fullFrameIntervalMs: Double = 1000
|
|
25
|
+
public var housekeepEvery = 20
|
|
26
|
+
public var maxDim = 700
|
|
27
|
+
public init() {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// State version of the on-disk format. Must match STATE_VERSION in
|
|
31
|
+
/// src/daemon.js, or Node will treat this daemon as stale and replace it.
|
|
32
|
+
public static let stateVersion = 6
|
|
33
|
+
|
|
34
|
+
private let device: DeviceInfo
|
|
35
|
+
private let options: Options
|
|
36
|
+
private let root: URL
|
|
37
|
+
private let fm = FileManager.default
|
|
38
|
+
|
|
39
|
+
private let startedAt = FrameStore.nowMs()
|
|
40
|
+
private var seq = 0
|
|
41
|
+
private var prevSignature: [UInt8]?
|
|
42
|
+
private var lastChangeAt = FrameStore.nowMs()
|
|
43
|
+
private var history: [[String: Any]] = []
|
|
44
|
+
/// Recent frames as small grayscale grids, for settle and transition work.
|
|
45
|
+
private var motionHistory: [Motion.Frame] = []
|
|
46
|
+
private var lastSettledGray: [UInt8]?
|
|
47
|
+
private var lastTransition: Motion.Transition?
|
|
48
|
+
private var wasSettled = false
|
|
49
|
+
private var ringIndex: [(seq: Int, at: Double, small: Bool)] = []
|
|
50
|
+
|
|
51
|
+
public init(device: DeviceInfo, options: Options = Options()) throws {
|
|
52
|
+
self.device = device
|
|
53
|
+
self.options = options
|
|
54
|
+
let home = fm.homeDirectoryForCurrentUser
|
|
55
|
+
let base = ProcessInfo.processInfo.environment["SIMFRAME_HOME"]
|
|
56
|
+
.map { URL(fileURLWithPath: $0) } ?? home.appendingPathComponent(".simframe")
|
|
57
|
+
self.root = base.appendingPathComponent(device.udid)
|
|
58
|
+
for sub in ["ring", "full"] {
|
|
59
|
+
try fm.createDirectory(at: root.appendingPathComponent(sub), withIntermediateDirectories: true)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private var deviceJSON: [String: Any] {
|
|
64
|
+
["udid": device.udid, "name": device.name, "runtime": device.runtime, "state": "Booted"]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public static func nowMs() -> Double { (Date().timeIntervalSince1970 * 1000).rounded() }
|
|
68
|
+
|
|
69
|
+
private func writeAtomic(_ data: Data, to url: URL) throws {
|
|
70
|
+
let tmp = url.appendingPathExtension("tmp-\(getpid())")
|
|
71
|
+
try data.write(to: tmp)
|
|
72
|
+
_ = try fm.replaceItemAt(url, withItemAt: tmp)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Claim the device. Node checks meta.json to decide whether a capture loop
|
|
76
|
+
/// is already running, so writing it is what makes the Node CLI defer to us.
|
|
77
|
+
/// Where the control socket lives for this device. Beside the frames, so
|
|
78
|
+
/// one directory is the whole contract between daemon and clients.
|
|
79
|
+
public var controlSocketPath: String { root.appendingPathComponent("control.sock").path }
|
|
80
|
+
|
|
81
|
+
/// The newest state written, for callers that need the current hashes
|
|
82
|
+
/// without recomputing them.
|
|
83
|
+
public func latestState() -> [String: Any]? {
|
|
84
|
+
guard let data = try? Data(contentsOf: root.appendingPathComponent("state.json")) else { return nil }
|
|
85
|
+
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
public func claim() throws {
|
|
89
|
+
let meta: [String: Any] = [
|
|
90
|
+
"pid": Int(getpid()),
|
|
91
|
+
"device": deviceJSON,
|
|
92
|
+
"options": ["engine": "simframed", "maxDim": options.maxDim, "control": controlSocketPath],
|
|
93
|
+
"startedAt": Self.nowMs(),
|
|
94
|
+
"version": Self.stateVersion,
|
|
95
|
+
]
|
|
96
|
+
try writeAtomic(try JSONSerialization.data(withJSONObject: meta, options: [.prettyPrinted]),
|
|
97
|
+
to: root.appendingPathComponent("meta.json"))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public func release() {
|
|
101
|
+
let url = root.appendingPathComponent("meta.json")
|
|
102
|
+
guard var meta = (try? Data(contentsOf: url)).flatMap({ try? JSONSerialization.jsonObject(with: $0) }) as? [String: Any],
|
|
103
|
+
(meta["pid"] as? Int) == Int(getpid()) else { return }
|
|
104
|
+
meta["pid"] = NSNull()
|
|
105
|
+
meta["stoppedAt"] = Self.nowMs()
|
|
106
|
+
try? writeAtomic(try JSONSerialization.data(withJSONObject: meta, options: [.prettyPrinted]), to: url)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Milliseconds since a client last asked for anything, so an unattended
|
|
110
|
+
/// daemon can retire itself the way the Node one does.
|
|
111
|
+
///
|
|
112
|
+
/// Measured from daemon start when no client has ever checked in. Returning
|
|
113
|
+
/// infinity for a missing heartbeat made a freshly started daemon exit on
|
|
114
|
+
/// its first loop, before any client had the chance to connect.
|
|
115
|
+
public func heartbeatAge() -> Double {
|
|
116
|
+
let url = root.appendingPathComponent("heartbeat")
|
|
117
|
+
let since: Double
|
|
118
|
+
if let text = try? String(contentsOf: url, encoding: .utf8),
|
|
119
|
+
let at = Double(text.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
|
120
|
+
since = max(at, startedAt)
|
|
121
|
+
} else {
|
|
122
|
+
since = startedAt
|
|
123
|
+
}
|
|
124
|
+
return Self.nowMs() - since
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// Whether meta.json still names this process. A replacement daemon taking
|
|
128
|
+
/// over is how the single-writer rule is enforced.
|
|
129
|
+
public func stillOwner() -> Bool {
|
|
130
|
+
guard let data = try? Data(contentsOf: root.appendingPathComponent("meta.json")),
|
|
131
|
+
let meta = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false }
|
|
132
|
+
return (meta["pid"] as? Int) == Int(getpid())
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private let fullWriteQueue = DispatchQueue(label: "simframe.fullframe", qos: .utility)
|
|
136
|
+
private var lastFullAt: Double = 0
|
|
137
|
+
|
|
138
|
+
/// Whether a native-resolution frame is worth taking on this tick. These
|
|
139
|
+
/// exist only as an OCR source for the current screen, so one every so
|
|
140
|
+
/// often is plenty — and encoding one costs ~115ms, far more than the
|
|
141
|
+
/// entire rest of the loop.
|
|
142
|
+
public func wantsFullFrame(now: Double = FrameStore.nowMs()) -> Bool {
|
|
143
|
+
now - lastFullAt >= options.fullFrameIntervalMs
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@discardableResult
|
|
147
|
+
public func record(_ bmp: Bitmap, fullBitmap: Bitmap?, captureMs: Double) throws -> [String: Any] {
|
|
148
|
+
let now = Self.nowMs()
|
|
149
|
+
let nextSeq = seq + 1
|
|
150
|
+
|
|
151
|
+
let signature = Hashing.regionSignature(bmp)
|
|
152
|
+
let firstFrame = prevSignature == nil
|
|
153
|
+
let diff = Hashing.signatureDiff(signature, prevSignature)
|
|
154
|
+
let deltas = Hashing.regionDeltas(signature, prevSignature)
|
|
155
|
+
let changed = !firstFrame && diff > options.changeThreshold
|
|
156
|
+
if changed || firstFrame { lastChangeAt = now }
|
|
157
|
+
|
|
158
|
+
let ringURL = root.appendingPathComponent("ring/\(nextSeq).png")
|
|
159
|
+
let png = try PNGWriter.encode(bmp)
|
|
160
|
+
try png.write(to: ringURL)
|
|
161
|
+
try writeAtomic(png, to: root.appendingPathComponent("latest.png"))
|
|
162
|
+
|
|
163
|
+
// Encoded off the capture path: a native-resolution PNG costs more
|
|
164
|
+
// than everything else here combined.
|
|
165
|
+
let fullURL = root.appendingPathComponent("full/\(nextSeq).png")
|
|
166
|
+
if let fullBitmap {
|
|
167
|
+
lastFullAt = now
|
|
168
|
+
fullWriteQueue.async { try? PNGWriter.write(fullBitmap, to: fullURL) }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
seq = nextSeq
|
|
172
|
+
prevSignature = signature
|
|
173
|
+
|
|
174
|
+
// Motion analysis runs on its own grid: coarse enough to be free, fine
|
|
175
|
+
// enough to localise a spinner and measure a scroll offset.
|
|
176
|
+
let motionGray = Motion.grid(from: bmp)
|
|
177
|
+
motionHistory.append(Motion.Frame(at: now, gray: motionGray))
|
|
178
|
+
if motionHistory.count > 30 { motionHistory.removeFirst(motionHistory.count - 30) }
|
|
179
|
+
let motion = Motion.state(history: motionHistory, now: now)
|
|
180
|
+
// Classify only on the edge into settled: comparing two settled screens
|
|
181
|
+
// is the only comparison that describes a completed transition.
|
|
182
|
+
if motion.settled, !wasSettled, let previous = lastSettledGray {
|
|
183
|
+
lastTransition = Motion.classify(
|
|
184
|
+
before: previous, after: motionGray,
|
|
185
|
+
pointHeight: Double(device.pointHeight), pointWidth: Double(device.pointWidth)
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
if motion.settled { lastSettledGray = motionGray }
|
|
189
|
+
wasSettled = motion.settled
|
|
190
|
+
|
|
191
|
+
let hash = Hashing.frameHash(bmp)
|
|
192
|
+
history.append([
|
|
193
|
+
"seq": nextSeq, "at": now, "hash": hash,
|
|
194
|
+
"sig": Hashing.signatureToHex(signature),
|
|
195
|
+
"diff": firstFrame ? 0 : round(diff * 100_000) / 100_000,
|
|
196
|
+
])
|
|
197
|
+
let cutoff = now - options.historyMs
|
|
198
|
+
history = history.filter { ($0["at"] as? Double ?? 0) >= cutoff }.suffix(options.historySize).map { $0 }
|
|
199
|
+
|
|
200
|
+
ringIndex.append((nextSeq, now, false))
|
|
201
|
+
thinRing(now: now)
|
|
202
|
+
// Housekeeping stats every retained frame, so it runs periodically
|
|
203
|
+
// rather than on the capture path of every single tick.
|
|
204
|
+
if nextSeq % options.housekeepEvery == 0 {
|
|
205
|
+
shrinkAgedFrames(now: now)
|
|
206
|
+
enforceByteBudget()
|
|
207
|
+
pruneFull()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let state: [String: Any] = [
|
|
211
|
+
"seq": nextSeq,
|
|
212
|
+
"capturedAt": now,
|
|
213
|
+
"captureMs": round(captureMs),
|
|
214
|
+
"width": bmp.width,
|
|
215
|
+
"height": bmp.height,
|
|
216
|
+
"hash": hash,
|
|
217
|
+
"layoutHash": Hashing.layoutHash(bmp),
|
|
218
|
+
"diff": firstFrame ? NSNull() : round(diff * 100_000) / 100_000,
|
|
219
|
+
"changed": changed,
|
|
220
|
+
"firstFrame": firstFrame,
|
|
221
|
+
"stableForMs": now - lastChangeAt,
|
|
222
|
+
"regions": firstFrame ? deltas.map { _ in 0.0 } : deltas.map { round($0 * 10_000) / 10_000 },
|
|
223
|
+
"lastChangeAt": lastChangeAt,
|
|
224
|
+
"history": history,
|
|
225
|
+
"ring": ringIndex.map { r -> [String: Any] in
|
|
226
|
+
r.small ? ["seq": r.seq, "at": r.at, "small": true] : ["seq": r.seq, "at": r.at]
|
|
227
|
+
},
|
|
228
|
+
"settled": motion.settled,
|
|
229
|
+
"motion": motion.json,
|
|
230
|
+
"transition": lastTransition?.json as Any? ?? NSNull(),
|
|
231
|
+
"fullFile": fullURL.path,
|
|
232
|
+
"ringFile": ringURL.path,
|
|
233
|
+
"device": deviceJSON,
|
|
234
|
+
]
|
|
235
|
+
try writeAtomic(try JSONSerialization.data(withJSONObject: state), to: root.appendingPathComponent("state.json"))
|
|
236
|
+
return state
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/// Keep every frame inside `fineMs`, then roughly one per `keyframeMs` out
|
|
240
|
+
/// to `retainMs`. Fine detail where transitions live, cheap recall beyond.
|
|
241
|
+
private func thinRing(now: Double) {
|
|
242
|
+
var kept: [(seq: Int, at: Double, small: Bool)] = []
|
|
243
|
+
var lastKeptAt: Double?
|
|
244
|
+
for frame in ringIndex.reversed() {
|
|
245
|
+
let age = now - frame.at
|
|
246
|
+
if age > options.retainMs { drop(frame.seq); continue }
|
|
247
|
+
if age <= options.fineMs || lastKeptAt == nil || (lastKeptAt! - frame.at) >= options.keyframeMs {
|
|
248
|
+
kept.append(frame)
|
|
249
|
+
lastKeptAt = frame.at
|
|
250
|
+
} else {
|
|
251
|
+
drop(frame.seq)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
ringIndex = kept.reversed().suffix(options.ringSize).map { $0 }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private func drop(_ seq: Int) {
|
|
258
|
+
try? fm.removeItem(at: root.appendingPathComponent("ring/\(seq).png"))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private func shrinkAgedFrames(now: Double) {
|
|
262
|
+
for i in ringIndex.indices where !ringIndex[i].small && (now - ringIndex[i].at) > options.fineMs {
|
|
263
|
+
let url = root.appendingPathComponent("ring/\(ringIndex[i].seq).png")
|
|
264
|
+
if let small = CoreGraphicsScaler.downscalePNG(at: url, factor: options.recallScale) {
|
|
265
|
+
try? PNGWriter.write(small, to: url)
|
|
266
|
+
}
|
|
267
|
+
// Marked regardless: a frame we cannot shrink is still a frame we
|
|
268
|
+
// can serve, and retrying it every tick would be pure waste.
|
|
269
|
+
ringIndex[i].small = true
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private func enforceByteBudget() {
|
|
274
|
+
var total = 0
|
|
275
|
+
var sizes: [Int] = []
|
|
276
|
+
for f in ringIndex {
|
|
277
|
+
let path = root.appendingPathComponent("ring/\(f.seq).png").path
|
|
278
|
+
let size = ((try? fm.attributesOfItem(atPath: path))?[.size] as? Int) ?? 0
|
|
279
|
+
sizes.append(size)
|
|
280
|
+
total += size
|
|
281
|
+
}
|
|
282
|
+
var i = 0
|
|
283
|
+
while i < ringIndex.count && total > options.maxRingBytes {
|
|
284
|
+
drop(ringIndex[i].seq)
|
|
285
|
+
total -= sizes[i]
|
|
286
|
+
ringIndex.remove(at: i)
|
|
287
|
+
sizes.remove(at: i)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private func pruneFull() {
|
|
292
|
+
let dir = root.appendingPathComponent("full")
|
|
293
|
+
guard let names = try? fm.contentsOfDirectory(atPath: dir.path) else { return }
|
|
294
|
+
let pngs = names.filter { $0.hasSuffix(".png") }
|
|
295
|
+
.compactMap { name -> (Int, String)? in
|
|
296
|
+
Int(name.replacingOccurrences(of: ".png", with: "")).map { ($0, name) }
|
|
297
|
+
}
|
|
298
|
+
.sorted { $0.0 < $1.0 }
|
|
299
|
+
for (_, name) in pngs.dropLast(options.fullKeep) {
|
|
300
|
+
try? fm.removeItem(at: dir.appendingPathComponent(name))
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Ports of the algorithms in src/analyze.js.
|
|
4
|
+
///
|
|
5
|
+
/// These must stay bit-identical to the JavaScript, because screen-memory keys
|
|
6
|
+
/// and change baselines already on disk were computed by it. Any change here is
|
|
7
|
+
/// a change there, and vice versa.
|
|
8
|
+
public enum Hashing {
|
|
9
|
+
public static let hashCols = 8
|
|
10
|
+
public static let hashRows = 16
|
|
11
|
+
public static let regionCols = 4
|
|
12
|
+
public static let regionRows = 8
|
|
13
|
+
public static let layoutCols = 12
|
|
14
|
+
public static let layoutRows = 24
|
|
15
|
+
public static let layoutTopSkip = 0.07
|
|
16
|
+
|
|
17
|
+
/// Box-average into a small grayscale grid, matching grayGrid() exactly,
|
|
18
|
+
/// including its integer truncation and its round-half-away-from-zero.
|
|
19
|
+
public static func grayGrid(_ bmp: Bitmap, cols: Int, rows: Int) -> [UInt8] {
|
|
20
|
+
var gray = [UInt8](repeating: 0, count: cols * rows)
|
|
21
|
+
for ry in 0..<rows {
|
|
22
|
+
let y0 = (ry * bmp.height) / rows
|
|
23
|
+
let y1 = max(y0 + 1, ((ry + 1) * bmp.height) / rows)
|
|
24
|
+
for rx in 0..<cols {
|
|
25
|
+
let x0 = (rx * bmp.width) / cols
|
|
26
|
+
let x1 = max(x0 + 1, ((rx + 1) * bmp.width) / cols)
|
|
27
|
+
var sum = 0.0
|
|
28
|
+
var n = 0
|
|
29
|
+
for y in y0..<y1 {
|
|
30
|
+
for x in x0..<x1 {
|
|
31
|
+
let i = (y * bmp.width + x) * 4
|
|
32
|
+
sum += (Double(bmp.data[i]) * 299 + Double(bmp.data[i + 1]) * 587 + Double(bmp.data[i + 2]) * 114) / 1000
|
|
33
|
+
n += 1
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
gray[ry * cols + rx] = UInt8(clamping: Int((sum / Double(n)).rounded()))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return gray
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// 128-bit mean-threshold hash of the whole frame: identical screens hash
|
|
43
|
+
/// identically even though the encoded bytes never do.
|
|
44
|
+
public static func frameHash(_ bmp: Bitmap) -> String {
|
|
45
|
+
let gray = grayGrid(bmp, cols: hashCols, rows: hashRows)
|
|
46
|
+
let mean = gray.reduce(0.0) { $0 + Double($1) } / Double(gray.count)
|
|
47
|
+
var hex = ""
|
|
48
|
+
var i = 0
|
|
49
|
+
while i < gray.count {
|
|
50
|
+
var nibble = 0
|
|
51
|
+
for b in 0..<4 where Double(gray[i + b]) > mean { nibble |= 1 << b }
|
|
52
|
+
hex += String(nibble, radix: 16)
|
|
53
|
+
i += 4
|
|
54
|
+
}
|
|
55
|
+
return hex
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// A hash of layout rather than content: the status bar is cropped and each
|
|
59
|
+
/// bit compares a cell with its right-hand neighbour, so new list rows and a
|
|
60
|
+
/// ticking clock do not move it.
|
|
61
|
+
public static func layoutHash(_ bmp: Bitmap) -> String {
|
|
62
|
+
let top = Int(Double(bmp.height) * layoutTopSkip)
|
|
63
|
+
let cropped = Bitmap(
|
|
64
|
+
width: bmp.width,
|
|
65
|
+
height: bmp.height - top,
|
|
66
|
+
data: Array(bmp.data[(top * bmp.width * 4)...])
|
|
67
|
+
)
|
|
68
|
+
let gray = grayGrid(cropped, cols: layoutCols + 1, rows: layoutRows)
|
|
69
|
+
var bits: [Int] = []
|
|
70
|
+
bits.reserveCapacity(layoutCols * layoutRows)
|
|
71
|
+
for r in 0..<layoutRows {
|
|
72
|
+
for c in 0..<layoutCols {
|
|
73
|
+
let i = r * (layoutCols + 1) + c
|
|
74
|
+
bits.append(gray[i] > gray[i + 1] ? 1 : 0)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
var hex = ""
|
|
78
|
+
var i = 0
|
|
79
|
+
while i < bits.count {
|
|
80
|
+
let nibble = bits[i] | (bits[i + 1] << 1) | (bits[i + 2] << 2) | (bits[i + 3] << 3)
|
|
81
|
+
hex += String(nibble, radix: 16)
|
|
82
|
+
i += 4
|
|
83
|
+
}
|
|
84
|
+
return hex
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public static func regionSignature(_ bmp: Bitmap) -> [UInt8] {
|
|
88
|
+
grayGrid(bmp, cols: regionCols, rows: regionRows)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public static func signatureToHex(_ sig: [UInt8]) -> String {
|
|
92
|
+
sig.map { String(format: "%02x", $0) }.joined()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public static func signatureDiff(_ a: [UInt8], _ b: [UInt8]?) -> Double {
|
|
96
|
+
guard let b, a.count == b.count, !a.isEmpty else { return 1 }
|
|
97
|
+
var sum = 0.0
|
|
98
|
+
for i in 0..<a.count { sum += abs(Double(a[i]) - Double(b[i])) }
|
|
99
|
+
return sum / Double(a.count) / 255.0
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public static func regionDeltas(_ a: [UInt8], _ b: [UInt8]?) -> [Double] {
|
|
103
|
+
guard let b, a.count == b.count else { return a.map { _ in 1 } }
|
|
104
|
+
return (0..<a.count).map { abs(Double(a[$0]) - Double(b[$0])) / 255.0 }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private static let bitCount: [Int] = [0,1,1,2,1,2,2,3,1,2,2,3,2,3,3,4]
|
|
108
|
+
|
|
109
|
+
public static func hashDistance(_ a: String, _ b: String) -> Int {
|
|
110
|
+
guard a.count == b.count else { return Int.max }
|
|
111
|
+
var d = 0
|
|
112
|
+
for (x, y) in zip(a, b) {
|
|
113
|
+
let xv = Int(String(x), radix: 16) ?? 0
|
|
114
|
+
let yv = Int(String(y), radix: 16) ?? 0
|
|
115
|
+
d += bitCount[(xv ^ yv) & 0xf]
|
|
116
|
+
}
|
|
117
|
+
return d
|
|
118
|
+
}
|
|
119
|
+
}
|