Download classifier, database
This commit is contained in:
@@ -10,19 +10,18 @@ import Foundation
|
||||
import UIKit
|
||||
import CoreImage
|
||||
|
||||
import SwiftyDropbox
|
||||
import SQLite
|
||||
|
||||
protocol CapsDelegate: class {
|
||||
|
||||
func capHasUpdates(_ cap: Cap)
|
||||
|
||||
func capsLoaded()
|
||||
}
|
||||
|
||||
final class Cap {
|
||||
struct Cap {
|
||||
|
||||
// MARK: - Static variables
|
||||
|
||||
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
|
||||
@@ -33,657 +32,202 @@ final class Cap {
|
||||
|
||||
private static let mosaicMargin = mosaicCellSize - mosaicRowHeight
|
||||
|
||||
static var delegate: CapsDelegate?
|
||||
|
||||
static var shouldSave = true {
|
||||
didSet {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
static var hasMatches = false {
|
||||
didSet {
|
||||
guard !hasMatches else { return }
|
||||
all.forEach { _, cap in
|
||||
cap.match = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static var nextUnusedId: Int {
|
||||
return (all.keys.max() ?? 0) + 1
|
||||
}
|
||||
|
||||
/// The number of caps currently in the database
|
||||
static var totalCapCount: Int {
|
||||
return all.count
|
||||
}
|
||||
|
||||
/// The total number of images for all caps
|
||||
static var imageCount: Int {
|
||||
return all.reduce(0) { sum, cap in
|
||||
return sum + cap.value.count
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Match all cap names against the given string and return matches.
|
||||
- note: Each space-separated part of the string is matched individually
|
||||
*/
|
||||
static func caps(matching text: String) -> [Cap] {
|
||||
let cleaned = text.clean
|
||||
let found = all.compactMap { (_,cap) -> Cap? in
|
||||
// For each part of text, check if name contains it
|
||||
for textItem in cleaned.components(separatedBy: " ") {
|
||||
if textItem != "" && !cap.name.contains(textItem) { return nil }
|
||||
}
|
||||
return cap
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
/// The unique number of the cap
|
||||
let id: Int
|
||||
|
||||
/// The tile position of the cap
|
||||
var tile: Int
|
||||
let tile: Int
|
||||
|
||||
/// The name of the cap
|
||||
var name: String {
|
||||
didSet {
|
||||
cleanName = name.clean
|
||||
Cap.save()
|
||||
event("Updated name for cap \(id) to \(name)")
|
||||
Cap.delegate?.capHasUpdates(self)
|
||||
}
|
||||
}
|
||||
let name: String
|
||||
|
||||
/// The name of the cap wothout special characters
|
||||
private(set) var cleanName: String
|
||||
/// The name of the cap without special characters
|
||||
let cleanName: String
|
||||
|
||||
/// The number of images existing for the cap
|
||||
private(set) var count: Int {
|
||||
didSet {
|
||||
Cap.save()
|
||||
event("Updated count for cap \(id) to \(count)")
|
||||
Cap.delegate?.capHasUpdates(self)
|
||||
}
|
||||
}
|
||||
let count: Int
|
||||
|
||||
/// The average color of the cap
|
||||
var color: UIColor?
|
||||
let color: UIColor
|
||||
|
||||
/// The similarity of the cap to the currently processed image
|
||||
var match: Float? = nil
|
||||
/// Indicate if the cap can be found by the recognition model
|
||||
let matched: Bool
|
||||
|
||||
// MARK: - All caps
|
||||
/// Indicate if the cap is present on the server
|
||||
let uploaded: Bool
|
||||
|
||||
/// A dictionary of all known caps
|
||||
static var all = [Int : Cap]()
|
||||
// MARK: Init
|
||||
|
||||
// MARK: - Tile information
|
||||
|
||||
/// A dictionary of the caps for the tiles
|
||||
static var tiles = [Int : Cap]()
|
||||
|
||||
/**
|
||||
Get the cap image for a tile.
|
||||
*/
|
||||
static func tileImage(tile: Int) -> UIImage? {
|
||||
return tiles[tile]?.thumbnail
|
||||
}
|
||||
|
||||
static func tileColor(tile: Int) -> UIColor? {
|
||||
return tiles[tile]?.averageColor
|
||||
}
|
||||
|
||||
/**
|
||||
Switch two tiles.
|
||||
*/
|
||||
static func switchTiles(_ lhs: Int, _ rhs: Int) {
|
||||
let l = tiles[lhs]!
|
||||
let r = tiles[rhs]!
|
||||
l.tile = rhs
|
||||
r.tile = lhs
|
||||
tiles[rhs] = l
|
||||
tiles[lhs] = r
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/**
|
||||
Create a new cap with an image
|
||||
- parameter image: The main image of the cap
|
||||
- parameter name: The name of the cap
|
||||
*/
|
||||
init?(image: UIImage, name: String) {
|
||||
self.id = Cap.nextUnusedId
|
||||
self.tile = id - 1
|
||||
init(name: String, id: Int, color: UIColor) {
|
||||
self.id = id
|
||||
self.count = 1
|
||||
self.name = name
|
||||
self.count = 0
|
||||
self.cleanName = name.clean
|
||||
guard save(mainImage: image) else {
|
||||
return nil
|
||||
}
|
||||
Cap.all[self.id] = self
|
||||
Cap.tiles[self.id] = self
|
||||
Cap.shouldCreateFolderForCap(self.id)
|
||||
Cap.save()
|
||||
Cap.delegate?.capHasUpdates(self)
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
self.add(image: image) { _ in
|
||||
|
||||
}
|
||||
}
|
||||
self.cleanName = ""
|
||||
self.tile = id
|
||||
self.color = color
|
||||
self.matched = false
|
||||
self.uploaded = false
|
||||
}
|
||||
|
||||
/**
|
||||
Create a cap from a line in the cap list file
|
||||
*/
|
||||
init?(line: String) {
|
||||
guard line != "" else {
|
||||
return nil
|
||||
// MARK: SQLite
|
||||
|
||||
static let table = Table("data")
|
||||
|
||||
static let createQuery: String = {
|
||||
table.create(ifNotExists: true) { t in
|
||||
t.column(rowId, primaryKey: true)
|
||||
t.column(rowName)
|
||||
t.column(rowCount)
|
||||
t.column(rowTile)
|
||||
t.column(rowRed)
|
||||
t.column(rowGreen)
|
||||
t.column(rowBlue)
|
||||
t.column(rowMatched)
|
||||
t.column(rowUploaded)
|
||||
}
|
||||
let parts = line.components(separatedBy: ";")
|
||||
guard parts.count == 4 || parts.count == 8 else {
|
||||
Cap.error("Cap names: Invalid line \(line)")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let nr = Int(parts[0]) else {
|
||||
Cap.error("Invalid id in line \(line)")
|
||||
return nil
|
||||
}
|
||||
guard let count = Int(parts[2]) else {
|
||||
Cap.error("Invalid count in line \(line)")
|
||||
return nil
|
||||
}
|
||||
guard let tile = Int(parts[3]) else {
|
||||
Cap.error("Invalid tile in line \(line)")
|
||||
return nil
|
||||
}
|
||||
if parts.count == 8 {
|
||||
guard let r = Int(parts[4]), let g = Int(parts[5]), let b = Int(parts[6]), let a = Int(parts[7]) else {
|
||||
Cap.error("Invalid color in line \(line)")
|
||||
return nil
|
||||
}
|
||||
self.color = UIColor(red: CGFloat(r)/255, green: CGFloat(g)/255, blue: CGFloat(b)/255, alpha: CGFloat(a)/255)
|
||||
}
|
||||
|
||||
self.id = nr
|
||||
self.name = parts[1]
|
||||
self.count = count
|
||||
}()
|
||||
|
||||
static let rowId = Expression<Int>("id")
|
||||
|
||||
static let rowName = Expression<String>("name")
|
||||
|
||||
static let rowCount = Expression<Int>("count")
|
||||
|
||||
static let rowTile = Expression<Int>("tile")
|
||||
|
||||
static let rowRed = Expression<Int>("red")
|
||||
static let rowGreen = Expression<Int>("green")
|
||||
static let rowBlue = Expression<Int>("blue")
|
||||
|
||||
static let rowMatched = Expression<Bool>("matched")
|
||||
|
||||
static let rowUploaded = Expression<Bool>("uploaded")
|
||||
|
||||
init(row: Row) {
|
||||
self.id = row[Cap.rowId]
|
||||
self.name = row[Cap.rowName]
|
||||
self.count = row[Cap.rowCount]
|
||||
self.tile = row[Cap.rowTile]
|
||||
self.cleanName = name.clean
|
||||
self.tile = tile
|
||||
Cap.tiles[tile] = self
|
||||
Cap.all[id] = self
|
||||
self.matched = row[Cap.rowMatched]
|
||||
self.uploaded = row[Cap.rowUploaded]
|
||||
|
||||
let r = CGFloat(row[Cap.rowRed]) / 255
|
||||
let g = CGFloat(row[Cap.rowGreen]) / 255
|
||||
let b = CGFloat(row[Cap.rowBlue]) / 255
|
||||
self.color = UIColor(red: r, green: g, blue: b, alpha: 1.0)
|
||||
}
|
||||
|
||||
init(id: Int, name: String, count: Int) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.count = count
|
||||
self.tile = id - 1
|
||||
self.cleanName = name.clean
|
||||
self.matched = false
|
||||
self.color = UIColor.gray
|
||||
self.uploaded = false
|
||||
}
|
||||
|
||||
var insertQuery: Insert {
|
||||
let colors = color.rgb
|
||||
return Cap.table.insert(
|
||||
Cap.rowId <- id,
|
||||
Cap.rowName <- name,
|
||||
Cap.rowCount <- count,
|
||||
Cap.rowTile <- tile,
|
||||
Cap.rowRed <- colors.red,
|
||||
Cap.rowGreen <- colors.green,
|
||||
Cap.rowBlue <- colors.blue,
|
||||
Cap.rowMatched <- matched,
|
||||
Cap.rowUploaded <- uploaded)
|
||||
}
|
||||
|
||||
// MARK: Text
|
||||
|
||||
func matchDescription(match: Float?) -> String {
|
||||
guard let match = match else {
|
||||
return hasSufficientImages ? "" : "⚠️"
|
||||
}
|
||||
let percent = Int((match * 100).rounded())
|
||||
return String(format: "%d %%", arguments: [percent])
|
||||
}
|
||||
|
||||
/// The cap id and the number of images
|
||||
var subtitle: String {
|
||||
guard count != 1 else {
|
||||
return "\(id) (1 image)"
|
||||
}
|
||||
return "\(id) (\(count) images)"
|
||||
}
|
||||
|
||||
// MARK: - Images
|
||||
|
||||
var hasSufficientImages: Bool {
|
||||
count > Cap.sufficientImageCount
|
||||
}
|
||||
|
||||
var hasImage: Bool {
|
||||
app.storage.hasImage(for: id)
|
||||
}
|
||||
|
||||
/// The main image of the cap
|
||||
var image: UIImage? {
|
||||
guard let data = DiskManager.image(for: id) else {
|
||||
self.downloadImage { _ in
|
||||
Cap.delegate?.capHasUpdates(self)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return UIImage(data: data)
|
||||
app.storage.image(for: id)
|
||||
}
|
||||
|
||||
/// The main image of the cap
|
||||
var thumbnail: UIImage? {
|
||||
if let data = DiskManager.thumbnail(for: id) {
|
||||
return UIImage(data: data)
|
||||
}
|
||||
return makeThumbnail()
|
||||
app.storage.thumbnail(for: id)
|
||||
}
|
||||
|
||||
var averageColor: UIColor? {
|
||||
if let c = color {
|
||||
return c
|
||||
}
|
||||
return makeAverageColor()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func makeThumbnail() -> UIImage? {
|
||||
guard let img = image else {
|
||||
return nil
|
||||
}
|
||||
static func thumbnail(for image: UIImage) -> UIImage {
|
||||
let len = GridViewController.len * 2
|
||||
let thumb = img.resize(to: CGSize.init(width: len, height: len))
|
||||
guard let data = thumb.pngData() else {
|
||||
error("Failed to get PNG data from thumbnail for cap \(id)")
|
||||
return nil
|
||||
return image.resize(to: CGSize.init(width: len, height: len))
|
||||
}
|
||||
|
||||
func updateLocalThumbnail() {
|
||||
guard let img = image else {
|
||||
return
|
||||
}
|
||||
_ = DiskManager.save(thumbnailData: data, for: id)
|
||||
event("Created thumbnail for cap \(id)")
|
||||
return thumb
|
||||
let thumbnail = Cap.thumbnail(for: img)
|
||||
guard app.storage.save(thumbnail: thumbnail, for: id) else {
|
||||
error("Failed to save thumbnail")
|
||||
return
|
||||
}
|
||||
log("Created thumbnail for cap \(id)")
|
||||
}
|
||||
|
||||
func updateLocalColor() {
|
||||
guard let color = image?.averageColor else {
|
||||
return
|
||||
}
|
||||
app.database.update(color: color, for: id)
|
||||
}
|
||||
|
||||
/**
|
||||
Download the main image of the cap.
|
||||
- Note: The downloaded image is automatically saved to disk
|
||||
- returns: `true`, if the image will be downloaded, `false`, if the image is already being downloaded.
|
||||
*/
|
||||
@discardableResult
|
||||
func downloadMainImage(completion: @escaping (_ image: UIImage?) -> Void) -> Bool {
|
||||
app.database.downloadMainImage(for: id, completion: completion)
|
||||
}
|
||||
|
||||
/**
|
||||
Download a specified image of the cap.
|
||||
- Note: If the downloaded image is the main image, it is automatically saved to disk
|
||||
- Note: If the main image is requested and already downloaded, it is returned directly
|
||||
- parameter number: The number of the image
|
||||
- parameter completion: The completion handler, called with the image if successful
|
||||
- parameter image: The image, if the download was successful, or nil on error
|
||||
- returns: `true`, if the image will be downloaded, `false`, if the image is already being downloaded.
|
||||
*/
|
||||
func downloadImage(_ number: Int = 0, completion: @escaping (_ image: UIImage?) -> Void) {
|
||||
let path = imageFolderPath + "/\(id)-\(number).jpg"
|
||||
DropboxController.client.files.download(path: path).response { data, dbError in
|
||||
if let error = dbError {
|
||||
self.error("Failed to download image data (\(number)) for cap \(self.id): \(error)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let d = data?.1 else {
|
||||
self.error("Failed to download image data (\(number)) for cap \(self.id)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard let image = UIImage(data: d) else {
|
||||
self.error("Corrupted image data (\(number)) for cap \(self.id)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
if number == 0 {
|
||||
guard self.save(mainImage: image) else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
self.event("Downloaded image data (\(number)) for cap \(self.id)")
|
||||
completion(image)
|
||||
}
|
||||
func downloadImage(_ number: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool {
|
||||
app.database.downloadImage(for: id, version: number, completion: completion)
|
||||
}
|
||||
|
||||
func save(mainImage: UIImage) -> Bool {
|
||||
guard let data = mainImage.jpegData(compressionQuality: Cap.jpgQuality) else {
|
||||
error("Failed to convert main image to data for cap \(id)")
|
||||
return false
|
||||
}
|
||||
guard DiskManager.save(imageData: data, for: id) else {
|
||||
error("Failed to save main image for cap \(id)")
|
||||
return false
|
||||
}
|
||||
event("Saved main image for cap \(id) to disk")
|
||||
makeThumbnail()
|
||||
|
||||
Cap.delegate?.capHasUpdates(self)
|
||||
return true
|
||||
}
|
||||
|
||||
func add(image: UIImage, completion: @escaping (Bool) -> Void) {
|
||||
self.upload(image: image) { saved in
|
||||
guard saved else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
// Increment cap count
|
||||
self.count += 1
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
|
||||
private var imageFolderPath: String {
|
||||
return String(format: "/Images/%04d", id)
|
||||
}
|
||||
|
||||
private static func imageFolderPath(for cap: Int) -> String {
|
||||
return String(format: "/Images/%04d", cap)
|
||||
}
|
||||
|
||||
private func imageFilePath(imageId: Int) -> String {
|
||||
return imageFolderPath + "/\(id)-\(imageId).jpg"
|
||||
}
|
||||
|
||||
// MARK: - Image upload
|
||||
|
||||
private func folderExists(completion: @escaping (_ exists: Bool?) -> Void) {
|
||||
let path = "/Images"
|
||||
DropboxController.client.files.listFolder(path: path).response { response, error in
|
||||
if let e = error {
|
||||
self.error("Failed to get image folder list: \(e)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard let result = response else {
|
||||
self.error("Failed to get image folder list")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let exists = result.entries.contains { $0.name == "\(self.id)" }
|
||||
completion(exists)
|
||||
}
|
||||
}
|
||||
|
||||
private static func createFolder(forCap cap: Int, completion: @escaping (_ success: Bool) -> Void) {
|
||||
guard shouldCreateFolder(forCap: cap) else {
|
||||
completion(true)
|
||||
return
|
||||
}
|
||||
// Create folder for cap
|
||||
let path = imageFolderPath(for: cap)
|
||||
DropboxController.client.files.createFolderV2(path: path).response { _, error in
|
||||
if let err = error {
|
||||
self.event("Could not create folder for cap \(cap): \(err)")
|
||||
completion(false)
|
||||
} else {
|
||||
self.event("Created folder for cap \(cap)")
|
||||
didCreateFolder(forCap: cap)
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func upload(image: UIImage, savedCallback: @escaping (Bool) -> Void) {
|
||||
upload(image: image, number: count, savedCallback: savedCallback)
|
||||
}
|
||||
|
||||
private func upload(image: UIImage, number: Int, savedCallback: @escaping (Bool) -> Void) {
|
||||
// Convert to data
|
||||
guard let data = image.jpegData(compressionQuality: Cap.jpgQuality) else {
|
||||
error("Failed to convert image to data")
|
||||
return
|
||||
}
|
||||
let fileName = "\(id)-\(number).jpg"
|
||||
// Save image to upload folder
|
||||
guard let url = DiskManager.saveForUpload(imageData: data, name: fileName) else {
|
||||
error("Could not save image for cap \(id) to upload folder")
|
||||
savedCallback(false)
|
||||
return
|
||||
}
|
||||
event("Saved image \(number) for cap \(id) for upload")
|
||||
savedCallback(true)
|
||||
|
||||
Cap.uploadCapImage(at: url, forCap: id) { success in
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private static func uploadCapImage(at url: URL, forCap cap: Int, completion: @escaping (Bool) -> Void) {
|
||||
createFolder(forCap: cap) { created in
|
||||
guard created else { return }
|
||||
uploadCapImage(at: url, forCapWithExistingFolder: cap, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
private static func uploadCapImage(at url: URL, forCapWithExistingFolder cap: Int, completion: @escaping (Bool) -> Void) {
|
||||
let path = imageFolderPath(for: cap) + "/" + url.lastPathComponent
|
||||
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
} catch {
|
||||
self.error("Could not read data from url \(url.path): \(error)")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
DropboxController.client.files.upload(path: path, input: data).response { response, error in
|
||||
if let err = error {
|
||||
self.error("Failed to upload file at url: \(url): \(err)")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
Cap.event("Uploaded image \(path)")
|
||||
guard DiskManager.removeFromUpload(url: url) else {
|
||||
self.error("Could not delete uploaded image for cap \(cap) at url \(url)")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
|
||||
static func uploadRemainingImages() {
|
||||
guard let list = DiskManager.pendingUploads else {
|
||||
return
|
||||
}
|
||||
guard list.count != 0 else {
|
||||
event("No pending uploads")
|
||||
return
|
||||
}
|
||||
event("\(list.count) image uploads pending")
|
||||
|
||||
for url in list {
|
||||
let cap = Int(url.lastPathComponent.components(separatedBy: "-").first!)!
|
||||
uploadCapImage(at: url, forCap: cap) { didUpload in
|
||||
// Delete image from disk if uploaded
|
||||
guard didUpload else {
|
||||
self.error("Could not upload image at url \(url)")
|
||||
return
|
||||
}
|
||||
guard DiskManager.removeFromUpload(url: url) else {
|
||||
self.error("Could not delete uploaded image at url \(url)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setMainImage(to imageId: Int, image: UIImage) {
|
||||
guard imageId != 0 else {
|
||||
self.event("No need to switch main image with itself")
|
||||
return
|
||||
}
|
||||
let tempFile = imageFilePath(imageId: count)
|
||||
let oldFile = imageFilePath(imageId: 0)
|
||||
let newFile = imageFilePath(imageId: imageId)
|
||||
DropboxController.shared.move(file: oldFile, to: tempFile) { success in
|
||||
guard success else {
|
||||
self.error("Could not move \(oldFile) to \(tempFile)")
|
||||
return
|
||||
}
|
||||
DropboxController.shared.move(file: newFile, to: oldFile) { success in
|
||||
guard success else {
|
||||
self.error("Could not move \(newFile) to \(oldFile)")
|
||||
return
|
||||
}
|
||||
DropboxController.shared.move(file: tempFile, to: newFile) { success in
|
||||
if !success {
|
||||
self.error("Could not move \(tempFile) to \(newFile)")
|
||||
}
|
||||
guard self.save(mainImage: image) else {
|
||||
return
|
||||
}
|
||||
self.event("Successfully set image \(imageId) to main image for cap \(self.id)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Counts
|
||||
|
||||
func updateCount(completion: @escaping (Bool) -> Void) {
|
||||
getImageCount { response in
|
||||
guard let count = response else {
|
||||
self.error("Could not update count for cap \(self.id)")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
self.count = count
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func getImageCount(completion: @escaping (Int?) -> Void) {
|
||||
let path = imageFolderPath
|
||||
DropboxController.client.files.listFolder(path: path).response { response, error in
|
||||
if let err = error {
|
||||
self.error("Error getting folder content of cap \(self.id): \(err)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard let files = response?.entries else {
|
||||
self.error("No content for folder of cap \(self.id)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
completion(files.count)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sorted caps
|
||||
|
||||
static var unsortedCaps: Set<Cap> {
|
||||
return Set(all.values)
|
||||
}
|
||||
|
||||
static func capList(sortedBy criteria: SortCriteria, ascending: Bool) -> [Cap] {
|
||||
if ascending {
|
||||
return sorted([Cap](all.values), ascendingBy: criteria)
|
||||
} else {
|
||||
return sorted([Cap](all.values), descendingBy: criteria)
|
||||
}
|
||||
}
|
||||
|
||||
private static func sorted(_ list: [Cap], ascendingBy parameter: SortCriteria) -> [Cap] {
|
||||
switch parameter {
|
||||
case .id: return list.sorted { $0.id < $1.id }
|
||||
case .count: return list.sorted { $0.count < $1.count }
|
||||
case .name: return list.sorted { $0.name < $1.name }
|
||||
case .match: return list.sorted { $0.match ?? 0 < $1.match ?? 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private static func sorted(_ list: [Cap], descendingBy parameter: SortCriteria) -> [Cap] {
|
||||
switch parameter {
|
||||
case .id: return list.sorted { $0.id > $1.id }
|
||||
case .count: return list.sorted { $0.count > $1.count }
|
||||
case .name: return list.sorted { $0.name > $1.name }
|
||||
case .match: return list.sorted { $0.match ?? 0 > $1.match ?? 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading, Saving & Uploading cap list
|
||||
|
||||
/**
|
||||
Either load the names from disk or download them from dropbox.
|
||||
- parameter completion: The handler that is called with true on success, false on failure
|
||||
*/
|
||||
static func load() {
|
||||
NameFile.makeAvailable { content in
|
||||
guard let lines = content else {
|
||||
return
|
||||
}
|
||||
self.readNames(from: lines)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read all caps from the content of a file
|
||||
private static func readNames(from fileContent: String) {
|
||||
let parts = fileContent.components(separatedBy: "\n")
|
||||
for line in parts {
|
||||
_ = Cap(line: line)
|
||||
}
|
||||
event("Loaded \(totalCapCount) caps from file")
|
||||
delegate?.capsLoaded()
|
||||
}
|
||||
|
||||
static func getCapStatistics() -> [Int] {
|
||||
let counts = all.values.map { $0.count }
|
||||
var c = [Int](repeating: 0, count: counts.max()! + 1)
|
||||
counts.forEach { c[$0] += 1 }
|
||||
return c
|
||||
}
|
||||
|
||||
static func save() {
|
||||
guard shouldSave else { return }
|
||||
let content = namesAsString()
|
||||
NameFile.save(names: content)
|
||||
}
|
||||
|
||||
static func saveAndUpload(completion: @escaping (Bool) -> Void) {
|
||||
let content = namesAsString()
|
||||
NameFile.saveAndUpload(names: content) { success in
|
||||
guard success else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
Persistence.lastUploadedCapCount = totalCapCount
|
||||
Persistence.lastUploadedImageCount = imageCount
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
|
||||
private static func namesAsString() -> String {
|
||||
return capList(sortedBy: .id, ascending: true).reduce("") { $0 + $1.description }
|
||||
}
|
||||
|
||||
// MARK: - Folders to upload
|
||||
|
||||
static func shouldCreateFolderForCap(_ cap: Int) {
|
||||
let oldCaps = Persistence.folderNotCreated
|
||||
guard !oldCaps.contains(cap) else {
|
||||
return
|
||||
}
|
||||
let newCaps = oldCaps + [cap]
|
||||
Persistence.folderNotCreated = newCaps
|
||||
}
|
||||
|
||||
static func shouldCreateFolder(forCap cap: Int) -> Bool {
|
||||
return Persistence.folderNotCreated.contains(cap)
|
||||
}
|
||||
|
||||
static func didCreateFolder(forCap cap: Int) {
|
||||
let oldCaps = Persistence.folderNotCreated
|
||||
guard oldCaps.contains(cap) else {
|
||||
return
|
||||
}
|
||||
Persistence.folderNotCreated = oldCaps.filter { $0 != cap }
|
||||
}
|
||||
|
||||
// MARK: - Average color
|
||||
|
||||
@discardableResult
|
||||
func makeAverageColor() -> UIColor? {
|
||||
guard let url = DiskManager.imageUrlForCap(id) else {
|
||||
event("No main image for cap \(id), no average color calculated")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let inputImage = CIImage(contentsOf: url) else {
|
||||
error("Failed to read CIImage for main image of cap \(id)")
|
||||
return nil
|
||||
}
|
||||
let extentVector = CIVector(x: inputImage.extent.origin.x, y: inputImage.extent.origin.y, z: inputImage.extent.size.width, w: inputImage.extent.size.height)
|
||||
|
||||
guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: inputImage, kCIInputExtentKey: extentVector]) else {
|
||||
error("Failed to create filter to calculate average for cap \(id)")
|
||||
return nil
|
||||
}
|
||||
guard let outputImage = filter.outputImage else {
|
||||
error("Failed get filter output for image of cap \(id)")
|
||||
return nil
|
||||
}
|
||||
|
||||
var bitmap = [UInt8](repeating: 0, count: 4)
|
||||
let context = CIContext(options: [.workingColorSpace: kCFNull])
|
||||
context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)
|
||||
|
||||
color = UIColor(
|
||||
red: saturate(bitmap[0]),
|
||||
green: saturate(bitmap[1]),
|
||||
blue: saturate(bitmap[2]),
|
||||
alpha: CGFloat(bitmap[3]) / 255)
|
||||
|
||||
event("Average color updated for cap \(id)")
|
||||
Cap.save()
|
||||
return color
|
||||
}
|
||||
}
|
||||
|
||||
/// Map expected range 75-200 to 0-255
|
||||
private func saturate(_ component: UInt8) -> CGFloat {
|
||||
return max(min(CGFloat(component) * 2 - 150, 255), 0) / 255
|
||||
}
|
||||
|
||||
// MARK: - Protocol Hashable
|
||||
@@ -705,31 +249,14 @@ extension Cap: Hashable {
|
||||
extension Cap: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
guard let c = color else {
|
||||
return String(format: "%04d", id) + ";\(name);\(count);\(tile)\n"
|
||||
}
|
||||
|
||||
var fRed: CGFloat = 0
|
||||
var fGreen: CGFloat = 0
|
||||
var fBlue: CGFloat = 0
|
||||
var fAlpha: CGFloat = 0
|
||||
guard c.getRed(&fRed, green: &fGreen, blue: &fBlue, alpha: &fAlpha) else {
|
||||
return String(format: "%04d", id) + ";\(name);\(count);\(tile)\n"
|
||||
}
|
||||
let r = Int(fRed * 255.0)
|
||||
let g = Int(fGreen * 255.0)
|
||||
let b = Int(fBlue * 255.0)
|
||||
let a = Int(fAlpha * 255.0)
|
||||
return String(format: "%04d", id) + ";\(name);\(count);\(tile);\(r);\(g);\(b);\(a)\n"
|
||||
let rgb = color.rgb
|
||||
return String(format: "%04d", id) + ";\(name);\(count);\(tile);\(rgb.red);\(rgb.green);\(rgb.blue)\n"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Protocol Logger
|
||||
|
||||
extension Cap: Logger {
|
||||
|
||||
static let logToken = "[CAP]"
|
||||
}
|
||||
extension Cap: Logger { }
|
||||
|
||||
// MARK: - String extension
|
||||
|
||||
|
68
CapCollector/Data/Classifier.swift
Normal file
68
CapCollector/Data/Classifier.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// 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")
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
app.database.update(recognizedCaps: Set(matches.keys))
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
}
|
621
CapCollector/Data/Database.swift
Normal file
621
CapCollector/Data/Database.swift
Normal file
@@ -0,0 +1,621 @@
|
||||
//
|
||||
// Database.swift
|
||||
// CapCollector
|
||||
//
|
||||
// Created by Christoph on 14.04.20.
|
||||
// Copyright © 2020 CH. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import CoreML
|
||||
import SQLite
|
||||
|
||||
protocol DatabaseDelegate: class {
|
||||
|
||||
func database(didChangeCap cap: Int)
|
||||
|
||||
func database(didAddCap cap: Cap)
|
||||
|
||||
func databaseRequiresFullRefresh()
|
||||
}
|
||||
|
||||
struct Weak {
|
||||
|
||||
weak var value : DatabaseDelegate?
|
||||
|
||||
init (_ value: DatabaseDelegate) {
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == Weak {
|
||||
|
||||
mutating func reap () {
|
||||
self = self.filter { $0.value != nil }
|
||||
}
|
||||
}
|
||||
|
||||
final class Database {
|
||||
|
||||
// MARK: Variables
|
||||
|
||||
let db: Connection
|
||||
|
||||
let upload: Upload
|
||||
|
||||
let download: Download
|
||||
|
||||
private var listeners = [Weak]()
|
||||
|
||||
// MARK: Listeners
|
||||
|
||||
func add(listener: DatabaseDelegate) {
|
||||
listeners.append(Weak(listener))
|
||||
}
|
||||
|
||||
init?(url: URL, server: URL) {
|
||||
guard let db = try? Connection(url.path) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let upload = Upload(server: server)
|
||||
let download = Download(server: server)
|
||||
|
||||
do {
|
||||
try db.run(Cap.createQuery)
|
||||
try db.run(upload.createQuery)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.db = db
|
||||
self.upload = upload
|
||||
self.download = download
|
||||
log("Database loaded with \(capCount) caps")
|
||||
}
|
||||
|
||||
// MARK: Computed properties
|
||||
|
||||
/// All caps currently in the database
|
||||
var caps: [Cap] {
|
||||
(try? db.prepare(Cap.table))?.map(Cap.init) ?? []
|
||||
}
|
||||
|
||||
/// The ids of the caps which weren't included in the last classification
|
||||
var unmatchedCaps: [Int] {
|
||||
let query = Cap.table.select(Cap.rowId).filter(Cap.rowMatched == false)
|
||||
return (try? db.prepare(query).map { $0[Cap.rowId] }) ?? []
|
||||
}
|
||||
|
||||
/// The number of caps which could be recognized during the last classification
|
||||
var recognizedCapCount: Int {
|
||||
(try? db.scalar(Cap.table.filter(Cap.rowMatched == true).count)) ?? 0
|
||||
}
|
||||
|
||||
/// The number of caps currently in the database
|
||||
var capCount: Int {
|
||||
(try? db.scalar(Cap.table.count)) ?? 0
|
||||
}
|
||||
|
||||
/// The total number of images for all caps
|
||||
var imageCount: Int {
|
||||
(try? db.prepare(Cap.table).reduce(0) { $0 + $1[Cap.rowCount] }) ?? 0
|
||||
}
|
||||
|
||||
/// The number of caps without a downloaded image
|
||||
var capsWithoutImages: Int {
|
||||
caps.filter({ !$0.hasImage }).count
|
||||
}
|
||||
|
||||
|
||||
|
||||
var pendingUploads: [(cap: Int, version: Int)] {
|
||||
do {
|
||||
return try db.prepare(upload.table).map { row in
|
||||
(cap: row[upload.rowCapId], version: row[upload.rowCapVersion])
|
||||
}
|
||||
} catch {
|
||||
log("Failed to get pending uploads")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicate if there are any unfinished uploads
|
||||
var hasPendingUploads: Bool {
|
||||
((try? db.scalar(upload.table.count)) ?? 0) > 0
|
||||
}
|
||||
|
||||
var classifierVersion: Int {
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: Classifier.userDefaultsKey)
|
||||
log("Classifier version set to \(newValue)")
|
||||
}
|
||||
get {
|
||||
UserDefaults.standard.integer(forKey: Classifier.userDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Data updates
|
||||
|
||||
/**
|
||||
Create a new cap with an image.
|
||||
|
||||
The cap is inserted into the database, and the name and image will be uploaded to the server.
|
||||
|
||||
- parameter image: The main image of the cap
|
||||
- parameter name: The name of the cap
|
||||
- note: Must be called on the main queue.
|
||||
- note: The registered delegates will be informed about the added cap through `database(didAddCap:)`
|
||||
- returns: `true`, if the cap was created.
|
||||
*/
|
||||
func createCap(image: UIImage, name: String) -> Bool {
|
||||
guard let color = image.averageColor else {
|
||||
return false
|
||||
}
|
||||
let cap = Cap(name: name, id: capCount, color: color)
|
||||
guard insert(cap: cap) else {
|
||||
return false
|
||||
}
|
||||
guard app.storage.save(image: image, for: cap.id) else {
|
||||
return false
|
||||
}
|
||||
listeners.forEach { $0.value?.database(didAddCap: cap) }
|
||||
upload.upload(name: name, for: cap.id) { success in
|
||||
guard success else {
|
||||
return
|
||||
}
|
||||
self.update(uploaded: true, for: cap.id)
|
||||
self.upload.uploadImage(for: cap.id, version: 0) { count in
|
||||
guard let count = count else {
|
||||
self.log("Failed to upload first image for cap \(cap.id)")
|
||||
return
|
||||
}
|
||||
self.log("Uploaded first image for cap \(cap.id)")
|
||||
self.update(count: count, for: cap.id)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
Insert a new cap.
|
||||
|
||||
Only inserts the cap into the database, and optionally notifies the delegates.
|
||||
- note: When a new cap is created, use `createCap(image:name:)` instead
|
||||
*/
|
||||
@discardableResult
|
||||
private func insert(cap: Cap, notifyDelegate: Bool = true) -> Bool {
|
||||
do {
|
||||
try db.run(cap.insertQuery)
|
||||
if notifyDelegate {
|
||||
listeners.forEach { $0.value?.database(didAddCap: cap) }
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
log("Failed to insert cap \(cap.id): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func add(image: UIImage, for cap: Int) -> Bool {
|
||||
guard let version = count(for: cap) else {
|
||||
log("Failed to get count for cap \(cap)")
|
||||
return false
|
||||
}
|
||||
guard app.storage.save(image: image, for: cap, version: version) else {
|
||||
log("Failed to save image \(version) for cap \(cap) to disk")
|
||||
return false
|
||||
}
|
||||
guard update(count: version + 1, for: cap) else {
|
||||
log("Failed update count \(version) for cap \(cap)")
|
||||
return false
|
||||
}
|
||||
listeners.forEach { $0.value?.database(didChangeCap: cap) }
|
||||
|
||||
guard addPendingUpload(for: cap, version: version) else {
|
||||
log("Failed to add cap \(cap) version \(version) to upload queue")
|
||||
return false
|
||||
}
|
||||
upload.uploadImage(for: cap, version: version) { count in
|
||||
guard let _ = count else {
|
||||
self.log("Failed to upload image \(version) for cap \(cap)")
|
||||
return
|
||||
}
|
||||
guard self.removePendingUpload(of: cap, version: version) else {
|
||||
self.log("Failed to remove version \(version) for cap \(cap) from upload queue")
|
||||
return
|
||||
}
|
||||
self.log("Uploaded version \(version) for cap \(cap)")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: Updating cap properties
|
||||
|
||||
private func update(_ property: String, for cap: Int, setter: Setter...) -> Bool {
|
||||
do {
|
||||
let query = updateQuery(for: cap).update(setter)
|
||||
try db.run(query)
|
||||
listeners.forEach { $0.value?.database(didChangeCap: cap) }
|
||||
return true
|
||||
} catch {
|
||||
log("Failed to update \(property) for cap \(cap): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func update(uploaded: Bool, for cap: Int) -> Bool {
|
||||
update("uploaded", for: cap, setter: Cap.rowUploaded <- uploaded)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func update(name: String, for cap: Int) -> Bool {
|
||||
update("name", for: cap, setter: Cap.rowName <- name)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func update(color: UIColor, for cap: Int) -> Bool {
|
||||
let (red, green, blue) = color.rgb
|
||||
return update("color", for: cap, setter: Cap.rowRed <- red, Cap.rowGreen <- green, Cap.rowBlue <- blue)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func update(tile: Int, for cap: Int) -> Bool {
|
||||
update("tile", for: cap, setter: Cap.rowTile <- tile)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func update(count: Int, for cap: Int) -> Bool {
|
||||
update("count", for: cap, setter: Cap.rowCount <- count)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func update(matched: Bool, for cap: Int) -> Bool {
|
||||
update("matched", for: cap, setter: Cap.rowMatched <- matched)
|
||||
}
|
||||
|
||||
func update(recognizedCaps: Set<Int>) {
|
||||
let unrecognized = self.unmatchedCaps
|
||||
// Update caps which haven't been recognized before
|
||||
let newlyRecognized = recognizedCaps.intersection(unrecognized)
|
||||
let logIndividualMessages = newlyRecognized.count < 10
|
||||
if !logIndividualMessages {
|
||||
log("Marking \(newlyRecognized.count) caps as matched")
|
||||
}
|
||||
for cap in newlyRecognized {
|
||||
if logIndividualMessages {
|
||||
log("Marking cap \(cap) as matched")
|
||||
}
|
||||
update(matched: true, for: cap)
|
||||
}
|
||||
// Update caps which are no longer recognized
|
||||
let missing = Set(1...capCount).subtracting(recognizedCaps).subtracting(unrecognized)
|
||||
for cap in missing {
|
||||
log("Marking cap \(cap) as not matched")
|
||||
update(matched: false, for: cap)
|
||||
}
|
||||
}
|
||||
|
||||
func addPendingUpload(for cap: Int, version: Int) -> Bool {
|
||||
do {
|
||||
try db.run(upload.insertQuery(for: cap, version: version))
|
||||
return true
|
||||
} catch {
|
||||
log("Failed to add pending upload of cap \(cap) version \(version): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func removePendingUpload(for cap: Int, version: Int) -> Bool {
|
||||
do {
|
||||
try db.run(upload.deleteQuery(for: cap, version: version))
|
||||
return true
|
||||
} catch {
|
||||
log("Failed to remove pending upload of cap \(cap) version \(version): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Information retrieval
|
||||
|
||||
func cap(for id: Int) -> Cap? {
|
||||
do {
|
||||
guard let row = try db.pluck(updateQuery(for: id)) else {
|
||||
log("No cap with id \(id) in database")
|
||||
return nil
|
||||
}
|
||||
return Cap(row: row)
|
||||
} catch {
|
||||
log("Failed to get cap \(id): \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func count(for cap: Int) -> Int? {
|
||||
do {
|
||||
let row = try db.pluck(updateQuery(for: cap).select(Cap.rowCount))
|
||||
return row?[Cap.rowCount]
|
||||
} catch {
|
||||
log("Failed to get count for cap \(cap)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func countOfCaps(withImageCountLessThan limit: Int) -> Int {
|
||||
do {
|
||||
return try db.scalar(Cap.table.filter(Cap.rowCount < limit).count)
|
||||
} catch {
|
||||
log("Failed to get caps with less than \(limit) images")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func lowestImageCountForCaps(startingAt start: Int) -> (count: Int, numberOfCaps: Int) {
|
||||
do {
|
||||
var currentCount = start - 1
|
||||
var capsFound = 0
|
||||
repeat {
|
||||
currentCount += 1
|
||||
capsFound = try db.scalar(Cap.table.filter(Cap.rowCount == currentCount).count)
|
||||
} while capsFound == 0
|
||||
|
||||
return (currentCount, capsFound)
|
||||
} catch {
|
||||
return (0,0)
|
||||
}
|
||||
}
|
||||
|
||||
func updateQuery(for cap: Int) -> Table {
|
||||
Cap.table.filter(Cap.rowId == cap)
|
||||
}
|
||||
|
||||
// MARK: Downloads
|
||||
|
||||
@discardableResult
|
||||
func downloadMainImage(for cap: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool {
|
||||
return download.image(for: cap, version: 0) { image in
|
||||
// Guaranteed to be on the main queue
|
||||
guard let image = image else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
defer {
|
||||
completion(image)
|
||||
}
|
||||
if !app.storage.save(thumbnail: Cap.thumbnail(for: image), for: cap) {
|
||||
self.log("Failed to save thumbnail for cap \(cap)")
|
||||
}
|
||||
guard let color = image.averageColor else {
|
||||
self.log("Failed to calculate color for cap \(cap)")
|
||||
return
|
||||
}
|
||||
self.update(color: color, for: cap)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func downloadImage(for cap: Int, version: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool {
|
||||
return download.image(for: cap, version: version, completion: completion)
|
||||
}
|
||||
|
||||
func getServerDatabaseSize(completion: @escaping (_ size: Int64?) -> Void) {
|
||||
download.databaseSize(completion: completion)
|
||||
}
|
||||
|
||||
func downloadServerDatabase(progress: Download.Delegate.ProgressHandler? = nil, completion: @escaping (_ success: Bool) -> Void, processed: (() -> Void)? = nil) {
|
||||
download.database(progress: progress) { tempUrl in
|
||||
guard let url = tempUrl else {
|
||||
self.log("Failed to download database")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
completion(true)
|
||||
self.processServerDatabase(at: url)
|
||||
processed?()
|
||||
}
|
||||
}
|
||||
|
||||
func downloadMainCapImages(progress: @escaping (_ current: Int, _ total: Int) -> Void) {
|
||||
let caps = self.caps.filter { !$0.hasImage }.map { $0.id }
|
||||
|
||||
var downloaded = 0
|
||||
let total = caps.count
|
||||
|
||||
func update() {
|
||||
DispatchQueue.main.async {
|
||||
progress(downloaded, total)
|
||||
}
|
||||
}
|
||||
update()
|
||||
|
||||
guard total > 0 else {
|
||||
log("No images to download")
|
||||
return
|
||||
}
|
||||
log("Starting to download \(total) images")
|
||||
|
||||
let group = DispatchGroup()
|
||||
let split = 50
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
for part in caps.split(intoPartsOf: split) {
|
||||
for id in part {
|
||||
let downloading = self.downloadMainImage(for: id) { _ in
|
||||
group.leave()
|
||||
}
|
||||
if downloading {
|
||||
group.enter()
|
||||
}
|
||||
}
|
||||
if group.wait(timeout: .now() + .seconds(30)) != .success {
|
||||
self.log("Timed out waiting for images to be downloaded")
|
||||
}
|
||||
downloaded += part.count
|
||||
self.log("Finished \(downloaded) of \(total) image downloads")
|
||||
update()
|
||||
}
|
||||
self.log("Finished all image downloads")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func hasNewClassifier(completion: @escaping (_ version: Int?, _ size: Int64?) -> Void) {
|
||||
download.classifierVersion { version in
|
||||
guard let version = version else {
|
||||
self.log("Failed to download server model version")
|
||||
completion(nil, nil)
|
||||
return
|
||||
}
|
||||
let ownVersion = self.classifierVersion
|
||||
guard ownVersion < version else {
|
||||
self.log("Not updating classifier: Own version \(ownVersion), server version \(version)")
|
||||
completion(nil, nil)
|
||||
return
|
||||
}
|
||||
self.log("Getting classifier size: Own version \(ownVersion), server version \(version)")
|
||||
self.download.classifierSize { size in
|
||||
completion(version, size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadClassifier(progress: Download.Delegate.ProgressHandler? = nil, completion: @escaping (_ success: Bool) -> Void) {
|
||||
download.classifier(progress: progress) { url in
|
||||
guard let url = url else {
|
||||
self.log("Failed to download classifier")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
let compiledUrl: URL
|
||||
do {
|
||||
compiledUrl = try MLModel.compileModel(at: url)
|
||||
} catch {
|
||||
self.log("Failed to compile downloaded classifier: \(error)")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
guard app.storage.save(recognitionModelAt: compiledUrl) else {
|
||||
self.log("Failed to save classifier")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
completion(true)
|
||||
self.download.classifierVersion { version in
|
||||
guard let version = version else {
|
||||
self.log("Failed to download classifier version")
|
||||
return
|
||||
}
|
||||
self.classifierVersion = version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadImageCounts() {
|
||||
guard !hasPendingUploads else {
|
||||
log("Waiting to refresh server image counts (uploads pending)")
|
||||
return
|
||||
}
|
||||
log("Refreshing server image counts")
|
||||
app.database.download.imageCounts { counts in
|
||||
guard let counts = counts else {
|
||||
self.log("Failed to download server image counts")
|
||||
return
|
||||
}
|
||||
self.didDownload(imageCounts: counts)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private func didDownload(imageCounts newCounts: [(cap: Int, count: Int)]) {
|
||||
let capsCounts = self.caps.reduce(into: [:]) { $0[$1.id] = $1.count }
|
||||
if newCounts.count != capsCounts.count {
|
||||
log("Downloaded \(newCounts.count) image counts, but \(app.database.capCount) caps stored locally")
|
||||
return
|
||||
}
|
||||
let changed = newCounts.compactMap { id, newCount -> Int? in
|
||||
guard let oldCount = capsCounts[id] else {
|
||||
log("Received count \(newCount) for unknown cap \(id)")
|
||||
return nil
|
||||
}
|
||||
guard oldCount != newCount else {
|
||||
return nil
|
||||
}
|
||||
app.database.update(count: newCount, for: id)
|
||||
return id
|
||||
}
|
||||
switch changed.count {
|
||||
case 0:
|
||||
log("Refreshed image counts for all caps without changes")
|
||||
case 1:
|
||||
log("Refreshed image counts for caps, changed cap \(changed[0])")
|
||||
case 2...10:
|
||||
log("Refreshed image counts for caps \(changed.map(String.init).joined(separator: ", ")).")
|
||||
default:
|
||||
log("Refreshed image counts for all caps (\(changed.count) changed)")
|
||||
}
|
||||
}
|
||||
|
||||
private func processServerDatabase(at url: URL) {
|
||||
guard let db = ServerDatabase(downloadedTo: url) else {
|
||||
log("Failed to open downloaded server database")
|
||||
return
|
||||
}
|
||||
for (id, count, name) in db.caps {
|
||||
let cap = Cap(id: id, name: name, count: count)
|
||||
insert(cap: cap, notifyDelegate: false)
|
||||
}
|
||||
listeners.forEach { $0.value?.databaseRequiresFullRefresh() }
|
||||
}
|
||||
|
||||
func uploadRemainingImages() {
|
||||
guard pendingUploads.count > 0 else {
|
||||
log("No pending uploads")
|
||||
return
|
||||
}
|
||||
log("\(pendingUploads.count) image uploads pending")
|
||||
|
||||
for (cap, version) in pendingUploads {
|
||||
upload.uploadImage(for: cap, version: version) { count in
|
||||
guard let _ = count else {
|
||||
self.log("Failed to upload version \(version) of cap \(cap)")
|
||||
return
|
||||
}
|
||||
self.log("Uploaded version \(version) of cap \(cap)")
|
||||
self.removePendingUpload(of: cap, version: version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func removePendingUpload(of cap: Int, version: Int) -> Bool {
|
||||
do {
|
||||
let query = upload.table.filter(upload.rowCapId == cap && upload.rowCapVersion == version).delete()
|
||||
try db.run(query)
|
||||
log("Deleted pending upload of cap \(cap) version \(version)")
|
||||
return true
|
||||
} catch {
|
||||
log("Failed to delete pending upload of cap \(cap) version \(version)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func setMainImage(of cap: Int, to version: Int) {
|
||||
guard version != 0 else {
|
||||
log("No need to switch main image with itself for cap \(cap)")
|
||||
return
|
||||
}
|
||||
upload.setMainImage(for: cap, to: version) { color in
|
||||
guard let color = color else {
|
||||
self.log("Could not make \(version) the main image for cap \(cap)")
|
||||
return
|
||||
}
|
||||
self.update(color: color, for: cap)
|
||||
self.listeners.forEach { $0.value?.database(didChangeCap: cap) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Database: Logger { }
|
@@ -1,207 +0,0 @@
|
||||
//
|
||||
// DiskManager.swift
|
||||
// CapFinder
|
||||
//
|
||||
// Created by User on 23.04.18.
|
||||
// Copyright © 2018 User. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
final class DiskManager {
|
||||
|
||||
enum LocalDirectory: String {
|
||||
/// Folder for new images to upload
|
||||
case upload = "Upload"
|
||||
|
||||
/// Folder for downloaded images
|
||||
case images = "Images"
|
||||
|
||||
/// Folder for downloaded images
|
||||
case thumbnails = "Thumbnails"
|
||||
|
||||
/// Directory for name file
|
||||
case files = "Files"
|
||||
|
||||
private static let fm = FileManager.default
|
||||
|
||||
/// The url to the file sstem
|
||||
var url: URL {
|
||||
return URL(fileURLWithPath: self.rawValue, isDirectory: true, relativeTo: DiskManager.documentsDirectory)
|
||||
}
|
||||
|
||||
fileprivate func create() -> Bool {
|
||||
return DiskManager.create(directory: url)
|
||||
}
|
||||
|
||||
var content: [URL]? {
|
||||
do {
|
||||
return try LocalDirectory.fm.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [])
|
||||
} catch {
|
||||
print("[LocalDirectory] Could not read directory \(self.rawValue): \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder where images and name list are stored
|
||||
static let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
|
||||
private static let fm = FileManager.default
|
||||
|
||||
// MARK: - First launch
|
||||
|
||||
@discardableResult static func setupOnFirstLaunch() -> Bool {
|
||||
return LocalDirectory.files.create() &&
|
||||
LocalDirectory.images.create() &&
|
||||
LocalDirectory.thumbnails.create() &&
|
||||
LocalDirectory.upload.create()
|
||||
}
|
||||
|
||||
private static func create(directory: URL) -> Bool {
|
||||
do {
|
||||
if !fm.fileExists(atPath: directory.path) {
|
||||
try fm.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
event("Could not create \(directory): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image retrieval
|
||||
|
||||
/**
|
||||
Check if an image exists for a cap
|
||||
- parameter cap: The id of the cap
|
||||
- returns: True, if an image exists
|
||||
*/
|
||||
static func hasImage(for cap: Int) -> Bool {
|
||||
let url = localUrl(for: cap)
|
||||
return fm.fileExists(atPath: url.path)
|
||||
}
|
||||
|
||||
static func imageUrlForCap(_ id: Int) -> URL? {
|
||||
let url = localUrl(for: id)
|
||||
guard fm.fileExists(atPath: url.path) else {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private static func localUrl(for cap: Int) -> URL {
|
||||
return URL(fileURLWithPath: "\(cap).jpg", isDirectory: true, relativeTo: LocalDirectory.images.url)
|
||||
}
|
||||
|
||||
private static func thumbnailUrl(for cap: Int) -> URL {
|
||||
return URL(fileURLWithPath: "\(cap).jpg", isDirectory: true, relativeTo: LocalDirectory.thumbnails.url)
|
||||
}
|
||||
|
||||
/**
|
||||
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
|
||||
- returns: The image data, or `nil`
|
||||
*/
|
||||
static func image(for cap: Int) -> Data? {
|
||||
// If the image exists on disk, get it
|
||||
let url = localUrl(for: cap)
|
||||
return readData(from: url)
|
||||
}
|
||||
|
||||
/**
|
||||
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 data, or `nil`
|
||||
*/
|
||||
static func thumbnail(for cap: Int) -> Data? {
|
||||
// If the image exists on disk, get it
|
||||
let url = thumbnailUrl(for: cap)
|
||||
return readData(from: url)
|
||||
}
|
||||
|
||||
private static 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Save an image to the download folder
|
||||
- parameter imageData: The data of the image
|
||||
- parameter cap: The cap id
|
||||
- returns: True, if the image was saved
|
||||
*/
|
||||
static func save(imageData: Data, for cap: Int) -> Bool {
|
||||
let url = localUrl(for: cap)
|
||||
return write(imageData, to: url)
|
||||
}
|
||||
|
||||
/**
|
||||
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
|
||||
*/
|
||||
static func save(thumbnailData: Data, for cap: Int) -> Bool {
|
||||
let url = thumbnailUrl(for: cap)
|
||||
return write(thumbnailData, to: url)
|
||||
}
|
||||
|
||||
private static 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
|
||||
}
|
||||
|
||||
static func removeFromUpload(url: URL) -> Bool {
|
||||
guard fm.fileExists(atPath: url.path) else {
|
||||
return true
|
||||
}
|
||||
do {
|
||||
try fm.removeItem(at: url)
|
||||
return true
|
||||
} catch {
|
||||
self.error("Could not delete file \(url.path)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static var pendingUploads: [URL]? {
|
||||
return LocalDirectory.upload.content
|
||||
}
|
||||
|
||||
static var availableImages: [Int]? {
|
||||
return LocalDirectory.images.content?.compactMap {
|
||||
Int($0.lastPathComponent.components(separatedBy: ".").first ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Save an image to the uploads folder for later
|
||||
*/
|
||||
static func saveForUpload(imageData: Data, name: String) -> URL? {
|
||||
let url = LocalDirectory.upload.url.appendingPathComponent(name)
|
||||
return write(imageData, to: url) ? url : nil
|
||||
}
|
||||
}
|
||||
|
||||
extension DiskManager: Logger {
|
||||
|
||||
static let logToken = "[DiskManager]"
|
||||
}
|
328
CapCollector/Data/Download.swift
Normal file
328
CapCollector/Data/Download.swift
Normal file
@@ -0,0 +1,328 @@
|
||||
//
|
||||
// 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: .default, delegate: delegate, delegateQueue: nil)
|
||||
self.delegate = delegate
|
||||
}
|
||||
|
||||
// MARK: Paths
|
||||
|
||||
private static func serverDatabaseUrl(server: URL) -> URL {
|
||||
server.appendingPathComponent("db.sqlite3")
|
||||
}
|
||||
|
||||
var serverDatabaseUrl: URL {
|
||||
Download.serverDatabaseUrl(server: serverUrl)
|
||||
}
|
||||
|
||||
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 serverImageCountUrl(for cap: Int) -> URL {
|
||||
serverUrl.appendingPathComponent("count/\(cap)")
|
||||
}
|
||||
|
||||
private var serverClassifierVersionUrl: URL {
|
||||
serverUrl.appendingPathComponent("classifier.version")
|
||||
}
|
||||
|
||||
private var serverAllCountsUrl: URL {
|
||||
serverUrl.appendingPathComponent("count/all")
|
||||
}
|
||||
|
||||
var serverRecognitionModelUrl: URL {
|
||||
serverUrl.appendingPathComponent("classifier.mlmodel")
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
/**
|
||||
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
|
||||
- Note: The closure will be called from the main queue.
|
||||
- Returns: `true`, of the file download was started, `false`, if the image is already downloading.
|
||||
*/
|
||||
@discardableResult
|
||||
func mainImage(for cap: Int, completion: ((_ image: UIImage?) -> Void)?) -> Bool {
|
||||
let url = serverImageUrl(for: cap)
|
||||
let query = "Main image of cap \(cap)"
|
||||
guard !downloadingMainImages.contains(cap) else {
|
||||
return false
|
||||
}
|
||||
downloadingMainImages.insert(cap)
|
||||
|
||||
let task = session.downloadTask(with: url) { fileUrl, response, error in
|
||||
self.downloadingMainImages.remove(cap)
|
||||
guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else {
|
||||
DispatchQueue.main.async {
|
||||
completion?(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard app.storage.saveImage(at: fileUrl, for: cap) else {
|
||||
self.log("Request '\(query)' could not move downloaded file")
|
||||
DispatchQueue.main.async {
|
||||
completion?(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
guard let image = app.storage.image(for: cap) else {
|
||||
self.log("Request '\(query)' received an invalid image")
|
||||
completion?(nil)
|
||||
return
|
||||
}
|
||||
completion?(image)
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
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
|
||||
- Note: The closure will be called from the main queue.
|
||||
- Returns: `true`, of the file download was started, `false`, if the image is already downloading.
|
||||
*/
|
||||
@discardableResult
|
||||
func image(for cap: Int, version: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool {
|
||||
let url = serverImageUrl(for: cap, version: version)
|
||||
let query = "Image of cap \(cap) version \(version)"
|
||||
let task = session.downloadTask(with: url) { fileUrl, response, error in
|
||||
guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else {
|
||||
DispatchQueue.main.async {
|
||||
completion(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard app.storage.saveImage(at: fileUrl, for: cap, version: version) else {
|
||||
self.log("Request '\(query)' could not move downloaded file")
|
||||
DispatchQueue.main.async {
|
||||
completion(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
guard let image = app.storage.image(for: cap, version: version) else {
|
||||
self.log("Request '\(query)' received an invalid image")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
completion(image)
|
||||
}
|
||||
}
|
||||
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)"
|
||||
let task = session.dataTask(with: url) { data, response, error in
|
||||
let int = self.convertIntResponse(to: query, data, response, error)
|
||||
completion(int)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
func imageCounts(completion: @escaping ([(cap: Int, count: Int)]?) -> Void) {
|
||||
let url = serverAllCountsUrl
|
||||
let query = "Image count of all caps"
|
||||
let task = session.dataTask(with: url) { data, response, error in
|
||||
guard let string = self.convertStringResponse(to: query, data, response, error) else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert the encoded string into (id, count) pairs
|
||||
let parts = string.components(separatedBy: ";")
|
||||
let array: [(cap: Int, count: Int)] = parts.compactMap { s in
|
||||
let p = s.components(separatedBy: "#")
|
||||
guard p.count == 2, let cap = Int(p[0]), let count = Int(p[1]) else {
|
||||
return nil
|
||||
}
|
||||
return (cap, count)
|
||||
}
|
||||
completion(array)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
func databaseSize(completion: @escaping (_ size: Int64?) -> Void) {
|
||||
size(of: "database size", to: serverDatabaseUrl, completion: completion)
|
||||
}
|
||||
func database(progress: Delegate.ProgressHandler? = nil, completion: @escaping (URL?) -> Void) {
|
||||
//let query = "Download of server database"
|
||||
let task = session.downloadTask(with: serverDatabaseUrl)
|
||||
delegate.registerForProgress(task, callback: progress) {url in
|
||||
self.log("Database download complete")
|
||||
completion(url)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
|
||||
func classifierVersion(completion: @escaping (Int?) -> Void) {
|
||||
let query = "Server classifier version"
|
||||
let task = session.dataTask(with: serverClassifierVersionUrl) { data, response, error in
|
||||
let int = self.convertIntResponse(to: query, data, response, error)
|
||||
completion(int)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
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 { }
|
@@ -1,145 +0,0 @@
|
||||
//
|
||||
// NameFile.swift
|
||||
// CapFinder
|
||||
//
|
||||
// Created by User on 23.04.18.
|
||||
// Copyright © 2018 User. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftyDropbox
|
||||
|
||||
final class NameFile: Logger {
|
||||
|
||||
static let logToken = "[NameFile]"
|
||||
|
||||
/// The name of the file
|
||||
private static let fileName = "names.txt"
|
||||
|
||||
private static let path = "/" + fileName
|
||||
|
||||
/// The url of the file on disk
|
||||
private static let url = DiskManager.documentsDirectory.appendingPathComponent(fileName)
|
||||
|
||||
private static let fm = FileManager.default
|
||||
|
||||
// MARK: - Reading from disk
|
||||
|
||||
/// Indicates if the name list was written to disk
|
||||
private static var nameFileExistsOnDisk: Bool {
|
||||
return fm.fileExists(atPath: url.path)
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
@discardableResult static func save(names: String) -> Bool {
|
||||
let data = names.data(using: .utf8)!
|
||||
return save(names: data)
|
||||
}
|
||||
|
||||
static func saveAndUpload(names: String, completion: @escaping (Bool) -> Void) {
|
||||
let data = names.data(using: .utf8)!
|
||||
guard save(names: data) else {
|
||||
return
|
||||
}
|
||||
|
||||
let client = DropboxController.client
|
||||
client.files.upload(path: path, mode: .overwrite, input: data).response { _ , error in
|
||||
if let error = error {
|
||||
self.error("Error uploading name list: \(error)")
|
||||
completion(false)
|
||||
} else {
|
||||
self.event("Uploaded name list")
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// The content of the name file as a String
|
||||
private static var content: String? {
|
||||
do {
|
||||
return try String(contentsOf: url, encoding: .utf8)
|
||||
} catch {
|
||||
self.error("Error reading \(url): \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Save the name file to disk
|
||||
- parameter names: The new name file content
|
||||
- returns: True, if the data was written to disk
|
||||
*/
|
||||
@discardableResult private static func save(names: Data) -> Bool {
|
||||
do {
|
||||
try names.write(to: url, options: .atomic)
|
||||
event("Name file saved to disk")
|
||||
return true
|
||||
} catch {
|
||||
self.error("Could not save names to file: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static func makeAvailable(completion: ((String?) -> Void)? = nil) {
|
||||
if nameFileExistsOnDisk {
|
||||
completion?(self.content)
|
||||
} else {
|
||||
download() { success in
|
||||
guard success else {
|
||||
completion?(nil)
|
||||
return
|
||||
}
|
||||
completion?(self.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The data of the name list
|
||||
private static var data: Data? {
|
||||
guard nameFileExistsOnDisk else { return nil }
|
||||
do {
|
||||
return try Data(contentsOf: url)
|
||||
} catch {
|
||||
self.error("Could not read data from \(url): \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Delete the file on disk
|
||||
- returns: True, if the file no longer exists on disk
|
||||
*/
|
||||
@discardableResult private static func delete() -> Bool {
|
||||
guard nameFileExistsOnDisk else {
|
||||
event("No name file to delete")
|
||||
return true
|
||||
}
|
||||
|
||||
do {
|
||||
try fm.removeItem(at: url)
|
||||
} catch {
|
||||
self.error("Could not delete name file: \(error)")
|
||||
return false
|
||||
}
|
||||
event("Deleted name file on disk")
|
||||
return true
|
||||
}
|
||||
|
||||
private static func download(completion: ((Bool) -> Void)? = nil) {
|
||||
let client = DropboxController.client
|
||||
event("Downloading names from Dropbox")
|
||||
client.files.download(path: path).response { response, error in
|
||||
guard let data = response?.1 else {
|
||||
self.error("Error downloading file: \(error!)")
|
||||
completion?(false)
|
||||
return
|
||||
}
|
||||
self.event("Downloaded name file")
|
||||
completion?(NameFile.save(names: data))
|
||||
}
|
||||
}
|
||||
}
|
47
CapCollector/Data/ServerDatabase.swift
Normal file
47
CapCollector/Data/ServerDatabase.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// ServerDatabase.swift
|
||||
// CapCollector
|
||||
//
|
||||
// Created by Christoph on 27.04.20.
|
||||
// Copyright © 2020 CH. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
final class ServerDatabase {
|
||||
|
||||
let db: Connection
|
||||
|
||||
var table: Table {
|
||||
Table("caps")
|
||||
}
|
||||
|
||||
let rowId = Expression<Int>("id")
|
||||
|
||||
let rowName = Expression<String>("name")
|
||||
|
||||
let rowCount = Expression<Int>("count")
|
||||
|
||||
init?(downloadedTo url: URL) {
|
||||
guard let db = try? Connection(url.path) else {
|
||||
return nil
|
||||
}
|
||||
self.db = db
|
||||
log("Server database loaded with \(capCount) caps")
|
||||
}
|
||||
|
||||
/// The number of caps currently in the database
|
||||
var capCount: Int {
|
||||
(try? db.scalar(table.count)) ?? 0
|
||||
}
|
||||
|
||||
var caps: [(id: Int, count: Int, name: String)] {
|
||||
guard let rows = try? db.prepare(table) else {
|
||||
return []
|
||||
}
|
||||
return rows.map { ($0[rowId], $0[rowCount], $0[rowName]) }
|
||||
}
|
||||
}
|
||||
|
||||
extension ServerDatabase: Logger { }
|
321
CapCollector/Data/Storage.swift
Normal file
321
CapCollector/Data/Storage.swift
Normal file
@@ -0,0 +1,321 @@
|
||||
//
|
||||
// DiskManager.swift
|
||||
// CapFinder
|
||||
//
|
||||
// Created by User on 23.04.18.
|
||||
// Copyright © 2018 User. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import CoreML
|
||||
import Vision
|
||||
|
||||
final class Storage {
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
private func localImageUrl(for cap: Int, version: Int) -> URL {
|
||||
baseUrl.appendingPathComponent("\(cap)-\(version).jpg")
|
||||
}
|
||||
|
||||
private func thumbnailUrl(for cap: Int) -> URL {
|
||||
baseUrl.appendingPathComponent("\(cap)-thumb.jpg")
|
||||
}
|
||||
|
||||
// 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) -> Bool {
|
||||
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 true
|
||||
} catch {
|
||||
log("Failed to delete or move image \(version) for cap \(cap)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
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
|
||||
*/
|
||||
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: 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)
|
||||
}
|
||||
|
||||
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 = 0) -> 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
|
||||
}
|
||||
|
||||
/**
|
||||
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`
|
||||
*/
|
||||
func ciImage(for cap: Int, version: Int = 0) -> CIImage? {
|
||||
guard let url = existingImageUrl(for: cap, version: version) else {
|
||||
return nil
|
||||
}
|
||||
return CIImage(contentsOf: url)
|
||||
}
|
||||
|
||||
/**
|
||||
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 averageColor(for cap: Int, version: Int = 0) -> UIColor? {
|
||||
guard let inputImage = ciImage(for: cap, version: version) else {
|
||||
error("Failed to read CIImage for main image of cap \(cap)")
|
||||
return nil
|
||||
}
|
||||
return inputImage.averageColor
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
guard let url = existingImageUrl(for: cap, version: version) else {
|
||||
return true
|
||||
}
|
||||
do {
|
||||
try fm.removeItem(at: url)
|
||||
return true
|
||||
} catch {
|
||||
log("Failed to delete image \(version) for cap \(cap): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Storage: Logger { }
|
||||
|
180
CapCollector/Data/Upload.swift
Normal file
180
CapCollector/Data/Upload.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// Upload.swift
|
||||
// CapCollector
|
||||
//
|
||||
// Created by Christoph on 26.04.20.
|
||||
// Copyright © 2020 CH. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SQLite
|
||||
|
||||
struct Upload {
|
||||
|
||||
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 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
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
func uploadImage(for cap: Int, version: Int, completion: @escaping (_ count: Int?) -> Void) {
|
||||
guard let url = app.storage.existingImageUrl(for: cap, version: version) else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
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 \(version) of cap \(cap): \(error)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard let response = response else {
|
||||
self.log("Failed to upload image \(version) of cap \(cap): No response")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard let urlResponse = response as? HTTPURLResponse else {
|
||||
self.log("Failed to upload image \(version) of cap \(cap): \(response)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard urlResponse.statusCode == 200 else {
|
||||
self.log("Failed to upload image \(version) 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 \(version) of cap \(cap): Invalid response")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
completion(int)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
/**
|
||||
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 new average color on completion.
|
||||
*/
|
||||
func setMainImage(for cap: Int, to version: Int, completion: @escaping (_ averageColor: UIColor?) -> Void) {
|
||||
guard let averageColor = app.storage.averageColor(for: cap, version: version) else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
let url = serverChangeMainImageUrl(for: cap, to: version)
|
||||
var request = URLRequest(url: url)
|
||||
let averageRGB = averageColor.rgb
|
||||
request.addValue("\(averageRGB.red)", forHTTPHeaderField: "r")
|
||||
request.addValue("\(averageRGB.green)", forHTTPHeaderField: "g")
|
||||
request.addValue("\(averageRGB.blue)", forHTTPHeaderField: "b")
|
||||
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
if let error = error {
|
||||
self.log("Failed to set main image of cap \(cap) to \(version): \(error)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard let response = response else {
|
||||
self.log("Failed to set main image of cap \(cap) to \(version): No response")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard let urlResponse = response as? HTTPURLResponse else {
|
||||
self.log("Failed to set main image of cap \(cap) to \(version): \(response)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
guard urlResponse.statusCode == 200 else {
|
||||
self.log("Failed to set main image of cap \(cap) to \(version): Response \(urlResponse.statusCode)")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
completion(averageColor)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
extension Upload: Logger { }
|
@@ -1,67 +0,0 @@
|
||||
//
|
||||
// UserDefaults.swift
|
||||
// CapCollector
|
||||
//
|
||||
// Created by Christoph on 16.10.18.
|
||||
// Copyright © 2018 CH. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class Persistence {
|
||||
|
||||
static var recognizedCapCount: Int {
|
||||
get {
|
||||
return UserDefaults.standard.integer(forKey: "recognizedCaps")
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: "recognizedCaps")
|
||||
}
|
||||
}
|
||||
|
||||
static var newImageCount: Int {
|
||||
get {
|
||||
return UserDefaults.standard.integer(forKey: "newImages")
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: "newImages")
|
||||
}
|
||||
}
|
||||
|
||||
static var lastUploadedCapCount: Int {
|
||||
get {
|
||||
return UserDefaults.standard.integer(forKey: "lastUploadedCaps")
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: "lastUploadedCaps")
|
||||
}
|
||||
}
|
||||
|
||||
static var lastUploadedImageCount: Int {
|
||||
get {
|
||||
return UserDefaults.standard.integer(forKey: "lastUploadedImages")
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: "lastUploadedImages")
|
||||
}
|
||||
}
|
||||
|
||||
static var useMobileNet: Bool {
|
||||
get {
|
||||
return UserDefaults.standard.bool(forKey: "mobileNet")
|
||||
}
|
||||
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: "mobileNet")
|
||||
}
|
||||
}
|
||||
|
||||
static var folderNotCreated: [Int] {
|
||||
get {
|
||||
return UserDefaults.standard.array(forKey: "folders") as? [Int] ?? []
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: "folders")
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user