Add grid, camera focus
This commit is contained in:
@ -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
8
Caps/Data/CapImage.swift
Normal file
@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
struct CapImage: Codable, Equatable, Hashable {
|
||||
|
||||
let cap: Int
|
||||
|
||||
let version: Int
|
||||
}
|
@ -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
209
Caps/Data/ImageCache.swift
Normal 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
82
Caps/Data/ImageGrid.swift
Normal 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))
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user