Compare commits
8 Commits
1fd63b8cc3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cf3c8d0c1 | ||
|
|
7b3210169a | ||
|
|
e34845ab24 | ||
|
|
400753a9a2 | ||
|
|
1c57b538be | ||
|
|
5138bb543e | ||
|
|
e6132a38b3 | ||
|
|
6aaa9cb458 |
13
.gitignore
vendored
@@ -8,12 +8,11 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Package.resolved
|
Package.resolved
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
|
Public/caps.json
|
||||||
|
Public/changes.txt
|
||||||
|
Public/classifier.*
|
||||||
|
Public/count.*
|
||||||
Public/images/
|
Public/images/
|
||||||
Public/thumbnails/
|
Public/thumbnails/
|
||||||
Public/classifier.version
|
Resources/config.json
|
||||||
Public/classifier.mlmodel
|
Resources/logs/
|
||||||
Public/caps.json
|
|
||||||
Training/backup/
|
|
||||||
Training/config.json
|
|
||||||
Public/thumbnails
|
|
||||||
Public/count.js
|
|
||||||
|
|||||||
@@ -13,21 +13,15 @@ let package = Package(
|
|||||||
.package(url: "https://github.com/christophhagen/ClairvoyantBinaryCodable", from: "0.3.0"),
|
.package(url: "https://github.com/christophhagen/ClairvoyantBinaryCodable", from: "0.3.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "App",
|
.executableTarget(
|
||||||
dependencies: [
|
name: "App",
|
||||||
.product(name: "Vapor", package: "vapor"),
|
dependencies: [
|
||||||
.product(name: "Clairvoyant", package: "Clairvoyant"),
|
.product(name: "Vapor", package: "vapor"),
|
||||||
.product(name: "ClairvoyantVapor", package: "ClairvoyantVapor"),
|
.product(name: "Clairvoyant", package: "Clairvoyant"),
|
||||||
.product(name: "ClairvoyantBinaryCodable", package: "ClairvoyantBinaryCodable"),
|
.product(name: "ClairvoyantVapor", package: "ClairvoyantVapor"),
|
||||||
],
|
.product(name: "ClairvoyantBinaryCodable", package: "ClairvoyantBinaryCodable"),
|
||||||
swiftSettings: [
|
]
|
||||||
// Enable better optimizations when building in Release configuration. Despite the use of
|
)
|
||||||
// the `.unsafeFlags` construct required by SwiftPM, this flag is recommended for Release
|
|
||||||
// builds. See <https://github.com/swift-server/guides#building-for-production> for details.
|
|
||||||
.unsafeFlags(["-cross-module-optimization"], .when(configuration: .release))
|
|
||||||
]),
|
|
||||||
.executableTarget(name: "Run", dependencies: ["App"]),
|
|
||||||
.testTarget(name: "AppTests", dependencies: ["App"]),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
BIN
Public/assets/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
Public/assets/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
Public/assets/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
9
Public/assets/browserconfig.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square150x150logo src="/assets/mstile-150x150.png?v=1"/>
|
||||||
|
<TileColor>#a36490</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
||||||
BIN
Public/assets/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 953 B |
BIN
Public/assets/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
Public/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Public/assets/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
1
Public/assets/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
@@ -5,13 +5,13 @@
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="robots" content="noindex"/>
|
<meta name="robots" content="noindex"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png?v=1"/>
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png?v=1"/>
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32x32.png?v=1"/>
|
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png?v=1"/>
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png?v=1"/>
|
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png?v=1"/>
|
||||||
<link rel="manifest" href="/assets/icons/site.webmanifest?v=1"/>
|
<link rel="manifest" href="/assets/site.webmanifest?v=1"/>
|
||||||
<link rel="shortcut icon" href="/assets/icons/favicon.ico?v=1"/>
|
<link rel="shortcut icon" href="/assets/favicon.ico?v=1"/>
|
||||||
<meta name="msapplication-TileColor" content="#da532c">
|
<meta name="msapplication-TileColor" content="#da532c">
|
||||||
<meta name="msapplication-config" content="/assets/icons/browserconfig.xml?v=1"/>
|
<meta name="msapplication-config" content="/assets/browserconfig.xml?v=1"/>
|
||||||
<meta name="theme-color" content="#ffffff"/>
|
<meta name="theme-color" content="#ffffff"/>
|
||||||
<link href="grid.css?v=2" rel="stylesheet"/>
|
<link href="grid.css?v=2" rel="stylesheet"/>
|
||||||
<meta name="author" content="Christoph Hagen"/>
|
<meta name="author" content="Christoph Hagen"/>
|
||||||
|
|||||||
26
Readme.md
@@ -51,31 +51,7 @@ Note: The data for the mosaic is currently not updated automatically, since Swif
|
|||||||
## Classifier training
|
## Classifier training
|
||||||
|
|
||||||
The main server is running on Linux, which doesn't provide the CreateML framework required for classifier training.
|
The main server is running on Linux, which doesn't provide the CreateML framework required for classifier training.
|
||||||
This has to be done on macOS using the `train.swift` script in the `Training` folder.
|
This has to be done on macOS using the [Caps-Train](https://christophhagen.de/git/ch/Caps-Train) repository.
|
||||||
It will:
|
|
||||||
- Fetch the current image catalog by checking missing and changed images
|
|
||||||
- Train a classifier on the image set
|
|
||||||
- Increment the classifier version and upload the new model
|
|
||||||
- Update the list of caps which can be recognized using the classifier
|
|
||||||
- Create missing thumbnails for each cap for the cap grid
|
|
||||||
|
|
||||||
A configuration file is required to run the training, with a valid access token for the server:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"imageDirectory": "../Public/images",
|
|
||||||
"classifierModelPath": "../Public/classifier.mlmodel",
|
|
||||||
"trainingIterations": 20,
|
|
||||||
"serverPath": "https://mydomain.com/caps",
|
|
||||||
"authenticationToken": "mysecretkey",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The configuration file `config.json` must be located in the folder from which the script is run.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
swift train.swift
|
|
||||||
```
|
|
||||||
|
|
||||||
## Future work
|
## Future work
|
||||||
- Create thumbnails on the server using [JPEG](https://github.com/kelvin13/jpeg)
|
- Create thumbnails on the server using [JPEG](https://github.com/kelvin13/jpeg)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"maxBodySize" : "2mb",
|
"maxBodySize" : "2mb",
|
||||||
"logPath": "\/var\/log\/caps/metrics",
|
"logPath": "\/var\/log\/caps/metrics",
|
||||||
"serveFiles": true,
|
"serveFiles": true,
|
||||||
|
"dataDirectory" : "/ch/data/caps",
|
||||||
"writers" : [
|
"writers" : [
|
||||||
"auth_key_1"
|
"auth_key_1"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ struct Config: Codable {
|
|||||||
/// Authentication tokens for remotes allowed to write
|
/// Authentication tokens for remotes allowed to write
|
||||||
let writers: [String]
|
let writers: [String]
|
||||||
|
|
||||||
|
/**
|
||||||
|
The folder where the data should be stored.
|
||||||
|
|
||||||
|
If the folder is set to `nil`, then the `Resources` folder is used.
|
||||||
|
*/
|
||||||
|
let dataDirectory: String?
|
||||||
|
|
||||||
|
func customDataDirectory(or publicDirectory: String) -> URL {
|
||||||
|
guard let dataDirectory else {
|
||||||
|
return URL(fileURLWithPath: publicDirectory)
|
||||||
|
}
|
||||||
|
return URL(fileURLWithPath: dataDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
func logURL(possiblyRelativeTo resourcesDirectory: URL) -> URL {
|
func logURL(possiblyRelativeTo resourcesDirectory: URL) -> URL {
|
||||||
guard let logPath else {
|
guard let logPath else {
|
||||||
return resourcesDirectory.appendingPathComponent("logs")
|
return resourcesDirectory.appendingPathComponent("logs")
|
||||||
|
|||||||
@@ -3,32 +3,12 @@ import Clairvoyant
|
|||||||
import Vapor
|
import Vapor
|
||||||
import NIOCore
|
import NIOCore
|
||||||
|
|
||||||
final class EventLoopScheduler {
|
extension MultiThreadedEventLoopGroup: AsyncScheduler {
|
||||||
|
|
||||||
private let backgroundGroup: EventLoopGroup
|
func test() {
|
||||||
|
|
||||||
init(numberOfThreads: Int = 2) {
|
|
||||||
backgroundGroup = MultiThreadedEventLoopGroup(numberOfThreads: numberOfThreads)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func next() -> EventLoop {
|
public func schedule(asyncJob: @escaping @Sendable () async throws -> Void) {
|
||||||
backgroundGroup.next()
|
_ = any().makeFutureWithTask(asyncJob)
|
||||||
}
|
|
||||||
|
|
||||||
func provider() -> NIOEventLoopGroupProvider {
|
|
||||||
return .shared(backgroundGroup)
|
|
||||||
}
|
|
||||||
|
|
||||||
func shutdown() {
|
|
||||||
backgroundGroup.shutdownGracefully { _ in
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EventLoopScheduler: AsyncScheduler {
|
|
||||||
|
|
||||||
func schedule(asyncJob: @escaping @Sendable () async throws -> Void) {
|
|
||||||
_ = backgroundGroup.any().makeFutureWithTask(asyncJob)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,11 @@ import ClairvoyantBinaryCodable
|
|||||||
private var provider: VaporMetricProvider!
|
private var provider: VaporMetricProvider!
|
||||||
private var serverStatus: Metric<ServerStatus>!
|
private var serverStatus: Metric<ServerStatus>!
|
||||||
|
|
||||||
private let asyncScheduler = EventLoopScheduler()
|
private let asyncScheduler = MultiThreadedEventLoopGroup(numberOfThreads: 2)
|
||||||
|
|
||||||
private var server: CapServer!
|
private var server: CapServer!
|
||||||
|
|
||||||
private func status(_ newStatus: ServerStatus) {
|
func configure(_ app: Application) async throws {
|
||||||
asyncScheduler.schedule {
|
|
||||||
try await serverStatus.update(newStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func configure(_ app: Application) throws {
|
|
||||||
|
|
||||||
let resourceDirectory = URL(fileURLWithPath: app.directory.resourcesDirectory)
|
let resourceDirectory = URL(fileURLWithPath: app.directory.resourcesDirectory)
|
||||||
let publicDirectory = app.directory.publicDirectory
|
let publicDirectory = app.directory.publicDirectory
|
||||||
@@ -32,12 +26,13 @@ public func configure(_ app: Application) throws {
|
|||||||
serverStatus = Metric<ServerStatus>("caps.status",
|
serverStatus = Metric<ServerStatus>("caps.status",
|
||||||
name: "Status",
|
name: "Status",
|
||||||
description: "The general status of the service")
|
description: "The general status of the service")
|
||||||
status(.initializing)
|
try await serverStatus.update(.initializing)
|
||||||
|
|
||||||
app.http.server.configuration.port = config.port
|
app.http.server.configuration.port = config.port
|
||||||
app.routes.defaultMaxBodySize = .init(stringLiteral: config.maxBodySize)
|
app.routes.defaultMaxBodySize = .init(stringLiteral: config.maxBodySize)
|
||||||
|
|
||||||
server = CapServer(in: URL(fileURLWithPath: publicDirectory))
|
let dataDirectory = config.customDataDirectory(or: publicDirectory)
|
||||||
|
server = CapServer(in: dataDirectory)
|
||||||
|
|
||||||
provider = .init(observer: monitor, accessManager: config.writers)
|
provider = .init(observer: monitor, accessManager: config.writers)
|
||||||
provider.asyncScheduler = asyncScheduler
|
provider.asyncScheduler = asyncScheduler
|
||||||
@@ -55,16 +50,27 @@ public func configure(_ app: Application) throws {
|
|||||||
do {
|
do {
|
||||||
try server.loadData()
|
try server.loadData()
|
||||||
} catch {
|
} catch {
|
||||||
status(.initializationFailure)
|
try await serverStatus.update(.initializationFailure)
|
||||||
print("[\(df.string(from: Date()))] Server failed to start: \(error)")
|
print("[\(df.string(from: Date()))] Server failed to start: \(error)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if server.canResizeImages {
|
if server.canResizeImages {
|
||||||
status(.nominal)
|
try await serverStatus.update(.nominal)
|
||||||
} else {
|
} else {
|
||||||
status(.reducedFunctionality)
|
try await serverStatus.update(.reducedFunctionality)
|
||||||
|
}
|
||||||
|
print("[\(df.string(from: Date()))] Server started (\(app.environment.name), \(server.capCount) caps)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func shutdown() {
|
||||||
|
Task {
|
||||||
|
print("[\(df.string(from: Date()))] Server shutdown")
|
||||||
|
do {
|
||||||
|
try await asyncScheduler.shutdownGracefully()
|
||||||
|
} catch {
|
||||||
|
print("Failed to shut down MultiThreadedEventLoopGroup: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
print("[\(df.string(from: Date()))] Server started (\(server.capCount) caps)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func log(_ message: String) {
|
func log(_ message: String) {
|
||||||
|
|||||||
43
Sources/App/entrypoint.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Vapor
|
||||||
|
import Dispatch
|
||||||
|
import Logging
|
||||||
|
|
||||||
|
/// This extension is temporary and can be removed once Vapor gets this support.
|
||||||
|
private extension Vapor.Application {
|
||||||
|
static let baseExecutionQueue = DispatchQueue(label: "vapor.codes.entrypoint")
|
||||||
|
|
||||||
|
func runFromAsyncMainEntrypoint() async throws {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
Vapor.Application.baseExecutionQueue.async { [self] in
|
||||||
|
do {
|
||||||
|
try self.run()
|
||||||
|
continuation.resume()
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@main
|
||||||
|
enum Entrypoint {
|
||||||
|
static func main() async throws {
|
||||||
|
var env = try Environment.detect()
|
||||||
|
try LoggingSystem.bootstrap(from: &env)
|
||||||
|
|
||||||
|
let app = Application(env)
|
||||||
|
defer {
|
||||||
|
shutdown()
|
||||||
|
app.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await configure(app)
|
||||||
|
} catch {
|
||||||
|
app.logger.report(error: error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
try await app.runFromAsyncMainEntrypoint()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import App
|
|
||||||
import Vapor
|
|
||||||
|
|
||||||
var env = Environment.production
|
|
||||||
try LoggingSystem.bootstrap(from: &env)
|
|
||||||
let app = Application(env)
|
|
||||||
defer { app.shutdown() }
|
|
||||||
try configure(app)
|
|
||||||
try app.run()
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import App
|
|
||||||
import Dispatch
|
|
||||||
import XCTest
|
|
||||||
|
|
||||||
final class AppTests : XCTestCase {
|
|
||||||
func testNothing() throws {
|
|
||||||
XCTAssert(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
static let allTests = [
|
|
||||||
("testNothing", testNothing),
|
|
||||||
]
|
|
||||||
}
|
|
||||||