Caps-iOS/CapCollector/Data/Cap.swift
Christoph Hagen dceb3ca07d - Remove Xcode and Regnet classifier
- Add MobileNet classifier
- Add average color for each cap
- Add option to show average colors in mosaic
2019-07-17 11:10:07 +02:00

750 lines
23 KiB
Swift

//
// Cap.swift
// CapCollector
//
// Created by Christoph on 19.11.18.
// Copyright © 2018 CH. All rights reserved.
//
import Foundation
import UIKit
import CoreImage
import SwiftyDropbox
protocol CapsDelegate: class {
func capHasUpdates(_ cap: Cap)
func capsLoaded()
}
final class Cap {
// MARK: - Static variables
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
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
/// 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)
}
}
/// The name of the cap wothout special characters
private(set) var 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)
}
}
/// The average color of the cap
var color: UIColor?
/// The similarity of the cap to the currently processed image
var match: Float? = nil
// MARK: - All caps
/// A dictionary of all known caps
static var all = [Int : Cap]()
// 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
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
}
}
}
/**
Create a cap from a line in the cap list file
*/
init?(line: String) {
guard line != "" else {
return nil
}
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
self.cleanName = name.clean
self.tile = tile
Cap.tiles[tile] = self
Cap.all[id] = self
}
// MARK: - Images
/// 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)
}
/// The main image of the cap
var thumbnail: UIImage? {
if let data = DiskManager.thumbnail(for: id) {
return UIImage(data: data)
}
return makeThumbnail()
}
var averageColor: UIColor? {
if let c = color {
return c
}
return makeAverageColor()
}
@discardableResult
func makeThumbnail() -> UIImage? {
guard let img = image else {
return nil
}
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
}
_ = DiskManager.save(thumbnailData: data, for: id)
event("Created thumbnail for cap \(id)")
return thumb
}
/**
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
*/
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 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
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 CustomStringConvertible
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"
}
}
// MARK: - Protocol Logger
extension Cap: Logger {
static let logToken = "[CAP]"
}
// 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
}
}