Skip to content

Commit

Permalink
Initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
Ucodia committed Aug 9, 2024
0 parents commit 23e0fae
Show file tree
Hide file tree
Showing 6 changed files with 352 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.DS_Store
.build
.vscode
21 changes: 21 additions & 0 deletions LICENSE
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.
15 changes: 15 additions & 0 deletions Package.resolved
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
}
19 changes: 19 additions & 0 deletions Package.swift
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"),
]),
]
)
48 changes: 48 additions & 0 deletions README.md
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.
```
246 changes: 246 additions & 0 deletions Sources/threedify/main.swift
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")
}

0 comments on commit 23e0fae

Please sign in to comment.