Rename repo and app

This commit is contained in:
Christoph Hagen
2022-04-28 15:54:13 +02:00
parent 1636932805
commit c119885743
78 changed files with 91 additions and 34 deletions

203
Caps/Data/Cap.swift Normal file
View File

@@ -0,0 +1,203 @@
//
// Cap.swift
// CapCollector
//
// Created by Christoph on 19.11.18.
// Copyright © 2018 CH. All rights reserved.
//
import Foundation
import UIKit
import CoreImage
import SQLite
struct Cap {
// MARK: - Static constants
static let sufficientImageCount = 10
static let imageWidth = 299 // New for XCode models, 227/229 for turicreate
static let imageSize = CGSize(width: imageWidth, height: imageWidth)
static let jpgQuality: CGFloat = 0.3
private static let mosaicColumns = 40
static let mosaicCellSize: CGFloat = 60
private static let mosaicRowHeight = mosaicCellSize * 0.866
private static let mosaicMargin = mosaicCellSize - mosaicRowHeight
// MARK: - Variables
/// The unique number of the cap
let id: Int
/// The name of the cap
let name: String
/// The name of the cap without special characters
let cleanName: String
/// The number of images existing for the cap
let count: Int
/// Indicate if the cap can be found by the recognition model
let matched: Bool
/// Indicate if the cap is present on the server
let uploaded: Bool
// MARK: Init
init(name: String, id: Int) {
self.id = id
self.count = 1
self.name = name
self.cleanName = ""
self.matched = false
self.uploaded = false
}
init(id: Int, name: String, count: Int) {
self.id = id
self.name = name
self.count = count
self.cleanName = name.clean
self.matched = false
self.uploaded = true
}
func renamed(to name: String) -> Cap {
Cap(from: self, renamed: name)
}
init(from cap: Cap, renamed newName: String) {
self.id = cap.id
self.count = cap.count
self.name = newName
self.cleanName = newName.clean
self.matched = cap.matched
self.uploaded = cap.uploaded
}
// MARK: SQLite
init(row: Row) {
self.id = row[Cap.columnId]
self.name = row[Cap.columnName]
self.count = row[Cap.columnCount]
self.cleanName = name.clean
self.matched = row[Cap.columnMatched]
self.uploaded = row[Cap.columnUploaded]
}
static let table = Table("data")
static var createQuery: String {
table.create(ifNotExists: true) { t in
t.column(columnId, primaryKey: true)
t.column(columnName)
t.column(columnCount)
t.column(columnMatched)
t.column(columnUploaded)
}
}
static let columnId = Expression<Int>("id")
static let columnName = Expression<String>("name")
static let columnCount = Expression<Int>("count")
static let columnMatched = Expression<Bool>("matched")
static let columnUploaded = Expression<Bool>("uploaded")
var insertQuery: Insert {
return Cap.table.insert(
Cap.columnId <- id,
Cap.columnName <- name,
Cap.columnCount <- count,
Cap.columnMatched <- matched,
Cap.columnUploaded <- uploaded)
}
// MARK: Display
func matchLabelText(match: Float?, appIsUnlocked: Bool) -> String {
if let match = match {
let percent = Int((match * 100).rounded())
return String(format: "%d %%", arguments: [percent])
}
guard matched else {
return "📵"
}
guard appIsUnlocked, !hasSufficientImages else {
return ""
}
return "⚠️"
}
func countLabelText(appIsUnlocked: Bool) -> String {
guard appIsUnlocked else {
return "\(id)"
}
guard count != 1 else {
return "\(id) (1 image)"
}
return "\(id) (\(count) images)"
}
// MARK: Images
var hasSufficientImages: Bool {
count >= Cap.sufficientImageCount
}
static func thumbnail(for image: UIImage) -> UIImage {
let len = GridViewController.len * 2
return image.resize(to: CGSize.init(width: len, height: len))
}
}
// MARK: - Protocol Hashable
extension Cap: Hashable {
static func == (lhs: Cap, rhs: Cap) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
// MARK: - Protocol Logger
extension Cap: Logger { }
// MARK: - String extension
extension String {
var clean: String {
return lowercased().replacingOccurrences(of: "[^a-z0-9 ]", with: "", options: .regularExpression)
}
}
// MARK: - Int extension
private extension Int {
var isEven: Bool {
return self % 2 == 0
}
}

View File

@@ -0,0 +1,64 @@
//
// VisionHandler.swift
// CapFinder
//
// Created by User on 12.02.18.
// Copyright © 2018 User. All rights reserved.
//
import Foundation
import Vision
import CoreML
import UIKit
/// Recognise categories in images
class Classifier: Logger {
static let userDefaultsKey = "classifier.version"
let model: VNCoreMLModel
init(model: VNCoreMLModel) {
self.model = model
}
/**
Classify an image
- Parameter image: The image to classify
- Note: This method should not be scheduled on the main thread.
*/
func recognize(image: UIImage, completion: @escaping (_ matches: [Int: Float]?) -> Void) {
guard let ciImage = CIImage(image: image) else {
error("Unable to create CIImage")
completion(nil)
return
}
let orientation = CGImagePropertyOrientation(image.imageOrientation)
let handler = VNImageRequestHandler(ciImage: ciImage, orientation: orientation)
let request = VNCoreMLRequest(model: model) { request, error in
let matches = self.process(request: request, error: error)
completion(matches)
}
request.imageCropAndScaleOption = .centerCrop
do {
try handler.perform([request])
} catch {
self.error("Failed to perform classification: \(error)")
}
}
private func process(request: VNRequest, error: Error?) -> [Int : Float]? {
if let e = error {
self.error("Unable to classify image: \(e.localizedDescription)")
return nil
}
guard let result = request.results as? [VNClassificationObservation] else {
self.error("Invalid classifier result: \(String(describing: request.results))")
return nil
}
let matches = result.reduce(into: [:]) { $0[Int($1.identifier)!] = $1.confidence }
log("Classifed image with \(matches.count) classes")
return matches
}
}

132
Caps/Data/Colors.swift Normal file
View File

@@ -0,0 +1,132 @@
//
// Colors.swift
// CapCollector
//
// Created by Christoph on 26.05.20.
// Copyright © 2020 CH. All rights reserved.
//
import UIKit
import SQLite
extension Database {
enum Colors {
static let table = Table("colors")
static let columnRed = Expression<Double>("red")
static let columnGreen = Expression<Double>("green")
static let columnBlue = Expression<Double>("blue")
static var createQuery: String {
table.create(ifNotExists: true) { t in
t.column(Cap.columnId, primaryKey: true)
t.column(columnRed)
t.column(columnGreen)
t.column(columnBlue)
}
}
}
var colors: [Int : UIColor] {
do {
let rows = try db.prepare(Database.Colors.table)
return rows.reduce(into: [:]) { dict, row in
let id = row[Cap.columnId]
let r = CGFloat(row[Database.Colors.columnRed])
let g = CGFloat(row[Database.Colors.columnGreen])
let b = CGFloat(row[Database.Colors.columnBlue])
dict[id] = UIColor(red: r, green: g, blue: b, alpha: 1.0)
}
} catch {
log("Failed to load cap colors: \(error)")
return [:]
}
}
var capsWithColors: Set<Int> {
do {
let rows = try db.prepare(Database.Colors.table.select(Cap.columnId))
return Set(rows.map { $0[Cap.columnId]})
} catch {
log("Failed to load caps with colors: \(error)")
return []
}
}
var capsWithoutColors: Set<Int> {
Set(1...capCount).subtracting(capsWithColors)
}
func removeColor(for cap: Int) -> Bool {
do {
try db.run(Colors.table.filter(Cap.columnId == cap).delete())
return true
} catch {
log("Failed to delete cap color \(cap): \(error)")
return false
}
}
func set(color: UIColor, for cap: Int) -> Bool {
guard let _ = row(for: cap) else {
return insert(color: color, for: cap)
}
return update(color: color, for: cap)
}
private func insert(color: UIColor, for cap: Int) -> Bool {
let (red, green, blue) = color.rgb
let query = Database.Colors.table.insert(
Cap.columnId <- cap,
Database.Colors.columnRed <- red,
Database.Colors.columnGreen <- green,
Database.Colors.columnBlue <- blue)
do {
try db.run(query)
return true
} catch {
log("Failed to insert color for cap \(cap): \(error)")
return false
}
}
private func update(color: UIColor, for cap: Int) -> Bool {
let (red, green, blue) = color.rgb
let query = Database.Colors.table.filter(Cap.columnId == cap).update(
Database.Colors.columnRed <- red,
Database.Colors.columnGreen <- green,
Database.Colors.columnBlue <- blue)
do {
try db.run(query)
return true
} catch {
log("Failed to update color for cap \(cap): \(error)")
return false
}
}
private func row(for cap: Int) -> Row? {
do {
return try db.pluck(Database.Colors.table.filter(Cap.columnId == cap))
} catch {
log("Failed to get color for cap \(cap): \(error)")
return nil
}
}
func color(for cap: Int) -> UIColor? {
guard let row = self.row(for: cap) else {
return nil
}
let r = CGFloat(row[Database.Colors.columnRed])
let g = CGFloat(row[Database.Colors.columnGreen])
let b = CGFloat(row[Database.Colors.columnBlue])
return UIColor(red: r, green: g, blue: b, alpha: 1.0)
}
}

1042
Caps/Data/Database.swift Normal file

File diff suppressed because it is too large Load Diff

358
Caps/Data/Download.swift Normal file
View File

@@ -0,0 +1,358 @@
//
// Download.swift
// CapCollector
//
// Created by Christoph on 26.04.20.
// Copyright © 2020 CH. All rights reserved.
//
import Foundation
import UIKit
final class Download {
let serverUrl: URL
let session: URLSession
let delegate: Delegate
private var downloadingMainImages = Set<Int>()
init(server: URL) {
let delegate = Delegate()
self.serverUrl = server
self.session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)
self.delegate = delegate
}
// MARK: Paths
var serverNameListUrl: URL {
Download.serverNameListUrl(server: serverUrl)
}
private static func serverNameListUrl(server: URL) -> URL {
server.appendingPathComponent("names.txt")
}
private var serverClassifierVersionUrl: URL {
serverUrl.appendingPathComponent("classifier.version")
}
private var serverRecognitionModelUrl: URL {
serverUrl.appendingPathComponent("classifier.mlmodel")
}
private var serverAllCountsUrl: URL {
serverUrl.appendingPathComponent("counts")
}
var serverImageUrl: URL {
serverUrl.appendingPathComponent("images")
}
private func serverImageUrl(for cap: Int, version: Int = 0) -> URL {
serverImageUrl.appendingPathComponent(String(format: "%04d/%04d-%02d.jpg", cap, cap, version))
}
private func serverNameUrl(for cap: Int) -> URL {
serverUrl.appendingPathComponent("name/\(cap)")
}
private func serverImageCountUrl(for cap: Int) -> URL {
serverUrl.appendingPathComponent("count/\(cap)")
}
// MARK: Delegate
final class Delegate: NSObject, URLSessionDownloadDelegate {
typealias ProgressHandler = (_ progress: Float, _ bytesWritten: Int64, _ totalBytes: Int64) -> Void
typealias CompletionHandler = (_ url: URL?) -> Void
private var progress = [URLSessionDownloadTask : Float]()
private var callbacks = [URLSessionDownloadTask : ProgressHandler]()
private var completions = [URLSessionDownloadTask : CompletionHandler]()
func registerForProgress(_ downloadTask: URLSessionDownloadTask, callback: ProgressHandler?, completion: @escaping CompletionHandler) {
progress[downloadTask] = 0
callbacks[downloadTask] = callback
completions[downloadTask] = completion
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
completions[downloadTask]?(location)
callbacks[downloadTask] = nil
progress[downloadTask] = nil
completions[downloadTask] = nil
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
let ratio = totalBytesExpectedToWrite > 0 ? Float(totalBytesWritten) / Float(totalBytesExpectedToWrite) : 0
progress[downloadTask] = ratio
callbacks[downloadTask]?(ratio, totalBytesWritten, totalBytesExpectedToWrite)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let downloadTask = task as? URLSessionDownloadTask else {
return
}
completions[downloadTask]?(nil)
callbacks[downloadTask] = nil
progress[downloadTask] = nil
completions[downloadTask] = nil
}
}
// MARK: Downloading data
func image(for cap: Int, to url: URL, timeout: TimeInterval = 30) -> Bool {
let group = DispatchGroup()
group.enter()
var result = true
let success = image(for: cap, version: 0, to: url) { success in
result = success
group.leave()
}
guard success else {
log("Already downloading image for cap \(cap)")
return false
}
guard group.wait(timeout: .now() + timeout) == .success else {
log("Timed out downloading image for cap \(cap)")
return false
}
return result
}
/**
Download an image for a cap.
- Parameter cap: The id of the cap.
- Parameter version: The image version to download.
- Parameter completion: A closure with the resulting image
- Returns: `true`, of the file download was started, `false`, if the image is already downloading.
*/
@discardableResult
func image(for cap: Int, version: Int = 0, to url: URL, queue: DispatchQueue = .main, completion: @escaping (Bool) -> Void) -> Bool {
// Check if main image, and already being downloaded
if version == 0 {
guard !downloadingMainImages.contains(cap) else {
return false
}
downloadingMainImages.insert(cap)
}
let serverUrl = serverImageUrl(for: cap, version: version)
let query = "Image of cap \(cap) version \(version)"
let task = session.downloadTask(with: serverUrl) { fileUrl, response, error in
if version == 0 {
queue.async {
self.downloadingMainImages.remove(cap)
}
}
guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else {
completion(false)
return
}
do {
if FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url)
}
try FileManager.default.moveItem(at: fileUrl, to: url)
} catch {
self.log("Failed to move downloaded image for cap \(cap): \(error)")
completion(false)
}
completion(true)
}
task.resume()
return true
}
func imageCount(for cap: Int, completion: @escaping (_ count: Int?) -> Void) {
let url = serverImageCountUrl(for: cap)
let query = "Image count for cap \(cap)"
session.startTaskExpectingInt(with: url, query: query, completion: completion)
}
func name(for cap: Int, completion: @escaping (_ name: String?) -> Void) {
let url = serverNameUrl(for: cap)
let query = "Name for cap \(cap)"
session.startTaskExpectingString(with: url, query: query, completion: completion)
}
func imageCounts(completion: @escaping ([Int]?) -> Void) {
let query = "Image count of all caps"
session.startTaskExpectingData(with: serverAllCountsUrl, query: query) { data in
guard let data = data else {
completion(nil)
return
}
completion(data.map(Int.init))
}
}
func names(completion: @escaping ([String]?) -> Void) {
let query = "Download of server database"
session.startTaskExpectingString(with: serverNameListUrl, query: query) { string in
completion(string?.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\n"))
}
}
func databaseSize(completion: @escaping (_ size: Int64?) -> Void) {
size(of: "database size", to: serverNameListUrl, completion: completion)
}
func classifierVersion(completion: @escaping (Int?) -> Void) {
let query = "Server classifier version"
session.startTaskExpectingInt(with: serverClassifierVersionUrl, query: query, completion: completion)
}
func classifierSize(completion: @escaping (Int64?) -> Void) {
size(of: "classifier size", to: serverRecognitionModelUrl, completion: completion)
}
func classifier(progress: Delegate.ProgressHandler? = nil, completion: @escaping (URL?) -> Void) {
let task = session.downloadTask(with: serverRecognitionModelUrl)
delegate.registerForProgress(task, callback: progress) { url in
self.log("Classifier download complete")
completion(url)
}
task.resume()
}
// MARK: Requests
private func size(of query: String, to url: URL, completion: @escaping (_ size: Int64?) -> Void) {
var request = URLRequest(url: url)
request.httpMethod = "HEAD"
let task = session.dataTask(with: request) { _, response, _ in
guard let r = response else {
self.log("Request '\(query)' received no response")
completion(nil)
return
}
completion(r.expectedContentLength)
}
task.resume()
}
private func convertIntResponse(to query: String, _ data: Data?, _ response: URLResponse?, _ error: Error?) -> Int? {
guard let string = self.convertStringResponse(to: query, data, response, error) else {
return nil
}
guard let int = Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) else {
self.log("Request '\(query)' received an invalid value '\(string)'")
return nil
}
return int
}
private func convertStringResponse(to query: String, _ data: Data?, _ response: URLResponse?, _ error: Error?) -> String? {
guard let data = self.convertResponse(to: query, data, response, error) else {
return nil
}
guard let string = String(data: data, encoding: .utf8) else {
self.log("Request '\(query)' received invalid data (not a string)")
return nil
}
return string
}
private func convertResponse<T>(to query: String, _ result: T?, _ response: URLResponse?, _ error: Error?) -> T? {
if let error = error {
log("Request '\(query)' produced an error: \(error)")
return nil
}
guard let response = response else {
log("Request '\(query)' received no response")
return nil
}
guard let urlResponse = response as? HTTPURLResponse else {
log("Request '\(query)' received an invalid response: \(response)")
return nil
}
guard urlResponse.statusCode == 200 else {
log("Request '\(query)' failed with status code \(urlResponse.statusCode)")
return nil
}
guard let r = result else {
log("Request '\(query)' received no item")
return nil
}
return r
}
}
extension Download: Logger { }
extension URLSession {
func startTaskExpectingData(with url: URL, query: String, completion: @escaping (Data?) -> Void) {
let task = dataTask(with: url) { data, response, error in
if let error = error {
log("Request '\(query)' produced an error: \(error)")
completion(nil)
return
}
guard let response = response else {
log("Request '\(query)' received no response")
completion(nil)
return
}
guard let urlResponse = response as? HTTPURLResponse else {
log("Request '\(query)' received an invalid response: \(response)")
completion(nil)
return
}
guard urlResponse.statusCode == 200 else {
log("Request '\(query)' failed with status code \(urlResponse.statusCode)")
completion(nil)
return
}
guard let d = data else {
log("Request '\(query)' received no data")
completion(nil)
return
}
completion(d)
}
task.resume()
}
func startTaskExpectingString(with url: URL, query: String, completion: @escaping (String?) -> Void) {
startTaskExpectingData(with: url, query: query) { data in
guard let data = data else {
completion(nil)
return
}
guard let string = String(data: data, encoding: .utf8) else {
log("Request '\(query)' received invalid data (not a string)")
completion(nil)
return
}
completion(string)
}
}
func startTaskExpectingInt(with url: URL, query: String, completion: @escaping (Int?) -> Void) {
startTaskExpectingString(with: url, query: query) { string in
guard let string = string else {
completion(nil)
return
}
guard let int = Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) else {
log("Request '\(query)' received an invalid value '\(string)'")
completion(nil)
return
}
completion(int)
}
}
}

374
Caps/Data/Storage.swift Normal file
View File

@@ -0,0 +1,374 @@
//
// DiskManager.swift
// CapFinder
//
// Created by User on 23.04.18.
// Copyright © 2018 User. All rights reserved.
//
import Foundation
import UIKit
import CoreML
import Vision
protocol ImageProvider: AnyObject {
func image(for cap: Int) -> UIImage?
func image(for cap: Int, version: Int) -> UIImage?
func ciImage(for cap: Int) -> CIImage?
}
protocol ThumbnailCreationDelegate {
func thumbnailCreation(progress: Int, total: Int)
func thumbnailCreationFailed()
func thumbnailCreationIsMissingImages()
func thumbnailCreationCompleted()
}
final class Storage: ImageProvider {
// MARK: Paths
let fm = FileManager.default
let baseUrl: URL
// MARK: INIT
init(in folder: URL) {
self.baseUrl = folder
}
// MARK: File/folder urls
var dbUrl: URL {
baseUrl.appendingPathComponent("db.sqlite3")
}
var modelUrl: URL {
baseUrl.appendingPathComponent("model.mlmodel")
}
func localImageUrl(for cap: Int, version: Int = 0) -> URL {
baseUrl.appendingPathComponent("\(cap)-\(version).jpg")
}
private func thumbnailUrl(for cap: Int) -> URL {
baseUrl.appendingPathComponent("\(cap)-thumb.jpg")
}
private func tileImageUrl(for image: String) -> URL {
baseUrl.appendingPathComponent(image.clean + ".tile")
}
// MARK: Storage
/**
Save an image to disk
- parameter url: The url where the downloaded image is stored
- parameter cap: The cap id
- parameter version: The version of the image to get
- returns: True, if the image was saved
*/
func saveImage(at url: URL, for cap: Int, version: Int = 0) -> UIImage? {
let targetUrl = localImageUrl(for: cap, version: version)
do {
if fm.fileExists(atPath: targetUrl.path) {
try fm.removeItem(at: targetUrl)
}
try fm.moveItem(at: url, to: targetUrl)
return UIImage(contentsOfFile: targetUrl.path)
} catch {
log("Failed to delete or move image \(version) for cap \(cap)")
return nil
}
}
/**
Save an image to disk
- parameter image: The image
- parameter cap: The cap id
- parameter version: The version of the image
- returns: True, if the image was saved
*/
func save(image: UIImage, for cap: Int, version: Int = 0) -> Bool {
guard let data = image.jpegData(compressionQuality: Cap.jpgQuality) else {
return false
}
return save(imageData: data, for: cap, version: version)
}
/**
Save image data to disk
- parameter image: The data of the image
- parameter cap: The cap id
- parameter version: The version of the image
- returns: True, if the image was saved
*/
func save(imageData: Data, for cap: Int, version: Int = 0) -> Bool {
write(imageData, to: localImageUrl(for: cap, version: version))
}
/**
Save a thumbnail.
- parameter thumbnail: The image
- parameter cap: The cap id
- returns: True, if the image was saved
*/
func save(thumbnail: UIImage, for cap: Int) -> Bool {
guard let data = thumbnail.jpegData(compressionQuality: Cap.jpgQuality) else {
return false
}
return save(thumbnailData: data, for: cap)
}
/**
Save a thumbnail to the download folder
- parameter thumbnailData: The data of the image
- parameter cap: The cap id
- returns: True, if the image was saved
*/
private func save(thumbnailData: Data, for cap: Int) -> Bool {
write(thumbnailData, to: thumbnailUrl(for: cap))
}
/**
Save the downloaded and compiled recognition model.
- Parameter url: The temporary location to which the model was compiled.
- Returns: `true`, if the model was moved.
*/
func save(recognitionModelAt url: URL) -> Bool {
move(url, to: modelUrl)
}
/**
Save the downloaded and database.
- Parameter url: The temporary location to which the database was downloaded.
- Returns: `true`, if the database was moved.
*/
func save(databaseAt url: URL) -> Bool {
move(url, to: dbUrl)
}
private func move(_ url: URL, to destination: URL) -> Bool {
if fm.fileExists(atPath: destination.path) {
do {
try fm.removeItem(at: destination)
} catch {
log("Failed to remove file \(destination.lastPathComponent) before writing new version: \(error)")
return false
}
}
do {
try fm.moveItem(at: url, to: destination)
return true
} catch {
self.error("Failed to move file \(destination.lastPathComponent) to permanent location: \(error)")
return false
}
}
private func write(_ data: Data, to url: URL) -> Bool {
do {
try data.write(to: url)
} catch {
self.error("Could not write data to \(url): \(error)")
return false
}
return true
}
// MARK: High-level functions
func switchMainImage(to version: Int, for cap: Int) -> Bool {
guard deleteThumbnail(for: cap) else {
return false
}
let newImagePath = localImageUrl(for: cap, version: version)
guard fm.fileExists(atPath: newImagePath.path) else {
return deleteImage(for: cap, version: version)
}
let oldImagePath = localImageUrl(for: cap, version: 0)
return move(newImagePath, to: oldImagePath)
}
// MARK: Status
/**
Check if an image exists for a cap
- parameter cap: The id of the cap
- returns: True, if an image exists
*/
func hasImage(for cap: Int) -> Bool {
fm.fileExists(atPath: localImageUrl(for: cap, version: 0).path)
}
/**
Check if a thumbnail exists for a cap
- parameter cap: The id of the cap
- returns: True, if a thumbnail exists
*/
func hasThumbnail(for cap: Int) -> Bool {
fm.fileExists(atPath: thumbnailUrl(for: cap).path)
}
func existingImageUrl(for cap: Int, version: Int = 0) -> URL? {
let url = localImageUrl(for: cap, version: version)
return fm.fileExists(atPath: url.path) ? url : nil
}
// MARK: Retrieval
/**
Get the image data for a cap.
If the image exists on disk, it is returned.
If no image exists locally, then this function returns nil.
- parameter cap: The id of the cap
- parameter version: The image version
- returns: The image data, or `nil`
*/
func imageData(for cap: Int, version: Int = 0) -> Data? {
readData(from: localImageUrl(for: cap, version: version))
}
/**
Get the image for a cap.
If the image exists on disk, it is returned.
If no image exists locally, then this function returns nil.
- parameter cap: The id of the cap
- parameter version: The image version
- returns: The image, or `nil`
- note: Removes invalid image data on disk, if the data is not a valid image
- note: Must be called on the main thread
*/
func image(for cap: Int, version: Int) -> UIImage? {
guard let data = imageData(for: cap, version: version) else {
return nil
}
guard let image = UIImage(data: data) else {
log("Removing invalid image \(version) of cap \(cap) from disk")
deleteImage(for: cap, version: version)
return nil
}
return image
}
func image(for cap: Int) -> UIImage? {
image(for: cap, version: 0)
}
/**
Get the thumbnail data for a cap.
If the image exists on disk, it is returned.
If no image exists locally, then this function returns nil.
- parameter cap: The id of the cap
- returns: The image data, or `nil`
*/
func thumbnailData(for cap: Int) -> Data? {
readData(from: thumbnailUrl(for: cap))
}
/**
Get the thumbnail for a cap.
If the image exists on disk, it is returned.
If no image exists locally, then this function returns nil.
- parameter cap: The id of the cap
- returns: The image, or `nil`
*/
func thumbnail(for cap: Int) -> UIImage? {
guard let data = thumbnailData(for: cap) else {
return nil
}
return UIImage(data: data)
}
/// The compiled recognition model on disk
var recognitionModel: VNCoreMLModel? {
guard fm.fileExists(atPath: modelUrl.path) else {
log("No recognition model to load")
return nil
}
do {
let model = try MLModel(contentsOf: modelUrl)
return try VNCoreMLModel(for: model)
} catch {
self.error("Failed to load recognition model: \(error)")
return nil
}
}
func ciImage(for cap: Int) -> CIImage? {
let url = thumbnailUrl(for: cap)
guard fm.fileExists(atPath: url.path) else {
return nil
}
guard let image = CIImage(contentsOf: url) else {
error("Failed to read CIImage for main image of cap \(cap)")
return nil
}
return image
}
private func readData(from url: URL) -> Data? {
guard fm.fileExists(atPath: url.path) else {
return nil
}
do {
return try Data(contentsOf: url)
} catch {
self.error("Could not read data from \(url): \(error)")
return nil
}
}
// MARK: Deleting data
@discardableResult
func deleteDatabase() -> Bool {
do {
try fm.removeItem(at: dbUrl)
return true
} catch {
log("Failed to delete database: \(error)")
return false
}
}
@discardableResult
func deleteImage(for cap: Int, version: Int) -> Bool {
let url = localImageUrl(for: cap, version: version)
return delete(at: url)
}
@discardableResult
func deleteThumbnail(for cap: Int) -> Bool {
let url = thumbnailUrl(for: cap)
return delete(at: url)
}
private func delete(at url: URL) -> Bool {
guard fm.fileExists(atPath: url.path) else {
return true
}
do {
try fm.removeItem(at: url)
return true
} catch {
log("Failed to delete file \(url.lastPathComponent): \(error)")
return false
}
}
}
extension Storage: Logger { }

134
Caps/Data/TileImage.swift Normal file
View File

@@ -0,0 +1,134 @@
//
// TileImage.swift
// CapCollector
//
// Created by Christoph on 20.05.20.
// Copyright © 2020 CH. All rights reserved.
//
import Foundation
import SQLite
extension Database {
struct TileImage {
let name: String
let width: Int
/// The tiles of each cap, with the index being the tile, and the value being the cap id.
let caps: [Int]
var encodedCaps: Data {
caps.map(UInt16.init).withUnsafeBytes { (p) in
Data(buffer: p.bindMemory(to: UInt8.self))
}
}
init(name: String, width: Int, caps: [Int]) {
self.name = name
self.width = width
self.caps = caps
}
init(row: Row) {
self.name = row[TileImage.columnName]
self.width = row[TileImage.columnWidth]
self.caps = row[TileImage.columnCaps].withUnsafeBytes { p in
p.bindMemory(to: UInt16.self).map(Int.init)
}
}
var insertQuery: Insert {
TileImage.table.insert(
TileImage.columnName <- name,
TileImage.columnWidth <- width,
TileImage.columnCaps <- encodedCaps)
}
var updateQuery: Update {
TileImage.table.update(
TileImage.columnWidth <- width,
TileImage.columnCaps <- encodedCaps)
}
static let columnName = Expression<String>("name")
static let columnWidth = Expression<Int>("width")
static let columnCaps = Expression<Data>("caps")
static let table = Table("images")
static func named(_ name: String) -> Table {
table.filter(columnName == name)
}
static func exists(_ name: String) -> Table {
named(name).select(columnName)
}
static var createQuery: String {
table.create(ifNotExists: true) { t in
t.column(Cap.columnId, primaryKey: true)
t.column(columnName)
t.column(columnWidth)
t.column(columnCaps)
}
}
}
func save(tileImage: TileImage) -> Bool {
guard exists(tileImage.name) else {
return insert(tileImage)
}
return update(tileImage)
}
var tileImages: [TileImage] {
(try? db.prepare(TileImage.table).map(TileImage.init)) ?? []
}
func tileImage(named name: String) -> TileImage? {
do {
guard let row = try db.pluck(TileImage.named(name)) else {
return nil
}
return TileImage(row: row)
} catch {
log("Failed to get tile image \(name): \(error)")
return nil
}
}
private func exists(_ tileImage: String) -> Bool {
do {
return try db.pluck(TileImage.exists(tileImage)) != nil
} catch {
log("Failed to check tile image \(tileImage): \(error)")
return false
}
}
private func insert(_ tileImage: TileImage) -> Bool {
do {
try db.run(tileImage.insertQuery)
return true
} catch {
log("Failed to insert tile image \(tileImage): \(error)")
return false
}
}
private func update(_ tileImage: TileImage) -> Bool {
do {
try db.run(tileImage.updateQuery)
return true
} catch {
log("Failed to update tile image \(tileImage): \(error)")
return false
}
}
}

212
Caps/Data/Upload.swift Normal file
View File

@@ -0,0 +1,212 @@
//
// Upload.swift
// CapCollector
//
// Created by Christoph on 26.04.20.
// Copyright © 2020 CH. All rights reserved.
//
import Foundation
import UIKit
import SQLite
struct Upload {
static let offlineKey = "offline"
let serverUrl: URL
let table = Table("uploads")
let rowCapId = Expression<Int>("cap")
let rowCapVersion = Expression<Int>("version")
init(server: URL) {
self.serverUrl = server
}
// MARK: Paths
var serverImageUrl: URL {
serverUrl.appendingPathComponent("images")
}
private func serverImageUrl(for cap: Int, version: Int = 0) -> URL {
serverImageUrl.appendingPathComponent("\(cap)/\(cap)-\(version).jpg")
}
private func serverImageUploadUrl(for cap: Int) -> URL {
serverImageUrl.appendingPathComponent("\(cap)")
}
private func serverNameUploadUrl(for cap: Int) -> URL {
serverUrl.appendingPathComponent("name/\(cap)")
}
private func serverChangeMainImageUrl(for cap: Int, to newValue: Int) -> URL {
serverUrl.appendingPathComponent("switch/\(cap)/\(newValue)")
}
// MARK: SQLite
var createQuery: String {
table.create(ifNotExists: true) { t in
t.column(rowCapId)
t.column(rowCapVersion)
}
}
func existsQuery(for cap: Int, version: Int) -> ScalarQuery<Int> {
table.filter(rowCapId == cap && rowCapVersion == version).count
}
func insertQuery(for cap: Int, version: Int) -> Insert {
table.insert(rowCapId <- cap, rowCapVersion <- version)
}
func deleteQuery(for cap: Int, version: Int) -> Delete {
table.filter(rowCapId == cap && rowCapVersion == version).delete()
}
// MARK: Uploading data
func upload(name: String, for cap: Int, completion: @escaping (_ success: Bool) -> Void) {
var request = URLRequest(url: serverNameUploadUrl(for: cap))
request.httpMethod = "POST"
request.httpBody = name.data(using: .utf8)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
self.log("Failed to upload name of cap \(cap): \(error)")
completion(false)
return
}
guard let response = response else {
self.log("Failed to upload name of cap \(cap): No response")
completion(false)
return
}
guard let urlResponse = response as? HTTPURLResponse else {
self.log("Failed to upload name of cap \(cap): \(response)")
completion(false)
return
}
guard urlResponse.statusCode == 200 else {
self.log("Failed to upload name of cap \(cap): Response \(urlResponse.statusCode)")
completion(false)
return
}
completion(true)
}
task.resume()
}
func upload(_ cap: Cap, timeout: TimeInterval = 30) -> Bool {
upload(name: cap.name, for: cap.id, timeout: timeout)
}
func upload(name: String, for cap: Int, timeout: TimeInterval = 30) -> Bool {
let group = DispatchGroup()
group.enter()
var result = true
upload(name: name, for: cap) { success in
if success {
self.log("Uploaded cap \(cap)")
} else {
result = false
}
group.leave()
}
guard group.wait(timeout: .now() + timeout) == .success else {
log("Timed out uploading cap \(cap)")
return false
}
return result
}
func upload(imageAt url: URL, for cap: Int, completion: @escaping (_ count: Int?) -> Void) {
var request = URLRequest(url: serverImageUploadUrl(for: cap))
request.httpMethod = "POST"
let task = URLSession.shared.uploadTask(with: request, fromFile: url) { data, response, error in
if let error = error {
self.log("Failed to upload image of cap \(cap): \(error)")
completion(nil)
return
}
guard let response = response else {
self.log("Failed to upload image of cap \(cap): No response")
completion(nil)
return
}
guard let urlResponse = response as? HTTPURLResponse else {
self.log("Failed to upload image of cap \(cap): \(response)")
completion(nil)
return
}
guard urlResponse.statusCode == 200 else {
self.log("Failed to upload image of cap \(cap): Response \(urlResponse.statusCode)")
completion(nil)
return
}
guard let d = data, let string = String(data: d, encoding: .utf8), let int = Int(string) else {
self.log("Failed to upload image of cap \(cap): Invalid response")
completion(nil)
return
}
completion(int)
}
task.resume()
}
func upload(imageAt url: URL, of cap: Int, timeout: TimeInterval = 30) -> Int? {
let group = DispatchGroup()
group.enter()
var result: Int? = nil
upload(imageAt: url, for: cap) { count in
result = count
group.leave()
}
guard group.wait(timeout: .now() + timeout) == .success else {
log("Timed out uploading image of \(cap)")
return nil
}
return result
}
/**
Sets the main image for a cap to a different version.
- Parameter cap: The id of the cap
- Parameter version: The version to set as the main version.
- Parameter completion: A callback with the result on completion.
*/
func setMainImage(for cap: Int, to version: Int, completion: @escaping (_ success: Bool) -> Void) {
let url = serverChangeMainImageUrl(for: cap, to: version)
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
self.log("Failed to set main image of cap \(cap) to \(version): \(error)")
completion(false)
return
}
guard let response = response else {
self.log("Failed to set main image of cap \(cap) to \(version): No response")
completion(false)
return
}
guard let urlResponse = response as? HTTPURLResponse else {
self.log("Failed to set main image of cap \(cap) to \(version): \(response)")
completion(false)
return
}
guard urlResponse.statusCode == 200 else {
self.log("Failed to set main image of cap \(cap) to \(version): Response \(urlResponse.statusCode)")
completion(false)
return
}
completion(true)
}
task.resume()
}
}
extension Upload: Logger { }