Add grid, camera focus

This commit is contained in:
Christoph Hagen
2022-06-21 19:38:51 +02:00
parent 2b3ab859fc
commit 4b91ebcd02
21 changed files with 895 additions and 104 deletions

View File

@ -27,6 +27,10 @@ struct Cap {
String(format: "images/%04d/%04d-%02d.jpg", id, id, mainImage)
}
var image: CapImage {
.init(cap: id, version: mainImage)
}
/**
Create a new cap.
- Parameter id: The unique id of the cap

8
Caps/Data/CapImage.swift Normal file
View File

@ -0,0 +1,8 @@
import Foundation
struct CapImage: Codable, Equatable, Hashable {
let cap: Int
let version: Int
}

View File

@ -5,52 +5,23 @@ import CryptoKit
final class Database: ObservableObject {
static let imageCacheMemory = 10_000_000
static let imageCacheStorage = 200_000_000
private let imageCompressionQuality: CGFloat = 0.3
private static var documentDirectory: URL {
try! FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil, create: true)
}
@AppStorage("classifier")
private(set) var classifierVersion = 0
private var fm: FileManager {
.default
}
@AppStorage("serverClassifier")
private(set) var serverClassifierVersion = 0
private var localDbUrl: URL {
Database.documentDirectory.appendingPathComponent("db.json")
}
private var localClassifierUrl: URL {
Database.documentDirectory.appendingPathComponent("classifier.mlmodel")
}
private var imageUploadFolderUrl: URL {
Database.documentDirectory.appendingPathComponent("uploads")
}
private var serverDbUrl: URL {
serverUrl.appendingPathComponent("caps.json")
}
private var serverClassifierUrl: URL {
serverUrl.appendingPathComponent("classifier.mlmodel")
}
private var serverClassifierVersionUrl: URL {
serverUrl.appendingPathComponent("classifier.version")
}
let images: ImageCache
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
let serverUrl: URL
let folderUrl: URL
@AppStorage("authKey")
private var serverAuthenticationKey: String = ""
@ -108,22 +79,68 @@ final class Database: ObservableObject {
*/
private var nextSaveTime: Date?
let imageCache: URLCache
@Published
var isUploading = false
init(server: URL) {
init(server: URL, folder: URL = FileManager.default.documentDirectory) {
self.serverUrl = server
self.folderUrl = folder
self.caps = [:]
let cacheDirectory = Database.documentDirectory.appendingPathComponent("images")
self.imageCache = URLCache(
memoryCapacity: Database.imageCacheMemory,
diskCapacity: Database.imageCacheStorage,
directory: cacheDirectory)
let imageFolder = folder.appendingPathComponent("images")
self.images = try! ImageCache(
folder: imageFolder,
server: server,
thumbnailSize: CapsApp.thumbnailImageSize)
ensureFolderExistence(gridStorageFolder)
loadCaps()
}
@Published
var isUploading = false
func mainImage(for cap: Int) -> Int {
caps[cap]?.mainImage ?? 0
}
// MARK: URLs
private var fm: FileManager {
.default
}
private var localDbUrl: URL {
folderUrl.appendingPathComponent("db.json")
}
private var localClassifierUrl: URL {
folderUrl.appendingPathComponent("classifier.mlmodel")
}
private var imageUploadFolderUrl: URL {
folderUrl.appendingPathComponent("uploads")
}
private var serverDbUrl: URL {
serverUrl.appendingPathComponent("caps.json")
}
private var serverClassifierUrl: URL {
serverUrl.appendingPathComponent("classifier.mlmodel")
}
private var serverClassifierVersionUrl: URL {
serverUrl.appendingPathComponent("classifier.version")
}
private var gridStorageFolder: URL {
folderUrl.appendingPathComponent("grids")
}
func mainImageUrl(for cap: Int) -> URL? {
guard let path = caps[cap]?.mainImagePath else {
return nil
}
return serverUrl.appendingPathComponent(path)
}
// MARK: Disk storage
@ -185,6 +202,7 @@ final class Database: ObservableObject {
print("Database saved")
}
@discardableResult
private func ensureFolderExistence(_ url: URL) -> Bool {
guard !fm.fileExists(atPath: url.path) else {
return true
@ -395,6 +413,14 @@ final class Database: ObservableObject {
DispatchQueue.main.async {
self.isUploading = true
}
defer {
DispatchQueue.main.async {
self.isUploading = false
}
}
guard !changedCaps.isEmpty || pendingImageUploadCount > 0 else {
return
}
log("Starting uploads")
let uploaded = await uploadAllChangedCaps()
DispatchQueue.main.async {
@ -402,9 +428,6 @@ final class Database: ObservableObject {
}
await uploadAllImages()
log("Uploads finished")
DispatchQueue.main.async {
self.isUploading = false
}
}
/**
@ -595,6 +618,82 @@ final class Database: ObservableObject {
return Classifier(model: model)
}
// MARK: Grid
var availableGrids: [String] {
do {
return try fm.contentsOfDirectory(at: gridStorageFolder, includingPropertiesForKeys: nil)
.filter { $0.pathExtension == "caps" }
.map { $0.deletingPathExtension().lastPathComponent }
} catch {
print("Failed to load available grids: \(error)")
return []
}
}
private func gridFileUrl(_ grid: String) -> URL {
gridStorageFolder.appendingPathComponent(grid).appendingPathExtension("caps")
}
private func gridImageUrl(_ grid: String) -> URL {
gridStorageFolder.appendingPathComponent(grid).appendingPathExtension("jpg")
}
func load(grid: String) -> ImageGrid? {
let url = gridFileUrl(grid)
guard fm.fileExists(atPath: url.path) else {
return nil
}
do {
let data = try Data(contentsOf: url)
var loaded = try decoder.decode(ImageGrid.self, from: data)
// Add all missing caps to the end of the image
let newCaps = Set(caps.keys).subtracting(loaded.capPlacements).sorted()
loaded.capPlacements += newCaps
print("Grid \(grid) loaded (\(newCaps.count) new caps)")
return loaded
} catch {
print("Failed to load grid \(grid): \(error)")
return nil
}
}
@discardableResult
func save(_ grid: ImageGrid, named name: String) -> Bool {
let url = gridFileUrl(name)
do {
let data = try encoder.encode(grid)
try data.write(to: url)
print("Grid \(name) saved")
return true
} catch {
print("Failed to save grid \(name): \(error)")
return false
}
}
// MARK: Grid images
func load(gridImage: String) -> UIImage? {
let url = gridImageUrl(gridImage)
return UIImage(at: url)
}
@discardableResult
func save(gridImage: UIImage, for grid: String) -> Bool {
guard let data = gridImage.jpegData(compressionQuality: 0.9) else {
return false
}
let url = gridImageUrl(grid)
do {
try data.write(to: url)
return true
} catch {
print("Failed to save grid image \(grid): \(error)")
return false
}
}
// MARK: Statistics
var numberOfCaps: Int {
@ -609,19 +708,13 @@ final class Database: ObservableObject {
Float(numberOfImages) / Float(numberOfCaps)
}
@AppStorage("classifier")
private(set) var classifierVersion = 0
@AppStorage("serverClassifier")
private(set) var serverClassifierVersion = 0
var classifierClassCount: Int {
let version = classifierVersion
return caps.values.filter { $0.classifiable(by: version) }.count
}
var imageCacheSize: Int {
imageCache.currentDiskUsage
fm.directorySize(images.folder)
}
var databaseSize: Int {
@ -648,4 +741,13 @@ extension Database {
db.image = UIImage(systemSymbol: .photo)
return db
}
static var largeMock: Database {
let db = Database(server: URL(string: "https://christophhagen.de/caps")!)
db.caps = (1..<500)
.map { Cap(id: $0, name: "Cap \($0)", classifier: nil)}
.reduce(into: [:]) { $0[$1.id] = $1 }
db.image = UIImage(systemSymbol: .photo)
return db
}
}

209
Caps/Data/ImageCache.swift Normal file
View File

@ -0,0 +1,209 @@
import Foundation
import UIKit
final class ImageCache {
let folder: URL
let server: URL
let thumbnailSize: CGFloat
private let fm: FileManager = .default
private let session: URLSession = .shared
private let thumbnailQuality: CGFloat = 0.7
init(folder: URL, server: URL, thumbnailSize: CGFloat) throws {
self.folder = folder
self.server = server
self.thumbnailSize = thumbnailSize * UIScreen.main.scale
if !fm.fileExists(atPath: folder.path) {
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
}
}
private func localImageUrl(_ image: CapImage) -> URL {
folder.appendingPathComponent(String(format: "%04d-%02d.jpg", image.cap, image.cap, image.version))
}
private func remoteImageUrl(_ image: CapImage) -> URL {
server.appendingPathComponent(String(format: "images/%04d/%04d-%02d.jpg", image.cap, image.cap, image.version))
}
func image(_ image: CapImage, completion: @escaping (UIImage?) -> ()) {
Task {
let image = await self.image(image)
completion(image)
}
}
func image(_ image: CapImage, download: Bool = true) async -> UIImage? {
if let localUrl = existingLocalImageUrl(image) {
return UIImage(at: localUrl)
}
guard download else {
return nil
}
guard let downloadedImageUrl = await loadRemoteImage(image) else {
return nil
}
guard saveImage(image, at: downloadedImageUrl) else {
return UIImage(at: downloadedImageUrl)
}
let localUrl = localImageUrl(image)
return UIImage(at: localUrl)
}
func cachedImage(_ image: CapImage) -> UIImage? {
guard let localUrl = existingLocalImageUrl(image) else {
return nil
}
return UIImage(at: localUrl)
}
@discardableResult
func removeImage(_ image: CapImage) -> Bool {
let localUrl = localImageUrl(image)
return removePossibleFile(localUrl)
}
@discardableResult
func refreshImage(_ image: CapImage) async -> Bool {
guard let downloadedImageUrl = await loadRemoteImage(image) else {
return false
}
return saveImage(image, at: downloadedImageUrl)
}
private func loadRemoteImage(_ image: CapImage) async -> URL? {
let remoteURL = remoteImageUrl(image)
return await loadRemoteImage(at: remoteURL)
}
private func loadRemoteImage(at url: URL) async -> URL? {
let tempUrl: URL
let response: URLResponse
do {
(tempUrl, response) = try await session.download(from: url)
} catch {
print("Failed to download image \(url.lastPathComponent): \(error)")
return nil
}
guard let httpResponse = response as? HTTPURLResponse else {
print("Failed to download image \(url.lastPathComponent): Not a HTTP response: \(response)")
return nil
}
guard httpResponse.statusCode == 200 else {
print("Failed to download image \(url.path): Response \(httpResponse.statusCode)")
return nil
}
return tempUrl
}
private func saveImage(_ image: CapImage, at tempUrl: URL) -> Bool {
let localUrl = localImageUrl(image)
guard removePossibleFile(localUrl) else {
return false
}
do {
try fm.moveItem(at: tempUrl, to: localUrl)
return true
} catch {
print("failed to save image \(localUrl.lastPathComponent): \(error)")
return false
}
}
private func existingLocalImageUrl(_ image: CapImage) -> URL? {
let localFile = localImageUrl(image)
guard exists(localFile) else {
return nil
}
return localFile
}
private func exists(_ url: URL) -> Bool {
fm.fileExists(atPath: url.path)
}
@discardableResult
private func removePossibleFile(_ file: URL) -> Bool {
guard exists(file) else {
return true
}
return remove(file)
}
@discardableResult
private func remove(_ url: URL) -> Bool {
do {
try fm.removeItem(at: url)
return true
} catch {
print("Failed to remove \(url.lastPathComponent): \(error)")
return false
}
}
// MARK: Thumbnails
private func localThumbnailUrl(cap: Int) -> URL {
folder.appendingPathComponent(String(format: "%04d.jpg", cap))
}
func thumbnail(for image: CapImage, download: Bool = true) async -> UIImage? {
let localUrl = localThumbnailUrl(cap: image.cap)
if exists(localUrl) {
return UIImage(at: localUrl)
}
guard let mainImage = await self.image(image, download: download) else {
return nil
}
let thumbnail = await createThumbnail(mainImage)
save(thumbnail: thumbnail, for: image.cap)
return thumbnail
}
func cachedThumbnail(for image: CapImage) -> UIImage? {
let localUrl = localThumbnailUrl(cap: image.cap)
guard exists(localUrl) else {
return nil
}
return UIImage(at: localUrl)
}
@discardableResult
func createThumbnail(for image: CapImage, download: Bool = false) async -> Bool {
await thumbnail(for: image, download: download) != nil
}
private func createThumbnail(_ image: UIImage) async -> UIImage {
let size = thumbnailSize
return await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .background).async {
let small = image.resize(to: CGSize(width: size, height: size))
continuation.resume(returning: small)
}
}
}
@discardableResult
private func save(thumbnail: UIImage, for cap: Int) -> Bool {
guard let data = thumbnail.jpegData(compressionQuality: thumbnailQuality) else {
print("Failed to get thumbnail JPEG data")
return false
}
let localUrl = localThumbnailUrl(cap: cap)
do {
try data.write(to: localUrl)
return true
} catch {
print("Failed to save thumbnail \(cap): \(error)")
return false
}
}
}

82
Caps/Data/ImageGrid.swift Normal file
View File

@ -0,0 +1,82 @@
import Foundation
struct ImageGrid: Codable {
struct Position {
let x: Int
let y: Int
}
struct Item: Identifiable {
let id: Int
let cap: Int
}
let columns: Int
/**
The place of each cap.
The index is the position in the image,
where `x = index % columns` and `y = index / columns`
*/
var capPlacements: [Int]
/// All caps currently present in the image
var caps: Set<Int> {
Set(capPlacements)
}
var items: [Item] {
capPlacements.enumerated().map {
.init(id: $0, cap: $1)
}
}
var capCount: Int {
capPlacements.count
}
func index(of position: Position) -> Int? {
return index(x: position.x, y: position.y)
}
func index(x: Int, y: Int) -> Int? {
let index = y * columns + y
guard index < capCount else {
return nil
}
return capPlacements[index]
}
mutating func switchCaps(at x: Int, _ y: Int, with otherX: Int, _ otherY: Int) {
guard let other = index(x: x, y: y), let index = index(x: otherX, y: otherY) else {
return
}
switchCaps(at: index, with: other)
}
mutating func switchCaps(at position: Position, with other: Position) {
guard let other = index(of: other), let index = index(of: position) else {
return
}
switchCaps(at: index, with: other)
}
mutating func switchCaps(at index: Int, with other: Int) {
guard index < capCount, other < capCount else {
return
}
let temp = capPlacements[index]
capPlacements[index] = capPlacements[other]
capPlacements[other] = temp
}
static func mock(columns: Int, count: Int) -> ImageGrid {
.init(columns: columns, capPlacements: Array(0..<count))
}
}