-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 23e0fae
Showing
6 changed files
with
352 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
.DS_Store | ||
.build | ||
.vscode |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
The MIT License (MIT) | ||
|
||
Copyright (c) 2024 Lionel Ringenbach | ||
|
||
Permission is hereby granted, free of charge, to any person obtaining a copy | ||
of this software and associated documentation files (the "Software"), to deal | ||
in the Software without restriction, including without limitation the rights | ||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
copies of the Software, and to permit persons to whom the Software is | ||
furnished to do so, subject to the following conditions: | ||
|
||
The above copyright notice and this permission notice shall be included in all | ||
copies or substantial portions of the Software. | ||
|
||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
SOFTWARE. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
{ | ||
"originHash" : "13e479dbdcff1af909a449e6f375a5fb61b62dfa3c0261048a3d3da230139d65", | ||
"pins" : [ | ||
{ | ||
"identity" : "swift-argument-parser", | ||
"kind" : "remoteSourceControl", | ||
"location" : "https://github.com/apple/swift-argument-parser", | ||
"state" : { | ||
"revision" : "41982a3656a71c768319979febd796c6fd111d5c", | ||
"version" : "1.5.0" | ||
} | ||
} | ||
], | ||
"version" : 3 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
// swift-tools-version:5.10 | ||
import PackageDescription | ||
|
||
let package: Package = Package( | ||
name: "threedify", | ||
platforms: [ | ||
.macOS(.v14) | ||
], | ||
dependencies: [ | ||
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), | ||
], | ||
targets: [ | ||
.executableTarget( | ||
name: "threedify", | ||
dependencies: [ | ||
.product(name: "ArgumentParser", package: "swift-argument-parser"), | ||
]), | ||
] | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
# threedify - a easy to use command line tool to automate photogrammetry | ||
|
||
`threedify` creates 3D models from images using photogrammetry. | ||
|
||
``` | ||
threedify ./input-images ./model.usdz | ||
``` | ||
|
||
## Requirements | ||
|
||
macOS 14.0+ | ||
|
||
## Installation | ||
|
||
Install with [brew](https://brew.sh/): | ||
``` | ||
brew install ucodia/tools/threedify | ||
``` | ||
|
||
## Usage | ||
|
||
``` | ||
OVERVIEW: Creates 3D models from images using photogrammetry. | ||
USAGE: threedify <input-folder> <output-filename> [--disable-masking] [--detail <detail>] [--checkpoint-directory <checkpoint-directory>] [--sample-ordering <sample-ordering>] [--feature-sensitivity <feature-sensitivity>] [--max-polygons <max-polygons>] | ||
ARGUMENTS: | ||
<input-folder> The folder of images. | ||
<output-filename> The output filename. If the path is a .usdz file path, the export will generatea a USDZ file, if | ||
the path is a directory, it will generate an OBJ in the directory. | ||
OPTIONS: | ||
--disable-masking Determines whether or not to disable masking of the scene around the model. | ||
-d, --detail <detail> detail {preview, reduced, medium, full, raw, custom} Detail of output model in terms of mesh size | ||
and texture size. | ||
-c, --checkpoint-directory <checkpoint-directory> | ||
Provide a checkoint directory to be able to restart a session which was interrupted. | ||
-o, --sample-ordering <sample-ordering> | ||
sampleOrdering {unordered, sequential} Setting to sequential may speed up computation if images | ||
are captured in a spatially sequential pattern. | ||
-f, --feature-sensitivity <feature-sensitivity> | ||
featureSensitivity {normal, high} Set to high if the scanned object does not contain a lot of | ||
discernible structures, edges or textures. | ||
-p, --max-polygons <max-polygons> | ||
maxPolygons {number} Reducing the maximum number if polygons can help tweak the detail level of | ||
detail of the mesh. Only applies to custom detail level. | ||
-h, --help Show help information. | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,246 @@ | ||
import ArgumentParser | ||
import Foundation | ||
import os | ||
import RealityKit | ||
|
||
struct Threedify: ParsableCommand { | ||
|
||
private typealias Request = PhotogrammetrySession.Request | ||
private typealias Result = PhotogrammetrySession.Result | ||
private typealias ProgressInfo = PhotogrammetrySession.Output.ProgressInfo | ||
private typealias Configuration = PhotogrammetrySession.Configuration | ||
private typealias CustomDetailSpecification = PhotogrammetrySession.Configuration.CustomDetailSpecification | ||
private typealias TextureDimension = PhotogrammetrySession.Configuration.CustomDetailSpecification.TextureDimension; | ||
|
||
public static let configuration = CommandConfiguration( | ||
commandName: "threedify", | ||
abstract: "Creates 3D models from images using photogrammetry.", | ||
version: "0.1.0") | ||
|
||
@Argument(help: "The folder of images.") | ||
private var inputFolder: String | ||
|
||
@Argument(help: "The output filename. If the path is a .usdz file path, the export will generatea a USDZ file, if the path is a directory, it will generate an OBJ in the directory.") | ||
private var outputFilename: String | ||
|
||
@Flag(help: "Determines whether or not to disable masking of the scene around the model.") | ||
private var disableMasking = false | ||
|
||
@Option(name: .shortAndLong, | ||
parsing: .next, | ||
help: "detail {preview, reduced, medium, full, raw, custom} Detail of output model in terms of mesh size and texture size.", | ||
transform: Request.Detail.init) | ||
private var detail: Request.Detail? = nil | ||
|
||
@Option(name: .shortAndLong, | ||
parsing: .next, | ||
help: "Provide a checkoint directory to be able to restart a session which was interrupted.") | ||
private var checkpointDirectory: String = "" | ||
|
||
@Option(name: [.customShort("o"), .long], | ||
parsing: .next, | ||
help: "sampleOrdering {unordered, sequential} Setting to sequential may speed up computation if images are captured in a spatially sequential pattern.", | ||
transform: Configuration.SampleOrdering.init) | ||
private var sampleOrdering: Configuration.SampleOrdering? | ||
|
||
@Option(name: .shortAndLong, | ||
parsing: .next, | ||
help: "featureSensitivity {normal, high} Set to high if the scanned object does not contain a lot of discernible structures, edges or textures.", | ||
transform: Configuration.FeatureSensitivity.init) | ||
private var featureSensitivity: Configuration.FeatureSensitivity? | ||
|
||
@Option(name: [.customShort("p"), .long], | ||
parsing: .next, | ||
help: "maxPolygons {number} Reducing the maximum number if polygons can help tweak the detail level of detail of the mesh. Only applies to custom detail level.", | ||
transform: UInt.init) | ||
private var maxPolygons: UInt? = nil | ||
|
||
func run() { | ||
guard PhotogrammetrySession.isSupported else { | ||
print("Object Capture is not available on this computer.") | ||
Foundation.exit(1) | ||
} | ||
|
||
let inputFolderUrl: URL = URL(fileURLWithPath: inputFolder, isDirectory: true) | ||
let configuration: Threedify.Configuration = makeConfigurationFromArguments() | ||
|
||
var maybeSession: PhotogrammetrySession? = nil | ||
do { | ||
maybeSession = try PhotogrammetrySession(input: inputFolderUrl, configuration: configuration) | ||
} catch { | ||
Foundation.exit(1) | ||
} | ||
guard let session: PhotogrammetrySession = maybeSession else { | ||
Foundation.exit(1) | ||
} | ||
|
||
let waiter: Task<(), Never> = Task { | ||
do { | ||
for try await output: PhotogrammetrySession.Outputs.Element in session.outputs { | ||
switch output { | ||
case .processingComplete: | ||
Foundation.exit(0) | ||
case .requestProgress(let request, let fractionComplete): | ||
self.handleRequestProgress(request: request, fractionComplete: fractionComplete) | ||
case .requestProgressInfo(let request, let progressInfo): | ||
self.handleRequestProgressInfo(request: request, progressInfo: progressInfo) | ||
@unknown default: | ||
print("") | ||
} | ||
} | ||
} catch { | ||
Foundation.exit(0) | ||
} | ||
} | ||
|
||
withExtendedLifetime((session, waiter)) { | ||
do { | ||
let request: Threedify.Request = makeRequestFromArguments() | ||
try session.process(requests: [ request ]) | ||
RunLoop.main.run() | ||
} catch { | ||
Foundation.exit(1) | ||
} | ||
} | ||
} | ||
|
||
private func makeConfigurationFromArguments() -> Configuration { | ||
var configuration: Threedify.Configuration = Configuration(); | ||
|
||
if (checkpointDirectory != "") { | ||
var checkpointDirectoryUrl = URL(fileURLWithPath: checkpointDirectory); | ||
configuration = Configuration(checkpointDirectory: checkpointDirectoryUrl); | ||
} | ||
|
||
if (disableMasking) { | ||
configuration.isObjectMaskingEnabled = false; | ||
} | ||
|
||
if let maxPolygonsValue = maxPolygons { | ||
var customDetail: Threedify.CustomDetailSpecification = CustomDetailSpecification(); | ||
customDetail.maximumPolygonCount = maxPolygonsValue; | ||
configuration.customDetailSpecification = customDetail; | ||
} | ||
|
||
sampleOrdering.map { configuration.sampleOrdering = $0 } | ||
featureSensitivity.map { configuration.featureSensitivity = $0 } | ||
return configuration | ||
} | ||
|
||
private func makeRequestFromArguments() -> Request { | ||
let outputUrl: URL = URL(fileURLWithPath: outputFilename) | ||
if let detailSetting: Threedify.Request.Detail = detail { | ||
return Request.modelFile(url: outputUrl, detail: detailSetting) | ||
} else { | ||
return Request.modelFile(url: outputUrl) | ||
} | ||
} | ||
|
||
private func handleRequestProgress(request: Request, fractionComplete: Double) { | ||
print("Progress: \(fractionComplete.asPercentage())") | ||
} | ||
|
||
private func handleRequestProgressInfo(request: Request, progressInfo: ProgressInfo) { | ||
print("Processing: \(progressInfo.processingStage?.description ?? "Unknown stage")") | ||
print("ETA: \(progressInfo.estimatedRemainingTime?.asHHMMSS() ?? "Unknown")") | ||
} | ||
} | ||
|
||
private enum IllegalOption: Swift.Error { | ||
case invalidDetail(String) | ||
case invalidSampleOverlap(String) | ||
case invalidSampleOrdering(String) | ||
case invalidFeatureSensitivity(String) | ||
} | ||
|
||
@available(macOS 14.0, *) | ||
extension PhotogrammetrySession.Request.Detail { | ||
init(_ detail: String) throws { | ||
switch detail { | ||
case "preview": self = .preview | ||
case "reduced": self = .reduced | ||
case "medium": self = .medium | ||
case "full": self = .full | ||
case "raw": self = .raw | ||
case "custom": self = .custom | ||
default: throw IllegalOption.invalidDetail(detail) | ||
} | ||
} | ||
} | ||
|
||
@available(macOS 12.0, *) | ||
extension PhotogrammetrySession.Configuration.SampleOrdering { | ||
init(sampleOrdering: String) throws { | ||
if sampleOrdering == "unordered" { | ||
self = .unordered | ||
} else if sampleOrdering == "sequential" { | ||
self = .sequential | ||
} else { | ||
throw IllegalOption.invalidSampleOrdering(sampleOrdering) | ||
} | ||
} | ||
|
||
} | ||
|
||
@available(macOS 12.0, *) | ||
extension PhotogrammetrySession.Configuration.FeatureSensitivity { | ||
init(featureSensitivity: String) throws { | ||
if featureSensitivity == "normal" { | ||
self = .normal | ||
} else if featureSensitivity == "high" { | ||
self = .high | ||
} else { | ||
throw IllegalOption.invalidFeatureSensitivity(featureSensitivity) | ||
} | ||
} | ||
} | ||
|
||
extension PhotogrammetrySession.Output.ProcessingStage { | ||
var description: String { | ||
switch self { | ||
case .imageAlignment: | ||
return "Image alignment" | ||
case .meshGeneration: | ||
return "Mesh generation" | ||
case .optimization: | ||
return "Optimization" | ||
case .preProcessing: | ||
return "Pre-processing" | ||
case .textureMapping: | ||
return "Texture mapping" | ||
@unknown default: | ||
return "Unknown stage" | ||
} | ||
} | ||
} | ||
|
||
extension TimeInterval { | ||
func asHHMMSS() -> String { | ||
let totalSeconds = Int(self) | ||
let hours = totalSeconds / 3600 | ||
let minutes = (totalSeconds % 3600) / 60 | ||
let seconds = totalSeconds % 60 | ||
|
||
if hours > 0 { | ||
return String(format: "%02d:%02d:%02d", hours, minutes, seconds) | ||
} else { | ||
return String(format: "%02d:%02d", minutes, seconds) | ||
} | ||
} | ||
} | ||
|
||
extension Double { | ||
func asPercentage(decimalPlaces: Int = 2) -> String { | ||
let numberFormatter: NumberFormatter = NumberFormatter() | ||
numberFormatter.numberStyle = .percent | ||
numberFormatter.minimumFractionDigits = decimalPlaces | ||
numberFormatter.maximumFractionDigits = decimalPlaces | ||
return numberFormatter.string(from: NSNumber(value: self)) ?? "\(self)%" | ||
} | ||
} | ||
|
||
if #available(macOS 14.0, *) { | ||
Threedify.main() | ||
} else { | ||
fatalError("Requires minimum macOS 14.0") | ||
} |